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