theurian 0.1.0.dev3__tar.gz → 0.1.0.dev4__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 (223) hide show
  1. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/CHANGELOG.md +274 -1
  2. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/PKG-INFO +1 -1
  3. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/pyproject.toml +1 -1
  4. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/__init__.py +1 -1
  5. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/application/index_builder.py +32 -1
  6. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/application/project_service.py +177 -0
  7. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/application/retrieval_service.py +5 -2
  8. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/application/withdrawal_purge.py +11 -0
  9. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/cli/commands.py +136 -15
  10. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/cli/index_commands.py +25 -0
  11. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/knowledge.py +29 -0
  12. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/ports/canonical_store.py +35 -0
  13. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/sqlite/schema.py +92 -12
  14. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/sqlite/store.py +142 -0
  15. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/mcp/search.py +72 -9
  16. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/mcp/tools.py +181 -76
  17. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_absence_proof.py +55 -4
  18. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_canonical_store.py +77 -0
  19. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_canonical_store_corruption.py +467 -166
  20. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_mcp_tools.py +763 -16
  21. theurian-0.1.0.dev4/tests/integration/test_state_provenance.py +856 -0
  22. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/theurian/schemas/mcp/knowledge-search-response.schema.json +2 -2
  23. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/theurian/schemas/mcp/knowledge-status-response.schema.json +2 -2
  24. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/theurian/schemas/mcp/retrieval-metadata.schema.json +4 -0
  25. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/.gitignore +0 -0
  26. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/LICENSE +0 -0
  27. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/README.md +0 -0
  28. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/hatch_build.py +0 -0
  29. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/application/__init__.py +0 -0
  30. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/application/forest_builder.py +0 -0
  31. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/application/ingestion_service.py +0 -0
  32. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/application/migration_engine.py +0 -0
  33. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/application/setup_context.py +0 -0
  34. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/application/setup_service.py +0 -0
  35. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/application/setup_steps.py +0 -0
  36. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/application/setup_withholding.py +0 -0
  37. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/application/visibility.py +0 -0
  38. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/cli/__init__.py +0 -0
  39. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/cli/auth_commands.py +0 -0
  40. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/cli/context.py +0 -0
  41. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/cli/main.py +0 -0
  42. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/cli/setup_commands.py +0 -0
  43. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/daemon/__init__.py +0 -0
  44. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/daemon/instance.py +0 -0
  45. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/daemon/runner.py +0 -0
  46. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/daemon/server.py +0 -0
  47. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/__init__.py +0 -0
  48. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/chunking.py +0 -0
  49. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/compatibility.py +0 -0
  50. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/context.py +0 -0
  51. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/enums.py +0 -0
  52. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/errors.py +0 -0
  53. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/extras.py +0 -0
  54. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/identifiers.py +0 -0
  55. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/ingestion.py +0 -0
  56. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/migration.py +0 -0
  57. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/ports/__init__.py +0 -0
  58. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/ports/authorization.py +0 -0
  59. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/ports/daemon_manager.py +0 -0
  60. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/ports/determinism.py +0 -0
  61. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/ports/embedding.py +0 -0
  62. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/ports/index_store.py +0 -0
  63. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/ports/mcp_client_config.py +0 -0
  64. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/ports/object_store.py +0 -0
  65. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/ports/reranking.py +0 -0
  66. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/ports/review_provider.py +0 -0
  67. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/ports/secret_store.py +0 -0
  68. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/ports/source_parser.py +0 -0
  69. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/ports/specification_provider.py +0 -0
  70. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/ports/summarization.py +0 -0
  71. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/ports/vector_store.py +0 -0
  72. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/project.py +0 -0
  73. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/ranking.py +0 -0
  74. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/raptor.py +0 -0
  75. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/retrieval.py +0 -0
  76. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/review.py +0 -0
  77. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/setup.py +0 -0
  78. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/specification.py +0 -0
  79. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/state.py +0 -0
  80. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/domain/values.py +0 -0
  81. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/indexing/__init__.py +0 -0
  82. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/__init__.py +0 -0
  83. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/claude/__init__.py +0 -0
  84. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/claude/mcp_config.py +0 -0
  85. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/determinism.py +0 -0
  86. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/embedding/__init__.py +0 -0
  87. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/embedding/hashing.py +0 -0
  88. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/filesystem/__init__.py +0 -0
  89. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/filesystem/migration_loader.py +0 -0
  90. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/filesystem/parsers/markdown.py +0 -0
  91. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/filesystem/parsers/openapi.py +0 -0
  92. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/filesystem/parsers/registry.py +0 -0
  93. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/filesystem/parsers/structured.py +0 -0
  94. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/git/__init__.py +0 -0
  95. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/github/__init__.py +0 -0
  96. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/raptor/__init__.py +0 -0
  97. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/raptor/extractive.py +0 -0
  98. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/secrets/__init__.py +0 -0
  99. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/secrets/file_store.py +0 -0
  100. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/services/__init__.py +0 -0
  101. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/services/launchagent.py +0 -0
  102. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/services/runner.py +0 -0
  103. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/services/systemd_user.py +0 -0
  104. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/sqlite/__init__.py +0 -0
  105. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/sqlite/connection.py +0 -0
  106. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/sqlite/index_forest.py +0 -0
  107. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/sqlite/index_purge.py +0 -0
  108. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/sqlite/index_query.py +0 -0
  109. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/sqlite/index_scan.py +0 -0
  110. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/sqlite/index_schema.py +0 -0
  111. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/sqlite/index_store.py +0 -0
  112. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/infrastructure/vector/__init__.py +0 -0
  113. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/ingestion/__init__.py +0 -0
  114. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/mcp/__init__.py +0 -0
  115. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/mcp/results.py +0 -0
  116. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/migrations/__init__.py +0 -0
  117. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/normalization/__init__.py +0 -0
  118. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/normalization/projection.py +0 -0
  119. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/observability/__init__.py +0 -0
  120. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/retrieval/__init__.py +0 -0
  121. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/review/__init__.py +0 -0
  122. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/security/__init__.py +0 -0
  123. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/security/env_file.py +0 -0
  124. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/security/paths.py +0 -0
  125. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/security/tokens.py +0 -0
  126. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/security/yaml_loading.py +0 -0
  127. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/specification/__init__.py +0 -0
  128. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/src/theurian/traceability/__init__.py +0 -0
  129. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/conftest.py +0 -0
  130. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/fakes/__init__.py +0 -0
  131. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/fakes/clock.py +0 -0
  132. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/fakes/ids.py +0 -0
  133. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/fakes/pages.py +0 -0
  134. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/fakes/setup.py +0 -0
  135. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/fakes/store.py +0 -0
  136. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_auth_rotate.py +0 -0
  137. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_bare_install.py +0 -0
  138. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_claude_mcp_config.py +0 -0
  139. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_cli_commands.py +0 -0
  140. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_cli_help_without_rich.py +0 -0
  141. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_daemon.py +0 -0
  142. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_forest_builder.py +0 -0
  143. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_forest_builder_scale.py +0 -0
  144. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_forest_node_scope.py +0 -0
  145. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_forest_purge_equality.py +0 -0
  146. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_forest_purge_recompute.py +0 -0
  147. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_forest_retrieval.py +0 -0
  148. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_forest_store_retrieval.py +0 -0
  149. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_gc_during_a_search.py +0 -0
  150. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_index_fallback.py +0 -0
  151. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_index_gc_cli.py +0 -0
  152. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_index_purge.py +0 -0
  153. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_index_purge_differential.py +0 -0
  154. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_index_purge_nodes.py +0 -0
  155. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_index_scan_fold.py +0 -0
  156. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_index_schema_v4.py +0 -0
  157. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_index_store.py +0 -0
  158. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_ingestion.py +0 -0
  159. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_init_gitignore_block.py +0 -0
  160. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_project_registry.py +0 -0
  161. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_retrieval_service.py +0 -0
  162. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_revision_id_reuse.py +0 -0
  163. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_scan_exhaustion.py +0 -0
  164. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_service_adapters.py +0 -0
  165. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_session_start_hook.py +0 -0
  166. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_setup_cli.py +0 -0
  167. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_setup_env_file.py +0 -0
  168. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_setup_journal.py +0 -0
  169. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_setup_partial_failure.py +0 -0
  170. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_setup_probe_assertions.py +0 -0
  171. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_setup_report_withholding.py +0 -0
  172. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_setup_service.py +0 -0
  173. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_short_query_retrieval.py +0 -0
  174. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_unreadable_registry_surface.py +0 -0
  175. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_wire_contract.py +0 -0
  176. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_withdrawal_purge.py +0 -0
  177. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/integration/test_writer_contract.py +0 -0
  178. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_artifact_integrity_claim.py +0 -0
  179. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_candidate_cut.py +0 -0
  180. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_candidate_depth.py +0 -0
  181. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_chunking.py +0 -0
  182. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_cli_help_rendering.py +0 -0
  183. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_compatibility.py +0 -0
  184. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_daemon_extra.py +0 -0
  185. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_domain_invariants.py +0 -0
  186. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_env_file_merge.py +0 -0
  187. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_examples.py +0 -0
  188. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_extractive_summarizer.py +0 -0
  189. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_forest_derivation.py +0 -0
  190. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_forest_fanout.py +0 -0
  191. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_gate_call_sites.py +0 -0
  192. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_hashing_embedding.py +0 -0
  193. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_install_claims.py +0 -0
  194. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_layering.py +0 -0
  195. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_migration_engine.py +0 -0
  196. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_parsers.py +0 -0
  197. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_path_security.py +0 -0
  198. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_plugin_boundary.py +0 -0
  199. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_ports.py +0 -0
  200. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_project_and_traceability.py +0 -0
  201. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_projection.py +0 -0
  202. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_ranking.py +0 -0
  203. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_raptor_scope.py +0 -0
  204. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_result_gate_session.py +0 -0
  205. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_result_payload.py +0 -0
  206. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_retrieval_depth.py +0 -0
  207. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_schemas.py +0 -0
  208. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_scope_isolation.py +0 -0
  209. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_setup_changed_paths.py +0 -0
  210. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_setup_claims.py +0 -0
  211. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_setup_domain.py +0 -0
  212. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_state_hash.py +0 -0
  213. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_test_fixtures.py +0 -0
  214. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_tokens.py +0 -0
  215. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/tests/unit/test_yaml_loading.py +0 -0
  216. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/theurian/schemas/README.md +0 -0
  217. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/theurian/schemas/cli/version.schema.json +0 -0
  218. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/theurian/schemas/config/project-config.schema.json +0 -0
  219. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/theurian/schemas/knowledge/retrieval-result.schema.json +0 -0
  220. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/theurian/schemas/mcp/project-list-response.schema.json +0 -0
  221. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/theurian/schemas/mcp/tool-context.schema.json +0 -0
  222. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/theurian/schemas/migrations/migration.schema.json +0 -0
  223. {theurian-0.1.0.dev3 → theurian-0.1.0.dev4}/theurian/schemas/protocol/compatibility.schema.json +0 -0
