theurian 0.1.0.dev3__tar.gz → 0.1.0.dev5__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 (249) hide show
  1. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/CHANGELOG.md +981 -1
  2. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/PKG-INFO +4 -4
  3. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/pyproject.toml +3 -3
  4. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/__init__.py +1 -1
  5. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/application/index_builder.py +32 -1
  6. theurian-0.1.0.dev5/src/theurian/application/migration_body_guards.py +224 -0
  7. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/application/migration_engine.py +8 -0
  8. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/application/project_service.py +210 -9
  9. theurian-0.1.0.dev5/src/theurian/application/proposal_service.py +932 -0
  10. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/application/retrieval_service.py +5 -2
  11. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/application/withdrawal_purge.py +11 -0
  12. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/cli/commands.py +316 -29
  13. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/cli/context.py +26 -2
  14. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/cli/index_commands.py +25 -0
  15. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/cli/main.py +8 -1
  16. theurian-0.1.0.dev5/src/theurian/cli/propose_commands.py +577 -0
  17. theurian-0.1.0.dev5/src/theurian/domain/errors.py +578 -0
  18. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/knowledge.py +29 -0
  19. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/migration.py +58 -1
  20. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/ports/canonical_store.py +35 -0
  21. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/ports/review_provider.py +1 -1
  22. theurian-0.1.0.dev5/src/theurian/domain/proposal.py +215 -0
  23. theurian-0.1.0.dev5/src/theurian/infrastructure/filesystem/migration_loader.py +816 -0
  24. theurian-0.1.0.dev5/src/theurian/infrastructure/filesystem/parsers/openapi.py +484 -0
  25. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/filesystem/parsers/structured.py +17 -0
  26. theurian-0.1.0.dev5/src/theurian/infrastructure/github/__init__.py +16 -0
  27. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/sqlite/schema.py +92 -12
  28. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/sqlite/store.py +142 -0
  29. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/mcp/search.py +72 -9
  30. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/mcp/tools.py +181 -77
  31. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/review/__init__.py +7 -0
  32. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/security/yaml_loading.py +35 -1
  33. theurian-0.1.0.dev5/tests/command_extraction.py +600 -0
  34. theurian-0.1.0.dev5/tests/command_population.py +210 -0
  35. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_absence_proof.py +55 -4
  36. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_canonical_store.py +77 -0
  37. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_canonical_store_corruption.py +477 -168
  38. theurian-0.1.0.dev5/tests/integration/test_cli_commands.py +2317 -0
  39. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_mcp_tools.py +1049 -17
  40. theurian-0.1.0.dev5/tests/integration/test_proposal_service.py +1089 -0
  41. theurian-0.1.0.dev5/tests/integration/test_propose_cli.py +386 -0
  42. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_revision_id_reuse.py +17 -6
  43. theurian-0.1.0.dev5/tests/integration/test_state_provenance.py +856 -0
  44. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_cli_help_rendering.py +1 -1
  45. theurian-0.1.0.dev5/tests/unit/test_command_extraction.py +388 -0
  46. theurian-0.1.0.dev5/tests/unit/test_config_key_call_sites.py +474 -0
  47. theurian-0.1.0.dev5/tests/unit/test_documented_commands.py +709 -0
  48. theurian-0.1.0.dev5/tests/unit/test_examples.py +269 -0
  49. theurian-0.1.0.dev5/tests/unit/test_ingest_manifest_path.py +138 -0
  50. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_migration_engine.py +603 -0
  51. theurian-0.1.0.dev5/tests/unit/test_migration_loader_errors.py +1757 -0
  52. theurian-0.1.0.dev5/tests/unit/test_migration_loader_identity.py +122 -0
  53. theurian-0.1.0.dev5/tests/unit/test_network_call_sites.py +888 -0
  54. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_parsers.py +70 -0
  55. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_plugin_boundary.py +161 -63
  56. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_project_and_traceability.py +20 -0
  57. theurian-0.1.0.dev5/tests/unit/test_project_registry_errors.py +94 -0
  58. theurian-0.1.0.dev5/tests/unit/test_proposal.py +208 -0
  59. theurian-0.1.0.dev5/tests/unit/test_ref_recording.py +678 -0
  60. theurian-0.1.0.dev5/tests/unit/test_resolve_context_call_sites.py +336 -0
  61. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_schemas.py +104 -0
  62. theurian-0.1.0.dev5/tests/unit/test_secret_detector.py +609 -0
  63. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_yaml_loading.py +38 -0
  64. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/theurian/schemas/config/project-config.schema.json +5 -5
  65. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/theurian/schemas/mcp/knowledge-search-response.schema.json +2 -2
  66. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/theurian/schemas/mcp/knowledge-status-response.schema.json +2 -2
  67. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/theurian/schemas/mcp/retrieval-metadata.schema.json +4 -0
  68. theurian-0.1.0.dev3/src/theurian/domain/errors.py +0 -191
  69. theurian-0.1.0.dev3/src/theurian/infrastructure/filesystem/migration_loader.py +0 -336
  70. theurian-0.1.0.dev3/src/theurian/infrastructure/filesystem/parsers/openapi.py +0 -255
  71. theurian-0.1.0.dev3/src/theurian/infrastructure/github/__init__.py +0 -10
  72. theurian-0.1.0.dev3/tests/integration/test_cli_commands.py +0 -1148
  73. theurian-0.1.0.dev3/tests/unit/test_examples.py +0 -138
  74. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/.gitignore +0 -0
  75. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/LICENSE +0 -0
  76. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/README.md +0 -0
  77. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/hatch_build.py +0 -0
  78. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/application/__init__.py +0 -0
  79. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/application/forest_builder.py +0 -0
  80. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/application/ingestion_service.py +0 -0
  81. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/application/setup_context.py +0 -0
  82. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/application/setup_service.py +0 -0
  83. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/application/setup_steps.py +0 -0
  84. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/application/setup_withholding.py +0 -0
  85. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/application/visibility.py +0 -0
  86. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/cli/__init__.py +0 -0
  87. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/cli/auth_commands.py +0 -0
  88. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/cli/setup_commands.py +0 -0
  89. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/daemon/__init__.py +0 -0
  90. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/daemon/instance.py +0 -0
  91. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/daemon/runner.py +0 -0
  92. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/daemon/server.py +0 -0
  93. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/__init__.py +0 -0
  94. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/chunking.py +0 -0
  95. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/compatibility.py +0 -0
  96. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/context.py +0 -0
  97. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/enums.py +0 -0
  98. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/extras.py +0 -0
  99. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/identifiers.py +0 -0
  100. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/ingestion.py +0 -0
  101. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/ports/__init__.py +0 -0
  102. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/ports/authorization.py +0 -0
  103. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/ports/daemon_manager.py +0 -0
  104. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/ports/determinism.py +0 -0
  105. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/ports/embedding.py +0 -0
  106. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/ports/index_store.py +0 -0
  107. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/ports/mcp_client_config.py +0 -0
  108. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/ports/object_store.py +0 -0
  109. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/ports/reranking.py +0 -0
  110. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/ports/secret_store.py +0 -0
  111. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/ports/source_parser.py +0 -0
  112. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/ports/specification_provider.py +0 -0
  113. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/ports/summarization.py +0 -0
  114. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/ports/vector_store.py +0 -0
  115. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/project.py +0 -0
  116. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/ranking.py +0 -0
  117. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/raptor.py +0 -0
  118. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/retrieval.py +0 -0
  119. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/review.py +0 -0
  120. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/setup.py +0 -0
  121. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/specification.py +0 -0
  122. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/state.py +0 -0
  123. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/domain/values.py +0 -0
  124. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/indexing/__init__.py +0 -0
  125. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/__init__.py +0 -0
  126. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/claude/__init__.py +0 -0
  127. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/claude/mcp_config.py +0 -0
  128. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/determinism.py +0 -0
  129. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/embedding/__init__.py +0 -0
  130. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/embedding/hashing.py +0 -0
  131. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/filesystem/__init__.py +0 -0
  132. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/filesystem/parsers/markdown.py +0 -0
  133. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/filesystem/parsers/registry.py +0 -0
  134. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/git/__init__.py +0 -0
  135. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/raptor/__init__.py +0 -0
  136. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/raptor/extractive.py +0 -0
  137. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/secrets/__init__.py +0 -0
  138. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/secrets/file_store.py +0 -0
  139. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/services/__init__.py +0 -0
  140. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/services/launchagent.py +0 -0
  141. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/services/runner.py +0 -0
  142. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/services/systemd_user.py +0 -0
  143. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/sqlite/__init__.py +0 -0
  144. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/sqlite/connection.py +0 -0
  145. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/sqlite/index_forest.py +0 -0
  146. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/sqlite/index_purge.py +0 -0
  147. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/sqlite/index_query.py +0 -0
  148. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/sqlite/index_scan.py +0 -0
  149. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/sqlite/index_schema.py +0 -0
  150. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/sqlite/index_store.py +0 -0
  151. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/infrastructure/vector/__init__.py +0 -0
  152. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/ingestion/__init__.py +0 -0
  153. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/mcp/__init__.py +0 -0
  154. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/mcp/results.py +0 -0
  155. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/migrations/__init__.py +0 -0
  156. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/normalization/__init__.py +0 -0
  157. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/normalization/projection.py +0 -0
  158. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/observability/__init__.py +0 -0
  159. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/retrieval/__init__.py +0 -0
  160. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/security/__init__.py +0 -0
  161. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/security/env_file.py +0 -0
  162. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/security/paths.py +0 -0
  163. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/security/tokens.py +0 -0
  164. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/specification/__init__.py +0 -0
  165. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/src/theurian/traceability/__init__.py +0 -0
  166. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/conftest.py +0 -0
  167. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/fakes/__init__.py +0 -0
  168. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/fakes/clock.py +0 -0
  169. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/fakes/ids.py +0 -0
  170. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/fakes/pages.py +0 -0
  171. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/fakes/setup.py +0 -0
  172. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/fakes/store.py +0 -0
  173. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_auth_rotate.py +0 -0
  174. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_bare_install.py +0 -0
  175. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_claude_mcp_config.py +0 -0
  176. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_cli_help_without_rich.py +0 -0
  177. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_daemon.py +0 -0
  178. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_forest_builder.py +0 -0
  179. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_forest_builder_scale.py +0 -0
  180. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_forest_node_scope.py +0 -0
  181. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_forest_purge_equality.py +0 -0
  182. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_forest_purge_recompute.py +0 -0
  183. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_forest_retrieval.py +0 -0
  184. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_forest_store_retrieval.py +0 -0
  185. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_gc_during_a_search.py +0 -0
  186. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_index_fallback.py +0 -0
  187. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_index_gc_cli.py +0 -0
  188. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_index_purge.py +0 -0
  189. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_index_purge_differential.py +0 -0
  190. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_index_purge_nodes.py +0 -0
  191. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_index_scan_fold.py +0 -0
  192. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_index_schema_v4.py +0 -0
  193. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_index_store.py +0 -0
  194. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_ingestion.py +0 -0
  195. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_init_gitignore_block.py +0 -0
  196. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_project_registry.py +0 -0
  197. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_retrieval_service.py +0 -0
  198. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_scan_exhaustion.py +0 -0
  199. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_service_adapters.py +0 -0
  200. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_session_start_hook.py +0 -0
  201. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_setup_cli.py +0 -0
  202. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_setup_env_file.py +0 -0
  203. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_setup_journal.py +0 -0
  204. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_setup_partial_failure.py +0 -0
  205. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_setup_probe_assertions.py +0 -0
  206. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_setup_report_withholding.py +0 -0
  207. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_setup_service.py +0 -0
  208. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_short_query_retrieval.py +0 -0
  209. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_unreadable_registry_surface.py +0 -0
  210. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_wire_contract.py +0 -0
  211. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_withdrawal_purge.py +0 -0
  212. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/integration/test_writer_contract.py +0 -0
  213. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_artifact_integrity_claim.py +0 -0
  214. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_candidate_cut.py +0 -0
  215. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_candidate_depth.py +0 -0
  216. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_chunking.py +0 -0
  217. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_compatibility.py +0 -0
  218. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_daemon_extra.py +0 -0
  219. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_domain_invariants.py +0 -0
  220. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_env_file_merge.py +0 -0
  221. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_extractive_summarizer.py +0 -0
  222. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_forest_derivation.py +0 -0
  223. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_forest_fanout.py +0 -0
  224. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_gate_call_sites.py +0 -0
  225. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_hashing_embedding.py +0 -0
  226. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_install_claims.py +0 -0
  227. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_layering.py +0 -0
  228. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_path_security.py +0 -0
  229. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_ports.py +0 -0
  230. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_projection.py +0 -0
  231. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_ranking.py +0 -0
  232. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_raptor_scope.py +0 -0
  233. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_result_gate_session.py +0 -0
  234. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_result_payload.py +0 -0
  235. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_retrieval_depth.py +0 -0
  236. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_scope_isolation.py +0 -0
  237. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_setup_changed_paths.py +0 -0
  238. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_setup_claims.py +0 -0
  239. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_setup_domain.py +0 -0
  240. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_state_hash.py +0 -0
  241. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_test_fixtures.py +0 -0
  242. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/tests/unit/test_tokens.py +0 -0
  243. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/theurian/schemas/README.md +0 -0
  244. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/theurian/schemas/cli/version.schema.json +0 -0
  245. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/theurian/schemas/knowledge/retrieval-result.schema.json +0 -0
  246. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/theurian/schemas/mcp/project-list-response.schema.json +0 -0
  247. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/theurian/schemas/mcp/tool-context.schema.json +0 -0
  248. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/theurian/schemas/migrations/migration.schema.json +0 -0
  249. {theurian-0.1.0.dev3 → theurian-0.1.0.dev5}/theurian/schemas/protocol/compatibility.schema.json +0 -0
