theurian 0.1.0.dev4__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.dev4 → theurian-0.1.0.dev5}/CHANGELOG.md +707 -0
  2. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/PKG-INFO +4 -4
  3. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/pyproject.toml +3 -3
  4. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/__init__.py +1 -1
  5. theurian-0.1.0.dev5/src/theurian/application/migration_body_guards.py +224 -0
  6. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/application/migration_engine.py +8 -0
  7. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/application/project_service.py +33 -9
  8. theurian-0.1.0.dev5/src/theurian/application/proposal_service.py +932 -0
  9. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/cli/commands.py +180 -14
  10. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/cli/context.py +26 -2
  11. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/cli/main.py +8 -1
  12. theurian-0.1.0.dev5/src/theurian/cli/propose_commands.py +577 -0
  13. theurian-0.1.0.dev5/src/theurian/domain/errors.py +578 -0
  14. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/migration.py +58 -1
  15. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/ports/review_provider.py +1 -1
  16. theurian-0.1.0.dev5/src/theurian/domain/proposal.py +215 -0
  17. theurian-0.1.0.dev5/src/theurian/infrastructure/filesystem/migration_loader.py +816 -0
  18. theurian-0.1.0.dev5/src/theurian/infrastructure/filesystem/parsers/openapi.py +484 -0
  19. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/filesystem/parsers/structured.py +17 -0
  20. theurian-0.1.0.dev5/src/theurian/infrastructure/github/__init__.py +16 -0
  21. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/mcp/tools.py +0 -1
  22. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/review/__init__.py +7 -0
  23. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/security/yaml_loading.py +35 -1
  24. theurian-0.1.0.dev5/tests/command_extraction.py +600 -0
  25. theurian-0.1.0.dev5/tests/command_population.py +210 -0
  26. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_canonical_store_corruption.py +10 -2
  27. theurian-0.1.0.dev5/tests/integration/test_cli_commands.py +2317 -0
  28. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_mcp_tools.py +286 -1
  29. theurian-0.1.0.dev5/tests/integration/test_proposal_service.py +1089 -0
  30. theurian-0.1.0.dev5/tests/integration/test_propose_cli.py +386 -0
  31. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_revision_id_reuse.py +17 -6
  32. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_cli_help_rendering.py +1 -1
  33. theurian-0.1.0.dev5/tests/unit/test_command_extraction.py +388 -0
  34. theurian-0.1.0.dev5/tests/unit/test_config_key_call_sites.py +474 -0
  35. theurian-0.1.0.dev5/tests/unit/test_documented_commands.py +709 -0
  36. theurian-0.1.0.dev5/tests/unit/test_examples.py +269 -0
  37. theurian-0.1.0.dev5/tests/unit/test_ingest_manifest_path.py +138 -0
  38. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_migration_engine.py +603 -0
  39. theurian-0.1.0.dev5/tests/unit/test_migration_loader_errors.py +1757 -0
  40. theurian-0.1.0.dev5/tests/unit/test_migration_loader_identity.py +122 -0
  41. theurian-0.1.0.dev5/tests/unit/test_network_call_sites.py +888 -0
  42. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_parsers.py +70 -0
  43. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_plugin_boundary.py +161 -63
  44. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_project_and_traceability.py +20 -0
  45. theurian-0.1.0.dev5/tests/unit/test_project_registry_errors.py +94 -0
  46. theurian-0.1.0.dev5/tests/unit/test_proposal.py +208 -0
  47. theurian-0.1.0.dev5/tests/unit/test_ref_recording.py +678 -0
  48. theurian-0.1.0.dev5/tests/unit/test_resolve_context_call_sites.py +336 -0
  49. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_schemas.py +104 -0
  50. theurian-0.1.0.dev5/tests/unit/test_secret_detector.py +609 -0
  51. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_yaml_loading.py +38 -0
  52. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/theurian/schemas/config/project-config.schema.json +5 -5
  53. theurian-0.1.0.dev4/src/theurian/domain/errors.py +0 -191
  54. theurian-0.1.0.dev4/src/theurian/infrastructure/filesystem/migration_loader.py +0 -336
  55. theurian-0.1.0.dev4/src/theurian/infrastructure/filesystem/parsers/openapi.py +0 -255
  56. theurian-0.1.0.dev4/src/theurian/infrastructure/github/__init__.py +0 -10
  57. theurian-0.1.0.dev4/tests/integration/test_cli_commands.py +0 -1148
  58. theurian-0.1.0.dev4/tests/unit/test_examples.py +0 -138
  59. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/.gitignore +0 -0
  60. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/LICENSE +0 -0
  61. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/README.md +0 -0
  62. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/hatch_build.py +0 -0
  63. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/application/__init__.py +0 -0
  64. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/application/forest_builder.py +0 -0
  65. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/application/index_builder.py +0 -0
  66. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/application/ingestion_service.py +0 -0
  67. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/application/retrieval_service.py +0 -0
  68. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/application/setup_context.py +0 -0
  69. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/application/setup_service.py +0 -0
  70. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/application/setup_steps.py +0 -0
  71. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/application/setup_withholding.py +0 -0
  72. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/application/visibility.py +0 -0
  73. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/application/withdrawal_purge.py +0 -0
  74. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/cli/__init__.py +0 -0
  75. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/cli/auth_commands.py +0 -0
  76. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/cli/index_commands.py +0 -0
  77. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/cli/setup_commands.py +0 -0
  78. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/daemon/__init__.py +0 -0
  79. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/daemon/instance.py +0 -0
  80. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/daemon/runner.py +0 -0
  81. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/daemon/server.py +0 -0
  82. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/__init__.py +0 -0
  83. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/chunking.py +0 -0
  84. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/compatibility.py +0 -0
  85. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/context.py +0 -0
  86. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/enums.py +0 -0
  87. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/extras.py +0 -0
  88. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/identifiers.py +0 -0
  89. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/ingestion.py +0 -0
  90. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/knowledge.py +0 -0
  91. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/ports/__init__.py +0 -0
  92. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/ports/authorization.py +0 -0
  93. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/ports/canonical_store.py +0 -0
  94. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/ports/daemon_manager.py +0 -0
  95. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/ports/determinism.py +0 -0
  96. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/ports/embedding.py +0 -0
  97. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/ports/index_store.py +0 -0
  98. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/ports/mcp_client_config.py +0 -0
  99. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/ports/object_store.py +0 -0
  100. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/ports/reranking.py +0 -0
  101. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/ports/secret_store.py +0 -0
  102. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/ports/source_parser.py +0 -0
  103. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/ports/specification_provider.py +0 -0
  104. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/ports/summarization.py +0 -0
  105. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/ports/vector_store.py +0 -0
  106. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/project.py +0 -0
  107. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/ranking.py +0 -0
  108. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/raptor.py +0 -0
  109. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/retrieval.py +0 -0
  110. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/review.py +0 -0
  111. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/setup.py +0 -0
  112. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/specification.py +0 -0
  113. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/state.py +0 -0
  114. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/domain/values.py +0 -0
  115. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/indexing/__init__.py +0 -0
  116. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/__init__.py +0 -0
  117. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/claude/__init__.py +0 -0
  118. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/claude/mcp_config.py +0 -0
  119. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/determinism.py +0 -0
  120. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/embedding/__init__.py +0 -0
  121. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/embedding/hashing.py +0 -0
  122. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/filesystem/__init__.py +0 -0
  123. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/filesystem/parsers/markdown.py +0 -0
  124. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/filesystem/parsers/registry.py +0 -0
  125. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/git/__init__.py +0 -0
  126. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/raptor/__init__.py +0 -0
  127. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/raptor/extractive.py +0 -0
  128. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/secrets/__init__.py +0 -0
  129. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/secrets/file_store.py +0 -0
  130. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/services/__init__.py +0 -0
  131. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/services/launchagent.py +0 -0
  132. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/services/runner.py +0 -0
  133. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/services/systemd_user.py +0 -0
  134. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/sqlite/__init__.py +0 -0
  135. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/sqlite/connection.py +0 -0
  136. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/sqlite/index_forest.py +0 -0
  137. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/sqlite/index_purge.py +0 -0
  138. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/sqlite/index_query.py +0 -0
  139. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/sqlite/index_scan.py +0 -0
  140. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/sqlite/index_schema.py +0 -0
  141. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/sqlite/index_store.py +0 -0
  142. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/sqlite/schema.py +0 -0
  143. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/sqlite/store.py +0 -0
  144. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/infrastructure/vector/__init__.py +0 -0
  145. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/ingestion/__init__.py +0 -0
  146. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/mcp/__init__.py +0 -0
  147. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/mcp/results.py +0 -0
  148. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/mcp/search.py +0 -0
  149. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/migrations/__init__.py +0 -0
  150. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/normalization/__init__.py +0 -0
  151. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/normalization/projection.py +0 -0
  152. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/observability/__init__.py +0 -0
  153. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/retrieval/__init__.py +0 -0
  154. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/security/__init__.py +0 -0
  155. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/security/env_file.py +0 -0
  156. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/security/paths.py +0 -0
  157. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/security/tokens.py +0 -0
  158. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/specification/__init__.py +0 -0
  159. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/src/theurian/traceability/__init__.py +0 -0
  160. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/conftest.py +0 -0
  161. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/fakes/__init__.py +0 -0
  162. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/fakes/clock.py +0 -0
  163. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/fakes/ids.py +0 -0
  164. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/fakes/pages.py +0 -0
  165. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/fakes/setup.py +0 -0
  166. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/fakes/store.py +0 -0
  167. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_absence_proof.py +0 -0
  168. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_auth_rotate.py +0 -0
  169. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_bare_install.py +0 -0
  170. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_canonical_store.py +0 -0
  171. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_claude_mcp_config.py +0 -0
  172. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_cli_help_without_rich.py +0 -0
  173. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_daemon.py +0 -0
  174. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_forest_builder.py +0 -0
  175. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_forest_builder_scale.py +0 -0
  176. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_forest_node_scope.py +0 -0
  177. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_forest_purge_equality.py +0 -0
  178. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_forest_purge_recompute.py +0 -0
  179. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_forest_retrieval.py +0 -0
  180. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_forest_store_retrieval.py +0 -0
  181. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_gc_during_a_search.py +0 -0
  182. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_index_fallback.py +0 -0
  183. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_index_gc_cli.py +0 -0
  184. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_index_purge.py +0 -0
  185. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_index_purge_differential.py +0 -0
  186. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_index_purge_nodes.py +0 -0
  187. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_index_scan_fold.py +0 -0
  188. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_index_schema_v4.py +0 -0
  189. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_index_store.py +0 -0
  190. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_ingestion.py +0 -0
  191. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_init_gitignore_block.py +0 -0
  192. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_project_registry.py +0 -0
  193. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_retrieval_service.py +0 -0
  194. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_scan_exhaustion.py +0 -0
  195. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_service_adapters.py +0 -0
  196. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_session_start_hook.py +0 -0
  197. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_setup_cli.py +0 -0
  198. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_setup_env_file.py +0 -0
  199. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_setup_journal.py +0 -0
  200. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_setup_partial_failure.py +0 -0
  201. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_setup_probe_assertions.py +0 -0
  202. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_setup_report_withholding.py +0 -0
  203. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_setup_service.py +0 -0
  204. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_short_query_retrieval.py +0 -0
  205. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_state_provenance.py +0 -0
  206. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_unreadable_registry_surface.py +0 -0
  207. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_wire_contract.py +0 -0
  208. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_withdrawal_purge.py +0 -0
  209. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/integration/test_writer_contract.py +0 -0
  210. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_artifact_integrity_claim.py +0 -0
  211. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_candidate_cut.py +0 -0
  212. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_candidate_depth.py +0 -0
  213. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_chunking.py +0 -0
  214. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_compatibility.py +0 -0
  215. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_daemon_extra.py +0 -0
  216. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_domain_invariants.py +0 -0
  217. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_env_file_merge.py +0 -0
  218. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_extractive_summarizer.py +0 -0
  219. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_forest_derivation.py +0 -0
  220. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_forest_fanout.py +0 -0
  221. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_gate_call_sites.py +0 -0
  222. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_hashing_embedding.py +0 -0
  223. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_install_claims.py +0 -0
  224. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_layering.py +0 -0
  225. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_path_security.py +0 -0
  226. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_ports.py +0 -0
  227. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_projection.py +0 -0
  228. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_ranking.py +0 -0
  229. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_raptor_scope.py +0 -0
  230. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_result_gate_session.py +0 -0
  231. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_result_payload.py +0 -0
  232. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_retrieval_depth.py +0 -0
  233. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_scope_isolation.py +0 -0
  234. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_setup_changed_paths.py +0 -0
  235. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_setup_claims.py +0 -0
  236. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_setup_domain.py +0 -0
  237. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_state_hash.py +0 -0
  238. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_test_fixtures.py +0 -0
  239. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/tests/unit/test_tokens.py +0 -0
  240. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/theurian/schemas/README.md +0 -0
  241. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/theurian/schemas/cli/version.schema.json +0 -0
  242. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/theurian/schemas/knowledge/retrieval-result.schema.json +0 -0
  243. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/theurian/schemas/mcp/knowledge-search-response.schema.json +0 -0
  244. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/theurian/schemas/mcp/knowledge-status-response.schema.json +0 -0
  245. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/theurian/schemas/mcp/project-list-response.schema.json +0 -0
  246. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/theurian/schemas/mcp/retrieval-metadata.schema.json +0 -0
  247. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/theurian/schemas/mcp/tool-context.schema.json +0 -0
  248. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/theurian/schemas/migrations/migration.schema.json +0 -0
  249. {theurian-0.1.0.dev4 → theurian-0.1.0.dev5}/theurian/schemas/protocol/compatibility.schema.json +0 -0
@@ -12,6 +12,713 @@ 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
+
15
722
  ## [0.1.0.dev4] - 2026-08-16
16
723
 
17
724
  ### Added