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.
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/CHANGELOG.md +766 -2
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/PKG-INFO +4 -4
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/pyproject.toml +3 -3
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/__init__.py +1 -1
- theurian-0.1.0.dev6/src/theurian/application/migration_alias_guards.py +183 -0
- theurian-0.1.0.dev6/src/theurian/application/migration_body_guards.py +224 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/application/migration_engine.py +17 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/application/project_service.py +33 -9
- theurian-0.1.0.dev6/src/theurian/application/proposal_service.py +932 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/cli/commands.py +227 -14
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/cli/context.py +26 -2
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/cli/main.py +8 -1
- theurian-0.1.0.dev6/src/theurian/cli/propose_commands.py +577 -0
- theurian-0.1.0.dev6/src/theurian/domain/errors.py +624 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/migration.py +58 -1
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/ports/canonical_store.py +16 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/ports/review_provider.py +1 -1
- theurian-0.1.0.dev6/src/theurian/domain/proposal.py +215 -0
- theurian-0.1.0.dev6/src/theurian/infrastructure/filesystem/migration_loader.py +816 -0
- theurian-0.1.0.dev6/src/theurian/infrastructure/filesystem/parsers/openapi.py +484 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/filesystem/parsers/structured.py +17 -0
- theurian-0.1.0.dev6/src/theurian/infrastructure/github/__init__.py +16 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/sqlite/store.py +18 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/mcp/tools.py +28 -13
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/review/__init__.py +7 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/security/yaml_loading.py +35 -1
- theurian-0.1.0.dev6/tests/command_extraction.py +600 -0
- theurian-0.1.0.dev6/tests/command_population.py +210 -0
- theurian-0.1.0.dev6/tests/integration/test_alias_item_id_collision.py +687 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_canonical_store_corruption.py +10 -2
- theurian-0.1.0.dev6/tests/integration/test_cli_commands.py +2317 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_mcp_tools.py +286 -1
- theurian-0.1.0.dev6/tests/integration/test_proposal_service.py +1089 -0
- theurian-0.1.0.dev6/tests/integration/test_propose_cli.py +386 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_revision_id_reuse.py +17 -6
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_cli_help_rendering.py +1 -1
- theurian-0.1.0.dev6/tests/unit/test_command_extraction.py +388 -0
- theurian-0.1.0.dev6/tests/unit/test_config_key_call_sites.py +474 -0
- theurian-0.1.0.dev6/tests/unit/test_documented_commands.py +709 -0
- theurian-0.1.0.dev6/tests/unit/test_examples.py +269 -0
- theurian-0.1.0.dev6/tests/unit/test_ingest_manifest_path.py +138 -0
- theurian-0.1.0.dev6/tests/unit/test_migration_alias_guards.py +183 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_migration_engine.py +603 -0
- theurian-0.1.0.dev6/tests/unit/test_migration_loader_errors.py +1757 -0
- theurian-0.1.0.dev6/tests/unit/test_migration_loader_identity.py +122 -0
- theurian-0.1.0.dev6/tests/unit/test_network_call_sites.py +888 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_parsers.py +70 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_plugin_boundary.py +161 -63
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_project_and_traceability.py +20 -0
- theurian-0.1.0.dev6/tests/unit/test_project_registry_errors.py +94 -0
- theurian-0.1.0.dev6/tests/unit/test_proposal.py +208 -0
- theurian-0.1.0.dev6/tests/unit/test_ref_recording.py +678 -0
- theurian-0.1.0.dev6/tests/unit/test_resolve_context_call_sites.py +336 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_result_gate_session.py +13 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_retrieval_depth.py +5 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_schemas.py +104 -0
- theurian-0.1.0.dev6/tests/unit/test_secret_detector.py +609 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_yaml_loading.py +38 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/theurian/schemas/config/project-config.schema.json +5 -5
- theurian-0.1.0.dev4/src/theurian/domain/errors.py +0 -191
- theurian-0.1.0.dev4/src/theurian/infrastructure/filesystem/migration_loader.py +0 -336
- theurian-0.1.0.dev4/src/theurian/infrastructure/filesystem/parsers/openapi.py +0 -255
- theurian-0.1.0.dev4/src/theurian/infrastructure/github/__init__.py +0 -10
- theurian-0.1.0.dev4/tests/integration/test_cli_commands.py +0 -1148
- theurian-0.1.0.dev4/tests/unit/test_examples.py +0 -138
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/.gitignore +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/LICENSE +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/README.md +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/hatch_build.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/application/__init__.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/application/forest_builder.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/application/index_builder.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/application/ingestion_service.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/application/retrieval_service.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/application/setup_context.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/application/setup_service.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/application/setup_steps.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/application/setup_withholding.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/application/visibility.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/application/withdrawal_purge.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/cli/__init__.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/cli/auth_commands.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/cli/index_commands.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/cli/setup_commands.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/daemon/__init__.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/daemon/instance.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/daemon/runner.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/daemon/server.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/__init__.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/chunking.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/compatibility.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/context.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/enums.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/extras.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/identifiers.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/ingestion.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/knowledge.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/ports/__init__.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/ports/authorization.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/ports/daemon_manager.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/ports/determinism.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/ports/embedding.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/ports/index_store.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/ports/mcp_client_config.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/ports/object_store.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/ports/reranking.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/ports/secret_store.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/ports/source_parser.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/ports/specification_provider.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/ports/summarization.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/ports/vector_store.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/project.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/ranking.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/raptor.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/retrieval.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/review.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/setup.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/specification.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/state.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/domain/values.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/indexing/__init__.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/__init__.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/claude/__init__.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/claude/mcp_config.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/determinism.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/embedding/__init__.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/embedding/hashing.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/filesystem/__init__.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/filesystem/parsers/markdown.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/filesystem/parsers/registry.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/git/__init__.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/raptor/__init__.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/raptor/extractive.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/secrets/__init__.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/secrets/file_store.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/services/__init__.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/services/launchagent.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/services/runner.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/services/systemd_user.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/sqlite/__init__.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/sqlite/connection.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/sqlite/index_forest.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/sqlite/index_purge.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/sqlite/index_query.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/sqlite/index_scan.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/sqlite/index_schema.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/sqlite/index_store.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/sqlite/schema.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/infrastructure/vector/__init__.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/ingestion/__init__.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/mcp/__init__.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/mcp/results.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/mcp/search.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/migrations/__init__.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/normalization/__init__.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/normalization/projection.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/observability/__init__.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/retrieval/__init__.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/security/__init__.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/security/env_file.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/security/paths.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/security/tokens.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/specification/__init__.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/src/theurian/traceability/__init__.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/conftest.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/fakes/__init__.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/fakes/clock.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/fakes/ids.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/fakes/pages.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/fakes/setup.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/fakes/store.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_absence_proof.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_auth_rotate.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_bare_install.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_canonical_store.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_claude_mcp_config.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_cli_help_without_rich.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_daemon.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_forest_builder.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_forest_builder_scale.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_forest_node_scope.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_forest_purge_equality.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_forest_purge_recompute.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_forest_retrieval.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_forest_store_retrieval.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_gc_during_a_search.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_index_fallback.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_index_gc_cli.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_index_purge.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_index_purge_differential.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_index_purge_nodes.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_index_scan_fold.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_index_schema_v4.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_index_store.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_ingestion.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_init_gitignore_block.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_project_registry.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_retrieval_service.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_scan_exhaustion.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_service_adapters.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_session_start_hook.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_setup_cli.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_setup_env_file.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_setup_journal.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_setup_partial_failure.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_setup_probe_assertions.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_setup_report_withholding.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_setup_service.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_short_query_retrieval.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_state_provenance.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_unreadable_registry_surface.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_wire_contract.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_withdrawal_purge.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/integration/test_writer_contract.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_artifact_integrity_claim.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_candidate_cut.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_candidate_depth.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_chunking.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_compatibility.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_daemon_extra.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_domain_invariants.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_env_file_merge.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_extractive_summarizer.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_forest_derivation.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_forest_fanout.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_gate_call_sites.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_hashing_embedding.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_install_claims.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_layering.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_path_security.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_ports.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_projection.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_ranking.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_raptor_scope.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_result_payload.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_scope_isolation.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_setup_changed_paths.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_setup_claims.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_setup_domain.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_state_hash.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_test_fixtures.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/tests/unit/test_tokens.py +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/theurian/schemas/README.md +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/theurian/schemas/cli/version.schema.json +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/theurian/schemas/knowledge/retrieval-result.schema.json +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/theurian/schemas/mcp/knowledge-search-response.schema.json +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/theurian/schemas/mcp/knowledge-status-response.schema.json +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/theurian/schemas/mcp/project-list-response.schema.json +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/theurian/schemas/mcp/retrieval-metadata.schema.json +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/theurian/schemas/mcp/tool-context.schema.json +0 -0
- {theurian-0.1.0.dev4 → theurian-0.1.0.dev6}/theurian/schemas/migrations/migration.schema.json +0 -0
- {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
|
-
|
|
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
|