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