@@ -12,6 +12,982 @@ 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.dev5] - 2026-08-19
16
+
17
+ ### Added
18
+
19
+ - **`theurian propose` drafts a knowledge change, `theurian propose accept`
20
+ moves it into place** ([#212](https://github.com/theurian/theurian/issues/212)).
21
+ Packages ADR-0013 §4's previously manual flow into the CLI: `theurian propose`
22
+ writes a proposal directory under `.theurian/proposals/<id>/` — a schema-valid,
23
+ directly applicable migration named `<migration-ulid>-<slug>.yaml`, the body in
24
+ its native format under a sub-path mirroring its knowledge namespace, and
25
+ `evidence.json` — and writes nowhere else. `theurian propose accept <id>` moves
26
+ the migration into `.theurian/migrations/` and the body to the path its
27
+ `contentFile` names. Neither approves anything: `accept` moves files and stops
28
+ short of the judgement, and approval is a human merging the pull request that
29
+ carries the proposal (ADR-0013 point 4). There is no CLI or MCP surface that
30
+ stands in for that merge.
31
+
32
+ - **`theurian migrate validate` names every revision whose body no digest pins**
33
+ ([#210](https://github.com/theurian/theurian/issues/210)). `contentSha256` is
34
+ optional in the schema and is the only thing that freezes a body: where it is
35
+ declared, the loader hashes the file on every load and refuses a mismatch;
36
+ where it is absent, the loader adopts whatever the file hashes to now as that
37
+ revision's own content hash. Measured on an unpinned migration: apply it, edit
38
+ the body out of band, and `migrate validate` still reports `valid: true` at
39
+ exit 0, while the next `migrate apply` records the edited bytes under the same
40
+ revision id and returns `changed: true`. Nothing recommended the field and
41
+ nothing reported its absence.
42
+
43
+ Validate's output now carries `unpinnedRevisions` — one line per
44
+ `upsertRevision` that declares no pin, naming the migration, the revision
45
+ inside it, the body's **project-relative** path (the one a reader can `shasum`
46
+ from the repository root, not the authored `contentFile`, which is relative to
47
+ the migration file), and the remedy — in `--json` and in the default human
48
+ output alike. The remedy carries its applied-case escape: the warning fires on
49
+ already-applied migrations too, and editing an applied migration to add the pin
50
+ trips FR-K5's checksum guard, so pinning an already-applied body means editing
51
+ it, deleting `.theurian/state/`, and rebuilding (FR-K4) — a warning that
52
+ stopped at "add the pin" would loop a reader between two errors, the way issue
53
+ #63's HIGH-1 did. Additive and **always present**, an empty list when
54
+ every revision pins. It is a **warning, not a refusal**: `valid` stays `true`
55
+ and the exit code stays 0. Requiring the pin instead would be a breaking schema
56
+ change with a measured cost — both shipped example migrations under
57
+ `examples/sample-project/` are unpinned, and at this branch's base
58
+ (`8b8abd7`) 21 of the 22 test files naming `upsertRevision` never mentioned the
59
+ field — so it is recorded on #210 as a Milestone 7 decision rather than taken
60
+ here. `theurian propose` already pins every revision it writes (ADR-0013).
61
+ Reported per operation rather than per migration, because the fix is a digest
62
+ taken from one named body file. Documented in
63
+ [`docs/protocol/migrations.md`](../../docs/protocol/migrations.md).
64
+
65
+ ### Changed
66
+
67
+ - **BREAKING — one body file may back only one revision across a migration set**
68
+ ([#210](https://github.com/theurian/theurian/issues/210)).
69
+
70
+ **Old shape:** a set in which two *different* revisions named one `contentFile`
71
+ applied. Measured: two hand-written migrations sharing one path, with a correct
72
+ `expectedRevision` chain and no `contentSha256`, both applied at exit 0 — and
73
+ the earlier revision recorded the *later* body under its own title and author.
74
+ Having adopted that body's hash where no pin was declared, the wrong record was
75
+ self-consistent afterwards, so nothing could detect it later.
76
+
77
+ **New shape:** `migrate validate` and `migrate apply` both refuse the whole set
78
+ with `DuplicateContentFileError` at exit 4, naming both revisions, both authored
79
+ paths, and the resolved body. `apply` refuses before it creates a database file,
80
+ so a refused set costs no state — the property issue #63's refusal already has.
81
+ The refusal is **unconditional of pinning**: even a pair that both pin the same
82
+ `contentSha256` is refused, because one file cannot be independently frozen or
83
+ attributed to two revisions — the hazard is the sharing, not the missing pin.
84
+ The remedy says to give the later revision a body file of its own, and says what
85
+ to do when the offending migration was already applied: editing it trips FR-K5's
86
+ applied-migration checksum guard, so the fix there is the edit plus a
87
+ `.theurian/state/` rebuild (FR-K4).
88
+
89
+ The comparison key is the body's **filesystem identity** (`st_dev`/`st_ino`),
90
+ not the path string, so two revisions that reach one physical file through
91
+ *different* spellings still collide — a `./` segment, and the case-variant and
92
+ NFC/NFD spellings a case-insensitive filesystem (APFS, NTFS) collapses onto one
93
+ inode, and a second hardlinked name. A string key left those distinct and let a
94
+ second revision name the same body through a variant spelling; casefolding the
95
+ string would go wrong the other way and refuse two genuinely different files on
96
+ a case-sensitive filesystem, so identity is the platform-correct key. Re-declaring
97
+ one revision against its own body — how an in-place status change such as
98
+ `reject` is written, where the revision id does not move, only `status` differs,
99
+ and `append_revision` stays the no-op FR-K8 requires (ADR-0024 decision 5) —
100
+ still passes, because the key that separates a re-declaration from a collision is
101
+ the revision id. `migrate status` does not refuse — its contract is observation —
102
+ but names every refused migration under `refusedIds`, matching how it treats the
103
+ tenant/ACL rule.
104
+
105
+ Breaking because a migration set that applied on `0.1.0.dev4` and earlier now
106
+ refuses. No stable release exists — the published versions are `0.1.0.devN` —
107
+ and no compatibility promise covers it, but the break is named here rather than
108
+ filed as a fix. Documented in
109
+ [`docs/protocol/migrations.md`](../../docs/protocol/migrations.md).
110
+
111
+ ### Removed
112
+
113
+ - **BREAKING — `system.capabilities` no longer publishes `milestone`**
114
+ ([#206](https://github.com/theurian/theurian/issues/206)). The field
115
+ reported a build-progress integer that had drifted stale against the
116
+ README's own milestone claim since the Milestone 6 close, and nothing —
117
+ test, schema, or `docs/protocol/mcp-tools.md` — pinned its value or even
118
+ its presence: a mutation setting it to `99` survived the whole suite. It
119
+ had exactly one producer (`mcp/tools.py`) and no consumer anywhere in this
120
+ repository, plugin included, and was never defined in that document or in
121
+ any schema under `schemas/mcp/`.
122
+
123
+ Breaking by the table in
124
+ [`docs/protocol/plugin-core-compatibility.md`](../../docs/protocol/plugin-core-compatibility.md)
125
+ ("Removing a field" is always breaking), and `protocolVersion` is not
126
+ bumped for it anyway — a recorded, narrowly-scoped exemption specific to
127
+ this one field, not a precedent for others. The reasoning is in *Changing
128
+ this contract* in
129
+ [`docs/protocol/mcp-tools.md`](../../docs/protocol/mcp-tools.md):
130
+ `milestone` was protocol-undefined and had no defined purpose anywhere in
131
+ this repository, which `version` and `protocolVersion` are not — both are
132
+ named in that document's field-role paragraph, and each re-publishes a
133
+ process constant a real consumer elsewhere (`theurian compat check`)
134
+ reads directly, even though that consumer reads the constant and never
135
+ this response.
136
+
137
+ `test_the_system_capabilities_response_holds_exactly_the_keys_that_are_pinned`
138
+ now pins the response's exact top-level key set, and
139
+ `test_capabilities_report_what_is_and_is_not_built` newly pins `version`,
140
+ `protocolVersion`, `schemaVersion` and the load-bearing substring of
141
+ `note` — closing the four siblings review found unpinned beside
142
+ `milestone`.
143
+
144
+ ### Fixed
145
+
146
+ - **An external `$ref` destined for a host no longer records as a local file,
147
+ and one past a walk cap no longer vanishes**
148
+ ([#203](https://github.com/theurian/theurian/issues/203)). `_external_refs`
149
+ recorded the scheme `urlsplit` found and defaulted to `relative-file` when it
150
+ found none, which put four measured shapes on the wrong side of the one field
151
+ Milestone 7's scheme allowlist (T-7, #129) will key on:
152
+
153
+ - `//evil.test/x.json` (a protocol-relative URL) and
154
+ `\\smb-host\share\x.json` (a UNC path) both name a host, and both recorded
155
+ `relative-file` — the label such a gate is most likely to accept, so the
156
+ error was in the fail-open direction;
157
+ - `C:\Windows\system32\x.json` recorded the scheme `c`, a drive letter
158
+ `urlsplit` reads as a scheme;
159
+ - a `$ref` nested past `MAX_REF_DEPTH` (64) was dropped in silence, and the
160
+ document reported `unresolvedRefCount` 0 — the same answer a document with
161
+ no external references gives;
162
+ - `http://[::1` made `urlsplit` raise `ValueError("Invalid IPv6 URL")` from
163
+ inside the recording branch. That exception escaped `parse`, so one
164
+ malformed reference discarded the *whole* document — every operation,
165
+ schema and other reference in it — with a message naming no remedy.
166
+
167
+ A reference carrying no scheme is now classified by its structure, following
168
+ RFC 3986 §4.2, into `protocol-relative`, `unc`, `absolute-file` or
169
+ `relative-file`; one that carries a scheme records that scheme, lowercased,
170
+ matched against RFC 3986 §3.1 rather than through `urllib.parse`. The split is
171
+ structural rather than a list of bad spellings, so the mixed `/\host\x` that
172
+ Windows and browsers accept lands on the network side without being
173
+ enumerated, and `x://host/y` keeps its one-letter *scheme* while
174
+ `C:\Windows\x` does not. `NETWORK_PATH_SCHEMES` and `LOCAL_PATH_SCHEMES`
175
+ publish the two groups for the gate that will read them.
176
+
177
+ **Both walk caps stay where they were** — `MAX_REFS` (5000) and
178
+ `MAX_REF_DEPTH` (64) — and each now records where it stopped, one record per
179
+ reason and two reasons, so the marker list holds at most two entries however
180
+ many nodes sit at a cap. That bound is on the marker list and not on the walk:
181
+ the traversal revisits shared sub-objects rather than memoising them, so
182
+ neither cap is a resource-exhaustion control
183
+ ([#245](https://github.com/theurian/theurian/issues/245)).
184
+ Neither marks a node that could not have held a reference: a scalar
185
+ has no children and an empty container has none either, and emptiness is
186
+ answerable without descending, which is what lets the check sit in front of a
187
+ cap that forbids descending. A non-empty container stays marked even when it
188
+ holds only scalars, because knowing better means reading the children the cap
189
+ refused. The parser's metadata gains `refWalkTruncated`, which says whether
190
+ `unresolvedRefCount` is a total or a lower bound, and the index gains
191
+ `refWalkTruncations`. That count is over the document's distinct `$ref`
192
+ strings only — not occurrences, not distinct targets, and not the other
193
+ resolution keywords a specification can carry, which this walk does not visit
194
+ ([#246](https://github.com/theurian/theurian/issues/246)). Both counts stop at the parser boundary — `IngestedDocument`
195
+ has no metadata field, so what survives ingestion is `_index`'s
196
+ `externalRefs` and `refWalkTruncations`, which is where a Milestone 7 gate
197
+ should read. Nothing fetches, and the never-fetched pins in
198
+ `tests/unit/test_network_call_sites.py` are untouched.
199
+
200
+ **Not covered: a scheme that is faithful and still remote.**
201
+ `file://evil.test/share/x.json` records `file`, which is what it is. A gate
202
+ that allows `file` at all has to inspect the authority, exactly as it has to
203
+ inspect the path of an equally local `file:///etc/shadow`; the residual is
204
+ recorded in `docs/security/threat-model.md` (T-7) and pinned as a decision by
205
+ `test_a_file_url_with_a_host_records_its_scheme_and_leaves_the_authority_alone`.
206
+
207
+ - **`--json` commands no longer crash with a raw traceback when a migration's
208
+ content cannot be read**
209
+ ([#205](https://github.com/theurian/theurian/issues/205)). Every CLI command
210
+ that resolves a project loads and validates its migrations first, and a raw
211
+ filesystem-call failure on that path escaped through Typer as a Rich traceback
212
+ — exit 1, empty stdout, no `{error, remedy}` document — even under `--json`.
213
+ Reproduced against `migrate validate --json` and `init`; it reached all eleven
214
+ `resolve_context`/`_require_project` call sites, `project status` included,
215
+ whose contract is to answer rather than crash. Each read failure now raises a
216
+ `TheurianError` subtype that those call sites already guard, so it renders as a
217
+ structured `{error, remedy}` failure at exit 1 instead. The class covered is
218
+ filesystem-call failures that *raise*:
219
+
220
+ - a `contentFile` that cannot be resolved or read — missing, a permission
221
+ problem, a directory, or a path holding a NUL byte, which makes
222
+ `Path.resolve()` raise `ValueError` before any syscall, one call site earlier
223
+ than the read;
224
+ - a migration file the loader cannot read;
225
+ - a `.theurian/migrations/` directory whose probe (`Path.is_dir()`) re-raises
226
+ `EACCES` — for example its execute bit removed;
227
+ - a project registry — data directory or registry file — that cannot be read;
228
+ - the installed package's JSON Schema, when a candidate is found but reading it
229
+ fails.
230
+
231
+ Each remedy is selected by *why* the read failed, not one guess per `OSError`:
232
+ a missing or malformed path points at path resolution, `EACCES`/`EPERM` at
233
+ permissions, `EISDIR` at "this names a directory, not a file." `project status
234
+ --json` now surfaces the remedy in its unresolved-project payload too.
235
+
236
+ **Not covered when this entry was written — two adjacent defects shared this
237
+ symptom but not its root cause.** A `.theurian/migrations/` directory that
238
+ `Path.glob` cannot list swallowed the `PermissionError` and reported
239
+ `valid: true, migrationCount: 0` — a silent false-negative, because nothing
240
+ raised ([#214](https://github.com/theurian/theurian/issues/214)). A malformed
241
+ migration YAML raised `yaml.YAMLError` at parse time, which the loader did not
242
+ catch, so it still escaped as a traceback under `--json`
243
+ ([#217](https://github.com/theurian/theurian/issues/217)). Both are fixed
244
+ below.
245
+
246
+ - **`migrate validate|status|apply --json` (and every other `resolve_context`
247
+ consumer) refuse a malformed migration, an unreadable migrations directory,
248
+ or a corrupted installed schema with the structured `{error, remedy}`
249
+ shape, instead of a raw traceback or a silent false positive**
250
+ ([#214](https://github.com/theurian/theurian/issues/214),
251
+ [#217](https://github.com/theurian/theurian/issues/217)). Closes the two
252
+ defects the `#205` fix above named as not covered, a third face of #214
253
+ that reproducing it found and that neither issue had named, three more
254
+ members of the same class that this branch's own review round two found on
255
+ the same load path, three further members round three found on the
256
+ round-two fixes themselves, and four further members round four found —
257
+ two more ways `migrations_dir` itself can behave like a broken symlink, a
258
+ dependency of a multi-failure refusal's named entry on filesystem
259
+ enumeration order, and a `RecursionError` escape round three's own new
260
+ `check_schema` call introduced — not new issues, escapes the round-one
261
+ through round-three fixes each left open.
262
+
263
+ - **#217 — a YAML syntax error, or a NUL byte in the migration source, no
264
+ longer crashes.** `load_yaml_mapping` raises `yaml.YAMLError` on either —
265
+ a scanner syntax error, and a reader NUL-byte error
266
+ (`yaml.reader.ReaderError`, also a `YAMLError` subclass) — neither of
267
+ which is the `UnicodeDecodeError` or `ValueError` that `_load_one`'s
268
+ `except` clause around it caught. Both now convert to `MigrationError`
269
+ naming the file and the parse problem. `proposal_service.py`'s own two
270
+ `load_yaml_mapping` call sites already catch `yaml.YAMLError`, but not
271
+ identically: `_parse_migration` (the `propose accept` path) already
272
+ translated it into a `ProposalError`, while `_pinned_digest_at` catches it
273
+ only to skip a malformed *existing* migration silently — documented there
274
+ as deliberate, since that migration already fails `migrate validate` on
275
+ its own and diagnosing it is not `propose accept`'s job.
276
+ - **#214 — an unreadable `migrations_dir` no longer reports the never-read
277
+ set as an empty one.** `chmod 000`/`0o111` on `.theurian/migrations/`
278
+ itself, not its parent (`is_dir()` needs no permission on the target,
279
+ only its ancestors), left `Path.glob("*.yaml")`'s own `scandir` catching
280
+ the `PermissionError` internally and yielding nothing: `migrate validate
281
+ --json` reported `valid: true` with `migrationCount: 0`, and `migrate
282
+ apply --json` went on to create a state database for the set it wrongly
283
+ believed was the whole story. Enumeration is now `iterdir()`-based, with
284
+ the directory listing and every entry's `is_file()` stat inside one
285
+ `try`, so any `OSError` there raises `MigrationsDirectoryUnreadableError`.
286
+ - **A third face of #214, found while reproducing it and named in neither
287
+ issue.** `chmod 0o444` leaves the directory listable but not traversable,
288
+ so the same per-entry `is_file()` stat raised an uncaught
289
+ `PermissionError` instead — a raw traceback, not the silent false
290
+ positive above. Same root cause, same fix: caught by the same `try`.
291
+ - **Round two — a YAML document nested past PyYAML's recursion limit no
292
+ longer crashes.** `RecursionError` is not outside `Exception`'s hierarchy
293
+ — it is a `RuntimeError` subclass, and a bare `except Exception` would
294
+ have caught it fine — but no `except` clause on this path named
295
+ `RuntimeError` or `RecursionError` at all: `load_yaml_mapping`'s three
296
+ callers (`migration_loader.py`'s `_load_one`, and `proposal_service.py`'s
297
+ `_parse_migration` and `_pinned_digest_at`) each caught only
298
+ `UnicodeDecodeError`, `ValueError`, and `yaml.YAMLError` —
299
+ `_pinned_digest_at` also catches `OSError`, which the other two do not,
300
+ but none of the three named `RuntimeError` or `RecursionError` — so it
301
+ escaped every one of them and reached `resolve_context` as a raw
302
+ traceback under `--json`. About 1 KB of nested YAML is already enough to
303
+ trigger it (measured: `"["*495 + "]"*495`, 990 bytes — 1,023 was the full
304
+ migration document this was reproduced against, not this bracket string
305
+ alone). `load_yaml` and
306
+ `load_yaml_mapping` now catch `RecursionError` and raise `ValueError` in
307
+ its place — the type every consumer on this path already handles.
308
+ `load_yaml`'s other three callers, which do not go through
309
+ `load_yaml_mapping` — the front-matter, structured-YAML, and OpenAPI
310
+ parsers (`infrastructure/filesystem/parsers/`) — get the identical fix
311
+ from the same one seam, rather than a catch clause added at each of the
312
+ six.
313
+ - **Round two — a symlink chain at `.theurian/migrations` longer than the
314
+ platform's loop limit no longer reports real migrations as an empty
315
+ set.** `Path.is_dir()` swallows every `OSError` it hits internally,
316
+ `ELOOP` included, and reports `False` — the same convenience the #214 fix
317
+ above relies on for the genuinely-absent case — so a directory that was
318
+ actually a loop was misreported as "does not exist": `migrate validate
319
+ --json` reported `valid: true` with `migrationCount: 0`, and `migrate
320
+ apply --json` seeded a state database for the empty set it wrongly
321
+ believed was the whole story. The probe is now an explicit `os.stat`:
322
+ `ENOENT`/`ENOTDIR` still answer the legitimate empty case, but `ELOOP`
323
+ and every other errno now raise `MigrationsDirectoryUnreadableError`,
324
+ whose remedy is keyed on `errno` (the loop, a permission problem, or an
325
+ unnamed residual case) rather than one permission-shaped message for all
326
+ three. **This is a behaviour change, not only a fix**: a symlink loop at
327
+ this path used to validate as an empty migration set by accident, and now
328
+ refuses instead — any earlier description of a symlink loop as a
329
+ legitimate empty shape no longer holds.
330
+ - **Round two, never itself given a line in this entry until now — the
331
+ enumeration's own `ENOENT`/`ENOTDIR` answer relaxed from a refusal (round
332
+ one) back to an empty set.** Round one's enumeration `except OSError`
333
+ (the #214 fix above) converted *every* `OSError` to
334
+ `MigrationsDirectoryUnreadableError`, `ENOENT`/`ENOTDIR` included, so a
335
+ `migrations_dir` that raced its own deletion between the directory probe
336
+ and the `iterdir()` listing refused rather than answering
337
+ `LoadedMigrations.empty()`. The `os.stat`-probe rewrite in the bullet
338
+ above added the identical `if exc.errno in (ENOENT, ENOTDIR): return
339
+ empty()` branch to the enumeration's own `except` too, quietly changing
340
+ this one case back to the pre-#214 answer — a deliberate race policy, not
341
+ an oversight: the probe's own answer went stale between the check and the
342
+ listing, and a directory that has since vanished means nothing to load,
343
+ not a corrupted install. Shipped in round two but not previously named
344
+ here, unlike the `ELOOP` change beside it, which this entry already
345
+ flagged as a behaviour change; pinned by
346
+ `test_load_migrations_treats_a_stale_enumeration_failure_as_an_empty_set`.
347
+ - **Round two — a corrupted installed schema no longer crashes
348
+ `_validator`.** Truncated or empty JSON raised `json.JSONDecodeError`;
349
+ non-UTF-8 bytes raised `UnicodeDecodeError` at the same
350
+ `read_text(encoding="utf-8")` call; and a schema that parses but is a
351
+ JSON list rather than an object raised `AttributeError` one line later,
352
+ at `Draft202012Validator` construction (`jsonschema` calls
353
+ `schema.get(...)` internally, and a list has no `.get`). All three, and
354
+ the original `OSError`, now convert to `SchemaUnreadableError`. Round
355
+ three (below) removes the `AttributeError` translation and replaces it
356
+ with a check performed before construction, rather than after.
357
+ - **Round three — a `*.yaml` entry that is a symlink loop, or a symlink
358
+ whose target is missing, no longer vanishes silently from the loaded
359
+ set.** `Path.is_file()` — used by both `glob` and round one/two's
360
+ `iterdir()`-based enumeration — swallows `ELOOP` and `ENOENT` internally
361
+ and reports `False` for both, the identical directory-level convenience
362
+ the #214 and round-two fixes above exist to close, one level down:
363
+ `migrate validate --json` reported a lower `migrationCount` with no error
364
+ at all, and `migrate apply --json` seeded a state database for the
365
+ migrations it did see. Per-entry classification now `lstat`s each entry
366
+ first to tell a symlink from an ordinary file, and a symlink whose
367
+ resolution fails is refused by name as `MigrationFileUnreadableError` —
368
+ `ENOENT` gets a dangling-link-specific remedy, every other errno (`ELOOP`
369
+ chief among them) the loop-specific one from the bullet above. A
370
+ non-symlink entry that raises `ENOENT`/`ENOTDIR` is still skipped rather
371
+ than refused: the identical race the enumeration-level bullet above
372
+ already answers with "nothing to load," one entry down instead of one
373
+ directory up. Handling the two identically was considered and rejected:
374
+ an entry-level `try` that answered every stat error with "skip the
375
+ entry," symlink or not, would turn a directory-wide permission refusal
376
+ (`chmod 0o444` on `migrations_dir`, #214's own third face above) into a
377
+ silently shrunken migration set the moment it reached one symlinked entry
378
+ first — the same worse-regression trap the loop/dangling fix itself
379
+ exists to avoid, one shape over. Any other non-symlink errno (`EACCES`
380
+ included) still re-raises to the enumeration's own `except OSError` and
381
+ surfaces as `MigrationsDirectoryUnreadableError`, unchanged. Reproduced
382
+ against the real CLI:
383
+ `test_validate_reports_a_symlink_loop_migration_entry_instead_of_silently_dropping_it`
384
+ and
385
+ `test_apply_refuses_a_symlink_loop_migration_entry_without_seeding_a_state_database`.
386
+ - **Round three — the last path where a deeply nested document could still
387
+ crash a parser now converts to `ValueError`, and the sibling leg that
388
+ already converted gains the attribution every other failure branch
389
+ carries.** `OpenApiParser`'s JSON leg
390
+ (`infrastructure/filesystem/parsers/openapi.py`) called `json.loads(text)`
391
+ with no guard around `RecursionError`, so a document nested past the
392
+ decoder's own recursion limit escaped `_load` uncaught (measured: 20,000
393
+ nested arrays) — `structured.py`'s `JsonParser` already carried the
394
+ identical guard, so this closes the class's one remaining crash.
395
+ `YamlParser.parse` (`structured.py`) never crashed the same way: round
396
+ two's `load_yaml` fix (above) had already converted its nesting-depth
397
+ failure to `ValueError`. What it lacked was `anchor.source_uri` on that
398
+ one branch, unlike every other failure branch in the same method — this
399
+ round adds it, so the message names the same document every sibling
400
+ failure already names. Reproduced against the real parsers directly:
401
+ `test_openapi_reports_the_source_uri_for_json_nested_past_the_recursion_limit`
402
+ and
403
+ `test_yaml_parser_names_the_source_uri_for_a_document_nested_past_the_recursion_limit`.
404
+ - **Round three — the installed schema is now refused, not silently
405
+ accepted, when it parses but is not usable as a schema.** Two checks in
406
+ `_validator`, both performed before `Draft202012Validator` construction
407
+ rather than after: `isinstance(schema, dict)` refuses a schema that is
408
+ not a JSON object at all — a list (already an `AttributeError` refusal
409
+ since round two, above) or a bare `true`/`false`, both otherwise-valid
410
+ top-level JSON Schema documents. Accepting `true` had been silently
411
+ fail-open: it builds a validator that matches every instance, so an
412
+ installation corrupted to `true` made every migration in every project
413
+ validate rather than refusing. `Draft202012Validator.check_schema(schema)`
414
+ separately refuses a schema whose own keywords are structurally
415
+ malformed — `required` must be an array of strings, and a bare string
416
+ previously surfaced only when a schema-*valid* migration tripped over it,
417
+ misattributed to whichever migration validated first as `'n' is a
418
+ required property`. Both convert to `SchemaUnreadableError`, and the
419
+ now-unreachable `except AttributeError` clause is removed rather than
420
+ kept as a defensive clause nothing can drive. `{}` remains accepted: a
421
+ valid, if vacuous, schema that matches every instance — deliberately not
422
+ a third refusal. `test_validator_raises_schema_unreadable_error_for_a_non_object_schema`,
423
+ `test_validator_raises_schema_unreadable_error_for_structurally_invalid_schema_keywords`,
424
+ and `test_validator_accepts_the_vacuous_empty_object_schema` pin all
425
+ three.
426
+ - **Round four — `migrations_dir` itself being a dangling, looping, or
427
+ outside-project symlink no longer reports "nothing to load" the way a
428
+ genuinely absent directory does.** The entry-level symlink policy round
429
+ three added (two bullets above) never covered the directory `_load_one`
430
+ reads *from*: the top-of-function probe (`migrations_dir.stat()`, which
431
+ follows symlinks) cannot tell a dangling symlink from a directory that
432
+ never existed — both raise the identical `ENOENT` — and an
433
+ outside-project target was never checked directly at all, only reached
434
+ incidentally through `_load_one`'s own containment check once a `*.yaml`
435
+ entry exists to trigger it, which an empty outside directory never
436
+ reaches. Orchestrator-measured before this fix: a dangling
437
+ `migrations_dir` symlink made `migrate apply --json` report
438
+ `databaseCreated: true` and create `.theurian/state/active.json` and a
439
+ `.sqlite` database for the empty set it wrongly believed was the whole
440
+ story; a symlink resolving outside `project_root` to an empty directory
441
+ made `migrate validate --json` report `valid: true, migrationCount: 0`
442
+ at exit 0. A new check, `_refuse_unusable_migrations_directory_symlink`,
443
+ now runs before the top-of-function probe: a dangling or looping target
444
+ raises `MigrationsDirectoryUnreadableError` (the identical dangling-link
445
+ and loop remedies the entry-level and round-two directory-level cases
446
+ already use), and a target outside `project_root` raises
447
+ `PathEscapeError` directly rather than depending on an entry existing to
448
+ trigger it. **This is a behaviour change, not only a fix**: a dangling or
449
+ outside-pointing `migrations_dir` symlink used to validate as an empty
450
+ migration set, and now refuses instead. A `migrations_dir` symlinked to a
451
+ real, in-project directory still loads normally — this policy narrows
452
+ only the two broken shapes, not every symlinked directory.
453
+ `test_load_migrations_refuses_a_dangling_migrations_directory_symlink`,
454
+ `test_load_migrations_refuses_a_migrations_directory_symlink_to_an_empty_outside_directory`,
455
+ and
456
+ `test_load_migrations_follows_a_migrations_directory_symlink_to_a_valid_in_project_directory`
457
+ pin all three.
458
+ - **Round four — a multi-failure refusal now names the
459
+ lexicographically-first failing entry on every filesystem, not whichever
460
+ one the directory listing happened to yield first.** The enumeration's
461
+ long-standing `sorted(...)` call sorts the *paths* it is given, but round
462
+ three's per-entry classification (`_entry_is_migration_file`, which can
463
+ itself raise `MigrationFileUnreadableError` for a dangling or looping
464
+ entry) ran *inside* the same generator expression `sorted()` consumed, so
465
+ it was still evaluated in whatever order `iterdir()` yielded, before
466
+ sorting ever ran. Two failing entries therefore named
467
+ whichever one the filesystem happened to enumerate first — APFS is
468
+ measured here to walk in creation order; ext4's documented `dir_index`
469
+ hashing walks in hash order (not measured on this machine, which is not
470
+ Linux), so the identical fixture could name a different offender on
471
+ each. Enumeration now
472
+ collects and sorts the `*.yaml` names first, then runs classification
473
+ over the already-sorted list.
474
+ `test_load_migrations_names_the_lexicographically_first_entry_when_classification_fails`
475
+ drives it directly, injecting a reversed-order fixture so the bug
476
+ reproduces regardless of the developer's own filesystem's enumeration
477
+ order.
478
+ - **Round four — a deeply nested installed schema no longer crashes
479
+ `_validator` through the `check_schema` call round three's own fix
480
+ added.** `Draft202012Validator.check_schema`, added last round to catch a
481
+ structurally malformed schema before any migration is checked against
482
+ it, recurses into a schema's own nested keywords the same way
483
+ `json.loads` recurses into its document structure — a schema deep enough
484
+ exhausts the interpreter stack the identical way an attacker-controlled
485
+ migration document already does, measured directly at 400 levels of
486
+ nested `not` keywords. Neither the read's own three `except` clauses nor
487
+ round three's new `except SchemaError` around `check_schema` named
488
+ `RecursionError` — a `RuntimeError` subclass, not a
489
+ `jsonschema.exceptions.SchemaError` — so it escaped `_validator` raw, the
490
+ identical class every other member of this entry was closed for. A
491
+ regression the branch caught and fixed within its own review loop rather
492
+ than shipping: `check_schema` did not exist in this function until round
493
+ three added it (the bullet above), so this gap did not exist before that
494
+ round either. `_validator` now catches `RecursionError` around both
495
+ `json.loads` and `check_schema`, converting each to
496
+ `SchemaUnreadableError`.
497
+ `test_validator_raises_schema_unreadable_error_for_a_schema_nested_past_the_recursion_limit`
498
+ pins it. **Not covered by this fix**: a validate-time `$ref` resolution
499
+ failure — including whatever network fetch `jsonschema`'s own reference
500
+ resolution performs for a remote `$ref` — is a different failure surface
501
+ than the schema document's own JSON and keyword nesting, and stays
502
+ untranslated ([#235](https://github.com/theurian/theurian/issues/235)).
503
+
504
+ **The legitimate empty shapes, current as of round four.**
505
+ `load_migrations` still answers `LoadedMigrations.empty()` — never a
506
+ refusal — for: a `migrations_dir` that does not exist at all; one that
507
+ exists but is a regular file rather than a directory
508
+ (`test_load_migrations_treats_a_migrations_path_that_is_a_regular_file_as_an_empty_set`,
509
+ round three); an existing, ordinarily-readable, genuinely empty directory
510
+ (`test_load_migrations_on_an_ordinarily_readable_empty_directory_returns_an_empty_set`);
511
+ a `*.yaml`-named entry that is a FIFO, a directory, or a symlink resolving
512
+ to a FIFO, a directory, or a character device, silently excluded from the
513
+ loaded set rather than counted — the FIFO and directory cases are pinned by
514
+ `test_load_migrations_skips_a_fifo_and_a_directory_both_named_dot_yaml`
515
+ (round three); the symlink-to-either-of-those (and to a character device)
516
+ case follows the identical `entry.stat()`-then-`S_ISREG` path — a symlink
517
+ to a non-regular target stats successfully and simply fails the type
518
+ check — and is reasoned from that code path, not pinned by a dedicated
519
+ test: the shape the round-three adversarial review flagged as missing from
520
+ this list, recorded here for the first time; and a non-symlink entry that
521
+ is simply gone by the time it is stat-ed, having raced its own deletion
522
+ between `iterdir()` listing it and the classification stat that follows
523
+ (`test_load_migrations_skips_only_a_non_symlink_entry_that_vanishes_mid_enumeration`,
524
+ round three) — the identical race policy the enumeration-level bullet above
525
+ already applies one directory up. A directory-level symlink loop at
526
+ `migrations_dir` itself is deliberately not a member of this list — refused
527
+ since round two, and refused here too, though round four's new symlink
528
+ check now catches it before the top-of-function directory probe ever runs
529
+ (identical `MigrationsDirectoryUnreadableError` loop remedy either way). A
530
+ dangling or outside-project `migrations_dir` symlink is not a member of
531
+ this list either, as of round four — see below.
532
+
533
+ **Not a legitimate empty shape any more, as of round three: a `*.yaml`
534
+ entry that is a symlink loop, or a symlink whose target is missing.** Both
535
+ used to vanish the identical silent way described above — `glob` and every
536
+ round before this one relied on `Path.is_file()`, which swallows `ELOOP`
537
+ and `ENOENT` internally and simply excludes the entry, no error, no count.
538
+ Per-entry classification now raises `MigrationFileUnreadableError` naming
539
+ the entry instead (round-three bullet above); any earlier description of
540
+ either as a legitimate empty or silently-skipped shape no longer holds.
541
+
542
+ **Not a legitimate empty shape any more, as of round four: a dangling or
543
+ outside-project `migrations_dir` symlink.** Both used to validate as an
544
+ empty migration set — a dangling target folded into the identical `ENOENT`
545
+ a missing directory raises, and an outside-project target holding no
546
+ `*.yaml` files never reached the containment check that would otherwise
547
+ catch it. The round-four directory-level bullet above closes both; any
548
+ earlier description of either as a legitimate empty shape no longer holds.
549
+
550
+ **Scope of the directory-level symlink check: the final path component
551
+ only.** Round four's check is an `lstat` on `migrations_dir` itself, so a
552
+ symlinked *ancestor* — most visibly `.theurian` being a symlink — is not
553
+ covered: a dangling `.theurian`, or one pointing at an empty outside
554
+ directory, still validates as an empty set, and a `.theurian` symlink
555
+ committed to a repository makes `migrate apply` write state through it,
556
+ outside the working tree, on `git clone` alone. That is a distinct class
557
+ from this one — its root cause is the writer/context stack trusting a
558
+ resolved `.theurian` for every consumer, not the migration load path's
559
+ error surfacing — and it is tracked separately at
560
+ [#237](https://github.com/theurian/theurian/issues/237), not closed here.
561
+
562
+ Every fault named above — across all four rounds, not counting the
563
+ enumeration-race policy note, which documents a round-two decision rather
564
+ than closing a new one — was reproduced against the real CLI, or the real
565
+ parsers and `_validator` directly for the parser and schema checks; covered
566
+ by `tests/unit/test_migration_loader_errors.py`,
567
+ `tests/unit/test_yaml_loading.py`, `tests/unit/test_parsers.py`, and
568
+ `tests/integration/test_cli_commands.py`.
569
+
570
+ ### Documentation
571
+
572
+ - **Documents describing review ingestion as shipped, corrected together with
573
+ the tests that hold the corrected claims**
574
+ ([#129](https://github.com/theurian/theurian/issues/129)). The class is a
575
+ security control named in the present tense whose component does not exist:
576
+ T-7's SSRF entry, the `.theurian/config.yaml` repository allowlist (SEC-10),
577
+ the `providers.review.repositories` schema key, the sample project's config,
578
+ and the `review`/`infrastructure.github` package docstrings all read as if the
579
+ allowlist were in force. No reader of that file exists in `src/`, and
580
+ `infrastructure/github/` holds no adapter, so nothing consults it; each
581
+ now says what is owed and names Milestone 7.
582
+
583
+ **What the allowlist's absence rests on is now pinned.** T-7's stand-in
584
+ control is not a filter but the absence of any way to make the request, and
585
+ that absence was enforced by nothing: a mutation that left `_external_refs`
586
+ recording exactly as before and added a real `urllib.request.urlopen` beside
587
+ it survived the whole suite, because
588
+ `test_external_refs_are_recorded_never_fetched` reads the recorded output and
589
+ the recording did not change. `tests/unit/test_network_call_sites.py` adds the
590
+ missing half in three arms —
591
+ `test_no_module_outside_the_daemon_health_probe_reaches_a_network_client`
592
+ scans the shipped package and pins the permitted network-client sites to
593
+ `daemon/instance.py` alone, resolving attribute chains and constant-string
594
+ dynamic imports; `test_no_module_outside_the_git_and_service_adapters_can_spawn_a_process`
595
+ does the same over `subprocess`, the `os` spawn/exec family
596
+ (`system`, `popen`, `spawn*`, `posix_spawn*`, `exec*`) and
597
+ `asyncio.create_subprocess_*`, because `curl` and `gh api` reach the network
598
+ with no client module in the diff; and
599
+ `test_parsing_a_hostile_document_opens_no_socket` watches the socket layer
600
+ while *every* parser `default_parsers()` returns handles a hostile document,
601
+ with `test_every_parser_the_registry_ships_has_a_hostile_document` failing
602
+ when the registry gains a format the table does not know. The threat model now
603
+ cites the recording pin and the never-fetched pins separately, rather than
604
+ crediting one test with both, and states the residual all three share: a
605
+ fetch both spelled at runtime and issued from a child process.
606
+
607
+ - **`system.capabilities`' `reviewIngestion`, `traceability` and
608
+ `knowledgeSearch` flags pinned** in
609
+ `test_capabilities_report_what_is_and_is_not_built`, with
610
+ `test_the_capability_block_holds_exactly_the_flags_that_are_pinned` holding
611
+ the key set so a flag added later cannot ship unasserted. All three were
612
+ unpinned: mutations flipping the first two to `true`, and rewriting
613
+ `knowledgeSearch` to `"substring"` — indistinguishable to a client from what
614
+ an un-indexed project reports — survived the suite. That is the same drift
615
+ that once let the test claim `hybridRetrieval is False` after hybrid retrieval
616
+ shipped. T-7 cites `reviewIngestion: false` as part of what stands in for the
617
+ missing allowlist, so the flag is a security-relevant declaration and not a
618
+ feature toggle.
619
+
620
+ - **`KnowledgeCandidate.trust_level` cannot be set at construction**, pinned by
621
+ `test_a_candidate_cannot_be_constructed_with_a_trust_level`. The invariant is
622
+ one `field(init=False)` keyword and no test named it, so removing it kept the
623
+ default green while `KnowledgeCandidate(trust_level=REVIEWED)` became
624
+ constructible — a candidate granting itself the trust a human reviewer exists
625
+ to grant (ADR-0013, INV-7). `docs/architecture/review-knowledge.md` now names
626
+ the mechanism behind each promotion invariant rather than attributing all of
627
+ them to construction.
628
+
629
+ - **`$ref` recording fidelity stated rather than overclaimed.** T-7 said
630
+ `_external_refs` "records the target's scheme"; it recorded the scheme only
631
+ where the target's form carried one, so a protocol-relative (`//host/x.yaml`)
632
+ or UNC target recorded as `relative-file`, and a ref past either walk cap —
633
+ `MAX_REFS` (5000) or `MAX_REF_DEPTH` (64), both now named — was dropped from
634
+ the count entirely. Stating it is what this entry did;
635
+ [#203](https://github.com/theurian/theurian/issues/203) then fixed the
636
+ recording itself, in the same release — see *Fixed* above, which is what T-7
637
+ now describes.
638
+
639
+ - **How many `git` reads `theurian ingest` performs, measured rather than
640
+ counted from the module.** `cli/context.py` defines four readers; the ingest
641
+ path runs three — `rev-parse --show-toplevel`, `rev-parse HEAD` and
642
+ `remote get-url origin` — because `default_branch`
643
+ (`symbolic-ref --short HEAD`) is reached only from `project register` and
644
+ `migrate apply`. Measured by running the command against a `git` shim that
645
+ logs every invocation. Recorded here because the count is a fact about
646
+ `cli/context.py`; the document it corrects is the plugin's `/theurian:ingest`,
647
+ whose own change is in
648
+ [the plugin changelog](../../plugins/claude-code/CHANGELOG.md).
649
+
650
+ - **T-7's owed controls listed wherever the entry is summarised.** The threat
651
+ table in `docs/architecture/requirements-analysis.md` named only the
652
+ repository allowlist as owed, while SEC-10 also requires the scheme allowlist
653
+ and the rejection of private-network destinations; it now lists all three, as
654
+ the threat model itself already did. In the same sweep,
655
+ `schemas/config/project-config.schema.json` stops attributing every absent
656
+ loader to Milestone 7 — #129 owes the review-ingestion allowlist reader
657
+ specifically, and the rest of the file simply has no loader yet — and
658
+ restores "Not in force." to the head of the `repositories` description, since
659
+ an editor showing a field's hover text does not show the root note.
660
+ `ReviewProvider`'s docstring now says the GitHub *adapter* is unbuilt; the
661
+ port itself exists.
662
+
663
+ - **The documents described a secret scanner that does not exist**
664
+ ([#198](https://github.com/theurian/theurian/issues/198)). SEC-11 — scan a
665
+ candidate revision for secrets and block (default), warn, or do nothing per
666
+ policy — is not implemented. No content scanner exists anywhere in `src/`, and
667
+ nothing reads `.theurian/config.yaml` at all (#129), so the
668
+ `security.secretScan` key selected no behaviour while the published schema
669
+ declared `"default": "block"`. Six surfaces asserted the control was in force
670
+ and each now names the absence: T-15's Controls block, the threat summary row,
671
+ the same T-15 row in `docs/architecture/requirements-analysis.md`,
672
+ `SECURITY.md`'s "Ingestion warns or blocks per policy", the schema key, and the
673
+ sample project's config. Same class as the T-7 correction above — a security
674
+ control written in the present tense whose component does not exist.
675
+
676
+ **What stands at SEC-11's trigger point is now stated rather than implied, and
677
+ neither control is automated:** human review of the authored migration
678
+ (ADR-0013 — no registered MCP tool can reach a canonical write, and `theurian
679
+ propose accept` moves files without approving), and supersede or retire with
680
+ Milestone 6's withdrawal→purge trigger for removing a secret after the fact.
681
+ Both entries record what neither control does: nothing enforces the merge —
682
+ `migrate apply` applies whatever is in `.theurian/migrations/`, committed or
683
+ not.
684
+
685
+ **The residual names the boundary the exposure actually starts at.** It is the
686
+ canonical write, not the index: a secret becomes readable through
687
+ `knowledge.search` and `knowledge.get` the moment `theurian migrate apply`
688
+ writes it, before any `index build`, because search degrades to a canonical
689
+ substring scan when no index can answer (`mcp/search.py`). The repository-side
690
+ `Secret scan` job (OSS-9, gitleaks) is a different control and is unchanged —
691
+ it scans this repository's Git history in CI and was never in a user project's
692
+ ingestion path.
693
+
694
+ **Each correction is now pinned, because a corrected claim with no test is a
695
+ claim that can quietly become false again.** `test_the_secret_scan_policy_publishes_no_default`
696
+ holds the dropped schema default, with the enum asserted beside it so the
697
+ no-default assertion cannot pass vacuously against a renamed or deleted key.
698
+ `tests/unit/test_config_key_call_sites.py` is new and holds the claim the other
699
+ five surfaces rest on — that no shipped code reads the key:
700
+ `test_no_shipped_module_reads_a_config_key_the_schema_publishes_as_not_in_force`
701
+ parses every `.py` under the imported package and matches whole identifiers and
702
+ whole string constants, which is what separates a reader from the six places
703
+ `repositories` appears in `src/` as English prose;
704
+ `test_each_reserved_key_still_publishes_the_absence_the_scan_enforces` closes the
705
+ reverse direction, where the description moves and no reader is added; and
706
+ `test_the_config_key_scan_sees_each_naming_form_and_no_other` guards the scanner
707
+ itself, since a scan that resolves nothing and a package with no reader produce
708
+ the same green. `test_a_key_the_example_sets_but_nothing_reads_stays_marked_not_in_force`
709
+ holds the sample project's annotation, which no schema validates. The same
710
+ three pins cover `providers.review.repositories` (#129), whose schema
711
+ description makes the identical not-in-force claim.
712
+
713
+ - **`theurian ingest`'s docstring said it "stores evidence"**, which overstates
714
+ what the command persists: `IngestionService` has no write path, parsed bodies
715
+ live in memory for the run, and the only file written is the content-hash
716
+ manifest `.theurian/cache/ingestion.json`. The docstring and T-15's
717
+ reference to it now say so. `schemas/config/project-config.schema.json`'s
718
+ `security.maxSourceFileBytes` gains the same treatment its annotated siblings
719
+ have: its default documents the shipped `MAX_SOURCE_FILE_BYTES` in
720
+ `security/paths.py` rather than setting it, and nothing reads the key (#129).
721
+
722
+ ## [0.1.0.dev4] - 2026-08-16
723
+
724
+ ### Added
725
+
726
+ - **The `integrity` signal takes a second measurement: how many items a caller
727
+ should be able to see, against the number `theurian migrate apply` recorded**
728
+ ([#30](https://github.com/theurian/theurian/issues/30) PR2). This is what makes
729
+ a response's *own* emptiness visible, where PR1 — shipped in `0.1.0.dev3` —
730
+ could only report damage elsewhere in the state. Three of the four positions
731
+ PR1 left now disclose, and `SILENTLY_EMPTIED` is deleted — #30's stated closure
732
+ condition.
733
+
734
+ The record is a new `project_integrity` table, one row per project, written by
735
+ `migrate apply` inside its own write transaction and counted over the rows that
736
+ transaction just wrote. Nothing on a query path computes it: the reader's half
737
+ is one `COUNT` over `idx_items_status`, catching a change in the *number* of
738
+ surfaceable rows — a row leaving or entering the surfaceable scope, in either
739
+ direction — so an expectation cannot be satisfied by the state it is meant to
740
+ check
741
+ (`CanonicalStore.count_surfaceable_items`,
742
+ `CanonicalStore.expected_surfaceable_count`, `SqliteWriter.record_expected_surfaceable_count`).
743
+
744
+ **A missing record is damage, not "not recorded", and a schema bump is what
745
+ makes that sound.** `SCHEMA_VERSION` went 2 → 3 for this table (below), and
746
+ `is_supported` is exact-match, so every database this build can open was written
747
+ by a build that records. `test_a_missing_integrity_record_is_damage_and_not_silence`
748
+ holds the inference and `test_a_pre_integrity_database_is_refused_unread_by_every_tool`
749
+ holds the premise on all three tools and over every version below the current
750
+ one — including that an old database is not reported as a damaged one.
751
+
752
+ **What it now catches, pinned by the corruption sweep against the real tools
753
+ over a real damaged database.** A sentinel in `knowledge_items.project_id`
754
+ takes every item out of the project scope, and one in `knowledge_items.status`
755
+ takes a row out of the
756
+ surfaceable scope: `knowledge.search` answers `count: 0, results: []` and
757
+ `knowledge.status` answers a shrunken `itemCount` **with the key beside them**,
758
+ where PR1 answered the same numbers alone; `knowledge.get` refuses those cells
759
+ as damage rather than reporting them as absence
760
+ (`test_a_lost_surfaceable_item_is_damage_on_every_read_tool`,
761
+ `test_a_lost_surfaceable_item_makes_get_refuse_an_absent_id_as_damage`). So an
762
+ agent can now tell "this project holds nothing" from "part of this project could
763
+ not be read", and `knowledge.status` no longer publishes a positive
764
+ `appliedMigrations` beside `itemCount: 0` without comment. The `status` cell is
765
+ the member `0.1.0.dev2` recorded as "the fifth `SILENTLY_EMPTIED` member,
766
+ carried to Milestone 6" when #19 stopped parsing every row: it still
767
+ under-reports, because no read tool can repair a row it cannot parse, but it no
768
+ longer does so in silence.
769
+
770
+ **`knowledge.get`'s damage refusal is reworded, and deliberately says less.**
771
+ `0.1.0.dev3` reported a project that "could not be fully read: its derived state
772
+ holds a different number of migration-history rows than its own records expect".
773
+ Either comparison now reaches that branch, so the text says only that the
774
+ derived state "disagrees with its own records about what it holds" and names no
775
+ record — a caller matching on the old substring stops matching. The SEC-13 rule
776
+ that a withheld id and an absent id get the same message as each other is
777
+ unchanged, and so is the pair that pins both directions
778
+ (`test_an_absent_item_over_a_damaged_state_is_refused_as_damage_not_absence`,
779
+ `test_an_absent_item_over_a_healthy_state_is_refused_as_absence`).
780
+
781
+ **One position stays silent and is named rather than rounded away.**
782
+ `(knowledge.search, knowledge_items, item_id)` moves neither count — the row
783
+ keeps its `project_id` and its `status`, so both sides still count it — while
784
+ the item → revision pointer the search walks is broken, and the tool answers one
785
+ result short with no key. A count is not a checksum, and that is the shape a
786
+ count cannot see. It is `UNDETECTED_UNDERREPORT` in
787
+ `tests/integration/test_canonical_store_corruption.py`, an exact set of exactly
788
+ one member: a second position appearing there is a failure, not an expectation
789
+ to update.
790
+
791
+ **`SILENTLY_EMPTIED` is replaced by three sets that partition the same
792
+ question**, so no position can slide between outcomes unremarked.
793
+ `DISCLOSED_AS_INTEGRITY` holds the nine positions that fire the key, keyed on
794
+ the key's presence and nothing else — six of them fire while every integer in
795
+ the response stays put, which is the detector's true shape;
796
+ `DISCLOSED_BESIDE_A_SHRUNKEN_COUNT` holds the three that also shrink a published
797
+ integer; `UNDETECTED_UNDERREPORT` holds the one that shrinks and says nothing.
798
+ The sweep asserts the whole shrinking class equals the union of the last two
799
+ (`test_exactly_these_positions_disclose_damage_as_integrity`,
800
+ `test_exactly_one_position_answers_with_less_than_the_file_holds_and_says_nothing`
801
+ — renamed and re-scoped from PR1's
802
+ `test_exactly_these_positions_disclose_migration_history_damage_as_integrity`
803
+ and `test_no_tool_answers_with_less_than_the_intact_database_holds`), which is
804
+ what closes the seam a pair of independently-keyed sets would leave. That
805
+ partition is over the swept single-cell positions, not a claim that the count
806
+ catches every wrong answer: a status moved *within* `SURFACEABLE_STATUSES`
807
+ changes the set's composition without its size and sets no key, and an item
808
+ whose `current_revision_id` names another item's revision would disclose rather
809
+ than under-report and is refused at read time by the read-back guard
810
+ (`61747b3`), a mechanism distinct from this detector.
811
+
812
+ **Neither side of the new comparison counts a row the caller may not read.**
813
+ Both count `SURFACEABLE_STATUSES` — at build time in the `INSERT … SELECT`, at
814
+ read time in the `COUNT` — so a `rejected`, `deprecated` or `superseded` row is
815
+ on neither side.
816
+ `test_the_integrity_signal_is_identical_across_a_withheld_only_difference` holds
817
+ that whether the key appears is identical across two corpora differing only in
818
+ twenty-five `rejected` items. Measured beyond what that pins: overwriting a
819
+ `deprecated` row's `status` produces no key on any tool, where the same
820
+ overwrite on an `approved` row fires it, and on a `draft` row it fires the key
821
+ while the default `knowledge.search` answer stays unchanged — a draft is
822
+ surfaceable even when the default answer omits it. The mirror of that is a
823
+ recorded residual: overwriting an `approved` row's `status` to another
824
+ surfaceable value moves neither count and sets no key, while the default answer
825
+ loses a row it used to surface — the count measures the size of the surfaceable
826
+ set, not its composition, and the moved row is caller-readable either way. The
827
+ read cost keeps PR1's
828
+ shape, `O(surfaceable)` over the covering index rather than `O(total)`, and
829
+ `knowledge.status` spends no extra query at all: it sums the breakdown it had
830
+ already read.
831
+
832
+ **The remedy's first step deliberately does not clear this shape, and the
833
+ residual is recorded.** `migrate apply` records only when it created the
834
+ database or applied a migration; an apply with nothing pending must not
835
+ re-record, because it is step one of the remedy this signal publishes and
836
+ re-recording from the damaged state would manufacture its own all-clear
837
+ (`test_a_pending_free_apply_does_not_re_record_over_a_damaged_state`,
838
+ `test_an_apply_that_changes_the_store_records_the_new_count`). What stays open,
839
+ recorded and not fixed: an apply that *does* have a migration to apply re-records
840
+ over the state as it then is, so damage already present becomes the new
841
+ expectation. It is the pointer's limit again — a count is not a checksum, and a
842
+ writer can record only what it can read.
843
+
844
+ The detector also found a defect in the test suite it does not own.
845
+ `tests/integration/test_absence_proof.py` built its pair by hand with a pointer
846
+ claiming one applied migration over a store holding none, so every response in
847
+ that file had carried `integrity` since PR1 and each equality was comparing two
848
+ damage reports. Both records are now written the way `migrate apply` writes
849
+ them, and `_assert_the_pair_bites` fails an example that answers as a damaged
850
+ project — the fifth way that file can be green while proving nothing.
851
+
852
+ ### Changed
853
+
854
+ - **BREAKING — `SCHEMA_VERSION` 2 → 3: every existing state database is refused
855
+ once, and one `theurian migrate apply` rebuilds it**
856
+ ([#30](https://github.com/theurian/theurian/issues/30) PR2,
857
+ [#24](https://github.com/theurian/theurian/issues/24)). The DDL gains the
858
+ `project_integrity` table and makes the item → revision pointer a composite
859
+ foreign key; `knowledge.status` therefore publishes `schemaVersion: 3` where it
860
+ published `2`. A state database is derived and Git-ignored (ADR-0004) and is
861
+ rebuilt rather than migrated (ADR-0017), so nothing authored is lost — but the
862
+ rebuild is not automatic, and until it runs the three read tools refuse.
863
+ BREAKING for a state database on disk, not for the wire: a published value
864
+ moves and no field, type or tool name does, so `protocolVersion` stays
865
+ `theurian/v1` (see *Changing this contract* in
866
+ [`docs/protocol/mcp-tools.md`](../../docs/protocol/mcp-tools.md)).
867
+
868
+ Measured end to end rather than argued, against a database `0.1.0.dev3` wrote
869
+ with the real CLI and then read by this build: `knowledge.search`,
870
+ `knowledge.get` and `knowledge.status` each refuse with
871
+
872
+ ```
873
+ theurian-state-f1711b98d302.sqlite was written at schema version 2, but this
874
+ build uses 3. State databases are derived; rebuild with `theurian migrate
875
+ apply` rather than migrating this file.
876
+ ```
877
+
878
+ and one `theurian migrate apply` answers `databaseCreated: true` with a new
879
+ state hash — the schema version is an input to that hash, so the rebuilt file is
880
+ `theurian-state-2e8880bf25be.sqlite` beside the old one — after which all three
881
+ tools answer, `schemaVersion` reads `3`, and no `integrity` key appears. The old
882
+ file is left on disk for `theurian index gc` (ADR-0017 decision 5), not deleted
883
+ under a pinned `snapshotId`.
884
+
885
+ A database written at *any* earlier version is refused *unread* rather than
886
+ reinterpreted, which is what lets the new detector read a missing
887
+ `project_integrity` row as damage rather than as "this file predates the table"
888
+ (`test_a_pre_integrity_database_is_refused_unread_by_every_tool`, parametrised
889
+ over every version below the current one).
890
+
891
+ - **The item → revision pointer is scoped to its project by the schema, not only
892
+ by every read of it** ([#24](https://github.com/theurian/theurian/issues/24),
893
+ closed). `knowledge_items.current_revision_id` referenced
894
+ `knowledge_revisions(revision_id)` alone while `get_revision` and
895
+ `list_revisions` both filter on `project_id` as well, so the two never met: a
896
+ revision whose `project_id` moved — what a project id changing over an unchanged
897
+ root does — left the item pointing at a row its own project-scoped read could no
898
+ longer see, and `PRAGMA foreign_key_check` called the database satisfied.
899
+
900
+ The key is now composite, `(project_id, current_revision_id)` referencing
901
+ `knowledge_revisions(project_id, revision_id)` over a new unique index. Measured
902
+ on SQLite 3.51.2, before and after, against the stranding `UPDATE`: before, the
903
+ writer's own connection accepted it and `foreign_key_check` returned `[]`;
904
+ after, the same statement is refused, and forced through with foreign keys off
905
+ it is reported as `('knowledge_items', <rowid>, 'knowledge_revisions', 0)`.
906
+ `test_a_revision_cannot_be_moved_out_from_under_the_item_pointing_at_it` holds
907
+ both arms — the stranding move refused, a revision no item points at still
908
+ movable — so a key that refused every write to that column would fail it too.
909
+
910
+ A `NULL` `current_revision_id` still satisfies the key, because a composite
911
+ child key with a NULL component imposes no constraint in SQLite; an item exists
912
+ before its first revision is upserted. The four `# pragma: no cover` branches
913
+ whose justification was "the pointer is a foreign key" now say what actually
914
+ holds them, and the claim is true for the first time. INV-2's other half — that
915
+ the revision belongs to the same *item* — is enforced above the database and in
916
+ nothing the schema declares: by `KnowledgeItem.with_revision` in the domain, and
917
+ by `append_revision` and `put_item` in both `MigrationWriter` adapters.
918
+
919
+ ### Security
920
+
921
+ - **Derived state under `.theurian/state/` is served only if this installation
922
+ built it (threat-model T-19, ADR-0004, SEC-7).** Everything there — the active
923
+ pointers and the SQLite databases they name — is derived and git-ignored, but a
924
+ repository contributor can force-add a doctored copy past that ignore (`git add
925
+ -f`), and a victim who clones (or downloads the ZIP/tarball), `theurian project
926
+ register`s, and serves over MCP **without ever running `theurian migrate
927
+ apply`** was served the attacker's bytes: a `rejected` body relabelled
928
+ `approved`, rows injected, titles and excerpts rewritten in the index.
929
+
930
+ This is the self-consistent face the read-back guards cannot catch. The #30 PR2
931
+ detector and the item → revision pointer guard above find a derived state that
932
+ *disagrees with its own records*; this attacker authors both sides, at the
933
+ current schema version, so there is no inconsistency to find. `active.json`'s
934
+ `stateHash` binds the migration *set*, not the database bytes, and the database
935
+ filename is derived from that hash, so the doctored pair is self-consistent by
936
+ construction. The only property a repository author cannot forge is whether
937
+ *this installation* built the artifact.
938
+
939
+ **Control: an out-of-tree build-provenance anchor.** `theurian migrate apply`
940
+ and `theurian index build` record the state hash and index build id this
941
+ install produced for each project root in `THEURIAN_DATA_DIR/provenance.json` —
942
+ beside the project registry, out of the repository tree where a contributor
943
+ cannot write (`BuildProvenance`). Every MCP read path checks it before a byte of
944
+ `.theurian/state/` reaches a caller: `_resolve` refuses a canonical state whose
945
+ hash this install did not build (`verify_state_provenance`, covering
946
+ `knowledge.get`, `knowledge.search`, `knowledge.status`); the ranked path stands
947
+ aside from an index build id this install did not build. Both paths that
948
+ generate an index are gated on source-index provenance, so neither launders a
949
+ committed one: `index build` refuses to build *from* an unprovenanced canonical
950
+ state, and — since commit `dc6aa79` — the withdrawal purge refuses to copy a
951
+ committed index forward and record it when this install did not build the source
952
+ index (`UNTRUSTED_SOURCE_INDEX`), the second laundering path review found. And
953
+ `migrate apply` discards an unprovenanced database and rebuilds from the
954
+ Git-tracked migrations; a committed `-wal` cannot replay because
955
+ `create_database` refuses to write over an existing file, so the rebuild deletes
956
+ the main database and creates a fresh one with no database for the sidecar to
957
+ replay into — the sidecars are removed too, redundant defense-in-depth.
958
+ Those migrations are vouched for by human PR review (T-1), not by FR-K5: on a
959
+ fresh clone nothing has been applied, so FR-K5 has no recorded checksum to
960
+ disagree with an attacker-authored migration, and re-derivation is safe because
961
+ a reviewer read the migration diff.
962
+
963
+ Delivery-independent by construction: the discriminator is "did this install
964
+ build it", not "is it tracked by Git", so a clone, a ZIP download and a
965
+ repackaged tarball are refused alike — which a `git ls-files` probe could not
966
+ do. Pinned by `tests/integration/test_state_provenance.py`, whose closure
967
+ invariant is one query against two checkouts: a checkout shipping derived state
968
+ and one shipping none produce identical served knowledge, both refused until the
969
+ state is built locally.
970
+
971
+ **Effect on existing installs, and it is deliberate.** A project already built
972
+ by a pre-`0.1.0.dev4` build has no provenance record, so the three read tools
973
+ refuse it until one `theurian migrate apply` (then `theurian index build`)
974
+ rebuilds it and records provenance — the same one-command rebuild the
975
+ `SCHEMA_VERSION` bump above already requires, and nothing authored is lost
976
+ (ADR-0004). The residual is recorded in T-19: provenance vouches for a hash, not
977
+ for the database bytes, so replacing a database *after* this install built the
978
+ matching hash is out of scope for this control and left to the schema gate, the
979
+ #30 read-back guards, and the corruption checks.
980
+
981
+ <!-- Advisory (GHSA) reference for T-19 to be filled at release, when the
982
+ advisory ships; embargoed until then. -->
983
+
984
+ - **`knowledge.search` gains one `fallbackReason`, `index-unbuilt`**, emitted when
985
+ the published index was not built by this installation and the ranked path
986
+ therefore stands aside to the provenance-gated canonical scan. A new published
987
+ *value*, not a new field, type or tool, so `protocolVersion` stays `theurian/v1`
988
+ by the same rule the `SCHEMA_VERSION` entry above applies; the JSON Schema
989
+ `schemas/mcp/retrieval-metadata.schema.json` adds the `const`.
990
+
15
991
  ## [0.1.0.dev3] - 2026-08-15
16
992
 
17
993
  ### Added
@@ -179,6 +1155,9 @@ Pre-1.0, a MINOR bump may change the protocol. Post-1.0, only a MAJOR may.
179
1155
  with is now a refusal. Only negative values are refused — a non-negative integer
180
1156
  that is simply wrong is still accepted, which is the one-way limit recorded
181
1157
  above and in the threat model.
1158
+
1159
+ - **`knowledge.status` no longer refuses over a corrupt
1160
+ `migration_history.migration_id` or `checksum`; it answers cleanly**
182
1161
  ([#30](https://github.com/theurian/theurian/issues/30) PR1). The refusal was a
183
1162
  side effect of parsing rows the tool no longer reads: it used to call
184
1163
  `applied_migrations`, which converts both cells and raises on a damaged one,
@@ -434,7 +1413,8 @@ Pre-1.0, a MINOR bump may change the protocol. Post-1.0, only a MAJOR may.
434
1413
 
435
1414
  - **A reused `revisionId` across two items no longer leaks a withheld item's body**
436
1415
  ([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).**
1416
+ fixed in [`67c0e81`](https://github.com/theurian/theurian/commit/67c0e81)).
1417
+ **BREAKING (state database schema).**
438
1418
  In 0.1.0.dev0–0.1.0.dev2 a migration that reused an existing `revisionId` under
439
1419
  a second `itemId` — the shape a copy-pasted `upsertRevision` block produces —
440
1420
  pointed the second (approved) item's current revision at the first item's