@@ -12,6 +12,275 @@ Pre-1.0, a MINOR bump may change the protocol. Post-1.0, only a MAJOR may.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.1.0.dev4] - 2026-08-16
16
+
17
+ ### Added
18
+
19
+ - **The `integrity` signal takes a second measurement: how many items a caller
20
+ should be able to see, against the number `theurian migrate apply` recorded**
21
+ ([#30](https://github.com/theurian/theurian/issues/30) PR2). This is what makes
22
+ a response's *own* emptiness visible, where PR1 — shipped in `0.1.0.dev3` —
23
+ could only report damage elsewhere in the state. Three of the four positions
24
+ PR1 left now disclose, and `SILENTLY_EMPTIED` is deleted — #30's stated closure
25
+ condition.
26
+
27
+ The record is a new `project_integrity` table, one row per project, written by
28
+ `migrate apply` inside its own write transaction and counted over the rows that
29
+ transaction just wrote. Nothing on a query path computes it: the reader's half
30
+ is one `COUNT` over `idx_items_status`, catching a change in the *number* of
31
+ surfaceable rows — a row leaving or entering the surfaceable scope, in either
32
+ direction — so an expectation cannot be satisfied by the state it is meant to
33
+ check
34
+ (`CanonicalStore.count_surfaceable_items`,
35
+ `CanonicalStore.expected_surfaceable_count`, `SqliteWriter.record_expected_surfaceable_count`).
36
+
37
+ **A missing record is damage, not "not recorded", and a schema bump is what
38
+ makes that sound.** `SCHEMA_VERSION` went 2 → 3 for this table (below), and
39
+ `is_supported` is exact-match, so every database this build can open was written
40
+ by a build that records. `test_a_missing_integrity_record_is_damage_and_not_silence`
41
+ holds the inference and `test_a_pre_integrity_database_is_refused_unread_by_every_tool`
42
+ holds the premise on all three tools and over every version below the current
43
+ one — including that an old database is not reported as a damaged one.
44
+
45
+ **What it now catches, pinned by the corruption sweep against the real tools
46
+ over a real damaged database.** A sentinel in `knowledge_items.project_id`
47
+ takes every item out of the project scope, and one in `knowledge_items.status`
48
+ takes a row out of the
49
+ surfaceable scope: `knowledge.search` answers `count: 0, results: []` and
50
+ `knowledge.status` answers a shrunken `itemCount` **with the key beside them**,
51
+ where PR1 answered the same numbers alone; `knowledge.get` refuses those cells
52
+ as damage rather than reporting them as absence
53
+ (`test_a_lost_surfaceable_item_is_damage_on_every_read_tool`,
54
+ `test_a_lost_surfaceable_item_makes_get_refuse_an_absent_id_as_damage`). So an
55
+ agent can now tell "this project holds nothing" from "part of this project could
56
+ not be read", and `knowledge.status` no longer publishes a positive
57
+ `appliedMigrations` beside `itemCount: 0` without comment. The `status` cell is
58
+ the member `0.1.0.dev2` recorded as "the fifth `SILENTLY_EMPTIED` member,
59
+ carried to Milestone 6" when #19 stopped parsing every row: it still
60
+ under-reports, because no read tool can repair a row it cannot parse, but it no
61
+ longer does so in silence.
62
+
63
+ **`knowledge.get`'s damage refusal is reworded, and deliberately says less.**
64
+ `0.1.0.dev3` reported a project that "could not be fully read: its derived state
65
+ holds a different number of migration-history rows than its own records expect".
66
+ Either comparison now reaches that branch, so the text says only that the
67
+ derived state "disagrees with its own records about what it holds" and names no
68
+ record — a caller matching on the old substring stops matching. The SEC-13 rule
69
+ that a withheld id and an absent id get the same message as each other is
70
+ unchanged, and so is the pair that pins both directions
71
+ (`test_an_absent_item_over_a_damaged_state_is_refused_as_damage_not_absence`,
72
+ `test_an_absent_item_over_a_healthy_state_is_refused_as_absence`).
73
+
74
+ **One position stays silent and is named rather than rounded away.**
75
+ `(knowledge.search, knowledge_items, item_id)` moves neither count — the row
76
+ keeps its `project_id` and its `status`, so both sides still count it — while
77
+ the item → revision pointer the search walks is broken, and the tool answers one
78
+ result short with no key. A count is not a checksum, and that is the shape a
79
+ count cannot see. It is `UNDETECTED_UNDERREPORT` in
80
+ `tests/integration/test_canonical_store_corruption.py`, an exact set of exactly
81
+ one member: a second position appearing there is a failure, not an expectation
82
+ to update.
83
+
84
+ **`SILENTLY_EMPTIED` is replaced by three sets that partition the same
85
+ question**, so no position can slide between outcomes unremarked.
86
+ `DISCLOSED_AS_INTEGRITY` holds the nine positions that fire the key, keyed on
87
+ the key's presence and nothing else — six of them fire while every integer in
88
+ the response stays put, which is the detector's true shape;
89
+ `DISCLOSED_BESIDE_A_SHRUNKEN_COUNT` holds the three that also shrink a published
90
+ integer; `UNDETECTED_UNDERREPORT` holds the one that shrinks and says nothing.
91
+ The sweep asserts the whole shrinking class equals the union of the last two
92
+ (`test_exactly_these_positions_disclose_damage_as_integrity`,
93
+ `test_exactly_one_position_answers_with_less_than_the_file_holds_and_says_nothing`
94
+ — renamed and re-scoped from PR1's
95
+ `test_exactly_these_positions_disclose_migration_history_damage_as_integrity`
96
+ and `test_no_tool_answers_with_less_than_the_intact_database_holds`), which is
97
+ what closes the seam a pair of independently-keyed sets would leave. That
98
+ partition is over the swept single-cell positions, not a claim that the count
99
+ catches every wrong answer: a status moved *within* `SURFACEABLE_STATUSES`
100
+ changes the set's composition without its size and sets no key, and an item
101
+ whose `current_revision_id` names another item's revision would disclose rather
102
+ than under-report and is refused at read time by the read-back guard
103
+ (`61747b3`), a mechanism distinct from this detector.
104
+
105
+ **Neither side of the new comparison counts a row the caller may not read.**
106
+ Both count `SURFACEABLE_STATUSES` — at build time in the `INSERT … SELECT`, at
107
+ read time in the `COUNT` — so a `rejected`, `deprecated` or `superseded` row is
108
+ on neither side.
109
+ `test_the_integrity_signal_is_identical_across_a_withheld_only_difference` holds
110
+ that whether the key appears is identical across two corpora differing only in
111
+ twenty-five `rejected` items. Measured beyond what that pins: overwriting a
112
+ `deprecated` row's `status` produces no key on any tool, where the same
113
+ overwrite on an `approved` row fires it, and on a `draft` row it fires the key
114
+ while the default `knowledge.search` answer stays unchanged — a draft is
115
+ surfaceable even when the default answer omits it. The mirror of that is a
116
+ recorded residual: overwriting an `approved` row's `status` to another
117
+ surfaceable value moves neither count and sets no key, while the default answer
118
+ loses a row it used to surface — the count measures the size of the surfaceable
119
+ set, not its composition, and the moved row is caller-readable either way. The
120
+ read cost keeps PR1's
121
+ shape, `O(surfaceable)` over the covering index rather than `O(total)`, and
122
+ `knowledge.status` spends no extra query at all: it sums the breakdown it had
123
+ already read.
124
+
125
+ **The remedy's first step deliberately does not clear this shape, and the
126
+ residual is recorded.** `migrate apply` records only when it created the
127
+ database or applied a migration; an apply with nothing pending must not
128
+ re-record, because it is step one of the remedy this signal publishes and
129
+ re-recording from the damaged state would manufacture its own all-clear
130
+ (`test_a_pending_free_apply_does_not_re_record_over_a_damaged_state`,
131
+ `test_an_apply_that_changes_the_store_records_the_new_count`). What stays open,
132
+ recorded and not fixed: an apply that *does* have a migration to apply re-records
133
+ over the state as it then is, so damage already present becomes the new
134
+ expectation. It is the pointer's limit again — a count is not a checksum, and a
135
+ writer can record only what it can read.
136
+
137
+ The detector also found a defect in the test suite it does not own.
138
+ `tests/integration/test_absence_proof.py` built its pair by hand with a pointer
139
+ claiming one applied migration over a store holding none, so every response in
140
+ that file had carried `integrity` since PR1 and each equality was comparing two
141
+ damage reports. Both records are now written the way `migrate apply` writes
142
+ them, and `_assert_the_pair_bites` fails an example that answers as a damaged
143
+ project — the fifth way that file can be green while proving nothing.
144
+
145
+ ### Changed
146
+
147
+ - **BREAKING — `SCHEMA_VERSION` 2 → 3: every existing state database is refused
148
+ once, and one `theurian migrate apply` rebuilds it**
149
+ ([#30](https://github.com/theurian/theurian/issues/30) PR2,
150
+ [#24](https://github.com/theurian/theurian/issues/24)). The DDL gains the
151
+ `project_integrity` table and makes the item → revision pointer a composite
152
+ foreign key; `knowledge.status` therefore publishes `schemaVersion: 3` where it
153
+ published `2`. A state database is derived and Git-ignored (ADR-0004) and is
154
+ rebuilt rather than migrated (ADR-0017), so nothing authored is lost — but the
155
+ rebuild is not automatic, and until it runs the three read tools refuse.
156
+ BREAKING for a state database on disk, not for the wire: a published value
157
+ moves and no field, type or tool name does, so `protocolVersion` stays
158
+ `theurian/v1` (see *Changing this contract* in
159
+ [`docs/protocol/mcp-tools.md`](../../docs/protocol/mcp-tools.md)).
160
+
161
+ Measured end to end rather than argued, against a database `0.1.0.dev3` wrote
162
+ with the real CLI and then read by this build: `knowledge.search`,
163
+ `knowledge.get` and `knowledge.status` each refuse with
164
+
165
+ ```
166
+ theurian-state-f1711b98d302.sqlite was written at schema version 2, but this
167
+ build uses 3. State databases are derived; rebuild with `theurian migrate
168
+ apply` rather than migrating this file.
169
+ ```
170
+
171
+ and one `theurian migrate apply` answers `databaseCreated: true` with a new
172
+ state hash — the schema version is an input to that hash, so the rebuilt file is
173
+ `theurian-state-2e8880bf25be.sqlite` beside the old one — after which all three
174
+ tools answer, `schemaVersion` reads `3`, and no `integrity` key appears. The old
175
+ file is left on disk for `theurian index gc` (ADR-0017 decision 5), not deleted
176
+ under a pinned `snapshotId`.
177
+
178
+ A database written at *any* earlier version is refused *unread* rather than
179
+ reinterpreted, which is what lets the new detector read a missing
180
+ `project_integrity` row as damage rather than as "this file predates the table"
181
+ (`test_a_pre_integrity_database_is_refused_unread_by_every_tool`, parametrised
182
+ over every version below the current one).
183
+
184
+ - **The item → revision pointer is scoped to its project by the schema, not only
185
+ by every read of it** ([#24](https://github.com/theurian/theurian/issues/24),
186
+ closed). `knowledge_items.current_revision_id` referenced
187
+ `knowledge_revisions(revision_id)` alone while `get_revision` and
188
+ `list_revisions` both filter on `project_id` as well, so the two never met: a
189
+ revision whose `project_id` moved — what a project id changing over an unchanged
190
+ root does — left the item pointing at a row its own project-scoped read could no
191
+ longer see, and `PRAGMA foreign_key_check` called the database satisfied.
192
+
193
+ The key is now composite, `(project_id, current_revision_id)` referencing
194
+ `knowledge_revisions(project_id, revision_id)` over a new unique index. Measured
195
+ on SQLite 3.51.2, before and after, against the stranding `UPDATE`: before, the
196
+ writer's own connection accepted it and `foreign_key_check` returned `[]`;
197
+ after, the same statement is refused, and forced through with foreign keys off
198
+ it is reported as `('knowledge_items', <rowid>, 'knowledge_revisions', 0)`.
199
+ `test_a_revision_cannot_be_moved_out_from_under_the_item_pointing_at_it` holds
200
+ both arms — the stranding move refused, a revision no item points at still
201
+ movable — so a key that refused every write to that column would fail it too.
202
+
203
+ A `NULL` `current_revision_id` still satisfies the key, because a composite
204
+ child key with a NULL component imposes no constraint in SQLite; an item exists
205
+ before its first revision is upserted. The four `# pragma: no cover` branches
206
+ whose justification was "the pointer is a foreign key" now say what actually
207
+ holds them, and the claim is true for the first time. INV-2's other half — that
208
+ the revision belongs to the same *item* — is enforced above the database and in
209
+ nothing the schema declares: by `KnowledgeItem.with_revision` in the domain, and
210
+ by `append_revision` and `put_item` in both `MigrationWriter` adapters.
211
+
212
+ ### Security
213
+
214
+ - **Derived state under `.theurian/state/` is served only if this installation
215
+ built it (threat-model T-19, ADR-0004, SEC-7).** Everything there — the active
216
+ pointers and the SQLite databases they name — is derived and git-ignored, but a
217
+ repository contributor can force-add a doctored copy past that ignore (`git add
218
+ -f`), and a victim who clones (or downloads the ZIP/tarball), `theurian project
219
+ register`s, and serves over MCP **without ever running `theurian migrate
220
+ apply`** was served the attacker's bytes: a `rejected` body relabelled
221
+ `approved`, rows injected, titles and excerpts rewritten in the index.
222
+
223
+ This is the self-consistent face the read-back guards cannot catch. The #30 PR2
224
+ detector and the item → revision pointer guard above find a derived state that
225
+ *disagrees with its own records*; this attacker authors both sides, at the
226
+ current schema version, so there is no inconsistency to find. `active.json`'s
227
+ `stateHash` binds the migration *set*, not the database bytes, and the database
228
+ filename is derived from that hash, so the doctored pair is self-consistent by
229
+ construction. The only property a repository author cannot forge is whether
230
+ *this installation* built the artifact.
231
+
232
+ **Control: an out-of-tree build-provenance anchor.** `theurian migrate apply`
233
+ and `theurian index build` record the state hash and index build id this
234
+ install produced for each project root in `THEURIAN_DATA_DIR/provenance.json` —
235
+ beside the project registry, out of the repository tree where a contributor
236
+ cannot write (`BuildProvenance`). Every MCP read path checks it before a byte of
237
+ `.theurian/state/` reaches a caller: `_resolve` refuses a canonical state whose
238
+ hash this install did not build (`verify_state_provenance`, covering
239
+ `knowledge.get`, `knowledge.search`, `knowledge.status`); the ranked path stands
240
+ aside from an index build id this install did not build. Both paths that
241
+ generate an index are gated on source-index provenance, so neither launders a
242
+ committed one: `index build` refuses to build *from* an unprovenanced canonical
243
+ state, and — since commit `dc6aa79` — the withdrawal purge refuses to copy a
244
+ committed index forward and record it when this install did not build the source
245
+ index (`UNTRUSTED_SOURCE_INDEX`), the second laundering path review found. And
246
+ `migrate apply` discards an unprovenanced database and rebuilds from the
247
+ Git-tracked migrations; a committed `-wal` cannot replay because
248
+ `create_database` refuses to write over an existing file, so the rebuild deletes
249
+ the main database and creates a fresh one with no database for the sidecar to
250
+ replay into — the sidecars are removed too, redundant defense-in-depth.
251
+ Those migrations are vouched for by human PR review (T-1), not by FR-K5: on a
252
+ fresh clone nothing has been applied, so FR-K5 has no recorded checksum to
253
+ disagree with an attacker-authored migration, and re-derivation is safe because
254
+ a reviewer read the migration diff.
255
+
256
+ Delivery-independent by construction: the discriminator is "did this install
257
+ build it", not "is it tracked by Git", so a clone, a ZIP download and a
258
+ repackaged tarball are refused alike — which a `git ls-files` probe could not
259
+ do. Pinned by `tests/integration/test_state_provenance.py`, whose closure
260
+ invariant is one query against two checkouts: a checkout shipping derived state
261
+ and one shipping none produce identical served knowledge, both refused until the
262
+ state is built locally.
263
+
264
+ **Effect on existing installs, and it is deliberate.** A project already built
265
+ by a pre-`0.1.0.dev4` build has no provenance record, so the three read tools
266
+ refuse it until one `theurian migrate apply` (then `theurian index build`)
267
+ rebuilds it and records provenance — the same one-command rebuild the
268
+ `SCHEMA_VERSION` bump above already requires, and nothing authored is lost
269
+ (ADR-0004). The residual is recorded in T-19: provenance vouches for a hash, not
270
+ for the database bytes, so replacing a database *after* this install built the
271
+ matching hash is out of scope for this control and left to the schema gate, the
272
+ #30 read-back guards, and the corruption checks.
273
+
274
+ <!-- Advisory (GHSA) reference for T-19 to be filled at release, when the
275
+ advisory ships; embargoed until then. -->
276
+
277
+ - **`knowledge.search` gains one `fallbackReason`, `index-unbuilt`**, emitted when
278
+ the published index was not built by this installation and the ranked path
279
+ therefore stands aside to the provenance-gated canonical scan. A new published
280
+ *value*, not a new field, type or tool, so `protocolVersion` stays `theurian/v1`
281
+ by the same rule the `SCHEMA_VERSION` entry above applies; the JSON Schema
282
+ `schemas/mcp/retrieval-metadata.schema.json` adds the `const`.
283
+
15
284
  ## [0.1.0.dev3] - 2026-08-15
16
285
 
17
286
  ### Added
@@ -179,6 +448,9 @@ Pre-1.0, a MINOR bump may change the protocol. Post-1.0, only a MAJOR may.
179
448
  with is now a refusal. Only negative values are refused — a non-negative integer
180
449
  that is simply wrong is still accepted, which is the one-way limit recorded
181
450
  above and in the threat model.
451
+
452
+ - **`knowledge.status` no longer refuses over a corrupt
453
+ `migration_history.migration_id` or `checksum`; it answers cleanly**
182
454
  ([#30](https://github.com/theurian/theurian/issues/30) PR1). The refusal was a
183
455
  side effect of parsing rows the tool no longer reads: it used to call
184
456
  `applied_migrations`, which converts both cells and raises on a damaged one,
@@ -434,7 +706,8 @@ Pre-1.0, a MINOR bump may change the protocol. Post-1.0, only a MAJOR may.
434
706
 
435
707
  - **A reused `revisionId` across two items no longer leaks a withheld item's body**
436
708
  ([GHSA-7997-g35f-q59h](https://github.com/theurian/theurian/security/advisories/GHSA-7997-g35f-q59h);
437
- fix commit to be linked at publication). **BREAKING (state database schema).**
709
+ fixed in [`67c0e81`](https://github.com/theurian/theurian/commit/67c0e81)).
710
+ **BREAKING (state database schema).**
438
711
  In 0.1.0.dev0–0.1.0.dev2 a migration that reused an existing `revisionId` under
439
712
  a second `itemId` — the shape a copy-pasted `upsertRevision` block produces —
440
713
  pointed the second (approved) item's current revision at the first item's
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: theurian
3
- Version: 0.1.0.dev3
3
+ Version: 0.1.0.dev4
4
4
  Summary: Git-native engineering knowledge platform: versioned, traceable context for AI agents.
5
5
  Project-URL: Homepage, https://github.com/theurian/theurian
6
6
  Project-URL: Documentation, https://github.com/theurian/theurian/tree/main/docs
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "theurian"
7
- version = "0.1.0.dev3"
7
+ version = "0.1.0.dev4"
8
8
  description = "Git-native engineering knowledge platform: versioned, traceable context for AI agents."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -8,7 +8,7 @@ plugin must never import them (ADR-0001, CP-2).
8
8
 
9
9
  from theurian.domain.compatibility import CURRENT_PROTOCOL_VERSION
10
10
 
11
- __version__ = "0.1.0.dev3"
11
+ __version__ = "0.1.0.dev4"
12
12
  __protocol_version__ = CURRENT_PROTOCOL_VERSION
13
13
 
14
14
  __all__ = ["__protocol_version__", "__version__"]
@@ -36,6 +36,7 @@ from theurian.application.forest_builder import ForestBuilder
36
36
  from theurian.domain.chunking import IndexableChunk, chunk_document
37
37
  from theurian.domain.context import RequestContext
38
38
  from theurian.domain.enums import may_surface
39
+ from theurian.domain.errors import InvariantViolationError
39
40
  from theurian.domain.identifiers import ProjectId
40
41
  from theurian.domain.ports.canonical_store import CanonicalReadSession
41
42
  from theurian.domain.ports.embedding import EmbeddingProvider
@@ -147,8 +148,38 @@ class IndexBuilder:
147
148
  if item.current_revision_id is None:
148
149
  continue
149
150
  revision = store.get_revision(context, item.current_revision_id)
150
- if revision is None: # pragma: no cover - the pointer is a foreign key
151
+ if revision is None: # pragma: no cover - a composite foreign key holds this (#24)
151
152
  continue
153
+ if not item.owns(revision):
154
+ # A `current_revision_id` naming a sibling item's revision is
155
+ # type-valid, satisfies the composite foreign key, and moves
156
+ # neither `#30` integrity count, so nothing upstream of this
157
+ # loop refuses it. Read through here it is the one face of
158
+ # that damage the query side cannot then catch:
159
+ # `CanonicalVisibility._may_surface` clears a ranked row by
160
+ # comparing the indexed `revision_id` against the live
161
+ # pointer, and a build that followed the corrupt pointer
162
+ # writes chunks the pointer *agrees* with -- so the withheld
163
+ # revision's body would rank and be excerpted under this
164
+ # item's approved status.
165
+ #
166
+ # Refused rather than skipped, and refused for the whole
167
+ # build: skipping would drop an approved document out of the
168
+ # index with nothing said, which reads as a relevance problem
169
+ # rather than as damage. `IndexBuilder.build` unlinks what
170
+ # this run wrote and `_run_build` converts this into
171
+ # `{error, remedy}` at exit 1, so nothing is published and the
172
+ # previous index still answers.
173
+ #
174
+ # Names no id and no cell: the message reaches the operator's
175
+ # terminal through `theurian index build`, and the id it would
176
+ # carry is the withheld item's.
177
+ msg = (
178
+ "This project's canonical state disagrees with its own records: an "
179
+ "item points at a revision that belongs to a different item, so this "
180
+ "build would index that revision's content under the wrong item."
181
+ )
182
+ raise InvariantViolationError(msg)
152
183
 
153
184
  # The title is prepended to the body before splitting so that a
154
185
  # query matching only the title still finds the document. A
@@ -78,6 +78,22 @@ ACTIVE_POINTER_REMEDY: Final = (
78
78
  "the pointer is derived, so nothing is lost."
79
79
  )
80
80
 
81
+ #: The cure for derived state that this installation did not build. Names the
82
+ #: whole `.theurian/state/` directory rather than the pointer alone: the
83
+ #: databases there are what carry the untrusted bytes, they are named by a hash
84
+ #: an attacker can compute, and `migrate apply` reuses a database file by name
85
+ #: (`database_for`) -- so deleting the pointer alone leaves the untrusted
86
+ #: database in place for the next apply to open. Also names the Git escape,
87
+ #: because the shape this closes is a repository contributor who force-added the
88
+ #: directory past its ADR-0004 ignore: a working-tree delete comes straight back
89
+ #: on the next checkout until the file is untracked.
90
+ UNBUILT_STATE_REMEDY: Final = (
91
+ "Delete .theurian/state/ and run `theurian migrate apply` (then "
92
+ "`theurian index build`) to rebuild it locally from the Git-tracked migrations. "
93
+ "If .theurian/state/ is tracked by Git, also run `git rm --cached -r .theurian/state`: "
94
+ "derived state must never be version-controlled (ADR-0004)."
95
+ )
96
+
81
97
  #: The half of "rename a project" that is easy to omit and impossible to notice.
82
98
  #: Canonical rows are stamped with the id in force at `migrate apply`, and
83
99
  #: `migrate apply` is idempotent, so it will not restamp them. An id changed
@@ -1147,3 +1163,164 @@ class ProjectRegistry:
1147
1163
  temporary = self.path.with_suffix(".json.tmp")
1148
1164
  temporary.write_text(json.dumps(entries, indent=2, sort_keys=True) + "\n", encoding="utf-8")
1149
1165
  os.replace(temporary, self.path) # noqa: PTH105 -- atomic replace
1166
+
1167
+
1168
+ @dataclass(frozen=True, slots=True)
1169
+ class BuildProvenance:
1170
+ """This installation's record of the derived state it built (ADR-0004, SEC-7).
1171
+
1172
+ The class this closes: everything under `.theurian/state/` -- the active
1173
+ pointers and the SQLite databases they name -- is derived and git-ignored
1174
+ (ADR-0004), but nothing stops a repository contributor from force-adding a
1175
+ doctored copy (`git add -f`, past the ignore) and a victim who clones (or
1176
+ downloads the ZIP/tarball) + `project register` + serves over MCP, *without
1177
+ ever running `migrate apply`*, from being served the attacker's bytes. The
1178
+ trust was filesystem presence: a database file whose name matched the
1179
+ pointer's hash was opened and read as authoritative.
1180
+
1181
+ Presence cannot be the discriminator, because `active.json`'s ``stateHash``
1182
+ binds the migration *set*, not the database bytes, and the database filename
1183
+ is derived from that hash (:meth:`StateHash.database_filename`). A
1184
+ self-consistent doctored pair -- status flipped, rows injected, every
1185
+ integrity record recomputed to match -- has no internal inconsistency to
1186
+ catch. The only thing an attacker who authored the repository cannot forge is
1187
+ whether *this installation* built the artifact, so provenance is recorded
1188
+ here, out of the repository tree, in ``THEURIAN_DATA_DIR`` beside the
1189
+ registry -- the one place a repository contributor cannot write to.
1190
+
1191
+ **Delivery-independent by construction.** The discriminator is "did this
1192
+ install build it", not "is it tracked by Git", so it refuses a clone, a ZIP
1193
+ download and a repackaged tarball alike -- a `git ls-files` probe would catch
1194
+ only the clone, since repackaging strips the tracking metadata and leaves the
1195
+ file present-but-untracked.
1196
+
1197
+ **Keyed by resolved root path, not project id.** The resolution layer that
1198
+ enforces the check holds the root (:attr:`ProjectPaths.root`) but not always
1199
+ the id; the root is the physical location the victim's own machine chose for
1200
+ the checkout; and two directories that would derive the same id from their
1201
+ name (:func:`derive_project_id` collides on directory name) stay distinct
1202
+ here.
1203
+
1204
+ **What it does not close, recorded rather than hidden.** The record vouches
1205
+ for a *hash*, not for the database bytes -- verifying bytes would mean hashing
1206
+ the whole database on every query. So an attacker who can replace a database
1207
+ *after* this install built the matching hash (a tracked sidecar overwriting a
1208
+ local build on the next `git pull`, or local filesystem write access) is out
1209
+ of scope for this control and left to the read-back integrity guards (#30
1210
+ PR2) and the schema-version and corruption checks. The primary vector -- a
1211
+ build this installation never produced -- is closed outright, because no
1212
+ record exists for it at all.
1213
+ """
1214
+
1215
+ path: Path
1216
+
1217
+ @classmethod
1218
+ def default(cls, data_dir: Path | None = None) -> BuildProvenance:
1219
+ base = data_dir or Path(os.environ.get("THEURIAN_DATA_DIR", Path.home() / ".theurian"))
1220
+ return cls(path=base / "provenance.json")
1221
+
1222
+ @classmethod
1223
+ def for_registry(cls, registry: ProjectRegistry) -> BuildProvenance:
1224
+ """The provenance store beside a registry, in the same data directory.
1225
+
1226
+ Derived from the registry rather than re-reading ``THEURIAN_DATA_DIR`` so
1227
+ the serve-side check reads exactly the directory the registry it was
1228
+ handed lives in, however that directory was resolved. The build side
1229
+ (``migrate apply``, ``index build``) reaches the same file through
1230
+ :meth:`default`, because both resolve the same environment variable.
1231
+ """
1232
+ return cls(path=registry.path.parent / "provenance.json")
1233
+
1234
+ def _load(self) -> dict[str, Any]:
1235
+ """The recorded builds, or an empty map on any failure to read them.
1236
+
1237
+ **Fail closed.** A provenance file this process cannot read or parse
1238
+ vouches for nothing, so every artifact is refused until a local build
1239
+ rewrites it. The file is derived and lives in the user's own data
1240
+ directory, so `migrate apply` is always the cure and losing it costs only
1241
+ a re-apply -- the same trade the registry and the state pointer make.
1242
+ """
1243
+ if not self.path.exists():
1244
+ return {}
1245
+ try:
1246
+ loaded = json.loads(self.path.read_text(encoding="utf-8"))
1247
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
1248
+ return {}
1249
+ return loaded if isinstance(loaded, dict) else {}
1250
+
1251
+ def _built(self, raw: dict[str, Any], root: Path, kind: str) -> list[str]:
1252
+ entry = raw.get(str(root.resolve()))
1253
+ recorded = entry.get(kind) if isinstance(entry, dict) else None
1254
+ if not isinstance(recorded, list):
1255
+ return []
1256
+ return [value for value in recorded if isinstance(value, str)]
1257
+
1258
+ def has_state(self, root: Path, state_hash: str) -> bool:
1259
+ """Whether this installation built the canonical state named by ``state_hash``."""
1260
+ return state_hash in self._built(self._load(), root, "state")
1261
+
1262
+ def has_index(self, root: Path, index_build_id: str) -> bool:
1263
+ """Whether this installation built the retrieval index named by ``index_build_id``."""
1264
+ return index_build_id in self._built(self._load(), root, "index")
1265
+
1266
+ def record_state(self, root: Path, state_hash: str) -> None:
1267
+ """Record that this installation built the canonical state ``state_hash``."""
1268
+ self._record(root, "state", state_hash)
1269
+
1270
+ def record_index(self, root: Path, index_build_id: str) -> None:
1271
+ """Record that this installation built the retrieval index ``index_build_id``."""
1272
+ self._record(root, "index", index_build_id)
1273
+
1274
+ def _record(self, root: Path, kind: str, value: str) -> None:
1275
+ """Append one built artifact to a root's record, atomically.
1276
+
1277
+ Accumulates rather than replaces: a prior build's state may still be
1278
+ served (a pinned snapshot, a not-yet-collected build), so the record
1279
+ keeps every hash this installation produced rather than only the latest.
1280
+ Read-modify-write with an :func:`os.replace` swap, the same discipline as
1281
+ the registry; a lost update under concurrent writers drops a hash and so
1282
+ fails closed -- the artifact is refused until the next build re-records
1283
+ it -- rather than vouching for one this installation did not build.
1284
+ """
1285
+ raw = self._load()
1286
+ key = str(root.resolve())
1287
+ entry_value = raw.get(key)
1288
+ entry = dict(entry_value) if isinstance(entry_value, dict) else {}
1289
+ built = self._built(raw, root, kind)
1290
+ if value not in built:
1291
+ built = [*built, value]
1292
+ entry[kind] = built
1293
+ updated = dict(raw)
1294
+ updated[key] = entry
1295
+ self._write(updated)
1296
+
1297
+ def _write(self, entries: dict[str, Any]) -> None:
1298
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
1299
+ temporary = self.path.with_suffix(".json.tmp")
1300
+ temporary.write_text(json.dumps(entries, indent=2, sort_keys=True) + "\n", encoding="utf-8")
1301
+ os.replace(temporary, self.path) # noqa: PTH105 -- atomic replace
1302
+
1303
+
1304
+ def verify_state_provenance(
1305
+ paths: ProjectPaths, active: ActiveState, provenance: BuildProvenance
1306
+ ) -> None:
1307
+ """Refuse canonical state this installation did not build (ADR-0004, SEC-7).
1308
+
1309
+ The enforcement point for :class:`BuildProvenance`. Called on the serve path
1310
+ (the MCP tools' ``_resolve``) and before an index is built from canonical
1311
+ state (``theurian index build``), so a database that this installation never
1312
+ produced never influences a served result -- whatever put it on disk.
1313
+
1314
+ Raises:
1315
+ ProjectError: If no out-of-tree record shows this installation built the
1316
+ state the in-tree pointer names. Carries :data:`UNBUILT_STATE_REMEDY`;
1317
+ quotes no cell content, only the state directory's own path.
1318
+ """
1319
+ if not provenance.has_state(paths.root, str(active.state_hash)):
1320
+ raise ProjectError(
1321
+ f"The derived knowledge state under {paths.state} was not built by this "
1322
+ f"Theurian installation, so it will not be served. It was delivered with the "
1323
+ f"project rather than rebuilt here from the Git-tracked migrations, which is "
1324
+ f"exactly what an untrusted repository must not be able to do (ADR-0004).",
1325
+ remedy=UNBUILT_STATE_REMEDY,
1326
+ )
@@ -1018,8 +1018,11 @@ class ResultGate:
1018
1018
  if item is None or revision is None: # pragma: no cover - a foreign key holds this
1019
1019
  # `visible` cleared this candidate moments ago, in this session,
1020
1020
  # against an item whose `current_revision_id` names this revision
1021
- # under a foreign key. Reaching here means the state database
1022
- # disagrees with itself, which is not something to answer around:
1021
+ # under a foreign key -- composite over `(project_id,
1022
+ # revision_id)` since #24, so the project-scoped read below cannot
1023
+ # miss a revision the constraint calls present. Reaching here
1024
+ # means the state database disagrees with itself, which is not
1025
+ # something to answer around:
1023
1026
  # a silently shorter answer would be indistinguishable from "we
1024
1027
  # have no such decision".
1025
1028
  msg = (
@@ -129,6 +129,16 @@ NO_PUBLISHED_INDEX: str = "no-published-index"
129
129
  INDEX_UNUSABLE: str = "index-unusable"
130
130
  NOTHING_TO_PURGE: str = "nothing-to-purge"
131
131
 
132
+ #: The published build a purge would copy forward was not produced by this
133
+ #: installation, so the composition root declines to purge it: copying an
134
+ #: unprovenanced build's surviving rows into a fresh build and recording that
135
+ #: build would launder a committed, doctored index into a provenanced one the
136
+ #: serve path trusts. Reported (not raised) so the skip is visible, and named
137
+ #: here because it is part of the ``reason`` vocabulary a command reports even
138
+ #: though the gate that produces it lives at the composition root (ADR-0004,
139
+ #: SEC-7).
140
+ UNTRUSTED_SOURCE_INDEX: str = "untrusted-source-index"
141
+
132
142
  #: What an operator does when the purge itself failed. The index is derived
133
143
  #: (ADR-0004), so the cure is always a rebuild -- and it is the load-bearing half
134
144
  #: of the failure report, because the current build still holds the withdrawn
@@ -480,6 +490,7 @@ __all__ = [
480
490
  "NO_PUBLISHED_INDEX",
481
491
  "NO_WITHDRAWAL",
482
492
  "PURGE_FAILED_REMEDY",
493
+ "UNTRUSTED_SOURCE_INDEX",
483
494
  "ForestRecomputeStore",
484
495
  "PurgeableIndex",
485
496
  "WithdrawalPurge",