diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 2dbf6006ef..6dbf34811d 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -215,10 +215,10 @@ jobs: else build_status=$? fi - if [ "$attempt" -eq 3 ] || ! grep -Fq 'hdiutil: create failed - Resource busy' "$attempt_log"; then + if [ "$attempt" -eq 3 ] || ! grep -Eq "hdiutil: (create failed - Resource busy|couldn't eject .* - Resource busy)" "$attempt_log"; then exit "$build_status" fi - echo "::warning::DMG creation found a busy resource; retrying after $((attempt * 5)) seconds (attempt $attempt of 3)." + echo "::warning::DMG creation or unmount found a busy resource; retrying after $((attempt * 5)) seconds (attempt $attempt of 3)." sleep "$((attempt * 5))" done - name: Upload failed macOS build diagnostics diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 80607cd91f..789be62309 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -471,7 +471,10 @@ jobs: code-seekdb: needs: [quality, tests] if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (fromJSON(needs.quality.outputs.suites || '{}').database) }} - timeout-minutes: 30 + # Shard 0 also runs indexing, bootstrap (20m), and archive validation (5m) + # before migrations (25m). Preserve each step's deadline and leave time + # for environment setup and diagnostic uploads. + timeout-minutes: 60 runs-on: ubuntu-latest strategy: fail-fast: false @@ -704,6 +707,16 @@ jobs: - name: Verify spawned worker logging on Windows run: uv run --locked --no-sync pytest -q tests/e2e/test_worker_logging.py + - name: Verify offline ownership migration on Windows + run: >- + uv run --locked --no-sync pytest -q + tests/e2e/test_access_ownership_migration.py::test_offline_cli_checks_future_background_principal_before_enforcement + tests/e2e/test_access_ownership_migration.py::test_offline_background_plan_uses_configured_static_preset_without_grants + tests/e2e/test_access_ownership_migration.py::test_migration_audit_preserves_request_context + tests/e2e/test_ownership_maintenance_dispatch.py + tests/e2e/test_owner_recovery_http.py::test_offline_migration_preserves_external_owner_evidence + tests/e2e/test_candidate_ownership_migration.py + - name: Verify bootstrap receipt persistence and failure recovery on Windows run: >- uv run --locked --no-sync pytest -q diff --git a/desktop/README.md b/desktop/README.md index a7354d66aa..98eb0952fa 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -178,7 +178,7 @@ Installed boundary checks exercise an 8192-byte Unicode note, reject over-budget After upgrading from an earlier preview, open Connections and select your saved connection. Profiles matching the bundled API contract appear under “Available for this Desktop build”; compare their tested Server builds with your deployment before selecting one, saving, checking and explicitly using the connection again. New connections start with no compatibility selection. -Historical profiles appear as disabled records when their contract differs from this build. A saved incompatible or unavailable selection remains visible with an explanation until explicitly replaced. The `sqlite-atomic-2d782c2a-v1` record matches this build's contract and qualifies eleven SQLite protocol operations against the exact Server source and wheel recorded in the validation guide. Select it only after comparing that tested build with your deployment. Existing records retain their original Server source, contract, wheel and operation identities. The `ci-fixtures` build is for disposable acceptance tests and does not qualify a production deployment. Qualification evidence and remaining requirements are documented in the [validation guide](VALIDATION.md#compatibility-scope). +Historical profiles appear as disabled records when their contract differs from this build. A saved incompatible or unavailable selection remains visible with an explanation until explicitly replaced. The `sqlite-atomic-3cefa45a-v1` record matches this build's contract and qualifies eleven SQLite protocol operations against the exact Server source and wheel recorded in the validation guide. Select it only after comparing that tested build with your deployment. Existing records retain their original Server source, contract, wheel and operation identities, including the historical `sqlite-atomic-f3a90b2f-v1` qualification. The `ci-fixtures` build is for disposable acceptance tests and does not qualify a production deployment. Qualification evidence and remaining requirements are documented in the [validation guide](VALIDATION.md#compatibility-scope). ## Build on macOS diff --git a/desktop/README.zh.md b/desktop/README.zh.md index 21365e738e..862c165483 100644 --- a/desktop/README.zh.md +++ b/desktop/README.zh.md @@ -161,7 +161,7 @@ Windows GitHub Actions 会构建未签名安装包,安装到临时中文路径 从早期预览版升级后,请打开「连接」并选中已保存的连接。与当前 API 契约匹配的配置显示在「此 Desktop 构建可用」中;先核对已测试的 Server 构建与实际部署,再明确选择、保存、检查并使用该连接。新连接默认不选择兼容配置。 -历史配置在契约不匹配时显示为不可选择的记录。已保存的不匹配或不可用配置仍会显示并说明原因,须明确替换,不会静默升级。`sqlite-atomic-2d782c2a-v1` 与当前构建的契约匹配,已针对验证说明中记录的精确 Server 源码和 wheel 验证 11 项 SQLite 协议操作;请先核对该受测构建与实际部署,再明确选择。现有记录保留各自原始的 Server 源码、契约、wheel 和操作身份。`ci-fixtures` 构建仅用于临时验收测试,不代表实际部署已通过兼容性验证。资格证据和剩余要求见[验证说明](VALIDATION.md#compatibility-scope)。 +历史配置在契约不匹配时显示为不可选择的记录。已保存的不匹配或不可用配置仍会显示并说明原因,须明确替换,不会静默升级。`sqlite-atomic-3cefa45a-v1` 与当前构建的契约匹配,已针对验证说明中记录的精确 Server 源码和 wheel 验证 11 项 SQLite 协议操作;请先核对该受测构建与实际部署,再明确选择。现有记录保留各自原始的 Server 源码、契约、wheel 和操作身份。`ci-fixtures` 构建仅用于临时验收测试,不代表实际部署已通过兼容性验证。资格证据和剩余要求见[验证说明](VALIDATION.md#compatibility-scope)。 ## 在 macOS 上构建 diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index be554956d0..5ab7962865 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -118,9 +118,9 @@ The historical Server c535d816 protocol qualification below records the specific ## Compatibility scope -[compatibility.json](src-tauri/src/connections/compatibility.json) preserves each recorded Server source, normalized OpenAPI contract digest, qualification-wheel digest and operation set. The `sqlite-atomic-112db11e-v1` record matches the bundled API contract and qualifies its eleven recorded SQLite protocol operations against the exact Server source and wheel below. Historical records retain their exact identities and evidence. Ordinary builds require explicitly selecting the record whose tested Server source and wheel match the deployment, then checking the connection before business operations become available. Records with a different contract digest are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. Selecting a profile does not extend its recorded operation set or qualify a different Server source. CI builds have their own artifact identities and do not change product qualification. +[compatibility.json](src-tauri/src/connections/compatibility.json) preserves each recorded Server source, normalized OpenAPI contract digest, qualification-wheel digest and operation set. The `sqlite-atomic-3cefa45a-v1` record matches the bundled API contract and qualifies its eleven recorded SQLite protocol operations against the exact Server source and wheel below. Historical records retain their exact identities and evidence. Ordinary builds require explicitly selecting the record whose tested Server source and wheel match the deployment, then checking the connection before business operations become available. Records with a different contract digest are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. Selecting a profile does not extend its recorded operation set or qualify a different Server source. CI builds have their own artifact identities and do not change product qualification. -The real Server harness covers SQLite with anonymous loopback, static Bearer, HTTPS with explicit CA/base path, and injected-provider/enforced access. It exercises save/search/exact reads, identity changes, binding revocation, ambiguous writes without replay and Scope pagination. Installed UI checks cover repeated launches preserving the existing editor, late committed-save responses preserving a new Scope draft, explicit connection/Scope selection, clipboard copies, connection isolation, draft cancellation, byte limits and independent Server survival after Desktop exit. +The real Server harness covers SQLite with anonymous loopback, static Bearer, HTTPS with explicit CA/base path, and injected-provider/enforced access. It exercises save/search/exact reads, identity changes, binding revocation, ambiguous writes without replay and Scope pagination. Installed UI checks cover repeated launches preserving the existing editor, late committed-save responses preserving a new Scope draft, explicit connection/Scope selection, clipboard copies, connection isolation, draft cancellation, byte limits and independent Server survival after Desktop exit. Historical record `sqlite-atomic-f3a90b2f-v1` remains preserved with its original identities and is disabled for this contract. Selecting a compatibility profile does not attest the remote binary identity. Existing `sqlite-1.1.1-v1` / `sqlite-63f918b7-v1` / `sqlite-ab43e3a7-v1` / `sqlite-f1089f4e-v1` / `sqlite-6e237568-v1` / `sqlite-legacy-58f7f4f6-v1` selections require explicitly choosing an available matching profile and rechecking the connection. Desktop reads memories only through Atomic Memory references, so Servers that answer only with Memory entry citations are not qualified. CLI diagnostics separately verify the registered executable path and digest, then validate the diagnostic response schema. The observed version is optional report information; a registration does not require a version. @@ -166,6 +166,43 @@ packaging, signing, and native credential persistence remain separate checks. 未修改验收断言或超时;通过 CPU 亲和性避开已观测到的虚拟机调度等待。该记录不替代数据库升级、 Windows/macOS 安装包、签名、原生凭据持久化或模型质量验收。 +#### Server 3cefa45a protocol qualification + +`sqlite-atomic-3cefa45a-v1` qualifies Server sources at +`3cefa45ad08490423391148439c9eb73f1d48f58` with OpenAPI digest +`d59da9040814d0f2b25d930ecb36b93d932ff8906440606d259d3b9456364d3d`. +The wheel is `powercontext-1.4.2.dev174+g3cefa45ad-py3-none-any.whl`, SHA-256 +`cead75231f0f6961735dd117f454731d989ba96880a08c988c24aa0bae71c38e`. +All 686 packaged runtime files in `powercontext/` and `powercontext_service_bootstrap/` +match the recorded source bytes. The sorted runtime manifest digest is +`70dbff69bfbd0011fcab5389b6956ec21f015f3c01f8755ed0c200d2237f31e7` +(one `wheel/path sha256` line per file, with a final newline). + +On 2026-11-03, macOS 26.5.2 ARM64 with Python 3.12.13 ran the unchanged +`desktop/tests/real_server.py` against independent SQLite Server processes loaded from this wheel. +Anonymous loopback, static Bearer, HTTPS with an explicit CA/base path, and injected-provider/enforced +access all passed. The eleven operations cover Server discovery, liveness/readiness, Principal, +capabilities, Scope listing/detail/default, Atomic Memory save/search, and exact revision reads. +The provider case verified identity changes, same-identity revocation, and 51 same-title Scopes. +Every mode verified a committed write with a lost response without replay and Server survival after +client exit. All four fixture Servers shut down normally. `desktopWorkingTreeDirty=false`. + +The Rust 1.95.0 native probe was built from the product Cargo lock. Its SHA-256 is +`0c156f7a2447449f5ecdcc94d997f3766c9109fe1d5e3da749db25004e1d6c0c`. +Ignored local reports are under `desktop/.artifacts/real-server.json` and +`desktop/.artifacts/qualification-wheel-identity.json`. This record qualifies the listed SQLite +protocol operations. Installed UI, platform packaging, persistent native credentials, model +protection behavior, and database upgrades require their separate checks. + +Reproduce from the recorded clean commit with an empty wheel output directory: + +```sh +uv build --wheel --out-dir desktop/.artifacts/server-wheel +cargo +1.95.0 build --locked --manifest-path desktop/src-tauri/Cargo.toml --example server_probe +uv run --no-sync python desktop/tests/fixture_qualification.py --server-commit 3cefa45ad08490423391148439c9eb73f1d48f58 +uv run --no-sync python desktop/tests/real_server.py +``` + #### Server 2d782c2a protocol qualification `sqlite-atomic-2d782c2a-v1` qualifies Server sources at @@ -351,6 +388,183 @@ uv run --no-sync python desktop/tests/real_server.py 本记录绑定上述精确源码、契约及 wheel;四种真实 SQLite/HTTP 连接场景均已通过。 保护和编码使用默认关闭配置。该记录不替代 Windows/macOS 安装包、GUI 或原生凭据库验收。 +#### Server f3a90b2f protocol qualification + +`sqlite-atomic-f3a90b2f-v1` records Server sources at +`f3a90b2fbc9edf9ab503e532fdc1c29418746a7b` for OpenAPI digest +`9c35d3d80035213a52598a62cd748fd5febb3a9afde69a5908b0dee40aaf73c2`. +The wheel is `powercontext-1.4.2.dev140+gf3a90b2fb-py3-none-any.whl`, SHA-256 +`76f142ccbe406411c60b5ba91429350ea8ebbd1f449275a82b6cfd5213981f9e`, built from that clean commit. +All 672 packaged runtime files in `powercontext/` and `powercontext_service_bootstrap/` +match the recorded source bytes. The sorted runtime manifest digest is +`6c8570ecaf816db49d1d5b45ecbabbcdfaa552e38199103bcbdce8181e907f08` +(one `wheel/path sha256` line per file, with a final newline). Generated distribution version +metadata is excluded. OpenAPI, project configuration and the lock file also match that commit. + +On 2026-11-01, macOS 26.5.2 ARM64 with Python 3.12.13 and Rust 1.95.0 ran +`desktop/tests/real_server.py` against independent SQLite Server processes loaded from this wheel. +All four modes passed: anonymous loopback, static Bearer, HTTPS with explicit CA/base path, +and an injected provider with enforced access. The eleven recorded operations cover discovery, +health, Principal, Scope selection and pagination, Atomic Memory save/search/exact revision reads, +identity changes, same-identity revocation, committed writes with lost responses without replay, +and Server survival after client exit. The native probe uses the committed product modules and +locked dependencies; its SHA-256 is +`e94b4df948cc5f1a4230a03c9f9cb4a422c9a0d5bdc2ddff7d964a0e8f828459`. +The harness records `desktopWorkingTreeDirty=false`. + +Reproduce from the recorded commit with an empty wheel output directory: + +```sh +uv build --wheel --out-dir desktop/.artifacts/server-wheel +cargo +1.95.0 build --locked --manifest-path desktop/src-tauri/Cargo.toml --example server_probe +uv run --frozen python desktop/tests/fixture_qualification.py --server-commit f3a90b2fbc9edf9ab503e532fdc1c29418746a7b +uv run --frozen python desktop/tests/real_server.py +``` + +Ignored local evidence is in `desktop/.artifacts/real-server.json` and +`desktop/.artifacts/qualification-wheel-identity.json`. This qualifies the listed SQLite protocol +behavior on that host. It does not establish Windows/Intel execution, installed GUI workflows, +persistent native credentials, signing, notarization or complete release-upgrade acceptance. +All historical records retain their original source, contract, artifact and operation identities. + +本记录绑定上述准确源码、契约和 wheel;672 个运行时文件均与记录提交逐字节一致。 +四种真实 SQLite/HTTP 连接模式均通过,覆盖所列十一项协议操作、身份变化、同身份撤权、 +Scope 分页及响应丢失后不重放已提交写入。该验证不替代 Windows/Intel、安装后 GUI、 +原生凭据持久化、签名、公证或完整升级验收。全部历史记录保留原有身份、操作集合和证据。 + +#### Server 581be595 protocol qualification + +`sqlite-atomic-581be595-v1` records Server sources at +`581be595a51b06fdbed208349fc8c2066c80b1b3` for OpenAPI digest +`4aeefdd602a11ebece4c74f120b0cc8671aa3cd62386822326198ca7a4b88ca5`. +The wheel is `powercontext-1.4.2.dev112+g581be595a-py3-none-any.whl`, SHA-256 +`ded0d06803205e6b34a93cf9c33e76897f65b5880e2530e5501006a5d05ce6c8`, built from that clean commit. +All 667 packaged runtime files in `powercontext/` and `powercontext_service_bootstrap/` +match the recorded source bytes. The sorted runtime manifest digest is +`10a7e17f59654d053d8c9c8ac568d6f3be9f54707384d27fa18aca72c5d233fb` +(one `wheel/path sha256` line per file, with a final newline). Distribution version metadata +is outside this runtime manifest. OpenAPI, project configuration and the lock file also match +that commit. + +On 2026-11-01, macOS 26.5.2 ARM64 with Python 3.12.13 and Rust 1.95.0 ran the existing +`desktop/tests/real_server.py` harness against independent SQLite Server processes loaded from +this wheel. All four modes passed: anonymous loopback, static Bearer, HTTPS with an explicit +CA/base path, and an injected provider with enforced access. The eleven recorded operations +cover Server discovery, liveness/readiness, access principal/capabilities, Scope listing, +detail and default, Atomic Memory save/search and exact Artifact revision reads. The native +connection manager also passed identity changes, same-identity revocation, Scope pagination, +committed writes with lost responses without replay, and Server survival after client exit. + +The native `server_probe` compiled the unchanged product modules and locked dependencies; +its executable SHA-256 is +`a4dfd54788c843a37c1ee4919513d92da5925519a2264ca9be273a0fd0540ca7`. +All native build source, Cargo manifest and lock bytes match the recorded commit. The completed +harness report records `desktopWorkingTreeDirty=false`. + +Reproduce from the recorded commit with an empty wheel output directory: + +```sh +uv build --wheel --out-dir desktop/.artifacts/server-wheel +cargo +1.95.0 build --locked --manifest-path desktop/src-tauri/Cargo.toml --example server_probe +uv run --frozen python desktop/tests/fixture_qualification.py --server-commit 581be595a51b06fdbed208349fc8c2066c80b1b3 +uv run --frozen python desktop/tests/real_server.py +``` + +Ignored local evidence is in `desktop/.artifacts/real-server.json` and +`desktop/.artifacts/qualification-wheel-identity.json`. This qualifies the listed SQLite +protocol behavior on that host. It does not establish native Windows/Intel execution, +installed GUI workflows, persistent native credentials, signing, notarization or full +release-upgrade acceptance. All twenty-four historical records retain their exact identities +and evidence. + +本记录绑定上述准确源码、契约和 wheel;667 个运行时文件均与记录提交逐字节一致。 +四种真实 SQLite/HTTP 连接模式均通过,覆盖所列十一项协议操作、身份变化、同身份撤权、 +Scope 分页及响应丢失后不重放已提交写入。原生探针使用该提交的产品模块及锁定依赖。 +该验证不替代 Windows/Intel、安装后 GUI、原生凭据持久化、签名、公证或完整升级验收。 +全部历史记录保留原有身份、操作集合和证据。 + +#### Server db9c8d6e protocol qualification + +`sqlite-atomic-db9c8d6e-v1` records Server sources at +`db9c8d6edc49166504b39e0d8038dd0082368dee` for OpenAPI digest +`e2a8b64371385e857a3bc44a6d83e09b909a72c9fead64964723ed30fd06226c`. +The wheel is `powercontext-1.4.2.dev90+gdb9c8d6ed-py3-none-any.whl`, SHA-256 +`788a157d27765cc33c16357617d217096bcb3bf056c5f1ecb512e6c14d209e3c`, built from that clean source commit. +All 625 packaged source files in `powercontext/` and `powercontext_service_bootstrap/` +match that commit byte for byte; generated version metadata is excluded. The sorted source +manifest digest is `b962c8c852b5ef31922d6d19cad621009841a47ac288940d4794746a297f52c6` +(one `wheel/path sha256` line per file, with a final newline). OpenAPI, project configuration +and the lock file also match the recorded sources. + +On 2026-10-30, macOS 26.5.2 ARM64 with Python 3.12.13 and Rust 1.95.0 ran the existing +`desktop/tests/real_server.py` harness against independent SQLite Servers loaded from this +wheel. Anonymous loopback, static Bearer, HTTPS with an explicit CA/base path, and an injected +provider with enforced access all passed. The unchanged native probe exercised discovery, +health, Principal, Scope selection and pagination, Atomic Memory save/search/exact revision +reads, identity changes, same-identity revocation, committed writes with lost responses without +replay, and Server survival after client exit. The candidate fixture supplies the exact +source/contract/wheel identity; the product record includes only the eleven exercised operations. +The harness reports `desktopWorkingTreeDirty=false`. + +Reproduce from the recorded commit with an empty wheel output directory: + +```sh +uv build --wheel --out-dir desktop/.artifacts/server-wheel +cargo +1.95.0 build --locked --manifest-path desktop/src-tauri/Cargo.toml --example server_probe +uv run --frozen python desktop/tests/fixture_qualification.py --server-commit db9c8d6edc49166504b39e0d8038dd0082368dee +uv run --frozen python desktop/tests/real_server.py +``` + +Local evidence is in ignored `desktop/.artifacts/real-server.json` and +`desktop/.artifacts/qualification-wheel-identity.json`. This qualifies the listed protocol +behavior. It does not establish native Windows/Intel execution, installed GUI workflows, +persistent credentials, signing or full release-upgrade acceptance. Every historical record +retains its original source, contract, artifact and operation identities. + +本记录绑定上述源码、契约和 wheel。原生探针通过四种真实 SQLite/HTTP 连接模式, +覆盖所列十一项操作、身份变化、撤权及响应丢失后的不确定写入。该验证不替代 Windows/Intel、 +安装界面、原生凭据持久化、签名或完整升级验收。历史资格记录保留原有身份与证据。 + +#### Server 15c9d932 protocol qualification + +`sqlite-atomic-15c9d932-v1` qualifies Server sources at +`15c9d9323dd071e595430c7b9d8a3ca68b4a4bee` for OpenAPI digest +`e37820efe88057fb7c77968b6c28d18c9698a919695d9c83e229ca0ca95cc08d`. +The wheel is `powercontext-1.4.2.dev49+g15c9d9323-py3-none-any.whl`, SHA-256 +`7c943cb6a43b7e2e359f70cc0779d2f998b8ed79079eadfcb60b93c6f30b5bb0`, built from that clean commit. +All 618 packaged source files in `powercontext/` and `powercontext_service_bootstrap/` match the +recorded source bytes; generated version metadata is excluded. The sorted source manifest digest is +`b6ded3cf00b2a08488c6ccea43126b48045ca3f57ff1824557d6a0e26d1109b2` +(one `wheel/path sha256` line per file, with a final newline). OpenAPI, project configuration and +the lock file also match the recorded commit. + +On 2026-10-29, macOS 26.5.2 ARM64 with Python 3.12.13 and Rust 1.95.0 ran the existing +`desktop/tests/real_server.py` harness against the extracted wheel and independent SQLite Server +processes. All four modes passed: anonymous loopback, static Bearer, HTTPS with an explicit CA +and base path, and injected-provider/enforced access. The eleven recorded operations cover +discovery, health, Principal, Scope selection and pagination, Atomic Memory save/search/exact +revision reads, identity changes, same-identity revocation, committed writes with lost responses +without replay, and Server survival after client exit. The native probe uses the unmodified +product modules and locked dependencies. The harness reports `desktopWorkingTreeDirty=false`. + +Reproduce from the recorded commit with an empty wheel output directory: + +```sh +uv build --wheel --out-dir desktop/.artifacts/server-wheel +cargo +1.95.0 build --locked --manifest-path desktop/src-tauri/Cargo.toml --example server_probe +uv run --frozen python desktop/tests/fixture_qualification.py --server-commit 15c9d9323dd071e595430c7b9d8a3ca68b4a4bee +uv run --frozen python desktop/tests/real_server.py +``` + +Local evidence is under ignored `desktop/.artifacts/real-server.json` and +`desktop/.artifacts/qualification-wheel-identity.json`. This qualifies the stated SQLite protocol +behavior. Installed GUI workflows, persistent native credentials, Windows/Intel execution, signing, +and accessibility require their separate checks. Historical records retain their exact identities. + +本记录绑定上述源码、契约和 wheel。四种真实 SQLite/HTTP 连接模式均已通过,覆盖发现、 +Scope、Atomic Memory、身份变化、撤权及响应丢失后的不确定写入。该结果不替代安装后 GUI、 +原生凭据持久化、Windows/Intel、签名或无障碍验收;历史资格记录保留原始身份。 + #### Server bae5d714 protocol qualification `sqlite-atomic-bae5d714-v1` qualifies Server source @@ -1271,7 +1485,7 @@ Wheel: `powercontext-1.4.1.dev273+g079de1030.d20261031-py3-none-any.whl`. Wheel SHA-256: `9915db1a7341622d8f22d97b1c02aa4cbd50a7c3a3be3dc9d9346cb77b3da56f`. The development version is build metadata; the formal release version is unchanged. -On 2026-10-31, Linux x86_64, Python 3.12.12 and Rust 1.95.0 passed all four built-wheel +On 2026-11-01, Linux x86_64, Python 3.12.12 and Rust 1.95.0 passed all four built-wheel SQLite modes in `desktop/tests/real_server.py`: anonymous loopback, Bearer, HTTPS with an explicit CA/base path, and injected-provider/enforced access. The existing native probe covers the eleven declared protocol operations, including server identity, save/search/exact diff --git a/desktop/src-tauri/src/connections/compatibility.json b/desktop/src-tauri/src/connections/compatibility.json index 83488c296e..3f6bc30f4b 100644 --- a/desktop/src-tauri/src/connections/compatibility.json +++ b/desktop/src-tauri/src/connections/compatibility.json @@ -306,29 +306,10 @@ "evidence": "desktop/VALIDATION.md#server-c535d816-protocol-qualification" }, { - "id": "sqlite-atomic-0d86e94b-v1", - "serverCommit": "0d86e94b6803b1eeaaf91231b717d716ad9de134", - "contractSha256": "7442678e3a9b33600c1cb64d531cc09972a7e3996dd740ed96f969c6649f842c", - "artifactSha256": "a295f16eaa66c911a1ee65b5d4e0937f7a1fc9533a41e1fe678d2f4fb0ab80e8", - "operations": [ - "get_access_principal", - "get_artifact_revision", - "get_capabilities", - "get_default_scope", - "get_liveness", - "get_readiness", - "get_scope", - "list_scopes", - "remember_memory", - "search_memory" - ], - "evidence": "desktop/VALIDATION.md#server-0d86e94b-protocol-qualification" - }, - { - "id": "sqlite-atomic-d3850bf6-v1", - "serverCommit": "d3850bf646993db5aa11cabc83d53b1e82571e6c", - "contractSha256": "d057f59a140211376772979f68f777295c88cf1c445bed605d85ce7b0e0c03bb", - "artifactSha256": "e6d606616347271946e30cd9980c248ee5d6b5c92fa91e78f06fa2bf92e7d273", + "id": "sqlite-atomic-15c9d932-v1", + "serverCommit": "15c9d9323dd071e595430c7b9d8a3ca68b4a4bee", + "contractSha256": "e37820efe88057fb7c77968b6c28d18c9698a919695d9c83e229ca0ca95cc08d", + "artifactSha256": "7c943cb6a43b7e2e359f70cc0779d2f998b8ed79079eadfcb60b93c6f30b5bb0", "operations": [ "get_access_principal", "get_artifact_revision", @@ -342,13 +323,13 @@ "remember_memory", "search_memory" ], - "evidence": "desktop/VALIDATION.md#server-d3850bf6-protocol-qualification" + "evidence": "desktop/VALIDATION.md#server-15c9d932-protocol-qualification" }, { - "id": "sqlite-atomic-68491568-v1", - "serverCommit": "68491568359fd9f5723208bbeeec2f808fd2bcc9", - "contractSha256": "b89e721f9fba1c4ef4267b4420af417e5a88a7f7fbe893f639df913b77d24dc0", - "artifactSha256": "91c54cfd8551cc1590abcedb8bbd7bf9865ffc41667fee7b3ac015c249f8bbfb", + "id": "sqlite-atomic-db9c8d6e-v1", + "serverCommit": "db9c8d6edc49166504b39e0d8038dd0082368dee", + "contractSha256": "e2a8b64371385e857a3bc44a6d83e09b909a72c9fead64964723ed30fd06226c", + "artifactSha256": "788a157d27765cc33c16357617d217096bcb3bf056c5f1ecb512e6c14d209e3c", "operations": [ "get_access_principal", "get_artifact_revision", @@ -357,17 +338,18 @@ "get_liveness", "get_readiness", "get_scope", + "get_server_info", "list_scopes", "remember_memory", "search_memory" ], - "evidence": "desktop/VALIDATION.md#server-68491568-protocol-qualification" + "evidence": "desktop/VALIDATION.md#server-db9c8d6e-protocol-qualification" }, { - "id": "sqlite-atomic-0a49db9b-v1", - "serverCommit": "0a49db9bf640703e1067ecda67650f20612d3427", - "contractSha256": "bce4e0d3deec56590a29c3c06af4d853fc8cad2917d8832b23c92dd160472ccd", - "artifactSha256": "8e57881de55502f99f44a6076bcffee2935f3d3666a00330664f4bcb0fb92150", + "id": "sqlite-artifact-d6820d88-v1", + "serverCommit": "d6820d884ccd240712df477e459622f60fd6f5f5", + "contractSha256": "f571a95d193f0dae91694ffcd6b37c93603a5ae731fddefbcdbfa9a5914762ae", + "artifactSha256": "5bcedfde3efa4ae028318a9b8848dec44b5604ac6f636f32bacb637ca5729f52", "operations": [ "get_access_principal", "get_artifact_revision", @@ -376,18 +358,17 @@ "get_liveness", "get_readiness", "get_scope", - "get_server_info", "list_scopes", "remember_memory", "search_memory" ], - "evidence": "desktop/VALIDATION.md#server-0a49db9b-protocol-qualification" + "evidence": "desktop/VALIDATION.md#server-d6820d88-protocol-qualification" }, { - "id": "sqlite-atomic-bbd7b1d8-v1", - "serverCommit": "bbd7b1d8469411d23777d9c60cb0484f9f0d54b8", - "contractSha256": "78120757a326c02230194f5b546b33ea6b7e438544a2d94aa03a1fe4b6626cf7", - "artifactSha256": "07a9fffc49736a5a4d093248e8bb2bb99753e88b48076f05979d63a0137180d0", + "id": "sqlite-artifact-merge-e30479df-v1", + "serverCommit": "working-tree-sha256:e30479dfd8e034b1cdca8a9b59d8b9860b350d9f1acc1f57e1b20a4f3d13c7b0", + "contractSha256": "bf9c258adaa03a652093fbc83f449a5eff95ac6e018cc7281b5c530a2214f7a9", + "artifactSha256": "6daf419858ae4eb618d49bc5e0cc3f3cc47d7d8ef473c8801b27d849b7c7b406", "operations": [ "get_access_principal", "get_artifact_revision", @@ -396,18 +377,17 @@ "get_liveness", "get_readiness", "get_scope", - "get_server_info", "list_scopes", "remember_memory", "search_memory" ], - "evidence": "desktop/VALIDATION.md#server-bbd7b1d8-protocol-qualification" + "evidence": "desktop/VALIDATION.md#artifact-migration-merge-protocol-qualification" }, { - "id": "sqlite-artifact-d6820d88-v1", - "serverCommit": "d6820d884ccd240712df477e459622f60fd6f5f5", - "contractSha256": "f571a95d193f0dae91694ffcd6b37c93603a5ae731fddefbcdbfa9a5914762ae", - "artifactSha256": "5bcedfde3efa4ae028318a9b8848dec44b5604ac6f636f32bacb637ca5729f52", + "id": "sqlite-artifact-merge-87d01f68-v1", + "serverCommit": "working-tree-sha256:87d01f6868f8337840f57985fd9be7f0772120d845d1b4ae80895e15724a5a80", + "contractSha256": "aa46c5f141190bc3516a34da9fd07ce756bdcf7537ab7109f151755e28cc6ed7", + "artifactSha256": "4806c56e3e948249ef097e504e11347833b1d8fe510f4b7639ef3eca16460ce2", "operations": [ "get_access_principal", "get_artifact_revision", @@ -420,13 +400,13 @@ "remember_memory", "search_memory" ], - "evidence": "desktop/VALIDATION.md#server-d6820d88-protocol-qualification" + "evidence": "desktop/VALIDATION.md#artifact-framework-merge-87d01f68-protocol-qualification" }, { - "id": "sqlite-artifact-merge-e30479df-v1", - "serverCommit": "working-tree-sha256:e30479dfd8e034b1cdca8a9b59d8b9860b350d9f1acc1f57e1b20a4f3d13c7b0", - "contractSha256": "bf9c258adaa03a652093fbc83f449a5eff95ac6e018cc7281b5c530a2214f7a9", - "artifactSha256": "6daf419858ae4eb618d49bc5e0cc3f3cc47d7d8ef473c8801b27d849b7c7b406", + "id": "sqlite-atomic-68491568-v1", + "serverCommit": "68491568359fd9f5723208bbeeec2f808fd2bcc9", + "contractSha256": "b89e721f9fba1c4ef4267b4420af417e5a88a7f7fbe893f639df913b77d24dc0", + "artifactSha256": "91c54cfd8551cc1590abcedb8bbd7bf9865ffc41667fee7b3ac015c249f8bbfb", "operations": [ "get_access_principal", "get_artifact_revision", @@ -439,13 +419,13 @@ "remember_memory", "search_memory" ], - "evidence": "desktop/VALIDATION.md#artifact-migration-merge-protocol-qualification" + "evidence": "desktop/VALIDATION.md#server-68491568-protocol-qualification" }, { - "id": "sqlite-artifact-merge-87d01f68-v1", - "serverCommit": "working-tree-sha256:87d01f6868f8337840f57985fd9be7f0772120d845d1b4ae80895e15724a5a80", - "contractSha256": "aa46c5f141190bc3516a34da9fd07ce756bdcf7537ab7109f151755e28cc6ed7", - "artifactSha256": "4806c56e3e948249ef097e504e11347833b1d8fe510f4b7639ef3eca16460ce2", + "id": "sqlite-atomic-0a49db9b-v1", + "serverCommit": "0a49db9bf640703e1067ecda67650f20612d3427", + "contractSha256": "bce4e0d3deec56590a29c3c06af4d853fc8cad2917d8832b23c92dd160472ccd", + "artifactSha256": "8e57881de55502f99f44a6076bcffee2935f3d3666a00330664f4bcb0fb92150", "operations": [ "get_access_principal", "get_artifact_revision", @@ -454,11 +434,12 @@ "get_liveness", "get_readiness", "get_scope", + "get_server_info", "list_scopes", "remember_memory", "search_memory" ], - "evidence": "desktop/VALIDATION.md#artifact-framework-merge-87d01f68-protocol-qualification" + "evidence": "desktop/VALIDATION.md#server-0a49db9b-protocol-qualification" }, { "id": "sqlite-source-artifact-87b6fae2-v1", @@ -481,10 +462,10 @@ "evidence": "desktop/VALIDATION.md#source-and-artifact-merge-87b6fae2-protocol-qualification" }, { - "id": "sqlite-atomic-e4fbead8-v1", - "serverCommit": "e4fbead875626a55f850933f4ef59a48cc10de48", - "contractSha256": "f6ef63adfc6eed12410e0559b9c2cbbefc9ab7cb5ec1d2dd4cf807e6b9ac618c", - "artifactSha256": "e19a4a250e5d1b60ae714439f60a9abf79135408b5a8c4fcc9a5747fa2fc20b0", + "id": "sqlite-atomic-581be595-v1", + "serverCommit": "581be595a51b06fdbed208349fc8c2066c80b1b3", + "contractSha256": "4aeefdd602a11ebece4c74f120b0cc8671aa3cd62386822326198ca7a4b88ca5", + "artifactSha256": "ded0d06803205e6b34a93cf9c33e76897f65b5880e2530e5501006a5d05ce6c8", "operations": [ "get_access_principal", "get_artifact_revision", @@ -498,7 +479,7 @@ "remember_memory", "search_memory" ], - "evidence": "desktop/VALIDATION.md#server-e4fbead8-protocol-qualification" + "evidence": "desktop/VALIDATION.md#server-581be595-protocol-qualification" }, { "id": "sqlite-atomic-8252ebb5-v1", @@ -597,6 +578,105 @@ ], "evidence": "desktop/VALIDATION.md#server-bae5d714-protocol-qualification" }, + { + "id": "sqlite-atomic-f3a90b2f-v1", + "serverCommit": "f3a90b2fbc9edf9ab503e532fdc1c29418746a7b", + "contractSha256": "9c35d3d80035213a52598a62cd748fd5febb3a9afde69a5908b0dee40aaf73c2", + "artifactSha256": "76f142ccbe406411c60b5ba91429350ea8ebbd1f449275a82b6cfd5213981f9e", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_server_info", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-f3a90b2f-protocol-qualification" + }, + { + "id": "sqlite-atomic-0d86e94b-v1", + "serverCommit": "0d86e94b6803b1eeaaf91231b717d716ad9de134", + "contractSha256": "7442678e3a9b33600c1cb64d531cc09972a7e3996dd740ed96f969c6649f842c", + "artifactSha256": "a295f16eaa66c911a1ee65b5d4e0937f7a1fc9533a41e1fe678d2f4fb0ab80e8", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-0d86e94b-protocol-qualification" + }, + { + "id": "sqlite-atomic-d3850bf6-v1", + "serverCommit": "d3850bf646993db5aa11cabc83d53b1e82571e6c", + "contractSha256": "d057f59a140211376772979f68f777295c88cf1c445bed605d85ce7b0e0c03bb", + "artifactSha256": "e6d606616347271946e30cd9980c248ee5d6b5c92fa91e78f06fa2bf92e7d273", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_server_info", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-d3850bf6-protocol-qualification" + }, + { + "id": "sqlite-atomic-bbd7b1d8-v1", + "serverCommit": "bbd7b1d8469411d23777d9c60cb0484f9f0d54b8", + "contractSha256": "78120757a326c02230194f5b546b33ea6b7e438544a2d94aa03a1fe4b6626cf7", + "artifactSha256": "07a9fffc49736a5a4d093248e8bb2bb99753e88b48076f05979d63a0137180d0", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_server_info", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-bbd7b1d8-protocol-qualification" + }, + { + "id": "sqlite-atomic-e4fbead8-v1", + "serverCommit": "e4fbead875626a55f850933f4ef59a48cc10de48", + "contractSha256": "f6ef63adfc6eed12410e0559b9c2cbbefc9ab7cb5ec1d2dd4cf807e6b9ac618c", + "artifactSha256": "e19a4a250e5d1b60ae714439f60a9abf79135408b5a8c4fcc9a5747fa2fc20b0", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_server_info", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-e4fbead8-protocol-qualification" + }, { "id": "sqlite-atomic-2d782c2a-v1", "serverCommit": "2d782c2a1283cf7b987f5aac924841d8dcd841c0", @@ -636,5 +716,25 @@ "search_memory" ], "evidence": "desktop/VALIDATION.md#server-112db11e-protocol-qualification" + }, + { + "id": "sqlite-atomic-3cefa45a-v1", + "serverCommit": "3cefa45ad08490423391148439c9eb73f1d48f58", + "contractSha256": "d59da9040814d0f2b25d930ecb36b93d932ff8906440606d259d3b9456364d3d", + "artifactSha256": "cead75231f0f6961735dd117f454731d989ba96880a08c988c24aa0bae71c38e", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_server_info", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-3cefa45a-protocol-qualification" } ] diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index f16a602072..6141bee260 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "83dc68194770a41facea55a3fe2279bc0e8b01812a82a1d838334232c67bedc7", + "contractSha256": "d59da9040814d0f2b25d930ecb36b93d932ff8906440606d259d3b9456364d3d", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts index 5f6ca8b625..a021e38622 100644 --- a/desktop/ui/src/generated/api.d.ts +++ b/desktop/ui/src/generated/api.d.ts @@ -2707,6 +2707,46 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/access/ownership/plan": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Inventory ownership data missing after switching Access enforcement on + * @description Inspect the Scopes that were written while Access was disabled: missing Artifact owners, pending Review Candidates without an owner attestation, and, when named, the background Principal's per-Scope write authority. Produces no change. In enforced mode completes as a deployment administrator; in disabled mode completes without authentication because the Server cannot yet protect anything. A background Principal check requires an available Access store; otherwise it reports 503 relationship_management_unavailable. Use the offline ownership command to check the proposed background Principal before enforcement is enabled. + */ + post: operations["plan_access_ownership"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/access/ownership/apply": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Backfill missing Artifact owners and Candidate owner attestations + * @description Grant the missing ownership data to the explicitly named Principal; the Server never derives it. Existing Artifact owners and Candidate attestations are preserved and reported as conflicts when they disagree with the named Principal. Identities with recorded ownership recovery evidence are reported as conflicts and must retain their original attribution. Every item is one idempotent write, so interrupted or repeated runs refill only still-missing items. Set dry_run first to preview the exact items. Requires a deployment administrator; while Access is disabled the Server has no ownership store and reports 503 ownership_migration_unavailable. + */ + post: operations["apply_access_ownership"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -6147,6 +6187,53 @@ export interface components { */ view: "discovery" | "control"; }; + PlanAccessOwnershipRequest: { + /** @description Scopes to inspect; every Scope when omitted. Pass Scopes incrementally for large deployments. */ + scope_ids?: string[]; + /** @description Optional background processing Principal, such as ACCESS_BACKGROUND_PRINCIPAL_ID; each selected Scope is checked for scope.contribute and write permission on an existing Profile, including after all ownership gaps have been filled. An available Access store is required. */ + background_principal?: components["schemas"]["AccessPrincipal"] | null; + }; + /** @enum {string} */ + OwnershipGapKind: "artifact_owner" | "candidate_attestation"; + OwnershipGapItem: { + kind: components["schemas"]["OwnershipGapKind"]; + scope_id: string; + family: string; + artifact_id?: string | null; + entry_id?: string | null; + candidate_id?: string | null; + version?: number | null; + conflict_code?: string | null; + }; + AccessOwnershipScopePlan: { + scope_id: string; + artifact_gaps: components["schemas"]["OwnershipGapItem"][]; + candidate_gaps: components["schemas"]["OwnershipGapItem"][]; + }; + BackgroundPermissionGap: { + scope_id: string; + principal_id: string; + /** @enum {boolean} */ + allowed: false; + reason_code: string | null; + }; + AccessOwnershipPlan: { + mode: components["schemas"]["AccessControlMode"]; + scopes: components["schemas"]["AccessOwnershipScopePlan"][]; + background: components["schemas"]["BackgroundPermissionGap"][]; + }; + ApplyAccessOwnershipRequest: { + owner: components["schemas"]["AccessPrincipal"]; + /** @description Scopes to migrate; every Scope when omitted. Pass Scopes incrementally for large deployments. */ + scope_ids?: string[]; + /** @default false */ + dry_run: boolean; + }; + AccessOwnershipApplyResult: { + dry_run: boolean; + applied: components["schemas"]["OwnershipGapItem"][]; + conflicts: components["schemas"]["OwnershipGapItem"][]; + }; }; responses: { /** @description This response requires a coordinated client supporting Dream contract 2. Upgrade the Python SDK, CLI or integration and send X-PowerContext-Dream-Contract: 2. Original operations and compatible candidate shapes remain available without the header. */ @@ -10991,4 +11078,60 @@ export interface operations { 503: components["responses"]["Unavailable"]; }; }; + plan_access_ownership: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PlanAccessOwnershipRequest"]; + }; + }; + responses: { + /** @description The missing-attribution inventory for the requested Scopes. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AccessOwnershipPlan"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; + apply_access_ownership: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ApplyAccessOwnershipRequest"]; + }; + }; + responses: { + /** @description The applied, or, in dry-run, previewed ownership backfill. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AccessOwnershipApplyResult"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; } diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index 31df14212e..1e2f36add1 100644 --- a/desktop/ui/src/generated/operations.ts +++ b/desktop/ui/src/generated/operations.ts @@ -15,7 +15,7 @@ */ // Generated from openapi/powercontext.yaml. Do not edit. -export const contractSha256 = "83dc68194770a41facea55a3fe2279bc0e8b01812a82a1d838334232c67bedc7"; +export const contractSha256 = "d59da9040814d0f2b25d930ecb36b93d932ff8906440606d259d3b9456364d3d"; export const operations = { "get_liveness": { "method": "GET", diff --git a/docs/en/docs/operate/deploy-server.md b/docs/en/docs/operate/deploy-server.md index a40e882942..d2662ed5db 100644 --- a/docs/en/docs/operate/deploy-server.md +++ b/docs/en/docs/operate/deploy-server.md @@ -316,6 +316,57 @@ A shared static token does not enforce human review or prevent an Agent from app submission and approval, use distinct authenticated Principals and grant review permission only to the reviewer. See [Separate Agent submission from review](../workflows/review-candidates.md#separate-agent-submission-from-review). +### Migrate data written before enforcement + +Data accepted while `ACCESS_MODE=disabled` has no Artifact owner and no Candidate owner attestation. After switching to +`enforced`, that data stays readable, but modifying it returns `403` and approving its pending Candidates returns +`503 artifact_owner_pending`, while data created under enforcement keeps working. The Server never derives ownership, +so an administrator migrates the missing attribution explicitly: + +1. Back up the database. The offline command below works while the Server is stopped, before or after the switch: +2. Inventory the affected data with `powercontext server access-ownership-migrate --action plan`. The same command + also runs before the switch, while `ACCESS_MODE=disabled`. Add + `--background-principal service:BACKGROUND_ID` with the future `ACCESS_BACKGROUND_PRINCIPAL_ID` to check + every selected Scope for `scope.contribute` and existing Profile write permission. These checks still run + after all ownership gaps have been filled. HTTP planning accepts `background_principal` when an Access + store is available; otherwise it returns `503 relationship_management_unavailable`. Use the offline + command for this check before enabling enforcement. + For a configured single-token deployment, the check includes the worker's static Scope preset without + creating bindings. An existing Profile still requires its own Artifact write permission. +3. Backfill with an explicitly named owning Principal — for a single-user static-token deployment that is the + `server-token` service Principal: + + ```bash + powercontext server access-ownership-migrate \ + --action apply --owner service:server-token --maintenance-confirmed + ``` + + `apply` requires `--maintenance-confirmed` after stopping every Server process. Pass `--scope-id` to migrate + Scopes incrementally on large deployments. Every item is one idempotent write, so interrupted or repeated runs + refill only still-missing items. Existing owners and Candidate attestations are preserved and reported as + conflicts, never overwritten. Run `apply` again with another `--owner` when different identities must own + different Scopes; multi-user deployments keep this identity boundary explicit. + + The inventory includes merged and retired Artifact identities so supported restoration remains authorized. + It also includes committed Handoffs and each published Handoff copy under its recorded identity. + Offline `apply`, HTTP `apply`, and the HTTP preview report `owner-recovery-pending` or + `owner-recovery-delivered_relation_missing` when durable recovery evidence already records an identity. + They preserve that evidence and do not replace its owner. Candidate recovery checks cover the families accepted + by migration, including Prompt Candidates, independently of which families require standalone Artifact ownership. + Complete pending ownership delivery through the + [ownership recovery procedure](../workflows/scopes-and-access.md#recover-external-ownership-writes). + With an external Access store, migration retains durable ownership delivery work if its audit write fails. + Repeating the same HTTP `apply` completes that original audit before reporting success, even if the owner + relation already exists. A preview does not replay pending work. + + Without a stopped-deployment window, the same flow is available over HTTP on a running Server: + `POST /v1/access/ownership/apply` with `{"owner": {"type": "service", "id": "server-token"}, "dry_run": true}` + previews first; in enforced mode both HTTP operations require `server.admin`, and `apply` reports + `503 ownership_migration_unavailable` while `ACCESS_MODE=disabled`. +4. Verify: modify a previously immutable Artifact, approve a previously blocked Candidate, and confirm `plan` now + reports no ownership gaps. Include the background Principal in the plan and check that `background` is empty. + Identities without the required Artifact write or Candidate review authority still receive `403`. + ## Check the deployment Use liveness to determine whether the process can answer HTTP requests: diff --git a/docs/zh/docs/operate/deploy-server.md b/docs/zh/docs/operate/deploy-server.md index c645d984c0..fe63f3590c 100644 --- a/docs/zh/docs/operate/deploy-server.md +++ b/docs/zh/docs/operate/deploy-server.md @@ -289,6 +289,46 @@ Cookie,最长八小时;HTTPS 下设置 Secure。反向代理应正确传递 共享静态 token 不会强制人工审核,也不会阻止 Agent 批准自己的 Candidate。需要分离提交和审批时,应使用不同的已认证 Principal,并仅向审核者授予审核权限。参见[分离 Agent 提交与审核](../workflows/review-candidates.md#分离-agent-提交与审核)。 +### 迁移认证开启前写入的数据 + +`ACCESS_MODE=disabled` 期间接受的数据没有 Artifact owner,也没有 Candidate owner attestation。切换到 `enforced` +之后,这些数据仍可读取,但修改会返回 `403`,审批其待审 Candidate 会返回 `503 artifact_owner_pending`;在认证模式下 +新建的数据不受影响。Server 不会推断归属,需要管理员显式迁移缺失的授权信息: + +1. 先备份数据库。下面的离线命令在 Server 停止时执行,切换前后都可以运行。 +2. 用 `powercontext server access-ownership-migrate --action plan` 盘点受影响的数据。`ACCESS_MODE=disabled` + 期间同样可以运行。添加 `--background-principal service:后台身份ID`,填写切换后准备使用的 + `ACCESS_BACKGROUND_PRINCIPAL_ID`,即可检查每个指定 Scope 的 `scope.contribute` 和已有 Profile 写权限。 + owner 缺口补齐后仍会执行这些检查。已配置单 Token 的部署会计入后台的静态 Scope 权限预设,预检不会创建授权。 + 已有 Profile 仍须单独通过 Artifact 写权限检查。HTTP 预检在授权存储可用时接受 `background_principal`;否则返回 + `503 relationship_management_unavailable`。开启认证前请使用离线命令执行后台权限预检。 +3. 用显式指定的归属 Principal 回补。单用户静态 token 部署就是 `server-token` 服务 Principal: + + ```bash + powercontext server access-ownership-migrate \ + --action apply --owner service:server-token --maintenance-confirmed + ``` + + `apply` 要求先用 `--maintenance-confirmed` 确认所有 Server 进程已停止。大型部署可以重复传 `--scope-id` + 分批迁移。每一项都是一次幂等写入,中断或重复执行只会补齐仍缺失的条目;已有的 owner 和 Candidate + attestation 会被保留,冲突时明确报告,不会被覆盖。不同身份需要拥有不同 Scope 时,用另一个 `--owner` + 再次执行;多用户部署由此保持明确的身份边界。 + + 盘点包含已合并和已退休的 Artifact 身份,以及已提交 Handoff 和各发布副本的实际身份,以保留恢复操作所需的权限。 + 如果持久化恢复记录已经确定归属, + 离线 `apply`、HTTP `apply` 及 HTTP 预览会报告 `owner-recovery-pending` 或 `owner-recovery-delivered_relation_missing`, + 并保留原始记录及其归属。待投递的归属信息应按[归属恢复流程](../workflows/scopes-and-access.md)继续处理。 + Candidate 恢复预检覆盖迁移接受的家族,包括 Prompt Candidate,不受独立 Artifact owner 家族范围限制。 + 使用外部 Access 存储时,如果审计写入失败,迁移会保留可重放的归属投递任务。即使 owner 关系已存在, + 重复同一 HTTP `apply` 也会先完成原始审计,再报告成功。预览不会重放待处理任务。 + + 如果没有停机窗口,也可以对运行中的 Server 走 HTTP:`POST /v1/access/ownership/apply`(先用 + `dry_run: true` 预览)。认证模式下两个 HTTP 操作都要求 `server.admin`;`ACCESS_MODE=disabled` 时 `apply` 返回 + `503 ownership_migration_unavailable`。 +4. 验证:修改一个此前不可变的 Artifact,审批一个此前被阻塞的 Candidate,并确认 `plan` 不再报告归属缺口。 + 预检时传入后台 Principal,确认 `background` 为空。没有相应 Artifact 写权限或 Candidate 审批权限的身份 + 仍会收到 `403`。 + ## 检查部署 使用 liveness 判断进程能否响应 HTTP 请求: diff --git a/e2e/bub/src/powercontext_e2e/access_audit.py b/e2e/bub/src/powercontext_e2e/access_audit.py index e62de78991..4c95ddc400 100644 --- a/e2e/bub/src/powercontext_e2e/access_audit.py +++ b/e2e/bub/src/powercontext_e2e/access_audit.py @@ -59,6 +59,8 @@ "revoke_access_binding", "replace_access_binding", "list_access_audit", + "plan_access_ownership", + "apply_access_ownership", }) # Checks the Server reports to the caller rather than enforces: which actions a Candidate offers, and the Access API's # permission check. Denying one does not reject the request that asked. diff --git a/integrations/cindy/plugins/powercontext-hooks/node/worker.cjs b/integrations/cindy/plugins/powercontext-hooks/node/worker.cjs index 96ca38b2e8..c146c7633f 100644 --- a/integrations/cindy/plugins/powercontext-hooks/node/worker.cjs +++ b/integrations/cindy/plugins/powercontext-hooks/node/worker.cjs @@ -1794,6 +1794,28 @@ const OPERATIONS = { headerParams: [], successStatuses: [200], emptyStatuses: [] + }, + plan_access_ownership: { + method: "POST", + path: "/v1/access/ownership/plan", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + apply_access_ownership: { + method: "POST", + path: "/v1/access/ownership/apply", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] } }; const OPERATION_IDS = Object.keys(OPERATIONS); diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index d9e4192081..146d95f363 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -1863,6 +1863,28 @@ const OPERATIONS = { headerParams: [], successStatuses: [200], emptyStatuses: [] + }, + plan_access_ownership: { + method: "POST", + path: "/v1/access/ownership/plan", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + apply_access_ownership: { + method: "POST", + path: "/v1/access/ownership/apply", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] } }; const OPERATION_IDS = Object.keys(OPERATIONS); diff --git a/integrations/dsh/plugins/powercontext/src/operations.generated.ts b/integrations/dsh/plugins/powercontext/src/operations.generated.ts index d2ab638ca2..752324ea32 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -166,6 +166,8 @@ export const OPERATIONS = { source_channel_reply: { method: 'POST', path: '/v1/source-management/channel/reply', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, source_channel_close: { method: 'POST', path: '/v1/source-management/channel/close', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, source_instance_exchange: { method: 'POST', path: '/v1/source-management/instances/{instance_id}/{operation}', location: "body", scopeMode: 'none', pathParameters: ['instance_id', 'operation'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + plan_access_ownership: { method: 'POST', path: '/v1/access/ownership/plan', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + apply_access_ownership: { method: 'POST', path: '/v1/access/ownership/apply', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, } as const export type OperationId = keyof typeof OPERATIONS diff --git a/integrations/opencode/plugins/powercontext/lib/index.js b/integrations/opencode/plugins/powercontext/lib/index.js index 7272395623..e498cdd1d2 100644 --- a/integrations/opencode/plugins/powercontext/lib/index.js +++ b/integrations/opencode/plugins/powercontext/lib/index.js @@ -1771,6 +1771,28 @@ const OPERATIONS = { headerParams: [], successStatuses: [200], emptyStatuses: [] + }, + plan_access_ownership: { + method: "POST", + path: "/v1/access/ownership/plan", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + apply_access_ownership: { + method: "POST", + path: "/v1/access/ownership/apply", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] } }; const OPERATION_IDS = Object.keys(OPERATIONS); diff --git a/integrations/opencode/plugins/powercontext/lib/tui.js b/integrations/opencode/plugins/powercontext/lib/tui.js index 0626fad3b3..9ebe798386 100644 --- a/integrations/opencode/plugins/powercontext/lib/tui.js +++ b/integrations/opencode/plugins/powercontext/lib/tui.js @@ -1771,6 +1771,28 @@ const OPERATIONS = { headerParams: [], successStatuses: [200], emptyStatuses: [] + }, + plan_access_ownership: { + method: "POST", + path: "/v1/access/ownership/plan", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + apply_access_ownership: { + method: "POST", + path: "/v1/access/ownership/apply", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] } }; const OPERATION_IDS = Object.keys(OPERATIONS); diff --git a/integrations/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index d2ab638ca2..752324ea32 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -166,6 +166,8 @@ export const OPERATIONS = { source_channel_reply: { method: 'POST', path: '/v1/source-management/channel/reply', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, source_channel_close: { method: 'POST', path: '/v1/source-management/channel/close', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, source_instance_exchange: { method: 'POST', path: '/v1/source-management/instances/{instance_id}/{operation}', location: "body", scopeMode: 'none', pathParameters: ['instance_id', 'operation'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + plan_access_ownership: { method: 'POST', path: '/v1/access/ownership/plan', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + apply_access_ownership: { method: 'POST', path: '/v1/access/ownership/apply', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, } as const export type OperationId = keyof typeof OPERATIONS diff --git a/integrations/pi/plugins/powercontext/src/operations.generated.ts b/integrations/pi/plugins/powercontext/src/operations.generated.ts index d2ab638ca2..752324ea32 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -166,6 +166,8 @@ export const OPERATIONS = { source_channel_reply: { method: 'POST', path: '/v1/source-management/channel/reply', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, source_channel_close: { method: 'POST', path: '/v1/source-management/channel/close', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, source_instance_exchange: { method: 'POST', path: '/v1/source-management/instances/{instance_id}/{operation}', location: "body", scopeMode: 'none', pathParameters: ['instance_id', 'operation'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + plan_access_ownership: { method: 'POST', path: '/v1/access/ownership/plan', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + apply_access_ownership: { method: 'POST', path: '/v1/access/ownership/apply', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, } as const export type OperationId = keyof typeof OPERATIONS diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index c4a149c345..8e3dbacd49 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -5862,6 +5862,77 @@ paths: $ref: '#/components/responses/InvalidRequest' '503': $ref: '#/components/responses/Unavailable' + /v1/access/ownership/plan: + post: + tags: [access] + summary: Inventory ownership data missing after switching Access enforcement on + operationId: plan_access_ownership + description: >- + Inspect the Scopes that were written while Access was disabled: missing Artifact owners, pending + Review Candidates without an owner attestation, and, when named, the background Principal's + per-Scope write authority. Produces no change. In enforced mode completes as a deployment + administrator; in disabled mode completes without authentication because the Server cannot yet + protect anything. A background Principal check requires an available Access store; otherwise it + reports 503 relationship_management_unavailable. Use the offline ownership command to check the + proposed background Principal before enforcement is enabled. + x-powercontext-access: {action: access.self, resource: {type: server}} + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PlanAccessOwnershipRequest" + responses: + "200": + description: The missing-attribution inventory for the requested Scopes. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessOwnershipPlan" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + /v1/access/ownership/apply: + post: + tags: [access] + summary: Backfill missing Artifact owners and Candidate owner attestations + operationId: apply_access_ownership + description: >- + Grant the missing ownership data to the explicitly named Principal; the Server never derives it. + Existing Artifact owners and Candidate attestations are preserved and reported as conflicts when + they disagree with the named Principal. Identities with recorded ownership recovery evidence + are reported as conflicts and must retain their original attribution. Every item is one idempotent + write, so interrupted or + repeated runs refill only still-missing items. Set dry_run first to preview the exact items. + Requires a deployment administrator; while Access is disabled the Server has no ownership store + and reports 503 ownership_migration_unavailable. + x-powercontext-access: {action: access.self, resource: {type: server}} + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ApplyAccessOwnershipRequest" + responses: + "200": + description: The applied, or, in dry-run, previewed ownership backfill. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessOwnershipApplyResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" components: securitySchemes: BearerAuth: @@ -14450,3 +14521,102 @@ components: - discovery - control default: discovery + PlanAccessOwnershipRequest: + type: object + additionalProperties: false + properties: + scope_ids: + type: array + minItems: 1 + items: {type: string, minLength: 1, maxLength: 256} + description: Scopes to inspect; every Scope when omitted. Pass Scopes incrementally for large deployments. + background_principal: + allOf: + - $ref: "#/components/schemas/AccessPrincipal" + nullable: true + description: >- + Optional background processing Principal, such as ACCESS_BACKGROUND_PRINCIPAL_ID; each + selected Scope is checked for scope.contribute and write permission on an existing Profile, + including after all ownership gaps have been filled. An available Access store is required. + OwnershipGapKind: + type: string + enum: [artifact_owner, candidate_attestation] + OwnershipGapItem: + type: object + additionalProperties: false + required: [kind, scope_id, family] + properties: + kind: + $ref: "#/components/schemas/OwnershipGapKind" + scope_id: {type: string, minLength: 1, maxLength: 256} + family: {type: string, minLength: 1, maxLength: 128} + artifact_id: {type: string, maxLength: 256, nullable: true} + entry_id: {type: string, maxLength: 256, nullable: true} + candidate_id: {type: string, maxLength: 128, nullable: true} + version: {type: integer, minimum: 1, nullable: true} + conflict_code: {type: string, maxLength: 64, nullable: true} + AccessOwnershipScopePlan: + type: object + additionalProperties: false + required: [scope_id, artifact_gaps, candidate_gaps] + properties: + scope_id: {type: string, minLength: 1, maxLength: 256} + artifact_gaps: + type: array + items: + $ref: "#/components/schemas/OwnershipGapItem" + candidate_gaps: + type: array + items: + $ref: "#/components/schemas/OwnershipGapItem" + BackgroundPermissionGap: + type: object + additionalProperties: false + required: [scope_id, principal_id, allowed, reason_code] + properties: + scope_id: {type: string, minLength: 1, maxLength: 256} + principal_id: {type: string, minLength: 1, maxLength: 255} + allowed: {type: boolean, enum: [false]} + reason_code: {type: string, maxLength: 64, nullable: true} + AccessOwnershipPlan: + type: object + additionalProperties: false + required: [mode, scopes, background] + properties: + mode: + $ref: "#/components/schemas/AccessControlMode" + scopes: + type: array + items: + $ref: "#/components/schemas/AccessOwnershipScopePlan" + background: + type: array + items: + $ref: "#/components/schemas/BackgroundPermissionGap" + ApplyAccessOwnershipRequest: + type: object + additionalProperties: false + required: [owner] + properties: + owner: + $ref: "#/components/schemas/AccessPrincipal" + scope_ids: + type: array + minItems: 1 + items: {type: string, minLength: 1, maxLength: 256} + description: Scopes to migrate; every Scope when omitted. Pass Scopes incrementally for large deployments. + dry_run: {type: boolean, default: false} + AccessOwnershipApplyResult: + type: object + additionalProperties: false + required: [dry_run, applied, conflicts] + properties: + dry_run: {type: boolean} + applied: + type: array + items: + $ref: "#/components/schemas/OwnershipGapItem" + conflicts: + type: array + items: + $ref: "#/components/schemas/OwnershipGapItem" diff --git a/release/upgrade-support.json b/release/upgrade-support.json index 027e9739b5..a1c4f174c8 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -3,8 +3,8 @@ "target_version": "1.5.0", "mode": "upgrade-required", "schema_review": { - "persistence_sha256": "7ff5a132a1b4cbd5c00554f0a79cf72d14da226e1389edc756dc88ab97eae82a", - "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. Unmanaged historical Server databases are admitted by required structure and domain state, then normalized before adopting an existing checkpoint. Historical Memory conversion, Scope preservation, SQLite rollback and native DDL recovery use the shared maintenance flow. Independent extension objects and harmless DDL differences do not require clean-install fingerprints. Existing immutable revision resources and the installed-release acceptance matrix remain unchanged. Local historical tests do not certify arbitrary tenant defaults, every vector/Code configuration or future releases. SQLite compatibility validation includes effective primary-key collations and rejects conflicting identity semantics. Historical normalization and the frozen s1400 rebuild preserve generated extension definitions while copying only writable columns. Both virtual and stored generated values remain computed by SQLite; immutable revision resources and the native backend paths are unchanged. SQLite table rebuilds preserve declared collations on ordinary and generated extension columns, including their inherited index comparison semantics. Expression-local COLLATE clauses remain part of their expressions and do not change the column collation. This changes only SQLite compatibility normalization; frozen revisions and native backend execution remain unchanged. Historical Memory admission recognizes lineage ownership columns and default non-merge references while rejecting unmapped payloads. Native compatibility journals record temporary Processing counter ownership before DDL and clear it after cleanup, including completed-phase recovery. SQLite extension preservation also covers the registered r1400 bridge. Frozen migration resources and the installed-release acceptance matrix remain unchanged. Native historical Atomic conversion creates nullable description metadata with the registered UTF-8 binary collation, allowing the later frozen ADD transition to recognize its completed postcondition without duplicate DDL. SQLite historical normalization and registered source bridges preserve dependent views and their triggers during same-name table replacement. Legacy rename behavior is limited to the exact staging-to-final rename statements and the original connection setting is restored; genuine table renames still update dependent references. Frozen migration resources and native backend behavior remain unchanged. SQLite admission validates required extension defaults using SQLite deterministic-expression restrictions and a non-NULL evaluation on an isolated connection. Connection-state, random and clock-dependent expressions are rejected; direct CURRENT_DATE, CURRENT_TIME and CURRENT_TIMESTAMP defaults remain supported alongside usable constant expressions. Regression coverage verifies consecutive Source writes in distinct scopes within the same transaction. Read-only verification and startup admission share this rule; supported defaults remain usable through Source write and readback. Independent extension tables remain preserved through managed upgrades and initialization recovery; missing recovery evidence and missing vector configuration still block maintenance. Registered native column additions and modifications materialize the charset and collation declared by their immutable postcondition when the SQL would otherwise inherit a historical table default. This preserves GBK-origin upgrade recovery without changing frozen resources, existing table defaults, independent extension encodings or readiness requirements. SQLite Candidate extension columns and their values survive both stages of the registered Dream rebuild, including generated columns and dependent views. Frozen source introspection projects the already-validated managed columns without altering stored extension data. Independent indexes use SQLite-rewritten definitions after genuine table renames. Native CHECK removal resolves the actual constraint name from the registered expression and enforcement semantics, including recovery after committed DDL. Frozen resources, registered revision checksums and the installed-release acceptance matrix remain unchanged. SQLite admission rejects additional NOT NULL generated columns because computed values can reject valid Source writes. Nullable virtual and stored extensions remain preserved across historical upgrades and support Source write/readback. Frozen migrations and native backend behavior are unchanged." + "persistence_sha256": "892a9ad6b459026bb9626c755d7a0ef4eef3ed4688c507887a2feaab1736e525", + "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. Unmanaged historical Server databases are admitted by required structure and domain state, then normalized before adopting an existing checkpoint. Historical Memory conversion, Scope preservation, SQLite rollback and native DDL recovery use the shared maintenance flow. Independent extension objects and harmless DDL differences do not require clean-install fingerprints. Existing immutable revision resources and the installed-release acceptance matrix remain unchanged. Local historical tests do not certify arbitrary tenant defaults, every vector/Code configuration or future releases. SQLite compatibility validation includes effective primary-key collations and rejects conflicting identity semantics. Historical normalization and the frozen s1400 rebuild preserve generated extension definitions while copying only writable columns. Both virtual and stored generated values remain computed by SQLite; immutable revision resources and the native backend paths are unchanged. SQLite table rebuilds preserve declared collations on ordinary and generated extension columns, including their inherited index comparison semantics. Expression-local COLLATE clauses remain part of their expressions and do not change the column collation. This changes only SQLite compatibility normalization; frozen revisions and native backend execution remain unchanged. Historical Memory admission recognizes lineage ownership columns and default non-merge references while rejecting unmapped payloads. Native compatibility journals record temporary Processing counter ownership before DDL and clear it after cleanup, including completed-phase recovery. SQLite extension preservation also covers the registered r1400 bridge. Frozen migration resources and the installed-release acceptance matrix remain unchanged. Native historical Atomic conversion creates nullable description metadata with the registered UTF-8 binary collation, allowing the later frozen ADD transition to recognize its completed postcondition without duplicate DDL. SQLite historical normalization and registered source bridges preserve dependent views and their triggers during same-name table replacement. Legacy rename behavior is limited to the exact staging-to-final rename statements and the original connection setting is restored; genuine table renames still update dependent references. Frozen migration resources and native backend behavior remain unchanged. SQLite admission validates required extension defaults using SQLite deterministic-expression restrictions and a non-NULL evaluation on an isolated connection. Connection-state, random and clock-dependent expressions are rejected; direct CURRENT_DATE, CURRENT_TIME and CURRENT_TIMESTAMP defaults remain supported alongside usable constant expressions. Regression coverage verifies consecutive Source writes in distinct scopes within the same transaction. Read-only verification and startup admission share this rule; supported defaults remain usable through Source write and readback. Independent extension tables remain preserved through managed upgrades and initialization recovery; missing recovery evidence and missing vector configuration still block maintenance. Registered native column additions and modifications materialize the charset and collation declared by their immutable postcondition when the SQL would otherwise inherit a historical table default. This preserves GBK-origin upgrade recovery without changing frozen resources, existing table defaults, independent extension encodings or readiness requirements. SQLite Candidate extension columns and their values survive both stages of the registered Dream rebuild, including generated columns and dependent views. Frozen source introspection projects the already-validated managed columns without altering stored extension data. Independent indexes use SQLite-rewritten definitions after genuine table renames. Native CHECK removal resolves the actual constraint name from the registered expression and enforcement semantics, including recovery after committed DDL. Frozen resources, registered revision checksums and the installed-release acceptance matrix remain unchanged. SQLite admission rejects additional NOT NULL generated columns because computed values can reject valid Source writes. Nullable virtual and stored extensions remain preserved across historical upgrades and support Source write/readback. Frozen migrations and native backend behavior are unchanged. Ownership recovery preflight derives candidate-family filters from the exact migration identities, including Prompt Candidates that do not have standalone Artifact owners. Static Scope permission previews now use the same complete internal receipt keys as bootstrap for long Scope IDs. These authorization-only changes add no stored fields, row encoding, schema, or migration registration. Static Scope preset keys retain existing short identities and use SHA-256 for oversized relationship keys. Bootstrap and read-only preview recognize both bounded keys and historical receipts, including revoked bindings. No schema or migration registration changes." }, "cases": [], "automatic_sources": { diff --git a/src/powercontext/builtin/persistence/schema.py b/src/powercontext/builtin/persistence/schema.py index b82a72dc52..96b7f736a6 100644 --- a/src/powercontext/builtin/persistence/schema.py +++ b/src/powercontext/builtin/persistence/schema.py @@ -16,6 +16,7 @@ from __future__ import annotations +import asyncio from collections.abc import Sequence from sqlalchemy import Connection, MetaData, Table, inspect, text @@ -60,9 +61,7 @@ async def create_tables(connection: AsyncConnection, tables: Sequence[Table], /) ) if any(table.name == "pc_artifact_search_text" for table in tables): - from powercontext.builtin.persistence.artifact_search_schema import ensure_search_support - - await connection.run_sync(ensure_search_support) + await _ensure_artifact_search_support(connection) if connection.dialect.name != "sqlite" and any(table.name == "pc_artifact_vector_spaces" for table in tables): from sqlalchemy import select @@ -78,6 +77,33 @@ async def create_tables(connection: AsyncConnection, tables: Sequence[Table], /) await connection.run_sync(provision_native_spaces) +async def _ensure_artifact_search_support(connection: AsyncConnection) -> None: + from powercontext.builtin.persistence.artifact_search_schema import ensure_search_support + + delays = (0.1, 0.2, 0.4, 0.8, 1.6) + for attempt in range(len(delays) + 1): + try: + await connection.run_sync(ensure_search_support) + except DBAPIError as error: + # OceanBase can reject index creation with a transient DDL lock. + # Recheck this derived index without replaying table/business writes + # or ending the caller's transaction, as for Topic Memory FTS. + if ( + connection.dialect.name != "mysql" + or mysql_error_code(error) != 1205 + or error.statement + != ( + "CREATE FULLTEXT INDEX ix_pc_artifact_search_fts " + "ON pc_artifact_search_text (searchable_text) WITH PARSER SPACE" + ) + or attempt == len(delays) + ): + raise + await asyncio.sleep(delays[attempt]) + else: + return + + def _create_mysql_tables(connection: Connection, tables: Sequence[Table]) -> None: """Use atomic table creation and normalize only verified index-name races. diff --git a/src/powercontext/builtin/runtime/artifact_plugins.py b/src/powercontext/builtin/runtime/artifact_plugins.py index db91b2f59e..64689fad9f 100644 --- a/src/powercontext/builtin/runtime/artifact_plugins.py +++ b/src/powercontext/builtin/runtime/artifact_plugins.py @@ -296,7 +296,7 @@ async def _commit_window( security, prepared_indexes=None, ): - from powercontext.server.authz import AccessAction, ResourceRef + from powercontext.server.authz import AccessAction, ResourceRef, bounded_idempotency_key validated = _validate_drafts(declaration, source_evidence, artifact_evidence, drafts) repository = custom_artifact_repository(declaration["family"], sources, prepared=prepared_indexes) @@ -318,7 +318,9 @@ async def _commit_window( await bound.establish_artifact_owner( resource, security.principal, - idempotency_key=f"background-custom-owner:{assignment.scope_id}:{draft.family}:{draft.artifact_id}", + idempotency_key=bounded_idempotency_key( + "background-custom-owner", assignment.scope_id, draft.family, draft.artifact_id + ), context=security.context, ) else: diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index d2396db363..d37c3bf674 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -557,6 +557,7 @@ async def open_builtin_runtime( cursor_secret: bytes | None = None, handoff_verification_keys: tuple[bytes, ...] = (), recall_effort_sink: RecallEffortSink | None = None, + dispatch_processing: bool = True, _archive_recovery: bool = False, ) -> AsyncIterator[BuiltinRuntime]: """Open the selected database, inference adapters, and built-in runtime. @@ -565,6 +566,10 @@ async def open_builtin_runtime( An explicit ``recall_effort_sink`` replaces that recorder rather than adding a second observation. A disabled gate invokes neither recorder nor sink. + Set ``dispatch_processing=False`` for offline maintenance. This skips supervisor + startup without changing deployment configuration, manifest validation, or archive + recovery behavior. + ``worker_initializer`` is an optional bootstrap for automatically composed spawn workers. A ``WorkerInitializerPreparation`` can own per-launch resources through a context manager yielding the picklable child bootstrap. Its resources remain @@ -918,6 +923,7 @@ async def run_profile(scope_id, high): contexts, processing_bindings, tracing=tracing, + dispatch_processing=dispatch_processing, recovery=_archive_recovery, processing_observer=processing_observer, ) @@ -1111,10 +1117,11 @@ async def _open_artifact_processing_supervisor( bindings: Sequence[ArtifactProcessingBinding], *, tracing: RuntimeTracing | None = None, + dispatch_processing: bool = True, recovery: bool = False, processing_observer: Callable[[str, str, float], None] | None = None, ) -> AsyncIterator[ArtifactProcessingSupervisors | None]: - if recovery or config.runtime.artifact_processing_role == "api" or not bindings: + if not dispatch_processing or recovery or config.runtime.artifact_processing_role == "api" or not bindings: yield None return groups = ( diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index a58569fbd2..2720cf8acc 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -31,6 +31,9 @@ AccessDecision, AccessGroup, AccessMeResponse, + AccessOwnershipApplyResult, + AccessOwnershipPlan, + AccessOwnershipScopePlan, AccessPrincipal, AccessProviderCapabilities, AccessRequirementMatch, @@ -46,6 +49,7 @@ ActivateHandoffRequest, AgentKind, AllScopeSelection, + ApplyAccessOwnershipRequest, ApproveCandidateRequest, ArtifactAccessResource, ArtifactAddress, @@ -313,6 +317,7 @@ ModelUsageValue, Omission, OpenQuestion, + PlanAccessOwnershipRequest, PrepareContextRequest, PrepareContextResult, PreparedContext, @@ -518,6 +523,9 @@ "AccessDecision", "AccessGroup", "AccessMeResponse", + "AccessOwnershipApplyResult", + "AccessOwnershipPlan", + "AccessOwnershipScopePlan", "AccessPrincipal", "AccessProviderCapabilities", "AccessRequirementMatch", @@ -533,6 +541,7 @@ "ActivateHandoffRequest", "AgentKind", "AllScopeSelection", + "ApplyAccessOwnershipRequest", "ApproveCandidateRequest", "ArtifactAccessResource", "ArtifactAddress", @@ -800,6 +809,7 @@ "ModelUsageValue", "Omission", "OpenQuestion", + "PlanAccessOwnershipRequest", "PrepareContextRequest", "PrepareContextResult", "PrepareHandoffHintRequest", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index b8e9e52b41..3a065b3b14 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import date as date_aliased -from enum import IntEnum, StrEnum +from enum import Enum, IntEnum, StrEnum from typing import Annotated, Any, Literal from uuid import UUID @@ -4280,6 +4280,104 @@ class SourceInstancesQuery(BaseModel): view: View1 = View1.DISCOVERY +class ScopeId1(RootModel[StrictStr]): + root: Annotated[StrictStr, Field(max_length=256, min_length=1)] + + +class PlanAccessOwnershipRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_ids: Annotated[ + list[ScopeId1] | None, + Field( + description="Scopes to inspect; every Scope when omitted. Pass Scopes incrementally for large deployments.", + min_length=1, + ), + ] = None + background_principal: Annotated[ + AccessPrincipal | None, + Field( + description="Optional background processing Principal, such as ACCESS_BACKGROUND_PRINCIPAL_ID; each selected Scope is checked for scope.contribute and write permission on an existing Profile, including after all ownership gaps have been filled. An available Access store is required." + ), + ] = None + + +class OwnershipGapKind(StrEnum): + ARTIFACT_OWNER = "artifact_owner" + CANDIDATE_ATTESTATION = "candidate_attestation" + + +class OwnershipGapItem(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + kind: OwnershipGapKind + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + family: Annotated[StrictStr, Field(max_length=128, min_length=1)] + artifact_id: Annotated[StrictStr | None, Field(max_length=256)] = None + entry_id: Annotated[StrictStr | None, Field(max_length=256)] = None + candidate_id: Annotated[StrictStr | None, Field(max_length=128)] = None + version: Annotated[StrictInt | None, Field(ge=1)] = None + conflict_code: Annotated[StrictStr | None, Field(max_length=64)] = None + + +class AccessOwnershipScopePlan(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + artifact_gaps: list[OwnershipGapItem] + candidate_gaps: list[OwnershipGapItem] + + +class Allowed(Enum): + BOOLEAN_FALSE = False + + +class BackgroundPermissionGap(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + principal_id: Annotated[StrictStr, Field(max_length=255, min_length=1)] + allowed: Allowed + reason_code: Annotated[StrictStr | None, Field(max_length=64)] + + +class AccessOwnershipPlan(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + mode: AccessControlMode + scopes: list[AccessOwnershipScopePlan] + background: list[BackgroundPermissionGap] + + +class ApplyAccessOwnershipRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + owner: AccessPrincipal + scope_ids: Annotated[ + list[ScopeId1] | None, + Field( + description="Scopes to migrate; every Scope when omitted. Pass Scopes incrementally for large deployments.", + min_length=1, + ), + ] = None + dry_run: StrictBool = False + + +class AccessOwnershipApplyResult(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + dry_run: StrictBool + applied: list[OwnershipGapItem] + conflicts: list[OwnershipGapItem] + + class RecallManifestRequest(BaseModel): model_config = ConfigDict( extra="forbid", diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index 87cadde0e2..4d8edb8e8f 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -14,10 +14,13 @@ AccessCheckRequest, AccessCheckResponse, AccessMeResponse, + AccessOwnershipApplyResult, + AccessOwnershipPlan, AccessResourcePage, AccessRolePage, AcknowledgeHandoffRequest, ActivateHandoffRequest, + ApplyAccessOwnershipRequest, ApproveCandidateRequest, ArtifactCreated, ArtifactHandleBinding, @@ -131,6 +134,7 @@ MemoryCapacity, MemoryMutationResponse, MergeAtomicMemoryRequest, + PlanAccessOwnershipRequest, PrepareContextRequest, PrepareContextResult, PreparedContext, @@ -4236,3 +4240,47 @@ class AccessRequirement(BaseModel): action=None, resource=None, scope_id_field=None, resolver="source_instance_channel_access" ), ) + +PLAN_ACCESS_OWNERSHIP = Operation[PlanAccessOwnershipRequest, AccessOwnershipPlan]( + method="POST", + path="/v1/access/ownership/plan", + operation_id="plan_access_ownership", + request_type=PlanAccessOwnershipRequest, + request_location="body", + path_parameters=(), + response_type=AccessOwnershipPlan, + success_status=200, + summary="Inventory ownership data missing after switching Access enforcement on", + tags=("access",), + scope_mode="none", + responses={ + 200: {"description": "The missing-attribution inventory for the requested Scopes."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action="access.self", resource="server", scope_id_field=None, resolver="static"), +) + +APPLY_ACCESS_OWNERSHIP = Operation[ApplyAccessOwnershipRequest, AccessOwnershipApplyResult]( + method="POST", + path="/v1/access/ownership/apply", + operation_id="apply_access_ownership", + request_type=ApplyAccessOwnershipRequest, + request_location="body", + path_parameters=(), + response_type=AccessOwnershipApplyResult, + success_status=200, + summary="Backfill missing Artifact owners and Candidate owner attestations", + tags=("access",), + scope_mode="none", + responses={ + 200: {"description": "The applied, or, in dry-run, previewed ownership backfill."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action="access.self", resource="server", scope_id_field=None, resolver="static"), +) diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 9a0a504d00..a3dd5a25c0 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -6473,6 +6473,103 @@ }, } }, + "/v1/access/ownership/plan": { + "post": { + "tags": ["access"], + "summary": "Inventory ownership data missing after switching Access enforcement on", + "description": "Inspect the Scopes that were " + "written while Access was " + "disabled: missing Artifact " + "owners, pending Review " + "Candidates without an owner " + "attestation, and, when named, " + "the background Principal's " + "per-Scope write authority. " + "Produces no change. In enforced " + "mode completes as a deployment " + "administrator; in disabled mode " + "completes without authentication " + "because the Server cannot yet " + "protect anything. A background " + "Principal check requires an " + "available Access store; " + "otherwise it reports 503 " + "relationship_management_unavailable. " + "Use the offline ownership " + "command to check the proposed " + "background Principal before " + "enforcement is enabled.", + "operationId": "plan_access_ownership", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/PlanAccessOwnershipRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "The missing-attribution inventory for the requested Scopes.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/AccessOwnershipPlan"}} + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-access": {"action": "access.self", "resource": {"type": "server"}}, + } + }, + "/v1/access/ownership/apply": { + "post": { + "tags": ["access"], + "summary": "Backfill missing Artifact owners and Candidate owner attestations", + "description": "Grant the missing ownership " + "data to the explicitly named " + "Principal; the Server never " + "derives it. Existing Artifact " + "owners and Candidate " + "attestations are preserved and " + "reported as conflicts when they " + "disagree with the named " + "Principal. Identities with " + "recorded ownership recovery " + "evidence are reported as " + "conflicts and must retain their " + "original attribution. Every " + "item is one idempotent write, " + "so interrupted or repeated runs " + "refill only still-missing " + "items. Set dry_run first to " + "preview the exact items. " + "Requires a deployment " + "administrator; while Access is " + "disabled the Server has no " + "ownership store and reports 503 " + "ownership_migration_unavailable.", + "operationId": "apply_access_ownership", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ApplyAccessOwnershipRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "The applied, or, in dry-run, previewed ownership backfill.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/AccessOwnershipApplyResult"}} + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-access": {"action": "access.self", "resource": {"type": "server"}}, + } + }, }, "components": { "schemas": { @@ -14663,6 +14760,153 @@ "additionalProperties": False, "type": "object", }, + "PlanAccessOwnershipRequest": { + "properties": { + "scope_ids": { + "items": {"type": "string", "maxLength": 256, "minLength": 1}, + "type": "array", + "minItems": 1, + "description": "Scopes " + "to " + "inspect; " + "every " + "Scope " + "when " + "omitted. " + "Pass " + "Scopes " + "incrementally " + "for " + "large " + "deployments.", + }, + "background_principal": { + "allOf": [{"$ref": "#/components/schemas/AccessPrincipal"}], + "description": "Optional " + "background " + "processing " + "Principal, " + "such " + "as " + "ACCESS_BACKGROUND_PRINCIPAL_ID; " + "each " + "selected " + "Scope " + "is " + "checked " + "for " + "scope.contribute " + "and " + "write " + "permission " + "on " + "an " + "existing " + "Profile, " + "including " + "after " + "all " + "ownership " + "gaps " + "have " + "been " + "filled. " + "An " + "available " + "Access " + "store " + "is " + "required.", + "nullable": True, + }, + }, + "additionalProperties": False, + "type": "object", + }, + "OwnershipGapKind": {"type": "string", "enum": ["artifact_owner", "candidate_attestation"]}, + "OwnershipGapItem": { + "properties": { + "kind": {"$ref": "#/components/schemas/OwnershipGapKind"}, + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "family": {"type": "string", "maxLength": 128, "minLength": 1}, + "artifact_id": {"type": "string", "maxLength": 256, "nullable": True}, + "entry_id": {"type": "string", "maxLength": 256, "nullable": True}, + "candidate_id": {"type": "string", "maxLength": 128, "nullable": True}, + "version": {"type": "integer", "minimum": 1.0, "nullable": True}, + "conflict_code": {"type": "string", "maxLength": 64, "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["kind", "scope_id", "family"], + }, + "AccessOwnershipScopePlan": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "artifact_gaps": {"items": {"$ref": "#/components/schemas/OwnershipGapItem"}, "type": "array"}, + "candidate_gaps": {"items": {"$ref": "#/components/schemas/OwnershipGapItem"}, "type": "array"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "artifact_gaps", "candidate_gaps"], + }, + "BackgroundPermissionGap": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "principal_id": {"type": "string", "maxLength": 255, "minLength": 1}, + "allowed": {"type": "boolean", "enum": [False]}, + "reason_code": {"type": "string", "maxLength": 64, "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "principal_id", "allowed", "reason_code"], + }, + "AccessOwnershipPlan": { + "properties": { + "mode": {"$ref": "#/components/schemas/AccessControlMode"}, + "scopes": {"items": {"$ref": "#/components/schemas/AccessOwnershipScopePlan"}, "type": "array"}, + "background": {"items": {"$ref": "#/components/schemas/BackgroundPermissionGap"}, "type": "array"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["mode", "scopes", "background"], + }, + "ApplyAccessOwnershipRequest": { + "properties": { + "owner": {"$ref": "#/components/schemas/AccessPrincipal"}, + "scope_ids": { + "items": {"type": "string", "maxLength": 256, "minLength": 1}, + "type": "array", + "minItems": 1, + "description": "Scopes " + "to " + "migrate; " + "every " + "Scope " + "when " + "omitted. " + "Pass " + "Scopes " + "incrementally " + "for " + "large " + "deployments.", + }, + "dry_run": {"type": "boolean", "default": False}, + }, + "additionalProperties": False, + "type": "object", + "required": ["owner"], + }, + "AccessOwnershipApplyResult": { + "properties": { + "dry_run": {"type": "boolean"}, + "applied": {"items": {"$ref": "#/components/schemas/OwnershipGapItem"}, "type": "array"}, + "conflicts": {"items": {"$ref": "#/components/schemas/OwnershipGapItem"}, "type": "array"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["dry_run", "applied", "conflicts"], + }, }, "responses": { "ClientUpgradeRequired": { diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index 5577a7433a..a84b8fcd8c 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -216,6 +216,9 @@ from powercontext.builtin.records import ( ArtifactWrite as RuntimeArtifactWrite, ) +from powercontext.builtin.records import ( + LogicalArtifactRecord as RuntimeLogicalArtifactRecord, +) from powercontext.builtin.records import ( SourceRecord as RuntimeSourceRecord, ) @@ -427,6 +430,7 @@ AccessRolePage, AcknowledgeHandoffRequest, ActivateHandoffRequest, + ApplyAccessOwnershipRequest, ApproveCandidateRequest, ArtifactAccessResource, ArtifactCollectionItem, @@ -535,6 +539,7 @@ MemoryCapacity, MemoryEntryAccessSelector, MemoryMutationResponse, + PlanAccessOwnershipRequest, PrepareContextRequest, PrepareContextResult, PreparedContext, @@ -678,12 +683,14 @@ AccessControlMode as TransportAccessControlMode, ) from powercontext.http._generated.models import ( - ArtifactFamily as TransportArtifactFamily, -) -from powercontext.http._generated.models import ( + AccessOwnershipApplyResult, + AccessOwnershipPlan, + AccessOwnershipScopePlan, ArtifactTagPage, ArtifactTagSet, + BackgroundPermissionGap, GetTopicMemoryProcessingBlockRequest, + OwnershipGapItem, QueryArtifactTagsRequest, ReplaceArtifactTagsRequest, RetryTopicMemoryProcessingRequest, @@ -695,6 +702,12 @@ TopicMemoryProcessingStatus, TopicMemoryProcessingStatusRequest, ) +from powercontext.http._generated.models import Allowed as TransportOwnershipAllowed +from powercontext.http._generated.models import ( + ArtifactFamily as TransportArtifactFamily, +) +from powercontext.http._generated.models import OwnershipGapKind as TransportOwnershipGapKind +from powercontext.http._generated.models import ScopeId1 as TransportScopeId from powercontext.http._generated.models import ( ShareUnit as TransportShareUnit, ) @@ -705,6 +718,7 @@ API_DESCRIPTION, API_TITLE, API_VERSION, + APPLY_ACCESS_OWNERSHIP, APPROVE_CANDIDATE, BIND_ARTIFACT_HANDLE, CAPTURE_CONTENT_SOURCE, @@ -784,6 +798,7 @@ LIST_SCOPES, LIST_SOURCES, OPENAPI_VERSION, + PLAN_ACCESS_OWNERSHIP, PREPARE_BOOTSTRAP_CONTEXT, PREPARE_CONTEXT, PREPARE_HANDOFF, @@ -868,6 +883,7 @@ ReplaceBinding, ResourceRef, access_control_for_mode, + bounded_idempotency_key, ) from powercontext.server.authz.models import ( ROLE_ACTIONS, @@ -902,6 +918,12 @@ negotiate_dream_contract, require_extended_dream, ) +from powercontext.server.ownership import ( + ArtifactOwnershipGap, + CandidateOwnershipGap, + apply_ownership_migration, + plan_ownership_migration, +) from powercontext.server.request_id import REQUEST_ID_HEADER from powercontext.server.tracing import request_id_from_span from powercontext.sources import ConnectorBinding as RuntimeConnectorBinding @@ -989,6 +1011,8 @@ async def get_artifact_revision( execution_context: Any = None, ) -> RuntimeArtifactRecord: ... + async def logical_artifacts(self) -> tuple[RuntimeLogicalArtifactRecord, ...]: ... + async def query_artifacts( self, family: str, @@ -1554,6 +1578,8 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, REVOKE_ACCESS_BINDING, revoke_access_binding) _add_route(app, REPLACE_ACCESS_BINDING, replace_access_binding) _add_route(app, LIST_ACCESS_AUDIT, list_access_audit) + _add_route(app, PLAN_ACCESS_OWNERSHIP, plan_access_ownership) + _add_route(app, APPLY_ACCESS_OWNERSHIP, apply_access_ownership) if handoff_report_enabled: _add_route(app, GET_HANDOFF_REPORT, get_handoff_report) _add_route(app, LIST_SOURCES, list_sources) @@ -2184,6 +2210,112 @@ async def list_access_audit(payload: ListAccessAuditRequest, request: Request) - ) +async def plan_access_ownership(payload: PlanAccessOwnershipRequest, request: Request) -> AccessOwnershipPlan: + application = _require_application(request) + scope_ids = await _migration_scope_ids(payload.scope_ids, request) + access = request.app.state.access_control + background = None if payload.background_principal is None else _access_principal(payload.background_principal) + if request.app.state.access_mode == "enforced": + access = _require_access_control(request) + await access.require( + _require_principal(), + *_binding_administrative_check(None, deployment_id=access.deployment_id), + context=_access_audit_context(PLAN_ACCESS_OWNERSHIP.operation_id), + ) + plan = await plan_ownership_migration( + application, + access, + scope_ids, + background_principal=background, + context=_access_audit_context(PLAN_ACCESS_OWNERSHIP.operation_id), + ) + return AccessOwnershipPlan( + mode=TransportAccessControlMode(request.app.state.access_mode), + scopes=[ + AccessOwnershipScopePlan( + scope_id=scope.scope_id, + artifact_gaps=[_ownership_gap_item(gap) for gap in scope.artifact_gaps], + candidate_gaps=[_ownership_gap_item(gap) for gap in scope.candidate_gaps], + ) + for scope in plan.scopes + ], + background=[ + BackgroundPermissionGap( + scope_id=gap.scope_id, + principal_id=gap.principal_id, + allowed=TransportOwnershipAllowed.BOOLEAN_FALSE, + reason_code=gap.reason_code, + ) + for gap in plan.background + ], + ) + + +async def apply_access_ownership(payload: ApplyAccessOwnershipRequest, request: Request) -> AccessOwnershipApplyResult: + application = _require_application(request) + if request.app.state.access_mode != "enforced": + raise AccessUnavailableError("ownership_migration_unavailable") + access = _require_access_control(request) + scope_ids = await _migration_scope_ids(payload.scope_ids, request) + await access.require( + _require_principal(), + *_binding_administrative_check(None, deployment_id=access.deployment_id), + context=_access_audit_context(APPLY_ACCESS_OWNERSHIP.operation_id), + ) + result = await apply_ownership_migration( + application, + access, + scope_ids, + owner=_access_principal(payload.owner), + dry_run=payload.dry_run, + owner_recovery=request.app.state.owner_recovery, + context=_access_audit_context(APPLY_ACCESS_OWNERSHIP.operation_id), + ) + return AccessOwnershipApplyResult( + dry_run=payload.dry_run, + applied=[_ownership_gap_item(item.gap, item.detail) for item in result.applied], + conflicts=[_ownership_gap_item(item.gap, item.detail) for item in result.conflicts], + ) + + +async def _migration_scope_ids( + requested: Sequence[str | TransportScopeId] | None, + request: Request, +) -> list[str]: + if requested is None: + return [scope.scope_id for scope in await _require_scope_application(request).list()] + unique: list[str] = [] + for value in requested: + scope_id = value.root if isinstance(value, TransportScopeId) else str(value) + if scope_id not in unique: + unique.append(scope_id) + return unique + + +def _ownership_gap_item( + gap: ArtifactOwnershipGap | CandidateOwnershipGap, + conflict_code: str | None = None, +) -> OwnershipGapItem: + if isinstance(gap, ArtifactOwnershipGap): + return OwnershipGapItem( + kind=TransportOwnershipGapKind.ARTIFACT_OWNER, + scope_id=gap.scope_id, + family=gap.family, + artifact_id=gap.artifact_id, + entry_id=gap.entry_id, + conflict_code=conflict_code, + ) + return OwnershipGapItem( + kind=TransportOwnershipGapKind.CANDIDATE_ATTESTATION, + scope_id=gap.scope_id, + family=gap.family, + candidate_id=gap.candidate_id, + version=gap.version, + artifact_id=gap.target_artifact_id, + conflict_code=conflict_code, + ) + + def _list_scopes_query( http_request: Request, query: Annotated[str | None, Query(max_length=256)] = None, @@ -2664,7 +2796,7 @@ async def on_commit(connection, artifact, candidate): await bound.establish_artifact_owner( resource, principal, - idempotency_key=f"profile-owner:{request.scope_id}", + idempotency_key=bounded_idempotency_key("profile-owner", request.scope_id), context=context, ) if candidate is not None: @@ -2674,7 +2806,7 @@ async def on_commit(connection, artifact, candidate): family="profile", proposed_owner=principal, target=None if candidate.target is None else resource, - idempotency_key=f"candidate-owner:{request.scope_id}:{candidate.candidate_id}", + idempotency_key=bounded_idempotency_key("candidate-owner", request.scope_id, candidate.candidate_id), ) result = await application.profiles.flush( @@ -3635,7 +3767,7 @@ async def on_commit(connection, artifact): await owner_write_access(access, connection).establish_artifact_owner( ResourceRef.artifact(request.scope_id, family="handoff", artifact_id=artifact.artifact_id), principal, - idempotency_key=f"handoff-owner:{request.scope_id}:{artifact.artifact_id}", + idempotency_key=bounded_idempotency_key("handoff-owner", request.scope_id, artifact.artifact_id), context=context, ) committed = True @@ -3653,7 +3785,7 @@ async def on_commit(connection, artifact): family="handoff", artifact_id=result.artifact_id, ), - idempotency_key=f"handoff-owner:{request.scope_id}:{result.artifact_id}", + idempotency_key=bounded_idempotency_key("handoff-owner", request.scope_id, result.artifact_id), operation=COMMIT_HANDOFF.operation_id, ) elif access is not None and not committed: @@ -4513,7 +4645,7 @@ async def _attest_candidate_owner( family=family, proposed_owner=_require_principal(), target=logical_target, - idempotency_key=f"candidate-owner:{scope_id}:{candidate_id}", + idempotency_key=bounded_idempotency_key("candidate-owner", scope_id, candidate_id), ) @@ -4569,7 +4701,7 @@ async def on_commit(connection, candidate): family=target.family, artifact_id=target.artifact_id, ), - idempotency_key=f"candidate-owner:{scope_id}:{candidate.candidate_id}", + idempotency_key=bounded_idempotency_key("candidate-owner", scope_id, candidate.candidate_id), ) return {"on_commit": on_commit} @@ -4744,7 +4876,7 @@ async def approve_candidate( artifact_id=artifact.artifact_id, ), attestation.proposed_owner, - idempotency_key=f"candidate-artifact-owner:{request.scope_id}:{request.candidate_id}", + idempotency_key=bounded_idempotency_key("candidate-artifact-owner", request.scope_id, request.candidate_id), context=_access_audit_context(APPROVE_CANDIDATE.operation_id), ) return await _candidate_response(http_request, request.scope_id, result) diff --git a/src/powercontext/server/authz/__init__.py b/src/powercontext/server/authz/__init__.py index f5adf83c04..e4d7784499 100644 --- a/src/powercontext/server/authz/__init__.py +++ b/src/powercontext/server/authz/__init__.py @@ -25,6 +25,7 @@ AccessInvalidRequestError, AccessUnavailableError, ) +from powercontext.server.authz.keys import IDEMPOTENCY_KEY_LIMIT, bounded_idempotency_key from powercontext.server.authz.models import ( DEFAULT_DEPLOYMENT_ID, PUBLIC_ACCESS_ACTIONS, @@ -93,6 +94,7 @@ def __getattr__(name: str) -> object: __all__ = ( "DEFAULT_DEPLOYMENT_ID", + "IDEMPOTENCY_KEY_LIMIT", "PUBLIC_ACCESS_ACTIONS", "ROLE_CARDINALITIES", "AccessAction", @@ -143,4 +145,5 @@ def __getattr__(name: str) -> object: "ResourceRef", "ResourceSearchRequest", "access_control_for_mode", + "bounded_idempotency_key", ) diff --git a/src/powercontext/server/authz/composition.py b/src/powercontext/server/authz/composition.py index 8f37d3739c..8db3da30f5 100644 --- a/src/powercontext/server/authz/composition.py +++ b/src/powercontext/server/authz/composition.py @@ -50,8 +50,13 @@ async def open_builtin_access_control( bootstrap_administrators: Sequence[PrincipalRef] = (), server_administrators: Sequence[PrincipalRef] = (), deployment_id: str = DEFAULT_DEPLOYMENT_ID, + static_scope_principal: PrincipalRef | None = None, ) -> AsyncIterator[AccessControlService]: - """Open Access; only legacy bootstrap administrators receive the static Scope preset.""" + """Open Access with the configured static Scope preset. + + Maintenance can declare the preset without bootstrapping administrator roles. + Declaring it does not materialize Scope bindings. + """ async with _open_access_repository(database) as repository: await _bootstrap_server_roles( @@ -72,7 +77,13 @@ async def open_builtin_access_control( relationship_management=True, group_subjects=False, ), - static_scope_principal=bootstrap_administrators[0] if len(bootstrap_administrators) == 1 else None, + static_scope_principal=( + static_scope_principal + if static_scope_principal is not None + else bootstrap_administrators[0] + if len(bootstrap_administrators) == 1 + else None + ), ) diff --git a/src/powercontext/server/authz/errors.py b/src/powercontext/server/authz/errors.py index 9f209a810c..e87bf81e31 100644 --- a/src/powercontext/server/authz/errors.py +++ b/src/powercontext/server/authz/errors.py @@ -54,6 +54,7 @@ def __init__(self, code: str = "access_unavailable") -> None: "relationship_management_unavailable": "Access relationship management is unavailable", "resource_filter_limit_exceeded": "the complete authorized resource filter exceeds its configured limit", "safe_resource_filtering_unavailable": "safe Access resource filtering is unavailable", + "ownership_migration_unavailable": "ownership migration requires enforced Access mode", } super().__init__(messages.get(code, messages["access_unavailable"])) diff --git a/src/powercontext/server/authz/keys.py b/src/powercontext/server/authz/keys.py new file mode 100644 index 0000000000..242ea2f2ce --- /dev/null +++ b/src/powercontext/server/authz/keys.py @@ -0,0 +1,39 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Deterministic idempotency keys for Access owner writes.""" + +from __future__ import annotations + +import json +from hashlib import sha256 + +IDEMPOTENCY_KEY_LIMIT = 255 + + +def bounded_idempotency_key(prefix: str, *identity: str) -> str: + """Build a stable key that Access accepts for any identity width. + + Accepted keys keep their historical spelling. Identities that would exceed + the Access limit are replaced by a digest of every component, so distinct + identities cannot collapse into the same key. + """ + key = f"{prefix}:{':'.join(identity)}" + if len(key) <= IDEMPOTENCY_KEY_LIMIT: + return key + encoded = json.dumps((prefix, *identity), ensure_ascii=False, separators=(",", ":")).encode() + return f"{prefix}:{sha256(encoded).hexdigest()}" + + +__all__ = ("IDEMPOTENCY_KEY_LIMIT", "bounded_idempotency_key") diff --git a/src/powercontext/server/authz/owner_recovery.py b/src/powercontext/server/authz/owner_recovery.py index 80afed2fb8..757757d54e 100644 --- a/src/powercontext/server/authz/owner_recovery.py +++ b/src/powercontext/server/authz/owner_recovery.py @@ -17,7 +17,7 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncIterator, Sequence +from collections.abc import AsyncIterator, Collection, Sequence from contextlib import asynccontextmanager, suppress from contextvars import ContextVar from dataclasses import dataclass, replace @@ -178,16 +178,25 @@ async def inventory_gaps( kind: Literal["artifact", "candidate"] = "artifact", after: tuple[str, str] | None = None, limit: int = 100, + families: Collection[str] | None = None, ) -> OwnerGapPage: - """Inspect metadata in bounded pages; unknown history grants no authority.""" + """Inspect metadata in bounded pages; unknown history grants no authority. + + Callers that accept families from persisted declarations pass them in; + the process-local profiles do not know those families in another process. + """ if kind not in {"artifact", "candidate"}: raise AccessInvalidRequestError("owner-recovery-kind") if not 1 <= limit <= 500: raise AccessInvalidRequestError("limit") table = ARTIFACT_HEADS_TABLE if kind == "artifact" else CANDIDATE_HEADS_TABLE identity = table.c.artifact_id if kind == "artifact" else table.c.candidate_id - families = tuple(family for family, profile in ARTIFACT_FAMILY_PROFILES.items() if profile.enabled) - statement = select(table.c.family, identity).where(table.c.scope_id == scope_id, table.c.family.in_(families)) + selected = ( + tuple(family for family, profile in ARTIFACT_FAMILY_PROFILES.items() if profile.enabled) + if families is None + else tuple(dict.fromkeys(families)) + ) + statement = select(table.c.family, identity).where(table.c.scope_id == scope_id, table.c.family.in_(selected)) if kind == "candidate": statement = statement.where(table.c.candidate_kind == "artifact") if after is not None: @@ -198,7 +207,8 @@ async def inventory_gaps( items = [] for family, object_id in rows[:limit]: resource = ResourceRef.artifact(scope_id, family=family, artifact_id=object_id) - if kind == "artifact" and ARTIFACT_FAMILY_PROFILES[family].share_unit == "memory_entry": + profile = ARTIFACT_FAMILY_PROFILES.get(family) + if kind == "artifact" and profile is not None and profile.share_unit == "memory_entry": items.append(OwnerGap(kind, scope_id, family, object_id, "legacy_selector_required", None)) continue owner = ( diff --git a/src/powercontext/server/authz/service.py b/src/powercontext/server/authz/service.py index 5372f4a88b..206a56fa21 100644 --- a/src/powercontext/server/authz/service.py +++ b/src/powercontext/server/authz/service.py @@ -84,6 +84,15 @@ ) +def _static_scope_receipt_keys( + deployment_id: str, principal_id: str, scope_id: str, role: AccessRole +) -> tuple[str, str]: + legacy = f"static-preset:{deployment_id}:{principal_id}:{scope_id}:{role.value}" + # The relationship schema stores keys in VARCHAR(255), including internal presets. + key = legacy if len(legacy) <= 255 else f"static-preset:sha256:{hashlib.sha256(legacy.encode()).hexdigest()}" + return key, legacy + + @dataclass(frozen=True, slots=True) class AuthorizedResourceFilter: """Complete bounded identities and parent constraints produced before data access.""" @@ -896,6 +905,37 @@ def can_bootstrap_static_scope( actions = ROLE_ACTIONS if resource.type is AccessResourceType.SCOPE else ROLE_CHILD_ACTIONS return any(action in actions[role] for role in _STATIC_SCOPE_ROLES) + async def static_scope_bootstrap_would_grant( + self, principal: PrincipalRef, action: AccessAction, resource: ResourceRef + ) -> bool: + """Inspect whether a denied check can gain a new local preset binding. + + An existing preset receipt is final, including after revocation. This + preview must not treat that receipt as a grant or recreate its binding. + """ + if not self.can_bootstrap_static_scope(principal, action, resource): + return False + from powercontext.server.authz.casbin import CasbinAuthorizationProvider + from powercontext.server.authz.repository import RelationalAccessRepository + + repository = self.relationships + if type(repository) is not RelationalAccessRepository or type(self.provider) not in { + BuiltinAuthorizationProvider, + CasbinAuthorizationProvider, + }: + return False + actions = ROLE_ACTIONS if resource.type is AccessResourceType.SCOPE else ROLE_CHILD_ACTIONS + for role in _STATIC_SCOPE_ROLES: + if action not in actions[role]: + continue + key, legacy = _static_scope_receipt_keys(self.deployment_id, principal.id, resource.scope_id, role) + if await _access_call(repository.static_scope_initialized(principal, (key,))): + continue + if key != legacy and await _access_call(repository.static_scope_initialized(principal, (legacy,))): + continue + return True + return False + async def bootstrap_static_scope( self, principal: PrincipalRef | None, @@ -912,14 +952,20 @@ async def bootstrap_static_scope( resource = ResourceRef.scope(scope_id) keys = tuple( - f"static-preset:{self.deployment_id}:{actor.id}:{scope_id}:{role.value}" for role in _STATIC_SCOPE_ROLES + _static_scope_receipt_keys(self.deployment_id, actor.id, scope_id, role)[0] for role in _STATIC_SCOPE_ROLES ) repository = self._relationship_writer() if type(repository) is RelationalAccessRepository and await repository.static_scope_initialized(actor, keys): return now = self._clock() for role in _STATIC_SCOPE_ROLES: - key = f"static-preset:{self.deployment_id}:{actor.id}:{scope_id}:{role.value}" + key, legacy = _static_scope_receipt_keys(self.deployment_id, actor.id, scope_id, role) + if ( + key != legacy + and type(repository) is RelationalAccessRepository + and await _access_call(repository.static_scope_initialized(actor, (legacy,))) + ): + continue binding = AccessBinding( binding_id=str(uuid4()), subject=actor, diff --git a/src/powercontext/server/cli.py b/src/powercontext/server/cli.py index be2154bcdc..d77b027214 100644 --- a/src/powercontext/server/cli.py +++ b/src/powercontext/server/cli.py @@ -19,11 +19,11 @@ import asyncio import json import signal -from collections.abc import Callable, Iterator +from collections.abc import Callable, Iterator, Sequence from contextlib import AsyncExitStack, ExitStack, contextmanager, nullcontext from pathlib import Path from sqlite3 import SQLITE_CANTOPEN -from typing import TYPE_CHECKING, Annotated, Any, Literal, Never +from typing import TYPE_CHECKING, Annotated, Any, Literal, Never, cast import typer from pydantic import ValidationError @@ -62,6 +62,7 @@ from powercontext.server.app import access_readiness_checks from powercontext.server.authz import PrincipalRef from powercontext.server.authz.composition import open_builtin_access_control +from powercontext.server.authz.owner_recovery import OwnerRecovery from powercontext.server.background_metrics import serve_background_metrics from powercontext.server.configuration import ( ServerConfigurationError, @@ -73,6 +74,11 @@ from powercontext.server.identity import open_server_identity_repository from powercontext.server.logging import configure_server_logging, shutdown_server_logging, worker_logging from powercontext.server.metrics import ServerMetrics +from powercontext.server.ownership import ( + apply_ownership_migration, + ownership_report, + plan_ownership_migration, +) from powercontext.server.processing_security import build_worker_security from powercontext.server.settings import ( MissingAuthenticationProviderError, @@ -568,6 +574,123 @@ def atomic_memory_rebuild_projection( raise typer.Exit(code=1) +@app.command("access-ownership-migrate") +def access_ownership_migrate( + action: Annotated[Literal["plan", "apply"], typer.Option(help="Ownership migration action.")] = "plan", + env_file: Annotated[Path | None, typer.Option(help="Load deployment settings from this environment file.")] = None, + owner: Annotated[ + str | None, + typer.Option(help="Owning Principal as TYPE:ID, e.g. service:server-token. Required for apply."), + ] = None, + scope_id: Annotated[ + list[str] | None, + typer.Option(help="Scope to migrate; repeat the option for several Scopes. Every Scope when omitted."), + ] = None, + background_principal: Annotated[ + str | None, + typer.Option(help="Background Principal as TYPE:ID to check while planning."), + ] = None, + maintenance_confirmed: Annotated[ + bool, + typer.Option(help="Confirm every Server process using the deployment database is stopped."), + ] = False, +) -> None: + """Inventory or backfill ownership data written before Access enforcement. + + Works while ACCESS_MODE is disabled, before switching to enforced mode, and against a database whose + Server ran either mode. Apply names one owning Principal explicitly; the Server never derives ownership. + """ + + if action == "apply": + if owner is None or ":" not in owner: + raise typer.BadParameter("apply requires --owner TYPE:ID, e.g. --owner service:server-token") # noqa: TRY003 + if not maintenance_confirmed: + raise typer.BadParameter( # noqa: TRY003 + "apply requires --maintenance-confirmed after stopping every Server process" + ) + background_ref = None + if background_principal is not None: + principal_type, separator, principal_id = background_principal.partition(":") + if ( + not separator + or principal_type not in {"user", "service"} + or not principal_id + or principal_id != principal_id.strip() + or len(principal_id) > 255 + ): + raise typer.BadParameter("background Principal must use TYPE:ID") # noqa: TRY003 + background_ref = PrincipalRef(type=principal_type, id=principal_id) + env_file = resolve_server_environment_file(env_file, discover=True) + with server_settings_context(env_file=env_file) as settings: + ready = asyncio.run( + _access_ownership_maintenance( + settings, + action, + owner=owner, + scope_ids=scope_id or (), + background_principal=background_ref, + ) + ) + if not ready: + raise typer.Exit(code=1) + + +async def _access_ownership_maintenance( + settings: ServerSettings, + action: Literal["plan", "apply"], + *, + owner: str | None, + scope_ids: Sequence[str], + background_principal: PrincipalRef | None = None, +) -> bool: + config = BuiltinConfig( + code=settings.code, + runtime=settings.runtime, + database=settings.database, + handoff_report=settings.handoff_report, + inference=settings.inference, + external_skills=settings.external_skills, + ) + async with AsyncExitStack() as resources: + access = await resources.enter_async_context( + open_builtin_access_control( + settings.database, + deployment_id=settings.access.deployment_id, + static_scope_principal=( + PrincipalRef(type="service", id="server-token") if settings.auth.token is not None else None + ), + ) + ) + runtime = await resources.enter_async_context(open_builtin_runtime(config, dispatch_processing=False)) + if runtime.scopes is None: + raise typer.BadParameter("ownership migration requires a runtime with Scope support") # noqa: TRY003 + recovery = OwnerRecovery(runtime.primary_database, access) + await recovery.initialize() + scopes = list(scope_ids) or [scope.scope_id for scope in await runtime.scopes.list()] + if action == "plan": + plan = await plan_ownership_migration( + runtime, + access, + scopes, + background_principal=background_principal, + transport="background", + ) + typer.echo(json.dumps(ownership_report(plan))) + return True + owner_type, _, owner_id = cast(str, owner).partition(":") + result = await apply_ownership_migration( + runtime, + access, + scopes, + owner=PrincipalRef(type=owner_type, id=owner_id), + transport="background", + owner_recovery=recovery, + ) + report = ownership_report(result) + typer.echo(json.dumps(report)) + return not report["conflicts"] + + async def _sqlite_snapshot_budget_preflight(database: SQLiteConfig, snapshot_budget_bytes: int) -> bool: from powercontext.builtin.persistence.migrations.atomic_memory_v1 import ( AtomicMemoryMigrationReport, diff --git a/src/powercontext/server/factory.py b/src/powercontext/server/factory.py index 0871833ee8..4fe26093c8 100644 --- a/src/powercontext/server/factory.py +++ b/src/powercontext/server/factory.py @@ -93,6 +93,7 @@ PrincipalRef, ResourceRef, access_control_for_mode, + bounded_idempotency_key, ) from powercontext.server.authz.composition import open_builtin_access_control from powercontext.server.authz.owner_recovery import ( @@ -625,7 +626,9 @@ async def on_commit(connection, candidates): family=candidate.target.family, artifact_id=candidate.target.artifact_id, ), - idempotency_key=f"background-candidate-owner:{scope_id}:{candidate.candidate_id}", + idempotency_key=bounded_idempotency_key( + "background-candidate-owner", scope_id, candidate.candidate_id + ), ) for candidate in candidates ) @@ -648,7 +651,7 @@ async def on_commit(connection, candidates): family="experience", proposed_owner=principal, target=None, - idempotency_key=f"background-candidate-owner:{scope_id}:{candidate_id}", + idempotency_key=bounded_idempotency_key("background-candidate-owner", scope_id, candidate_id), ) for candidate_id in result.candidate_ids ) @@ -684,7 +687,7 @@ async def on_commit(connection, artifact, candidate): await bound.establish_artifact_owner( resource, principal, - idempotency_key=f"profile-owner:{scope_id}", + idempotency_key=bounded_idempotency_key("profile-owner", scope_id), context=context, ) if candidate is not None: @@ -694,7 +697,7 @@ async def on_commit(connection, artifact, candidate): family="profile", proposed_owner=principal, target=None if candidate.target is None else resource, - idempotency_key=f"candidate-owner:{scope_id}:{candidate.candidate_id}", + idempotency_key=bounded_idempotency_key("candidate-owner", scope_id, candidate.candidate_id), ) recovery = ( diff --git a/src/powercontext/server/ownership.py b/src/powercontext/server/ownership.py new file mode 100644 index 0000000000..9282c95a52 --- /dev/null +++ b/src/powercontext/server/ownership.py @@ -0,0 +1,518 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Supported ownership migration after switching Access enforcement on. + +Data accepted while Access was disabled has no Artifact owner and no Review +Candidate owner attestation, so switching ``ACCESS_MODE`` to ``enforced`` +leaves that data read-only for every Principal and blocks Candidate approval. +Migration never invents identity: an administrator names the Principal for one +explicit call, existing relations stay untouched, and every item write is one +idempotent Access-store transaction that can be safely repeated. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass, replace +from hashlib import sha256 +from typing import TYPE_CHECKING, Literal + +from powercontext.server.authz import ( + AccessAction, + AccessAuditContext, + AccessConflictError, + AccessInvalidRequestError, + AccessUnavailableError, + PrincipalRef, + ResourceRef, + bounded_idempotency_key, +) +from powercontext.server.authz.models import MemoryEntrySelector +from powercontext.server.authz.owner_recovery import flush_owner_writes, owner_recovery_session, owner_write_access + +if TYPE_CHECKING: + from powercontext.builtin.runtime import BuiltinRuntime + from powercontext.server.app import ServerApplication + from powercontext.server.authz.owner_recovery import OwnerCommit, OwnerRecovery + from powercontext.server.authz.service import AccessControlService + + Application = ServerApplication | BuiltinRuntime + +OWNED_ARTIFACT_FAMILIES = ("atomic-memory", "experience", "skill", "profile", "handoff") +RESOURCE_PAGE_SIZE = 100 +CANDIDATE_PAGE_SIZE = 100 + +PLAN_OPERATION = "access.ownership.plan" +MIGRATION_OPERATION = "access.ownership.apply" + + +@dataclass(frozen=True, slots=True) +class ArtifactOwnershipGap: + """One logical Artifact identity whose owner relation is absent.""" + + scope_id: str + family: str + artifact_id: str + entry_id: str | None = None + + +@dataclass(frozen=True, slots=True) +class CandidateOwnershipGap: + """One pending artifact-kind Candidate whose owner attestation is absent.""" + + scope_id: str + candidate_id: str + family: str + version: int + entry_ids: tuple[str, ...] = () + target_family: str | None = None + target_artifact_id: str | None = None + + +@dataclass(frozen=True, slots=True) +class ScopeOwnershipPlan: + scope_id: str + artifact_gaps: tuple[ArtifactOwnershipGap, ...] + candidate_gaps: tuple[CandidateOwnershipGap, ...] + + +@dataclass(frozen=True, slots=True) +class BackgroundPermissionGap: + """One affected Scope where the named background Principal lacks write authority.""" + + scope_id: str + principal_id: str + reason_code: str | None = None + + +@dataclass(frozen=True, slots=True) +class OwnershipPlan: + """The complete missing-attribution inventory for the requested Scopes.""" + + scopes: tuple[ScopeOwnershipPlan, ...] + background: tuple[BackgroundPermissionGap, ...] + + +@dataclass(frozen=True, slots=True) +class OwnershipDisposition: + """One migration item with its per-item result.""" + + gap: ArtifactOwnershipGap | CandidateOwnershipGap + outcome: str # "applied" or "conflict" + detail: str | None = None + + +@dataclass(frozen=True, slots=True) +class OwnershipMigrationResult: + applied: tuple[OwnershipDisposition, ...] + conflicts: tuple[OwnershipDisposition, ...] + + +def migration_context( + operation: str, *, transport: str = "http", context: AccessAuditContext | None = None +) -> AccessAuditContext: + if context is not None: + return replace(context, operation=operation) + return AccessAuditContext(transport=transport, operation=operation) + + +def ownership_report(value: OwnershipPlan | OwnershipMigrationResult) -> dict[str, object]: + """Serialize a plan or migration result for operator-facing JSON output.""" + + from dataclasses import asdict + + return asdict(value) + + +async def plan_ownership_migration( + application: Application, + access: AccessControlService | None, + scope_ids: Sequence[str], + *, + background_principal: PrincipalRef | None = None, + transport: str = "http", + context: AccessAuditContext | None = None, +) -> OwnershipPlan: + """Inventory missing Artifact owners and Candidate attestations for Scopes. + + Without an active Access store (pre-switch ``disabled`` mode) every owned + Artifact Family and every pending Candidate is a gap, which is the check an + administrator runs before enabling enforcement. + """ + + if background_principal is not None and access is None: + raise AccessUnavailableError("relationship_management_unavailable") + scopes: list[ScopeOwnershipPlan] = [] + selected = tuple(dict.fromkeys(scope_ids)) + families = await _owned_artifact_families(application) + for scope_id in selected: + scopes.append( + ScopeOwnershipPlan( + scope_id=scope_id, + artifact_gaps=await _artifact_gaps(application, access, scope_id, families), + candidate_gaps=await _candidate_gaps(application, access, scope_id), + ) + ) + background: list[BackgroundPermissionGap] = [] + if background_principal is not None and access is not None: + # The checked background Principal does not inherit the administrator's groups. + context = replace(migration_context(PLAN_OPERATION, transport=transport, context=context), subject_groups=()) + for scope_id in selected: + decision = await access.check( + background_principal, + AccessAction.SCOPE_CONTRIBUTE, + ResourceRef.scope(scope_id), + context=context, + ) + if not decision.allowed and not ( + decision.reason_code in {"no-matching-binding", "no-matching-policy"} + and await access.static_scope_bootstrap_would_grant( + background_principal, AccessAction.SCOPE_CONTRIBUTE, ResourceRef.scope(scope_id) + ) + ): + background.append( + BackgroundPermissionGap( + scope_id=scope_id, + principal_id=background_principal.id, + reason_code=decision.reason_code, + ) + ) + continue + records = application.records.for_scope(scope_id) + page = await records.query_artifacts("profile", cursor=None, limit=RESOURCE_PAGE_SIZE) + if any(record.artifact_id == "profile" for record in page.items): + profile_decision = await access.check( + background_principal, + AccessAction.ARTIFACT_WRITE, + ResourceRef.artifact(scope_id, family="profile", artifact_id="profile"), + context=context, + ) + if not profile_decision.allowed: + background.append( + BackgroundPermissionGap( + scope_id=scope_id, + principal_id=background_principal.id, + reason_code=profile_decision.reason_code, + ) + ) + return OwnershipPlan(scopes=tuple(scopes), background=tuple(background)) + + +async def apply_ownership_migration( + application: Application, + access: AccessControlService, + scope_ids: Sequence[str], + *, + owner: PrincipalRef, + dry_run: bool = False, + transport: str = "http", + owner_recovery: OwnerRecovery | None = None, + context: AccessAuditContext | None = None, +) -> OwnershipMigrationResult: + """Backfill the relations missing per plan, attributed to ``owner``. + + The inventory is recomputed at apply time, so interrupted or repeated runs + refill only still-missing items. An existing relation with a different + Principal is reported as a conflict, never overwritten. + """ + + _validate_migration_owner(owner) + context = migration_context(MIGRATION_OPERATION, transport=transport, context=context) + items: list[ArtifactOwnershipGap | CandidateOwnershipGap] = [] + conflicts: list[OwnershipDisposition] = [] + families = await _owned_artifact_families(application) + for scope_id in dict.fromkeys(scope_ids): + inventory = ( + *await _artifact_gaps(application, None, scope_id, families), + *await _candidate_gaps(application, None, scope_id), + ) + for item in inventory: + if isinstance(item, ArtifactOwnershipGap): + relation = await access.artifact_owner(_owner_identity(item)[1]) + existing_owner = None if relation is None else relation.owner + conflict_code = "artifact-owner" + else: + attestation = await access.candidate_owner(item.scope_id, item.candidate_id) + existing_owner = None if attestation is None else attestation.proposed_owner + conflict_code = "candidate-owner" + if existing_owner is None: + items.append(item) + elif existing_owner != owner: + conflicts.append(_conflict(item, AccessConflictError(conflict_code))) + elif isinstance(item, ArtifactOwnershipGap) and not dry_run: + # The relation may have committed before its external audit failed. + # Complete the original intent before an idempotent retry succeeds. + async with owner_recovery_session(owner_recovery): + await flush_owner_writes(artifact=_owner_identity(item)[1]) + protected = await _owner_recovery_conflicts(owner_recovery, items) + protected_keys = {_migration_item_key(item.gap) for item in protected} + items = [item for item in items if _migration_item_key(item) not in protected_keys] + if dry_run: + return OwnershipMigrationResult(applied=_preview_gaps(items), conflicts=(*conflicts, *protected)) + applied: list[OwnershipDisposition] = [] + conflicts.extend(protected) + for item in items: + if owner_recovery is None: + item_applied, item_conflicts = await _apply_gap(access, item, owner, context) + else: + async with ( + owner_recovery_session(owner_recovery), + owner_recovery.database.transaction() as connection, + ): + writer = owner_write_access(access, connection) + item_applied, item_conflicts = await _apply_gap(writer, item, owner, context) + applied.extend(item_applied) + conflicts.extend(item_conflicts) + return OwnershipMigrationResult(applied=tuple(applied), conflicts=tuple(conflicts)) + + +def _preview_gaps(items: Sequence[ArtifactOwnershipGap | CandidateOwnershipGap]) -> tuple[OwnershipDisposition, ...]: + preview: list[OwnershipDisposition] = [] + for item in items: + if isinstance(item, CandidateOwnershipGap): + preview.extend( + OwnershipDisposition( + gap=ArtifactOwnershipGap( + scope_id=item.scope_id, + family=item.family, + artifact_id=item.target_artifact_id or "", + entry_id=entry_id, + ), + outcome="applied", + ) + for entry_id in item.entry_ids + ) + preview.append(OwnershipDisposition(gap=item, outcome="applied")) + return tuple(preview) + + +async def _owner_recovery_conflicts( + recovery: OwnerRecovery | None, + items: Sequence[ArtifactOwnershipGap | CandidateOwnershipGap], +) -> list[OwnershipDisposition]: + if recovery is None: + return [] + conflicts: list[OwnershipDisposition] = [] + by_identity = {_migration_item_key(item): item for item in items} + families_by_kind: dict[tuple[str, Literal["artifact", "candidate"]], set[str]] = {} + for item in items: + scope_id, kind, _ = _migration_item_key(item) + families_by_kind.setdefault((scope_id, kind), set()).add(item.family) + # Candidate attribution also covers families without standalone Artifact ownership. + # Protect exactly the identities accepted above instead of borrowing another whitelist. + for (scope_id, kind), families in families_by_kind.items(): + after: tuple[str, str] | None = None + while True: + page = await recovery.inventory_gaps(scope_id, kind=kind, after=after, families=families) + for evidence in page.items: + if evidence.operation_id is None: + continue + key = ( + ResourceRef.artifact(scope_id, family=evidence.family, artifact_id=evidence.identity).key + if kind == "artifact" + else evidence.identity + ) + item = by_identity.get((scope_id, kind, key)) + if item is not None: + conflicts.append( + OwnershipDisposition(gap=item, outcome="conflict", detail=f"owner-recovery-{evidence.reason}") + ) + after = page.after + if after is None: + break + return conflicts + + +def _migration_item_key( + item: ArtifactOwnershipGap | CandidateOwnershipGap, +) -> tuple[str, Literal["artifact", "candidate"], str]: + if isinstance(item, ArtifactOwnershipGap): + return (item.scope_id, "artifact", _owner_identity(item)[1].key) + return (item.scope_id, "candidate", item.candidate_id) + + +async def _apply_gap( + access: AccessControlService | OwnerCommit, + item: ArtifactOwnershipGap | CandidateOwnershipGap, + owner: PrincipalRef, + context: AccessAuditContext, +) -> tuple[list[OwnershipDisposition], list[OwnershipDisposition]]: + """Write one idempotent item; conflicts report the loser without failing the run.""" + + if isinstance(item, ArtifactOwnershipGap): + idempotency_key, resource = _owner_identity(item) + try: + await access.establish_artifact_owner(resource, owner, idempotency_key=idempotency_key, context=context) + except AccessConflictError as error: + return [], [_conflict(item, error)] + return [OwnershipDisposition(gap=item, outcome="applied")], [] + applied: list[OwnershipDisposition] = [] + conflicts: list[OwnershipDisposition] = [] + for entry_id in item.entry_ids: + entry_gap = ArtifactOwnershipGap( + scope_id=item.scope_id, + family=item.family, + artifact_id=item.target_artifact_id or "", + entry_id=entry_id, + ) + identity_key, entry_resource = _owner_identity(entry_gap) + try: + await access.establish_artifact_owner( + entry_resource, + owner, + idempotency_key=identity_key, + context=context, + ) + except AccessConflictError as error: + conflicts.append(_conflict(entry_gap, error)) + continue + applied.append(OwnershipDisposition(gap=entry_gap, outcome="applied")) + try: + await _attest_gap_owner(access, item, owner) + except AccessConflictError as error: + conflicts.append(_conflict(item, error)) + return applied, conflicts + applied.append(OwnershipDisposition(gap=item, outcome="applied")) + return applied, conflicts + + +def _conflict( + gap: ArtifactOwnershipGap | CandidateOwnershipGap, + error: AccessConflictError, +) -> OwnershipDisposition: + return OwnershipDisposition(gap=gap, outcome="conflict", detail=error.code) + + +def _validate_migration_owner(owner: PrincipalRef) -> None: + if owner.type not in {"user", "service"}: + raise AccessInvalidRequestError("principal") + if not owner.id or owner.id != owner.id.strip() or len(owner.id) > 256: + raise AccessInvalidRequestError("principal") + + +async def _owned_artifact_families(application: Application) -> frozenset[str]: + """Families whose Artifact lifecycle Access enforces, including saved plugins.""" + + from powercontext.server.artifact_plugins import read_artifact_declarations + + families = set(OWNED_ARTIFACT_FAMILIES) + database = getattr(application, "primary_database", None) + if database is not None: + families.update(declaration["family"] for declaration in await read_artifact_declarations(database)) + return frozenset(families) + + +async def _artifact_gaps( + application: Application, + access: AccessControlService | None, + scope_id: str, + families: frozenset[str], +) -> tuple[ArtifactOwnershipGap, ...]: + records = application.records.for_scope(scope_id) + + gaps: list[ArtifactOwnershipGap] = [] + for record in await records.logical_artifacts(): + if record.family not in families: + continue + resource = ResourceRef.artifact(scope_id, family=record.family, artifact_id=record.artifact_id) + if access is not None and await access.artifact_owner(resource) is not None: + continue + gaps.append(ArtifactOwnershipGap(scope_id, record.family, record.artifact_id)) + return tuple(gaps) + + +async def _candidate_gaps( + application: Application, + access: AccessControlService | None, + scope_id: str, +) -> tuple[CandidateOwnershipGap, ...]: + from powercontext.builtin.review.models import CandidateStatus + from powercontext.builtin.runtime.models import ListCandidatesRequest + + review = application.review.for_scope(scope_id) + gaps: list[CandidateOwnershipGap] = [] + cursor: str | None = None + while True: + page = await review.list( + ListCandidatesRequest(status=CandidateStatus.PENDING, cursor=cursor, limit=CANDIDATE_PAGE_SIZE) + ) + for candidate in page.candidates: + if candidate.candidate_kind != "artifact": + continue + if access is not None and await access.candidate_owner(scope_id, candidate.candidate_id) is not None: + continue + target = candidate.target + gaps.append( + CandidateOwnershipGap( + scope_id=scope_id, + candidate_id=candidate.candidate_id, + family=candidate.family, + version=candidate.version, + entry_ids=_memory_entry_ids(candidate) if candidate.family == "memory" else (), + target_family=None if target is None else target.family, + target_artifact_id=None if target is None else target.artifact_id, + ) + ) + cursor = page.next_cursor + if cursor is None: + return tuple(gaps) + + +async def _attest_gap_owner( + access: AccessControlService | OwnerCommit, + gap: CandidateOwnershipGap, + owner: PrincipalRef, +) -> None: + target = ( + None + if gap.target_family is None or gap.target_artifact_id is None + else ResourceRef.artifact(gap.scope_id, family=gap.target_family, artifact_id=gap.target_artifact_id) + ) + await access.attest_candidate_owner( + scope_id=gap.scope_id, + candidate_id=gap.candidate_id, + family=gap.family, + proposed_owner=owner, + target=target, + idempotency_key=bounded_idempotency_key("candidate-owner", gap.scope_id, gap.candidate_id), + ) + + +def _memory_entry_ids(candidate: object) -> tuple[str, ...]: + changes = getattr(getattr(candidate, "proposal", None), "changes", None) + if not isinstance(changes, Sequence): + return () + entry_ids: list[str] = [] + for change in changes: + entry_id = getattr(change, "entry_id", None) + if isinstance(entry_id, str) and entry_id not in entry_ids: + entry_ids.append(entry_id) + return tuple(entry_ids) + + +def _owner_identity(gap: ArtifactOwnershipGap) -> tuple[str, ResourceRef]: + if gap.entry_id is not None: + resource = ResourceRef.artifact( + gap.scope_id, + family=gap.family, + artifact_id=gap.artifact_id, + selector=MemoryEntrySelector(entry_id=gap.entry_id), + ) + return f"base-artifact-owner:{sha256(resource.key.encode()).hexdigest()}", resource + if gap.family == "profile" and gap.artifact_id == "profile": + key = bounded_idempotency_key("profile-owner", gap.scope_id) + else: + key = bounded_idempotency_key(f"{gap.family}-owner", gap.scope_id, gap.artifact_id) + return key, ResourceRef.artifact(gap.scope_id, family=gap.family, artifact_id=gap.artifact_id) diff --git a/src/powercontext/server/processing_security.py b/src/powercontext/server/processing_security.py index 17b7cddd74..c20edd5457 100644 --- a/src/powercontext/server/processing_security.py +++ b/src/powercontext/server/processing_security.py @@ -36,6 +36,7 @@ CandidateOwnerAttestation, PrincipalRef, ResourceRef, + bounded_idempotency_key, ) from powercontext.server.authz.repository import RelationalAccessRepository from powercontext.server.authz.service import BuiltinAuthorizationProvider @@ -117,7 +118,9 @@ async def experience_commit(self, connection: AsyncConnection, candidates: Seque family="experience", proposed_owner=self.principal, target=None, - idempotency_key=f"background-candidate-owner:{scope_id}:{candidate.candidate_id}", + idempotency_key=bounded_idempotency_key( + "background-candidate-owner", scope_id, candidate.candidate_id + ), ) for candidate in candidates ) @@ -149,7 +152,10 @@ async def profile_commit( if artifact is not None: if await bound.artifact_owner(resource) is None: await bound.establish_artifact_owner( - resource, self.principal, idempotency_key=f"profile-owner:{scope_id}", context=self.context + resource, + self.principal, + idempotency_key=bounded_idempotency_key("profile-owner", scope_id), + context=self.context, ) else: await bound.require(self.principal, AccessAction.ARTIFACT_WRITE, resource, context=self.context) @@ -162,7 +168,7 @@ async def profile_commit( family="profile", proposed_owner=self.principal, target=None if candidate.target is None else resource, - idempotency_key=f"candidate-owner:{scope_id}:{candidate.candidate_id}", + idempotency_key=bounded_idempotency_key("candidate-owner", scope_id, candidate.candidate_id), ) async def topic_commit(self, connection: AsyncConnection, scope_id: str, _operations: Sequence[Any]) -> None: diff --git a/src/powercontext/source_management/background.py b/src/powercontext/source_management/background.py index f6fd13a99b..26bf152388 100644 --- a/src/powercontext/source_management/background.py +++ b/src/powercontext/source_management/background.py @@ -151,6 +151,8 @@ def _address(directory): def _control(listener, identity, state_path): + if sys.platform != "linux": + raise ManagementError("unsupported", "Source process control requires Linux") stopping = False while True: try: diff --git a/tests/builtin/persistence/test_artifact_search_schema.py b/tests/builtin/persistence/test_artifact_search_schema.py new file mode 100644 index 0000000000..65d5becf28 --- /dev/null +++ b/tests/builtin/persistence/test_artifact_search_schema.py @@ -0,0 +1,130 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Artifact FTS initialization preserves the caller's transaction during DDL contention.""" + +import asyncio +from typing import cast +from unittest.mock import AsyncMock + +import pytest +import sqlalchemy +from pymysql.err import OperationalError as MySQLOperationalError +from sqlalchemy.dialects import mysql +from sqlalchemy.exc import OperationalError +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.builtin.persistence import schema +from powercontext.builtin.persistence.tables import ARTIFACT_SEARCH_TABLES, ARTIFACT_SEARCH_TEXT_TABLE + +_DDL = "CREATE FULLTEXT INDEX ix_pc_artifact_search_fts ON pc_artifact_search_text (searchable_text) WITH PARSER SPACE" + + +class _SearchSchema: + dialect = mysql.dialect() + + def __init__(self, failures, *, concurrent_index=False): + self.failures = list(failures) + self.concurrent_index = concurrent_index + self.index_exists = False + self.statements = [] + + def get_table_names(self): + return [table.name for table in ARTIFACT_SEARCH_TABLES] + + def has_table(self, name, **_kwargs): + return name in self.get_table_names() + + def get_columns(self, name): + return [{"name": column.name} for table in ARTIFACT_SEARCH_TABLES if table.name == name for column in table.c] + + def get_indexes(self, _name): + return [{"name": "ix_pc_artifact_search_fts"}] if self.index_exists else [] + + def exec_driver_sql(self, statement): + self.statements.append(statement) + assert statement == _DDL + if self.failures: + if self.concurrent_index: + self.index_exists = True + raise self.failures.pop(0) + self.index_exists = True + + +def _connection(monkeypatch, backend): + monkeypatch.setattr(schema, "inspect", lambda _connection: backend) + monkeypatch.setattr(sqlalchemy, "inspect", lambda _connection: backend) + connection = AsyncMock(spec=AsyncConnection) + connection.dialect = backend.dialect + + async def run_sync(callback): + return callback(backend) + + connection.run_sync.side_effect = run_sync + return connection + + +@pytest.mark.parametrize("concurrent_index", [False, True]) +def test_artifact_fts_recovers_from_a_transient_ddl_lock_without_ending_the_transaction(monkeypatch, concurrent_index): + async def scenario(): + error = OperationalError(_DDL, None, MySQLOperationalError(1205, "DDL lock conflict")) + backend = _SearchSchema([error], concurrent_index=concurrent_index) + connection = _connection(monkeypatch, backend) + await schema.create_tables(cast(AsyncConnection, connection), (ARTIFACT_SEARCH_TEXT_TABLE,)) + assert backend.index_exists + assert backend.statements == ([_DDL] if concurrent_index else [_DDL, _DDL]) + connection.commit.assert_not_awaited() + connection.rollback.assert_not_awaited() + + asyncio.run(scenario()) + + +def test_artifact_fts_bounds_persistent_lock_retries(monkeypatch): + async def scenario(): + error = OperationalError(_DDL, None, MySQLOperationalError(1205, "DDL lock conflict")) + backend = _SearchSchema([error] * 20) + connection = _connection(monkeypatch, backend) + with pytest.raises(OperationalError) as raised: + async with asyncio.timeout(5): + await schema.create_tables(cast(AsyncConnection, connection), (ARTIFACT_SEARCH_TEXT_TABLE,)) + assert raised.value is error + assert 1 <= len(backend.statements) <= 6 + connection.commit.assert_not_awaited() + connection.rollback.assert_not_awaited() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("failure", [1142, 1213, "cancelled", "other-statement"]) +def test_artifact_fts_preserves_other_errors_and_cancellation(monkeypatch, failure): + async def scenario(): + error = ( + asyncio.CancelledError() + if failure == "cancelled" + else OperationalError( + "SHOW FULL COLUMNS FROM pc_artifact_search_text" if failure == "other-statement" else _DDL, + None, + MySQLOperationalError(1205 if failure == "other-statement" else failure, "DDL failed"), + ) + ) + backend = _SearchSchema([error]) + connection = _connection(monkeypatch, backend) + with pytest.raises(type(error)) as raised: + await schema.create_tables(cast(AsyncConnection, connection), (ARTIFACT_SEARCH_TEXT_TABLE,)) + assert raised.value is error + assert not backend.index_exists + connection.commit.assert_not_awaited() + connection.rollback.assert_not_awaited() + + asyncio.run(scenario()) diff --git a/tests/e2e/test_access_control_regressions.py b/tests/e2e/test_access_control_regressions.py index 757fa85878..4ed44e32f4 100644 --- a/tests/e2e/test_access_control_regressions.py +++ b/tests/e2e/test_access_control_regressions.py @@ -2150,3 +2150,63 @@ async def scenario(): assert "Alpha scope fact." not in denied.text asyncio.run(scenario()) + + +def test_static_preset_preserves_revoked_legacy_long_scope_receipts(tmp_path): + from datetime import UTC, datetime + from uuid import uuid4 + + from powercontext.builtin.runtime import BuiltinConfig, open_builtin_contexts + from powercontext.server.authz import AccessBinding, AccessBindingState, AccessControlService, AccessRole + from powercontext.server.authz.repository import RelationalAccessRepository + + async def scenario(): + config = BuiltinConfig(database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'legacy-preset.db'}")) + async with open_builtin_contexts(config) as contexts: + scope_id = "s" * 256 + await contexts.get(scope_id) + actor = PrincipalRef(type="service", id="server-token") + repository = RelationalAccessRepository(contexts.database) + access = AccessControlService( + BuiltinAuthorizationProvider(repository), + relationships=repository, + audit=repository, + static_scope_principal=actor, + ) + resource = ResourceRef.scope(scope_id) + for role in ( + AccessRole.SCOPE_VIEWER, + AccessRole.SCOPE_CONTRIBUTOR, + AccessRole.SCOPE_REVIEWER, + AccessRole.SCOPE_DELEGATOR, + ): + binding = await repository.create_binding( + AccessBinding( + binding_id=str(uuid4()), + subject=actor, + resource=resource, + role=role, + granted_by=actor, + reason="static bearer preset", + created_at=datetime.now(UTC), + expires_at=None, + state=AccessBindingState.ACTIVE, + version=1, + policy_revision="pending", + idempotency_key=f"static-preset:{access.deployment_id}:{actor.id}:{scope_id}:{role.value}", + ) + ) + if role is AccessRole.SCOPE_CONTRIBUTOR: + await repository.revoke_binding( + binding.binding_id, + expected_version=1, + idempotency_key="revoke-legacy", + revoked_at=datetime.now(UTC), + revoked_by=actor, + ) + assert not await access.static_scope_bootstrap_would_grant(actor, AccessAction.SCOPE_CONTRIBUTE, resource) + await access.bootstrap_static_scope(actor, scope_id, context=AUDIT) + assert not (await access.check(actor, AccessAction.SCOPE_CONTRIBUTE, resource, context=AUDIT)).allowed + assert (await access.check(actor, AccessAction.SCOPE_READ, resource, context=AUDIT)).allowed + + asyncio.run(scenario()) diff --git a/tests/e2e/test_access_ownership_migration.py b/tests/e2e/test_access_ownership_migration.py new file mode 100644 index 0000000000..ac845e80d3 --- /dev/null +++ b/tests/e2e/test_access_ownership_migration.py @@ -0,0 +1,1157 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Public regressions for the supported Access enforcement switch (#2299). + +Data accepted while Access was disabled stays readable but immutable after +switching to enforced mode until an administrator migrates the missing +ownership data. These tests keep the switch flow honest: pre-switch inventory, +explicit attribution, idempotent application, preserved denials for +unauthorized identities, and conflict reporting. +""" + +from __future__ import annotations + +import asyncio +import json +import sqlite3 +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any, Literal + +import httpx +import pytest +from pydantic import SecretStr +from typer.testing import CliRunner + +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.runtime import RuntimeConfig +from powercontext.builtin.scope import ScopeApplication, ScopeDraft +from powercontext.cli.app import create_cli +from powercontext.server.authentication import AuthenticationResult, ProviderReadiness +from powercontext.server.authz import ( + AccessAction, + AccessAuditContext, + AccessConflictError, + AccessRole, + BindingSearchRequest, + PrincipalRef, + ResourceRef, +) +from powercontext.server.authz.composition import open_builtin_access_control +from powercontext.server.factory import create_server_app +from powercontext.server.ownership import ArtifactOwnershipGap, apply_ownership_migration +from powercontext.server.settings import AccessControlConfig, BearerAuthConfig, McpConfig, MetricsConfig, ServerSettings + +ADMIN = PrincipalRef(type="service", id="admin") +SERVER_TOKEN = PrincipalRef(type="service", id="server-token") +DEPLOYMENT = "ownership-migration" + + +class _Authentication: + async def authenticate(self, request): + identity = request.headers.get("authorization", "Bearer admin").removeprefix("Bearer ") + subject = ( + ADMIN + if identity == "admin" + else SERVER_TOKEN + if identity == "server-token" + else PrincipalRef(type="user", id=identity) + ) + return AuthenticationResult(subject=subject) + + async def readiness(self): + return ProviderReadiness(ready=True) + + +def _settings(database: SQLiteConfig, mode: Literal["disabled", "enforced"]) -> ServerSettings: + return ServerSettings( + database=database, + runtime=RuntimeConfig( + artifact_processing_families=(), + atomic_memory_preview_signing_secret=SecretStr("ownership-migration-test-signing-key"), + ), + access=AccessControlConfig(mode=mode, deployment_id=DEPLOYMENT), + mcp=McpConfig(enabled=False), + metrics=MetricsConfig(enabled=False), + ) + + +def checked(response: httpx.Response, expected: int) -> dict[str, Any]: + data = response.json() + assert response.status_code == expected, data + return data + + +async def _create_legacy_data(client: httpx.AsyncClient) -> tuple[str, str, dict[str, Any]]: + scope = checked( + await client.post( + "/v1/scopes", + json={"title": "Legacy scope", "summary": "Created before enforcement", "idempotency_key": "legacy"}, + ), + 201, + )["scope_id"] + artifact = checked( + await client.post( + f"/v1/scopes/{scope}/artifacts", + json={"family": "atomic-memory", "content": {"kind": "fact", "text": "Existing fact."}}, + ), + 201, + ) + source = checked( + await client.post( + "/v1/sources/content", + json={"scope_id": scope, "source_id": "legacy-source", "content": "A test passed."}, + ), + 202, + )["source"] + candidate = checked( + await client.post( + "/v1/experience/propose", + json={ + "scope_id": scope, + "source_refs": [source], + "artifact_refs": [], + "proposal": { + "situation": "API changes.", + "action": "Run tests.", + "outcome": "Tests pass.", + "lesson": "Run tests before delivery.", + }, + }, + ), + 201, + ) + return scope, f"/v1/scopes/{scope}/artifacts/atomic-memory/{artifact['artifact_id']}", candidate + + +@pytest.mark.parametrize("profile_owner", [None, "server-token", "other", "revoked"]) +@pytest.mark.parametrize("scope_length", [None, 220, 256]) +def test_background_plan_accounts_for_static_preset_without_granting_bindings( + tmp_path: Path, profile_owner: str | None, scope_length: int | None +) -> None: + async def scenario() -> None: + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{(tmp_path / 'static-background.db').as_posix()}") + app = create_server_app(settings=_settings(database, "disabled"), scheduler_path=tmp_path / "scheduler.db") + async with app.router.lifespan_context(app): + scopes = ( + app.state.application.scopes + if scope_length is None + else ScopeApplication(app.state.application.primary_database, id_factory=lambda: "s" * scope_length) + ) + scope = await scopes.create( + ScopeDraft(title="Legacy", summary="No static bindings", idempotency_key="static-background") + ) + if profile_owner in {"server-token", "other"}: + from powercontext.builtin.records import ArtifactWrite + + await app.state.application.records.for_scope(scope.scope_id).create_artifact( + "profile", ArtifactWrite(content={"content": "# Legacy profile"}) + ) + settings = _settings(database, "enforced").model_copy( + update={"auth": BearerAuthConfig(token=SecretStr("static-test-token"))} + ) + app = create_server_app(settings=settings, scheduler_path=tmp_path / "scheduler.db") + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://test", + headers={"Authorization": "Bearer static-test-token"}, + ) as client, + ): + # SQLite normally accepts oversized VARCHAR values. Enforce the strict + # relationship-key limit here without claiming a native MySQL run. + with sqlite3.connect(tmp_path / "static-background.db") as connection: + connection.execute( + "CREATE TRIGGER reject_oversized_static_key BEFORE INSERT ON pc_access_relationships " + "WHEN length(NEW.idempotency_key) > 255 " + "BEGIN SELECT RAISE(ABORT, 'idempotency_key too long'); END" + ) + access = app.state.access_control + context = AccessAuditContext(transport="background", operation="test.background") + if profile_owner in {"server-token", "other"}: + await access.establish_artifact_owner( + ResourceRef.artifact(scope.scope_id, family="profile", artifact_id="profile"), + PrincipalRef(type="service", id=profile_owner), + idempotency_key="static-profile-owner", + context=context, + ) + resource = ResourceRef.scope(scope.scope_id) + if profile_owner == "revoked": + checked( + await client.post(f"/v1/scopes/{scope.scope_id}/sources", json={"content": "Initialize preset."}), + 201, + ) + bindings = await access.list_bindings( + SERVER_TOKEN, + BindingSearchRequest(management_resource=resource, role=AccessRole.SCOPE_CONTRIBUTOR), + context=context, + ) + (binding,) = bindings.items + await access.revoke_binding( + SERVER_TOKEN, + binding.binding_id, + expected_version=binding.version, + idempotency_key="revoke-static", + context=context, + ) + assert not ( + await access.check(SERVER_TOKEN, AccessAction.SCOPE_CONTRIBUTE, resource, context=context) + ).allowed + for principal, gaps in (("server-token", int(profile_owner in {"other", "revoked"})), ("other-worker", 1)): + plan = checked( + await client.post( + "/v1/access/ownership/plan", + json={ + "scope_ids": [scope.scope_id], + "background_principal": {"type": "service", "id": principal}, + }, + ), + 200, + ) + assert len(plan["background"]) == gaps + assert not ( + await access.check(SERVER_TOKEN, AccessAction.SCOPE_CONTRIBUTE, resource, context=context) + ).allowed + written = await client.post( + f"/v1/scopes/{scope.scope_id}/sources", json={"content": "Exercise the actual static bootstrap."} + ) + assert written.status_code == (403 if profile_owner == "revoked" else 201), written.text + decision = await access.check(SERVER_TOKEN, AccessAction.SCOPE_CONTRIBUTE, resource, context=context) + assert decision.allowed == (profile_owner != "revoked") + after = checked( + await client.post( + "/v1/access/ownership/plan", + json={ + "scope_ids": [scope.scope_id], + "background_principal": {"type": "service", "id": "server-token"}, + }, + ), + 200, + ) + assert len(after["background"]) == int(profile_owner in {"other", "revoked"}) + + asyncio.run(scenario()) + + +def test_migration_inventories_published_handoff_identities(tmp_path: Path) -> None: + async def scenario() -> None: + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{(tmp_path / 'published.db').as_posix()}") + app = create_server_app(settings=_settings(database, "disabled"), scheduler_path=tmp_path / "scheduler.db") + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client, + ): + scope, _, _ = await _create_legacy_data(client) + original = checked( + await client.post( + f"/v1/scopes/{scope}/artifacts", + json={ + "family": "handoff", + "content": { + "schema": "powercontext.handoff.v1", + "objective": "Completed task.", + "state": [ + { + "text": "Verified task state.", + "citations": [ + { + "kind": "source", + "source_ref": {"name": "content", "source_id": "legacy-source"}, + } + ], + } + ], + "disposition": "complete", + "next_action": None, + "omissions": [], + }, + }, + ), + 201, + ) + target = checked( + await client.post( + "/v1/scopes", json={"title": "Target", "summary": "Copies", "idempotency_key": "copy-target"} + ), + 201, + )["scope_id"] + for key in ("first-copy", "second-copy"): + checked( + await client.post( + "/v1/artifact-publications", + json={ + "source": { + "scope_id": scope, + "artifact": { + "family": "handoff", + "artifact_id": "handoff", + "revision": original["revision"], + }, + }, + "target_scope_id": target, + "idempotency_key": key, + }, + ), + 201, + ) + records = await app.state.application.records.for_scope(target).logical_artifacts() + identities = {record.artifact_id for record in records if record.family == "handoff"} + assert len(identities) == 2 and all(identity.startswith("pub_") for identity in identities) + async with _enforced_server(database, tmp_path) as (client, access): + plan = checked(await client.post("/v1/access/ownership/plan", json={"scope_ids": [target]}), 200) + assert {item["artifact_id"] for item in plan["scopes"][0]["artifact_gaps"]} == identities + payload = {"scope_ids": [target], "owner": {"type": ADMIN.type, "id": ADMIN.id}} + preview = checked(await client.post("/v1/access/ownership/apply", json={**payload, "dry_run": True}), 200) + applied = checked(await client.post("/v1/access/ownership/apply", json=payload), 200) + assert preview["applied"] == applied["applied"] + assert applied["conflicts"] == [] + for identity in identities: + resource = ResourceRef.artifact(target, family="handoff", artifact_id=identity) + relation = await access.artifact_owner(resource) + assert relation is not None and relation.owner == ADMIN + await access.require( + ADMIN, + AccessAction.ARTIFACT_WRITE, + resource, + context=AccessAuditContext(transport="http", operation="test.migrated-handoff"), + ) + repeated = checked(await client.post("/v1/access/ownership/apply", json=payload), 200) + assert repeated["applied"] == [] and repeated["conflicts"] == [] + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("static_token", [False, True]) +@pytest.mark.parametrize("scope_length", [None, 220, 256]) +def test_offline_background_plan_uses_configured_static_preset_without_grants( + tmp_path: Path, static_token: bool, scope_length: int | None +) -> None: + from powercontext.server.cli import app as server_cli + + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{(tmp_path / 'offline-static.db').as_posix()}") + + async def seed() -> str: + app = create_server_app(settings=_settings(database, "disabled"), scheduler_path=tmp_path / "scheduler.db") + async with app.router.lifespan_context(app): + scopes = ( + app.state.application.scopes + if scope_length is None + else ScopeApplication(app.state.application.primary_database, id_factory=lambda: "s" * scope_length) + ) + scope = await scopes.create( + ScopeDraft(title="Legacy", summary="Static preview", idempotency_key="offline-static") + ) + return scope.scope_id + + scope_id = asyncio.run(seed()) + environment = tmp_path / "offline-static.env" + environment.write_text( + f"POWERCONTEXT_SERVER_DATABASE_KIND=sqlite\nPOWERCONTEXT_SERVER_DATABASE_URL={database.url}\n" + f"POWERCONTEXT_SERVER_ACCESS_MODE={'enforced' if static_token else 'disabled'}\n" + f"POWERCONTEXT_SERVER_ACCESS_DEPLOYMENT_ID={DEPLOYMENT}\n" + "POWERCONTEXT_SERVER_RUNTIME_ARTIFACT_PROCESSING_FAMILIES=[]\n" + + ("POWERCONTEXT_SERVER_AUTH_TOKEN=static-test-token\n" if static_token else ""), + encoding="utf-8", + ) + result = CliRunner().invoke( + create_cli([server_cli]), + [ + "server", + "access-ownership-migrate", + "--env-file", + str(environment), + "--action", + "plan", + "--scope-id", + scope_id, + "--background-principal", + "service:server-token", + ], + ) + assert result.exit_code == 0, result.output + assert len(json.loads(result.output)["background"]) == (0 if static_token else 1) + + async def verify() -> None: + async with open_builtin_access_control(database, deployment_id=DEPLOYMENT) as access: + decision = await access.check( + SERVER_TOKEN, + AccessAction.SCOPE_CONTRIBUTE, + ResourceRef.scope(scope_id), + context=AccessAuditContext(transport="background", operation="test.offline-static"), + ) + assert not decision.allowed + + asyncio.run(verify()) + + +def test_disabled_data_becomes_mutable_again_after_ownership_migration(tmp_path: Path) -> None: + async def scenario() -> None: + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'migration.db'}") + + app = create_server_app( + settings=_settings(database, "disabled"), + scheduler_path=tmp_path / "scheduler.db", + ) + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client, + ): + scope, artifact_path, candidate = await _create_legacy_data(client) + plan = checked( + await client.post("/v1/access/ownership/plan", json={"scope_ids": [scope]}), + 200, + ) + assert plan["mode"] == "disabled" + scope_plan = next(item for item in plan["scopes"] if item["scope_id"] == scope) + assert [gap["family"] for gap in scope_plan["artifact_gaps"]] == ["atomic-memory"] + assert [gap["candidate_id"] for gap in scope_plan["candidate_gaps"]] == [candidate["candidate_id"]] + background_without_store = await client.post( + "/v1/access/ownership/plan", + json={"scope_ids": [scope], "background_principal": {"type": "service", "id": "future-worker"}}, + ) + assert background_without_store.status_code == 503 + assert background_without_store.json()["error"]["code"] == "relationship_management_unavailable" + apply_while_disabled = await client.post( + "/v1/access/ownership/apply", + json={"owner": {"type": "service", "id": "admin"}, "scope_ids": [scope]}, + ) + assert apply_while_disabled.status_code == 503 + assert apply_while_disabled.json()["error"]["code"] == "ownership_migration_unavailable" + + async with open_builtin_access_control( + database, bootstrap_administrators=(ADMIN,), deployment_id=DEPLOYMENT + ) as access: + app = create_server_app( + settings=_settings(database, "enforced"), + authentication_provider=_Authentication(), + access_control=access, + scheduler_path=tmp_path / "scheduler.db", + ) + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client, + ): + headers = {"Authorization": "Bearer admin"} + artifact_headers = {**headers, "If-Match": '"revision:1"'} + assert ( + await client.put( + artifact_path, + headers=artifact_headers, + json={"content": {"kind": "fact", "text": "Updated fact."}}, + ) + ).status_code == 403 + assert ( + await client.post( + "/v1/candidates/approve", + headers=headers, + json={ + "scope_id": scope, + "candidate_id": candidate["candidate_id"], + "expected_version": candidate["version"], + }, + ) + ).status_code == 503 + + plan = checked( + await client.post("/v1/access/ownership/plan", headers=headers, json={"scope_ids": [scope]}), + 200, + ) + scope_plan = next(item for item in plan["scopes"] if item["scope_id"] == scope) + assert [gap["family"] for gap in scope_plan["artifact_gaps"]] == ["atomic-memory"] + + background_plan = checked( + await client.post( + "/v1/access/ownership/plan", + headers=headers, + json={ + "scope_ids": [scope], + "background_principal": {"type": "service", "id": "background-worker"}, + }, + ), + 200, + ) + assert [gap["scope_id"] for gap in background_plan["background"]] == [scope] + assert background_plan["background"][0]["principal_id"] == "background-worker" + assert background_plan["background"][0]["allowed"] is False + + preview = checked( + await client.post( + "/v1/access/ownership/apply", + headers=headers, + json={"owner": {"type": "service", "id": "admin"}, "scope_ids": [scope], "dry_run": True}, + ), + 200, + ) + assert preview["dry_run"] is True + assert len(preview["applied"]) == 2 + assert preview["conflicts"] == [] + + applied = checked( + await client.post( + "/v1/access/ownership/apply", + headers=headers, + json={"owner": {"type": "service", "id": "admin"}, "scope_ids": [scope]}, + ), + 200, + ) + assert sorted(item["kind"] for item in applied["applied"]) == [ + "artifact_owner", + "candidate_attestation", + ] + assert applied["conflicts"] == [] + + updated = checked( + await client.put( + artifact_path, + headers=artifact_headers, + json={"content": {"kind": "fact", "text": "Updated fact."}}, + ), + 200, + ) + assert updated["revision"] >= 2 + approved = checked( + await client.post( + "/v1/candidates/approve", + headers=headers, + json={ + "scope_id": scope, + "candidate_id": candidate["candidate_id"], + "expected_version": candidate["version"], + }, + ), + 200, + ) + assert approved["status"] == "approved" + + repeated = checked( + await client.post( + "/v1/access/ownership/apply", + headers=headers, + json={"owner": {"type": "service", "id": "admin"}, "scope_ids": [scope]}, + ), + 200, + ) + assert repeated["applied"] == [] + assert repeated["conflicts"] == [] + + unprivileged = {"Authorization": "Bearer worker"} + denied_plan = await client.post( + "/v1/access/ownership/plan", headers=unprivileged, json={"scope_ids": [scope]} + ) + assert denied_plan.status_code == 403 + denied_apply = await client.post( + "/v1/access/ownership/apply", + headers=unprivileged, + json={"owner": {"type": "user", "id": "worker"}, "scope_ids": [scope]}, + ) + assert denied_apply.status_code == 403 + + asyncio.run(scenario()) + + +@asynccontextmanager +async def _enforced_server(database: SQLiteConfig, tmp_path: Path): + async with open_builtin_access_control( + database, bootstrap_administrators=(ADMIN,), deployment_id=DEPLOYMENT + ) as access: + app = create_server_app( + settings=_settings(database, "enforced"), + authentication_provider=_Authentication(), + access_control=access, + scheduler_path=tmp_path / "scheduler.db", + ) + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client, + ): + yield client, access + + +@pytest.mark.parametrize("scope_length", [240, 256]) +@pytest.mark.parametrize("kind", ["experience", "profile", "candidate"]) +def test_long_scope_ownership_migration_matches_preview(tmp_path: Path, scope_length: int, kind: str) -> None: + async def scenario() -> None: + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'long-scope.db'}") + scope_id = "s" * scope_length + artifacts: list[dict[str, Any]] = [] + candidate: dict[str, Any] | None = None + app = create_server_app(settings=_settings(database, "disabled"), scheduler_path=tmp_path / "scheduler.db") + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client, + ): + scopes = ScopeApplication(app.state.application.primary_database, id_factory=lambda: scope_id) + await scopes.create( + ScopeDraft(title="Long Scope", summary="Legacy ownership", idempotency_key="long-scope") + ) + if kind == "candidate": + source = checked( + await client.post( + "/v1/sources/content", + json={"scope_id": scope_id, "source_id": "legacy-source", "content": "A test passed."}, + ), + 202, + )["source"] + candidate = checked( + await client.post( + "/v1/experience/propose", + json={ + "scope_id": scope_id, + "source_refs": [source], + "artifact_refs": [], + "proposal": { + "situation": "An identity is long.", + "action": "Migrate its owner.", + "outcome": "The owner is recorded.", + "lesson": "Accept supported identities.", + }, + }, + ), + 201, + ) + else: + for index in range(2 if kind == "experience" else 1): + content = ( + {"content": "# Legacy profile"} + if kind == "profile" + else { + "situation": "An identity is long.", + "action": "Migrate its owner.", + "outcome": "The owner is recorded.", + "lesson": f"Preserve identity {index}.", + } + ) + created = checked( + await client.post( + f"/v1/scopes/{scope_id}/artifacts", json={"family": kind, "content": content} + ), + 201, + ) + artifacts.append( + checked( + await client.get(f"/v1/scopes/{scope_id}/artifacts/{kind}/{created['artifact_id']}"), 200 + ) + ) + async with _enforced_server(database, tmp_path) as (client, access): + payload = {"scope_ids": [scope_id], "owner": {"type": ADMIN.type, "id": ADMIN.id}} + preview = checked(await client.post("/v1/access/ownership/apply", json={**payload, "dry_run": True}), 200) + assert preview["conflicts"] == [] + assert len(preview["applied"]) == len(artifacts) + int(candidate is not None) + applied = checked(await client.post("/v1/access/ownership/apply", json=payload), 200) + assert applied["applied"] == preview["applied"] + assert applied["conflicts"] == [] + keys = [] + for artifact in artifacts: + resource = ResourceRef.artifact(scope_id, family=kind, artifact_id=artifact["artifact_id"]) + relation = await access.artifact_owner(resource) + assert relation is not None and relation.owner == ADMIN + keys.append(relation.idempotency_key) + restored = checked( + await client.get(f"/v1/scopes/{scope_id}/artifacts/{kind}/{artifact['artifact_id']}"), 200 + ) + assert restored["artifact_id"] == artifact["artifact_id"] + assert restored["revision"] == artifact["revision"] + assert restored["content"] == artifact["content"] + if candidate is not None: + attestation = await access.candidate_owner(scope_id, candidate["candidate_id"]) + assert attestation is not None and attestation.proposed_owner == ADMIN + keys.append(attestation.idempotency_key) + approved = checked( + await client.post( + "/v1/candidates/approve", + json={ + "scope_id": scope_id, + "candidate_id": candidate["candidate_id"], + "expected_version": candidate["version"], + }, + ), + 200, + ) + assert approved["status"] == "approved" + target = approved["result_artifact"] + relation = await access.artifact_owner( + ResourceRef.artifact(scope_id, family=target["family"], artifact_id=target["artifact_id"]) + ) + assert relation is not None and relation.owner == ADMIN + keys.append(relation.idempotency_key) + assert all(len(key) <= 255 for key in keys) + assert len(set(keys)) == len(keys) + repeated = checked(await client.post("/v1/access/ownership/apply", json=payload), 200) + assert repeated["applied"] == repeated["conflicts"] == [] + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("retire_result_before_switch", [False, True]) +def test_migration_preserves_authority_to_undo_a_legacy_merge( + tmp_path: Path, retire_result_before_switch: bool +) -> None: + async def scenario() -> None: + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'merged.db'}") + app = create_server_app(settings=_settings(database, "disabled"), scheduler_path=tmp_path / "scheduler.db") + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client, + ): + scope, first_path, _ = await _create_legacy_data(client) + second = checked( + await client.post( + f"/v1/scopes/{scope}/artifacts", + json={"family": "atomic-memory", "content": {"kind": "fact", "text": "Second fact."}}, + ), + 201, + ) + states = [ + checked(await client.get(path + "/state"), 200) + for path in (first_path, f"/v1/scopes/{scope}/artifacts/atomic-memory/{second['artifact_id']}") + ] + merged = checked( + await client.post( + "/v1/atomic-memory/merges", + json={ + "scope_id": scope, + "inputs": [ + {"artifact": state["artifact"], "state_version": state["state_version"]} for state in states + ], + "content": {"kind": "fact", "text": "Combined fact."}, + }, + ), + 200, + ) + output = next(item for item in merged["records"] if item["state"] == "active") + identities = {item["artifact"]["artifact_id"] for item in merged["records"]} + payload = { + "scope_id": scope, + "target": {"artifact_id": output["artifact"]["artifact_id"]}, + "operation": "undo_merge", + } + if retire_result_before_switch: + preview = checked(await client.post("/v1/atomic-memory/restoration-previews", json=payload), 200) + checked( + await client.post( + "/v1/atomic-memory/restorations", json={**payload, "preview_token": preview["preview_token"]} + ), + 200, + ) + + async with _enforced_server(database, tmp_path) as (client, access): + plan = checked(await client.post("/v1/access/ownership/plan", json={"scope_ids": [scope]}), 200) + assert {item["artifact_id"] for item in plan["scopes"][0]["artifact_gaps"]} == identities + applied = checked( + await client.post( + "/v1/access/ownership/apply", + json={"scope_ids": [scope], "owner": {"type": ADMIN.type, "id": ADMIN.id}}, + ), + 200, + ) + assert applied["conflicts"] == [] + if retire_result_before_switch: + resource = ResourceRef.artifact( + scope, family="atomic-memory", artifact_id=output["artifact"]["artifact_id"] + ) + owner = await access.artifact_owner(resource) + assert owner is not None and owner.owner == ADMIN + state = checked( + await client.get(f"/v1/scopes/{scope}/artifacts/atomic-memory/{resource.artifact_id}/state"), 200 + ) + assert state["state"] == "retired" + return + preview = checked(await client.post("/v1/atomic-memory/restoration-previews", json=payload), 200) + restored = checked( + await client.post( + "/v1/atomic-memory/restorations", json={**payload, "preview_token": preview["preview_token"]} + ), + 200, + ) + assert {item["artifact_id"] for item in restored["restored"]} == { + state["artifact"]["artifact_id"] for state in states + } + assert [item["artifact_id"] for item in restored["retired"]] == [output["artifact"]["artifact_id"]] + + asyncio.run(scenario()) + + +def test_existing_owner_and_attestation_conflicts_are_reported_in_preview_and_apply(tmp_path: Path) -> None: + async def scenario() -> None: + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'existing-owners.db'}") + app = create_server_app(settings=_settings(database, "disabled"), scheduler_path=tmp_path / "scheduler.db") + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client, + ): + scope, path, candidate = await _create_legacy_data(client) + original = checked(await client.get(path), 200) + other = PrincipalRef(type="user", id="original-owner") + resource = ResourceRef.artifact(scope, family="atomic-memory", artifact_id=path.rsplit("/", 1)[-1]) + async with _enforced_server(database, tmp_path) as (client, access): + owner = await access.establish_artifact_owner( + resource, + other, + idempotency_key="original-owner", + context=AccessAuditContext(transport="http", operation="fixture"), + ) + attestation = await access.attest_candidate_owner( + scope_id=scope, + candidate_id=candidate["candidate_id"], + family="experience", + proposed_owner=other, + target=None, + idempotency_key="original-candidate-owner", + ) + for dry_run in (True, False): + result = checked( + await client.post( + "/v1/access/ownership/apply", + json={"scope_ids": [scope], "owner": {"type": ADMIN.type, "id": ADMIN.id}, "dry_run": dry_run}, + ), + 200, + ) + assert result["applied"] == [] + assert {item["conflict_code"] for item in result["conflicts"]} == {"artifact-owner", "candidate-owner"} + assert await access.artifact_owner(resource) == owner + assert await access.candidate_owner(scope, candidate["candidate_id"]) == attestation + retained = checked(await client.get(path, headers={"Authorization": "Bearer original-owner"}), 200) + assert retained["revision"] == original["revision"] + assert retained["content"] == original["content"] + + asyncio.run(scenario()) + + +def test_background_checks_remain_after_migration_and_cover_profile_writes(tmp_path: Path) -> None: + async def scenario() -> None: + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'background.db'}") + app = create_server_app(settings=_settings(database, "disabled"), scheduler_path=tmp_path / "scheduler.db") + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client, + ): + scope, _, _ = await _create_legacy_data(client) + checked( + await client.post( + f"/v1/scopes/{scope}/artifacts", + json={"family": "profile", "content": {"content": "# Legacy profile"}}, + ), + 201, + ) + async with _enforced_server(database, tmp_path) as (client, _): + checked( + await client.post( + "/v1/access/ownership/apply", + json={"scope_ids": [scope], "owner": {"type": ADMIN.type, "id": ADMIN.id}}, + ), + 200, + ) + checked( + await client.post( + "/v1/access/bindings/create", + json={ + "subject": {"type": "service", "id": "worker"}, + "resource": {"type": "scope", "scope_id": scope}, + "role": "scope.contributor", + "idempotency_key": "worker-contribution", + }, + ), + 201, + ) + checked( + await client.post( + "/v1/access/bindings/create", + json={ + "subject": {"type": ADMIN.type, "id": ADMIN.id}, + "resource": {"type": "scope", "scope_id": scope}, + "role": "scope.contributor", + "idempotency_key": "owner-contribution", + }, + ), + 201, + ) + for principal, expected_gaps in (("unbound-worker", 1), ("worker", 1), (ADMIN.id, 0)): + plan = checked( + await client.post( + "/v1/access/ownership/plan", + json={"scope_ids": [scope], "background_principal": {"type": "service", "id": principal}}, + ), + 200, + ) + assert plan["scopes"][0]["artifact_gaps"] == [] + assert len(plan["background"]) == expected_gaps + if expected_gaps: + assert plan["background"][0]["scope_id"] == scope + + asyncio.run(scenario()) + + +def test_offline_cli_checks_future_background_principal_before_enforcement(tmp_path: Path) -> None: + from powercontext.server.cli import app as server_cli + + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{(tmp_path / 'cli-background.db').as_posix()}") + + async def seed() -> str: + app = create_server_app(settings=_settings(database, "disabled"), scheduler_path=tmp_path / "scheduler.db") + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client, + ): + scope, _, _ = await _create_legacy_data(client) + return scope + + scope = asyncio.run(seed()) + environment = tmp_path / "server.env" + environment.write_text( + f"POWERCONTEXT_SERVER_DATABASE_KIND=sqlite\nPOWERCONTEXT_SERVER_DATABASE_URL={database.url}\n" + f"POWERCONTEXT_SERVER_ACCESS_MODE=disabled\nPOWERCONTEXT_SERVER_ACCESS_DEPLOYMENT_ID={DEPLOYMENT}\n" + "POWERCONTEXT_SERVER_RUNTIME_ARTIFACT_PROCESSING_FAMILIES=[]\n", + encoding="utf-8", + ) + result = CliRunner().invoke( + create_cli([server_cli]), + [ + "server", + "access-ownership-migrate", + "--env-file", + str(environment), + "--action", + "plan", + "--scope-id", + scope, + "--background-principal", + "service:future-worker", + ], + ) + assert result.exit_code == 0, result.output + report = json.loads(result.output) + assert [item["principal_id"] for item in report["background"]] == ["future-worker"] + assert [item["scope_id"] for item in report["background"]] == [scope] + + +@pytest.mark.parametrize("principal", ["missing-type", "user:", "group:workers", "service: worker"]) +def test_cli_rejects_invalid_background_identity(principal: str) -> None: + from powercontext.server.cli import app as server_cli + + result = CliRunner().invoke( + create_cli([server_cli]), ["server", "access-ownership-migrate", "--background-principal", principal] + ) + assert result.exit_code == 2 + + +def test_offline_cli_migration_repairs_the_switch(tmp_path: Path) -> None: + """The offline command plans and applies without a running Server, before or after the switch.""" + + async def scenario() -> None: + from powercontext.server.cli import _access_ownership_maintenance + + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'cli.db'}") + app = create_server_app( + settings=_settings(database, "disabled"), + scheduler_path=tmp_path / "scheduler.db", + ) + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client, + ): + scope, artifact_path, candidate = await _create_legacy_data(client) + + settings = _settings(database, "disabled") + planned = await _access_ownership_maintenance(settings, "plan", owner=None, scope_ids=(scope,)) + assert planned is True + applied = await _access_ownership_maintenance( + settings, "apply", owner="service:server-token", scope_ids=(scope,) + ) + assert applied is True + + async with open_builtin_access_control( + database, bootstrap_administrators=(ADMIN,), deployment_id=DEPLOYMENT + ) as access: + app = create_server_app( + settings=_settings(database, "enforced"), + authentication_provider=_Authentication(), + access_control=access, + scheduler_path=tmp_path / "scheduler.db", + ) + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client, + ): + headers = {"Authorization": "Bearer admin"} + assert ( + await client.put( + artifact_path, + headers={"Authorization": "Bearer server-token", "If-Match": '"revision:1"'}, + json={"content": {"kind": "fact", "text": "Updated fact."}}, + ) + ).status_code == 200 + approved = checked( + await client.post( + "/v1/candidates/approve", + headers=headers, + json={ + "scope_id": scope, + "candidate_id": candidate["candidate_id"], + "expected_version": candidate["version"], + }, + ), + 200, + ) + assert approved["status"] == "approved" + again = await _access_ownership_maintenance( + settings, "apply", owner="service:server-token", scope_ids=(scope,) + ) + assert again is True + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("store", ["local", "external", "external-retry"]) +def test_migration_audit_preserves_request_context(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, store: str) -> None: + from powercontext.builtin.records import ArtifactWrite + from powercontext.server.authz import AccessUnavailableError + + actor = PrincipalRef(type="service", id="trusted-delegate") + owner = PrincipalRef(type="user", id="bob") + + class DelegatedAuthentication(_Authentication): + async def authenticate(self, request): + authentication = await super().authenticate(request) + return AuthenticationResult(subject=authentication.subject, actor=actor) + + async def scenario() -> None: + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{(tmp_path / 'audit-domain.db').as_posix()}") + relationships = ( + database + if store == "local" + else SQLiteConfig(url=f"sqlite+aiosqlite:///{(tmp_path / 'audit-access.db').as_posix()}") + ) + seed = create_server_app(settings=_settings(database, "disabled"), scheduler_path=tmp_path / "scheduler.db") + async with ( + seed.router.lifespan_context(seed), + httpx.AsyncClient(transport=httpx.ASGITransport(app=seed), base_url="http://test") as client, + ): + scope = checked( + await client.post( + "/v1/scopes", + json={"title": "Audit context", "summary": "Legacy ownership", "idempotency_key": "audit"}, + ), + 201, + )["scope_id"] + await seed.state.application.records.for_scope(scope).create_artifact( + "profile", ArtifactWrite(content={"content": "# Legacy profile"}) + ) + async with open_builtin_access_control( + relationships, bootstrap_administrators=(ADMIN,), deployment_id=DEPLOYMENT + ) as access: + app = create_server_app( + settings=_settings(database, "enforced"), + authentication_provider=DelegatedAuthentication(), + access_control=access, + scheduler_path=tmp_path / "scheduler.db", + ) + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client, + ): + resource = ResourceRef.artifact(scope, family="profile", artifact_id="profile") + payload = {"scope_ids": [scope], "owner": {"type": owner.type, "id": owner.id}} + original_request_id = None + if store == "external-retry": + + async def unavailable(event): + raise AccessUnavailableError("external_audit_unavailable") + + with monkeypatch.context() as patch: + patch.setattr(access.audit, "append_audit_once", unavailable) + failed = await client.post("/v1/access/ownership/apply", json=payload) + assert failed.status_code == 503, failed.text + original_request_id = failed.headers["X-PowerContext-Request-ID"] + assert len(await app.state.owner_recovery.pending(scope_id=scope)) == 1 + response = await client.post("/v1/access/ownership/apply", json=payload) + assert response.status_code == 200, response.text + request_id = original_request_id or response.headers["X-PowerContext-Request-ID"] + events = await access.audit.list_audit(resource=resource, limit=100) + owners = [event for event in events if event.reason_code == "artifact-owner-established"] + assert len(owners) == 1 + assert owners[0].principal == owner + assert owners[0].request_id == request_id + assert owners[0].actor == actor + assert owners[0].transport == "http" + assert owners[0].operation == "access.ownership.apply" + preview = await client.post( + "/v1/access/ownership/plan", + json={"scope_ids": [scope], "background_principal": payload["owner"]}, + ) + assert preview.status_code == 200, preview.text + scope_events = await access.audit.list_audit(resource=ResourceRef.scope(scope), limit=100) + planned = [event for event in scope_events if event.operation == "access.ownership.plan"] + assert planned + assert all(event.request_id == preview.headers["X-PowerContext-Request-ID"] for event in planned) + assert all(event.actor == actor for event in planned) + + asyncio.run(scenario()) + + +def test_apply_reports_conflicting_ownership_without_failing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + async def scenario() -> None: + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'conflict.db'}") + app = create_server_app( + settings=_settings(database, "disabled"), + scheduler_path=tmp_path / "scheduler.db", + ) + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client, + ): + scope, artifact_path, _ = await _create_legacy_data(client) + artifact_id = artifact_path.rsplit("/", 1)[-1] + + async with open_builtin_access_control( + database, bootstrap_administrators=(ADMIN,), deployment_id=DEPLOYMENT + ) as access: + app = create_server_app( + settings=_settings(database, "enforced"), + authentication_provider=_Authentication(), + access_control=access, + scheduler_path=tmp_path / "scheduler.db", + ) + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client, + ): + application = app.state.application + original_establish = access.establish_artifact_owner + attempts = 0 + + async def conflicting_establish(resource, owner, *, idempotency_key, context): + nonlocal attempts + if resource.artifact_id == artifact_id: + attempts += 1 + raise AccessConflictError("artifact-owner") + return await original_establish(resource, owner, idempotency_key=idempotency_key, context=context) + + monkeypatch.setattr(access, "establish_artifact_owner", conflicting_establish) + result = await apply_ownership_migration( + application, + access, + [scope], + owner=ADMIN, + ) + assert attempts == 1 + assert len(result.conflicts) == 1 + gap = result.conflicts[0].gap + assert isinstance(gap, ArtifactOwnershipGap) + assert gap.artifact_id == artifact_id + assert result.conflicts[0].detail == "artifact-owner" + assert result.applied + + asyncio.run(scenario()) diff --git a/tests/e2e/test_artifact_plugins.py b/tests/e2e/test_artifact_plugins.py index 85df6a2f28..cf82978250 100644 --- a/tests/e2e/test_artifact_plugins.py +++ b/tests/e2e/test_artifact_plugins.py @@ -27,8 +27,15 @@ from typing import Any import httpx +import pytest import yaml +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from powercontext.server.authz import AccessAction, AccessAuditContext, AccessDeniedError, PrincipalRef, ResourceRef +from powercontext.server.authz.composition import open_builtin_access_control +from powercontext.server.authz.owner_recovery import OwnerRecovery +from powercontext.server.authz.profiles import register_custom_artifact_access_family + EXAMPLE = Path(__file__).resolve().parents[2] / "examples/artifact_plugins/incident_report" SUMMARY_EXAMPLE = EXAMPLE.parent / "ticket_summary" ADMIN_AUTHORIZATION_VALUE = "artifact-plugin-e2e-admin" @@ -99,6 +106,7 @@ async def running_server( directory_from_env: bool = True, database_url: str | None = None, database_settings: dict[str, Any] | None = None, + access_mode: str = "enforced", ): with socket.socket() as sock: sock.bind(("127.0.0.1", 0)) @@ -115,13 +123,14 @@ async def running_server( "POWERCONTEXT_SERVER_RUNTIME": json.dumps({"artifact_processing_families": []}), "POWERCONTEXT_SERVER_WORKSPACE": str(directory), "POWERCONTEXT_HOME": str(directory / "home"), - "POWERCONTEXT_SERVER_AUTH_ENABLED": "true", - "POWERCONTEXT_SERVER_AUTH_TOKEN": ADMIN_AUTHORIZATION_VALUE, - "POWERCONTEXT_SERVER_ACCESS_MODE": "enforced", + "POWERCONTEXT_SERVER_AUTH_ENABLED": "true" if access_mode != "disabled" else "false", + "POWERCONTEXT_SERVER_ACCESS_MODE": access_mode, "POWERCONTEXT_SERVER_MCP_ENABLED": "false", "POWERCONTEXT_SERVER_METRICS_ENABLED": "true", "POWERCONTEXT_SERVER_DASHBOARD_ENABLED": "false", }) + if access_mode != "disabled": + env["POWERCONTEXT_SERVER_AUTH_TOKEN"] = ADMIN_AUTHORIZATION_VALUE (directory / "plugins").mkdir(exist_ok=True) if directory_from_env: env["POWERCONTEXT_SERVER_ARTIFACT_PLUGINS_DIR"] = str(directory / "plugins") @@ -1021,3 +1030,112 @@ async def _repair_failed_restore(tmp_path: Path) -> None: assert repaired["revision"] == 1 assert repaired["content"]["title"] == "Repaired: during-failure" assert len((await query(client, healthy_scope, "ticket-summary"))["results"]) == 1 + + +def test_ownership_migration_restores_a_registered_custom_family(tmp_path: Path) -> None: + asyncio.run(_migrate_registered_custom_family(tmp_path)) + + +async def _migrate_registered_custom_family(tmp_path: Path) -> None: + plugin = deploy_plugin(EXAMPLE, tmp_path / "migration-v1", None) + async with running_server(tmp_path, access_mode="disabled") as client: + scope = await create_scope(client, "custom-migration") + await reload_plugin(client, tmp_path, plugin, 0) + await change(client, "enable", 1, "enabled") + await capture(client, scope, "legacy-report") + report = (await contents(client, scope, "incident-report", 1))[0] + # A disabled plugin stays disabled after restart, so no replay repairs the legacy owner. + await change(client, "disable", 2, "disabled") + async with running_server(tmp_path) as client: + plan = (await client.post("/v1/access/ownership/plan", json={"scope_ids": [scope]})).json() + assert [(gap["family"], gap["artifact_id"]) for gap in plan["scopes"][0]["artifact_gaps"]] == [ + ("incident-report", report["artifact_id"]) + ] + applied = await client.post( + "/v1/access/ownership/apply", + json={"scope_ids": [scope], "owner": {"type": "service", "id": "server-token"}}, + ) + assert applied.status_code == 200, applied.text + assert [item["kind"] for item in applied.json()["applied"]] == ["artifact_owner"] + async with open_builtin_access_control(SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'server.db'}")) as access: + register_custom_artifact_access_family("incident-report") + resource = ResourceRef.artifact(scope, family="incident-report", artifact_id=report["artifact_id"]) + relation = await access.artifact_owner(resource) + assert relation is not None and relation.owner == PrincipalRef(type="service", id="server-token") + await access.require( + relation.owner, + AccessAction.ARTIFACT_WRITE, + resource, + context=AccessAuditContext(transport="test", operation="artifact.write"), + ) + with pytest.raises(AccessDeniedError): + await access.require( + PrincipalRef(type="user", id="worker"), + AccessAction.ARTIFACT_WRITE, + resource, + context=AccessAuditContext(transport="test", operation="artifact.write"), + ) + + +def test_offline_migration_reports_pending_custom_family_owner(tmp_path: Path) -> None: + asyncio.run(_offline_migration_reports_pending_custom_family_owner(tmp_path)) + + +async def _offline_migration_reports_pending_custom_family_owner(tmp_path: Path) -> None: + plugin = deploy_plugin(EXAMPLE, tmp_path / "pending-v1", None) + async with running_server(tmp_path, access_mode="disabled") as client: + scope = await create_scope(client, "custom-pending") + await reload_plugin(client, tmp_path, plugin, 0) + await change(client, "enable", 1, "enabled") + await capture(client, scope, "pending-report") + (report,) = await contents(client, scope, "incident-report", 1) + await change(client, "disable", 2, "disabled") + + resource = ResourceRef.artifact(scope, family="incident-report", artifact_id=report["artifact_id"]) + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'server.db'}") + async with open_builtin_access_control(database) as access, SQLiteProfile.open(database, tables=()) as profile: + recovery = OwnerRecovery(profile.database, access) + await recovery.initialize() + async with profile.database.transaction() as connection: + await recovery.for_transaction(connection).establish_artifact_owner( + resource, + PrincipalRef(type="user", id="alice"), + idempotency_key="custom-family-pending", + context=AccessAuditContext(transport="test", operation="artifact.write"), + ) + assert [item.resource for item in await recovery.pending(scope_id=scope)] == [resource] + + # A separate process holds no custom family profile; only persisted declarations identify it. + environment = tmp_path / "offline.env" + environment.write_text( + "POWERCONTEXT_SERVER_DATABASE_KIND=sqlite\n" + f"POWERCONTEXT_SERVER_DATABASE_URL=sqlite+aiosqlite:///{(tmp_path / 'server.db').as_posix()}\n" + "POWERCONTEXT_SERVER_ACCESS_MODE=disabled\n" + "POWERCONTEXT_SERVER_RUNTIME_ARTIFACT_PROCESSING_FAMILIES=[]\n", + encoding="utf-8", + ) + result = await asyncio.create_subprocess_exec( + str(Path(sys.executable).with_name("powercontext")), + "server", + "access-ownership-migrate", + "--env-file", + str(environment), + "--action", + "apply", + "--owner", + "user:bob", + "--scope-id", + scope, + "--maintenance-confirmed", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await result.communicate() + assert result.returncode == 1, (stdout + stderr).decode() + migrated = json.loads(stdout) + assert migrated["applied"] == [] + assert [item["detail"] for item in migrated["conflicts"]] == ["owner-recovery-pending"] + + async with open_builtin_access_control(database) as access: + register_custom_artifact_access_family("incident-report") + assert await access.artifact_owner(resource) is None diff --git a/tests/e2e/test_candidate_ownership_migration.py b/tests/e2e/test_candidate_ownership_migration.py new file mode 100644 index 0000000000..1b26296977 --- /dev/null +++ b/tests/e2e/test_candidate_ownership_migration.py @@ -0,0 +1,185 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Candidate recovery follows accepted identities, independently of Artifact ownership.""" + +import asyncio +import json +import os +import sqlite3 +import sys +from contextlib import closing +from pathlib import Path + +import pytest + +from powercontext.artifacts import ArtifactRef +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.records import ArtifactWrite +from powercontext.builtin.runtime import ( + BuiltinConfig, + CaptureSource, + CreateDreamRunRequest, + GetCandidateRequest, + GetDreamRunRequest, + InferenceConfig, + RuntimeConfig, +) +from powercontext.builtin.scope import ScopeDraft +from powercontext.server.authz.composition import open_builtin_access_control +from tests.e2e.dream_support import open_dream_runtime, process_pending +from tests.e2e.test_dream_skill_prompt import PromptGenerator +from tests.e2e.test_owner_recovery_http import ALICE, _server + + +@pytest.mark.parametrize("completed", [False, True]) +@pytest.mark.parametrize("check_http", [False, True], ids=["cli", "http-and-cli"]) +def test_prompt_candidate_recovery_blocks_competing_http_and_offline_migration( + tmp_path: Path, completed: bool, check_http: bool +) -> None: + async def scenario() -> None: + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{(tmp_path / 'domain.db').as_posix()}") + # Seed through the supported complete Server schema before using the SDK Dream path. + async with _server(tmp_path): + pass + async with open_dream_runtime( + BuiltinConfig( + database=database, + runtime=RuntimeConfig(artifact_processing_families=()), + inference=InferenceConfig(generation_model="test"), + ), + dream_generator=PromptGenerator(), + ) as runtime: + scope = await runtime.scopes.create( + ScopeDraft(title="Prompt recovery", summary="Original attribution", idempotency_key="prompt-recovery") + ) + scope_id = scope.scope_id + prompt = await runtime.records.for_scope(scope_id).create_artifact( + "prompt", + ArtifactWrite( + prompt_key="atomic_memory.extract", + content={ + "schema_version": "powercontext.prompt.v1", + "mode": "custom", + "instructions": "Always treat trips as moves.", + "demonstrations": [], + }, + ), + ) + target = ArtifactRef(family="prompt", artifact_id=prompt.artifact_id, revision=prompt.revision) + source = await runtime.sources.for_scope(scope_id).capture( + CaptureSource( + source_id="correction", content="A two-day visit does not change the user's residence.", metadata={} + ) + ) + dream = runtime.dream.for_scope(scope_id) + run = await dream.create( + CreateDreamRunRequest( + operation="revise_prompt", + target=target, + artifacts=(target,), + sources=(source.source_ref,), + idempotency_key="correct-trip", + ) + ) + await process_pending(runtime) + run = await dream.get(GetDreamRunRequest(run_id=run.run_id)) + assert run.candidate is not None, run + candidate_id = run.candidate.candidate_id + candidate_before = await runtime.review.for_scope(scope_id).get( + GetCandidateRequest(candidate_id=candidate_id) + ) + + async with _server(tmp_path, interrupted=True) as (app, client, access): + initial = await client.post( + "/v1/access/ownership/apply", json={"scope_ids": [scope_id], "owner": {"type": "user", "id": "alice"}} + ) + assert initial.status_code == 503, initial.text + (intent,) = await app.state.owner_recovery.pending(scope_id=scope_id) + assert intent.kind == "candidate" and intent.candidate_id == candidate_id and intent.principal == ALICE + if completed: + access.relationships.unavailable = False + await app.state.owner_recovery.replay(intent.operation_id) + # An external relation can be lost after delivery; the primary evidence remains authoritative. + with closing(sqlite3.connect(tmp_path / "relationships.db")) as external: + external.execute( + "DELETE FROM pc_access_owners WHERE owner_kind = 'candidate' AND scope_id = ?", (scope_id,) + ) + external.commit() + assert await access.candidate_owner(scope_id, candidate_id) is None + reason = "delivered_relation_missing" if completed else "pending" + if check_http: + for dry_run in (True, False): + result = await client.post( + "/v1/access/ownership/apply", + json={"scope_ids": [scope_id], "owner": {"type": "user", "id": "bob"}, "dry_run": dry_run}, + ) + assert result.status_code == 200, result.text + assert result.json()["applied"] == [] + assert [(item["candidate_id"], item["conflict_code"]) for item in result.json()["conflicts"]] == [ + (candidate_id, f"owner-recovery-{reason}") + ] + + environment = tmp_path / "offline.env" + environment.write_text( + "POWERCONTEXT_SERVER_DATABASE_KIND=sqlite\n" + f"POWERCONTEXT_SERVER_DATABASE_URL={database.url}\n" + "POWERCONTEXT_SERVER_ACCESS_MODE=disabled\n" + "POWERCONTEXT_SERVER_ACCESS_DEPLOYMENT_ID=external-test\n" + "POWERCONTEXT_SERVER_RUNTIME_ARTIFACT_PROCESSING_FAMILIES=[]\n", + encoding="utf-8", + ) + process = await asyncio.create_subprocess_exec( + str(Path(sys.executable).with_name("powercontext.exe" if os.name == "nt" else "powercontext")), + "server", + "access-ownership-migrate", + "--env-file", + str(environment), + "--action", + "apply", + "--owner", + "user:bob", + "--scope-id", + scope_id, + "--maintenance-confirmed", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + async with asyncio.timeout(30): + stdout, stderr = await process.communicate() + finally: + if process.returncode is None: + process.kill() + await process.wait() + assert process.returncode == 1, (stdout + stderr).decode() + result = json.loads(stdout) + assert result["applied"] == [] + assert [(item["gap"]["candidate_id"], item["detail"]) for item in result["conflicts"]] == [ + (candidate_id, f"owner-recovery-{reason}") + ] + async with open_builtin_access_control(database, deployment_id="external-test") as local: + assert await local.candidate_owner(scope_id, candidate_id) is None + async with _server(tmp_path) as (app, _client, access): + if not completed: + await app.state.owner_recovery.replay(intent.operation_id) + attestation = await access.candidate_owner(scope_id, candidate_id) + assert attestation is not None and attestation.proposed_owner == ALICE + assert await app.state.owner_recovery.pending(scope_id=scope_id) == () + candidate_after = await app.state.application.review.for_scope(scope_id).get( + GetCandidateRequest(candidate_id=candidate_id) + ) + assert candidate_after.model_dump(mode="json") == candidate_before.model_dump(mode="json") + + asyncio.run(scenario()) diff --git a/tests/e2e/test_owner_recovery_http.py b/tests/e2e/test_owner_recovery_http.py index 09d8efdcc4..fea3c2b1ed 100644 --- a/tests/e2e/test_owner_recovery_http.py +++ b/tests/e2e/test_owner_recovery_http.py @@ -17,15 +17,19 @@ from __future__ import annotations import asyncio -from contextlib import asynccontextmanager +import json +import sqlite3 +from contextlib import asynccontextmanager, closing from typing import cast import httpx import pytest +from typer.testing import CliRunner from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.records import ArtifactWrite from powercontext.builtin.runtime import RuntimeConfig +from powercontext.cli.app import create_cli from powercontext.server.authentication import AuthenticationResult, ProviderReadiness from powercontext.server.authz import AccessUnavailableError, PrincipalRef, RelationshipStore, ResourceRef from powercontext.server.authz.composition import open_builtin_access_control @@ -182,6 +186,252 @@ async def scenario(): asyncio.run(scenario()) +@pytest.mark.parametrize(("kind", "count"), [("artifact", 1), ("candidate", 101)]) +def test_ownership_migration_cannot_replace_pending_original_attribution(tmp_path, kind, count): + async def scenario(): + async with _server(tmp_path, interrupted=True) as (app, client, access): + scope_id = await _scope(client) + if kind == "artifact": + created = await client.post( + f"/v1/scopes/{scope_id}/artifacts", + json={"family": "profile", "content": {"content": "# Original private profile"}}, + ) + assert created.status_code == 503, created.text + else: + source = await client.post(f"/v1/scopes/{scope_id}/sources", json={"content": "Original evidence."}) + assert source.status_code == 201, source.text + for index in range(count): + created = await client.post( + "/v1/experience/propose", + json={ + "scope_id": scope_id, + "proposal": { + "situation": f"Observation {index}.", + "action": "Retain the original proposer.", + "outcome": "Ownership delivery is pending.", + "lesson": "Preserve recorded attribution.", + }, + "source_refs": [{"name": "content", "source_id": source.json()["source_id"]}], + "artifact_refs": [], + }, + ) + assert created.status_code == 503, created.text + pending = await app.state.owner_recovery.pending(scope_id=scope_id, limit=500) + assert len(pending) == count + for dry_run in (True, False): + response = await client.post( + "/v1/access/ownership/apply", + json={"scope_ids": [scope_id], "owner": {"type": "user", "id": "bob"}, "dry_run": dry_run}, + ) + assert response.status_code == 200, response.text + result = response.json() + assert result["applied"] == [] + assert len(result["conflicts"]) == count + assert {item["conflict_code"] for item in result["conflicts"]} == {"owner-recovery-pending"} + access.relationships.unavailable = False + for intent in pending: + await app.state.owner_recovery.replay(intent.operation_id) + if intent.resource is not None: + relation = await access.artifact_owner(intent.resource) + assert relation is not None and relation.owner == ALICE + else: + attestation = await access.candidate_owner(scope_id, intent.candidate_id) + assert attestation is not None and attestation.proposed_owner == ALICE + assert await app.state.owner_recovery.pending(scope_id=scope_id) == () + + asyncio.run(scenario()) + + +def test_migration_preserves_completed_ownership_evidence_after_external_relation_loss(tmp_path): + async def scenario(): + async with _server(tmp_path, interrupted=True) as (app, client, access): + scope_id = await _scope(client) + created = await client.post( + f"/v1/scopes/{scope_id}/artifacts", + json={"family": "profile", "content": {"content": "# Original private profile"}}, + ) + assert created.status_code == 503, created.text + (intent,) = await app.state.owner_recovery.pending(scope_id=scope_id) + access.relationships.unavailable = False + await app.state.owner_recovery.replay(intent.operation_id) + # Model out-of-band loss in the external relationship store after delivery was acknowledged. + with closing(sqlite3.connect(tmp_path / "relationships.db")) as connection: + connection.execute("DELETE FROM pc_access_owners WHERE owner_kind = 'artifact'") + connection.commit() + for dry_run in (True, False): + response = await client.post( + "/v1/access/ownership/apply", + json={"scope_ids": [scope_id], "owner": {"type": "user", "id": "bob"}, "dry_run": dry_run}, + ) + assert response.status_code == 200, response.text + result = response.json() + assert result["applied"] == [] + assert [item["conflict_code"] for item in result["conflicts"]] == [ + "owner-recovery-delivered_relation_missing" + ] + assert await access.artifact_owner(intent.resource) is None + assert await app.state.owner_recovery.pending(scope_id=scope_id) == () + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("completed", [False, True]) +@pytest.mark.parametrize("operation", ["profile", "handoff", "publication"]) +def test_offline_migration_preserves_external_owner_evidence(tmp_path, completed, operation): + from powercontext.server.cli import app as server_cli + + async def seed(): + async with _server(tmp_path) as (app, client, access): + scope_id = await _scope(client) + path = f"/v1/scopes/{scope_id}/artifacts" + payload = {"family": "profile", "content": {"content": "# Original private profile"}} + if operation != "profile": + source = await client.post(f"/v1/scopes/{scope_id}/sources", json={"content": "Verified task state."}) + assert source.status_code == 201, source.text + content = { + "schema": "powercontext.handoff.v1", + "objective": "Continue verified work.", + "state": [ + { + "text": "Verified task state.", + "citations": [ + { + "kind": "source", + "source_ref": {"name": "content", "source_id": source.json()["source_id"]}, + } + ], + } + ], + "disposition": "complete", + "next_action": None, + "omissions": [], + } + payload = {"family": "handoff", "content": content} + if operation == "publication": + original = await client.post(path, json=payload) + assert original.status_code == 201, original.text + target = await client.post( + "/v1/scopes", + json={"title": "Target", "summary": "Recovery", "idempotency_key": "recovery-target"}, + ) + assert target.status_code == 201, target.text + destination = target.json()["scope_id"] + path = "/v1/artifact-publications" + payload = { + "source": { + "scope_id": scope_id, + "artifact": {"family": "handoff", "artifact_id": "handoff", "revision": 1}, + }, + "target_scope_id": destination, + "idempotency_key": "recovery-publication", + } + scope_id = destination + access.relationships.unavailable = True + created = await client.post(path, json=payload) + assert created.status_code == 503, created.text + (intent,) = await app.state.owner_recovery.pending(scope_id=scope_id) + if completed: + access.relationships.unavailable = False + await app.state.owner_recovery.replay(intent.operation_id) + else: + preview = await client.post( + "/v1/access/ownership/apply", + json={"scope_ids": [scope_id], "owner": {"type": "user", "id": "bob"}, "dry_run": True}, + ) + assert preview.status_code == 200, preview.text + assert preview.json()["applied"] == [] + assert [item["conflict_code"] for item in preview.json()["conflicts"]] == ["owner-recovery-pending"] + return scope_id, intent + + scope_id, intent = asyncio.run(seed()) + environment = tmp_path / "offline.env" + environment.write_text( + f"POWERCONTEXT_SERVER_DATABASE_KIND=sqlite\n" + f"POWERCONTEXT_SERVER_DATABASE_URL=sqlite+aiosqlite:///{(tmp_path / 'domain.db').as_posix()}\n" + "POWERCONTEXT_SERVER_ACCESS_MODE=disabled\n" + "POWERCONTEXT_SERVER_ACCESS_DEPLOYMENT_ID=external-test\n" + "POWERCONTEXT_SERVER_RUNTIME_ARTIFACT_PROCESSING_FAMILIES=[]\n", + encoding="utf-8", + ) + result = CliRunner().invoke( + create_cli([server_cli]), + [ + "server", + "access-ownership-migrate", + "--env-file", + str(environment), + "--action", + "apply", + "--owner", + "user:bob", + "--scope-id", + scope_id, + "--maintenance-confirmed", + ], + ) + assert result.exit_code == 1, result.output + report = json.loads(result.output) + assert report["applied"] == [] + reason = "delivered_relation_missing" if completed else "pending" + assert [item["detail"] for item in report["conflicts"]] == [f"owner-recovery-{reason}"] + + async def verify(): + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'domain.db'}") + async with open_builtin_access_control(database, deployment_id="external-test") as local: + assert await local.artifact_owner(intent.resource) is None + async with _server(tmp_path) as (app, _, access): + await app.state.owner_recovery.replay(intent.operation_id) + relation = await access.artifact_owner(intent.resource) + assert relation is not None and relation.owner == ALICE + assert await app.state.owner_recovery.pending(scope_id=scope_id) == () + + asyncio.run(verify()) + + +def test_migration_retries_complete_external_owner_audit(tmp_path, monkeypatch): + async def scenario(): + async with _server(tmp_path) as (app, client, access): + scope_id = await _scope(client) + await app.state.application.records.for_scope(scope_id).create_artifact( + "profile", ArtifactWrite(content={"content": "# Legacy profile"}) + ) + resource = ResourceRef.artifact(scope_id, family="profile", artifact_id="profile") + payload = {"scope_ids": [scope_id], "owner": {"type": ALICE.type, "id": ALICE.id}} + + async def unavailable(event): + if event.reason_code == "artifact-owner-established": + raise AccessUnavailableError("external_audit_unavailable") + return await append(event) + + append = access.audit.append_audit + with monkeypatch.context() as patch: + patch.setattr(access.audit, "append_audit", unavailable) + patch.setattr(access.audit, "append_audit_once", unavailable) + for _ in range(2): + response = await client.post("/v1/access/ownership/apply", json=payload) + assert response.status_code == 503, response.text + relation = await access.artifact_owner(resource) + assert relation is not None and relation.owner == ALICE + pending = await app.state.owner_recovery.pending(scope_id=scope_id) + assert len(pending) == 1 and pending[0].resource == resource + preview = await client.post("/v1/access/ownership/apply", json={**payload, "dry_run": True}) + assert preview.status_code == 200, preview.text + assert await app.state.owner_recovery.pending(scope_id=scope_id) == pending + response = await client.post("/v1/access/ownership/apply", json=payload) + assert response.status_code == 200, response.text + assert await app.state.owner_recovery.pending(scope_id=scope_id) == () + audit = await access.audit.list_audit(resource=resource, limit=100) + owners = [event for event in audit if event.reason_code == "artifact-owner-established"] + assert len(owners) == 1 and owners[0].principal == ALICE + assert owners[0].operation == "access.ownership.apply" + response = await client.post("/v1/access/ownership/apply", json=payload) + assert response.status_code == 200, response.text + audit = await access.audit.list_audit(resource=resource, limit=100) + assert [event for event in audit if event.reason_code == "artifact-owner-established"] == owners + + asyncio.run(scenario()) + + @pytest.mark.parametrize("operation", ["publication", "handoff"]) def test_idempotent_retry_requires_the_original_owner_audit_to_complete(tmp_path, monkeypatch, operation): async def scenario(): diff --git a/tests/e2e/test_ownership_maintenance_dispatch.py b/tests/e2e/test_ownership_maintenance_dispatch.py new file mode 100644 index 0000000000..a26da0414f --- /dev/null +++ b/tests/e2e/test_ownership_maintenance_dispatch.py @@ -0,0 +1,138 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Offline ownership maintenance must leave scheduled processing untouched.""" + +from __future__ import annotations + +import asyncio +import json +import sqlite3 +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Literal + +import pytest +from typer.testing import CliRunner + +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.persistence.supervision import ( + ArtifactProcessingBindingStateRepository, + ArtifactProcessingLeaseRepository, +) +from powercontext.builtin.runtime import BuiltinConfig, InferenceConfig, RuntimeConfig, open_builtin_contexts +from powercontext.builtin.runtime.family_processing import FAMILY_BINDINGS +from powercontext.builtin.scope import ScopeDraft +from powercontext.cli.app import create_cli +from powercontext.server.authz import PrincipalRef, ResourceRef +from powercontext.server.authz.composition import open_builtin_access_control +from powercontext.server.cli import app as server_cli + + +def _business_snapshot(path: Path) -> dict[str, list[tuple[Any, ...]]]: + with sqlite3.connect(path) as connection: + tables = connection.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").fetchall() + return { + name: sorted(connection.execute(f'SELECT * FROM "{name}"').fetchall(), key=repr) # noqa: S608 + for (name,) in tables + if name.startswith("pc_") and not name.startswith("pc_access_") + } + + +@pytest.mark.parametrize("action", ["plan", "apply"]) +def test_ownership_cli_preserves_pending_scheduled_work(tmp_path: Path, action: Literal["plan", "apply"]) -> None: + database_path = tmp_path / "maintenance.db" + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{database_path.as_posix()}") + config = BuiltinConfig( + database=database, + inference=InferenceConfig(generation_model="test"), + runtime=RuntimeConfig(artifact_processing_families=("memory",), memory_schedule_seconds=60), + ) + + async def seed() -> tuple[str, str]: + async with open_builtin_contexts(config) as contexts: + scope_ids = [] + for title in ("Migration target", "Unrelated scope"): + scope = await contexts.scopes.create(ScopeDraft(title=title, summary=title, idempotency_key=title)) + scope_ids.append(scope.scope_id) + await contexts.records.capture_source( + scope.scope_id, "content", "pending-source", "Keep this pending.", {} + ) + (artifact,) = await contexts.records.create_atomic_memories( + scope_ids[0], ({"kind": "fact", "text": "Legacy fact."},) + ) + async with contexts.database.transaction() as connection: + binding = FAMILY_BINDINGS["memory"] + states = ArtifactProcessingBindingStateRepository() + await states.start_scan(connection, binding, datetime(2020, 1, 1, tzinfo=UTC)) + await states.finish_scan(connection, binding) + await ArtifactProcessingLeaseRepository().start_single_process_term(connection, "stopped-server") + return scope_ids[0], artifact.artifact_id + + scope_id, artifact_id = asyncio.run(seed()) + before = _business_snapshot(database_path) + environment = tmp_path / "server.env" + environment.write_text( + f"POWERCONTEXT_SERVER_DATABASE_KIND=sqlite\nPOWERCONTEXT_SERVER_DATABASE_URL={database.url}\n" + "POWERCONTEXT_SERVER_ACCESS_MODE=disabled\n" + "POWERCONTEXT_SERVER_ACCESS_DEPLOYMENT_ID=ownership-maintenance-dispatch\n" + "POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL=test\n" + "POWERCONTEXT_SERVER_RUNTIME='" + config.runtime.model_dump_json() + "'\n", + encoding="utf-8", + ) + arguments = [ + "server", + "access-ownership-migrate", + "--env-file", + str(environment), + "--action", + action, + "--scope-id", + scope_id, + ] + if action == "apply": + arguments.extend(["--owner", "service:operator", "--maintenance-confirmed"]) + result = CliRunner().invoke(create_cli([server_cli]), arguments) + assert result.exit_code == 0, result.output or repr(result.exception) + report = json.loads(result.output) + if action == "plan": + assert len(report["scopes"][0]["artifact_gaps"]) == 1 + else: + assert len(report["applied"]) == 1 + assert report["applied"][0]["gap"]["family"] == "atomic-memory" + assert report["conflicts"] == [] + + after = _business_snapshot(database_path) + assert after["pc_artifact_processing_intents"] == before["pc_artifact_processing_intents"] + assert after["pc_artifact_processing_binding_states"] == before["pc_artifact_processing_binding_states"] + assert after["pc_artifact_processing_leases"] == before["pc_artifact_processing_leases"] + assert after == before + + if action == "apply": + + async def check_audit() -> None: + async with open_builtin_access_control(database, deployment_id="ownership-maintenance-dispatch") as access: + assert access.audit is not None + events = await access.audit.list_audit( + resource=ResourceRef.artifact(scope_id, family="atomic-memory", artifact_id=artifact_id) + ) + owners = [event for event in events if event.reason_code == "artifact-owner-established"] + assert len(owners) == 1 + assert owners[0].principal == PrincipalRef(type="service", id="operator") + assert owners[0].request_id is None + assert owners[0].actor is None + assert owners[0].transport == "background" + assert owners[0].operation == "access.ownership.apply" + + asyncio.run(check_audit()) diff --git a/tests/e2e/test_zcode_service_chain.py b/tests/e2e/test_zcode_service_chain.py index 8203a09b7f..017c21684b 100644 --- a/tests/e2e/test_zcode_service_chain.py +++ b/tests/e2e/test_zcode_service_chain.py @@ -64,7 +64,14 @@ async def generate(self, request: AtomicMemoryExtractionInput, /) -> GenerationR def _invoke_hook( - *, node: str, base_url: str, scope_id: str | None, session_id: str, turn_id: str, prompt: str + *, + node: str, + base_url: str, + scope_id: str | None, + session_id: str, + turn_id: str, + prompt: str, + accepted_stderr: tuple[str, ...] = ("",), ) -> dict[str, Any]: environment = dict(os.environ) environment.pop("POWERCONTEXT_ZCODE_SCOPE_ID", None) @@ -88,7 +95,7 @@ def _invoke_hook( env=environment, ) assert result.returncode == 0, result.stderr - assert result.stderr == "" + assert result.stderr in accepted_stderr return json.loads(result.stdout) @@ -106,6 +113,22 @@ def _wait_for_processed_memory(client: httpx.Client, scope_id: str, text: str) - pytest.fail("The original flush must commit the captured source") +def _delay_first_capture_response(): + delayed = False + + async def middleware(request, call_next): + nonlocal delayed + response = await call_next(request) + if request.url.path == "/v1/sources/content" and not delayed: + # The source commits before this delay. The Hook must report an + # unknown receipt, and the caller must verify the committed source. + delayed = True + await asyncio.sleep(1.2) + return response + + return middleware + + @pytest.mark.parametrize("slow_flush_receipt", [False, True]) def test_zcode_source_is_processed_and_recalled_in_a_new_session( tmp_path: Path, monkeypatch, slow_flush_receipt @@ -135,6 +158,8 @@ async def observe_flush(request, call_next): await asyncio.sleep(1.2) return response + app.middleware("http")(_delay_first_capture_response()) + with socket.socket() as socket_probe: socket_probe.bind(("127.0.0.1", 0)) port = socket_probe.getsockname()[1] @@ -212,6 +237,10 @@ def run_scope(action: str, *extra: str) -> dict[str, Any]: session_id="binding-probe", turn_id="probe-1", prompt="Scope binding synthetic probe.", + accepted_stderr=( + "", + '{"component":"powercontext.zcode","stage":"capture","code":"timeout"}\n', + ), ) assert session_scope in probe["hookSpecificOutput"]["additionalContext"] probe_sources = client.get(f"/v1/scopes/{session_scope}/sources") diff --git a/tests/test_native_command.py b/tests/test_native_command.py index 481ca2c117..d5eb42d3ff 100644 --- a/tests/test_native_command.py +++ b/tests/test_native_command.py @@ -228,7 +228,10 @@ def observe(event): os._exit(7) child = '''import os, sys, time from pathlib import Path -Path(sys.argv[1]).write_text(str(os.getpid())) +ready = Path(sys.argv[1]) +temporary = ready.with_suffix('.tmp') +temporary.write_text(str(os.getpid())) +os.replace(temporary, ready) deadline = time.monotonic() + 30 while not Path(sys.argv[2]).exists() and time.monotonic() < deadline: time.sleep(0.05) diff --git a/tests/test_server_http_limits.py b/tests/test_server_http_limits.py index 3e22373909..5957780d9d 100644 --- a/tests/test_server_http_limits.py +++ b/tests/test_server_http_limits.py @@ -349,15 +349,18 @@ async def observe_send(message: Message) -> None: first = asyncio.create_task(_invoke(app, response_send=observe_send)) second: asyncio.Task[tuple[list[Message], int]] | None = None try: - await asyncio.wait_for(cleaning_up.wait(), timeout=1) - await asyncio.wait_for(response_sent.wait(), timeout=1) - second = asyncio.create_task(_invoke(app)) - await asyncio.wait_for(second_started.wait(), timeout=1) - cleanup.set() - await first - overloaded, _ = await _invoke(app) - assert overloaded[0]["status"] == 503 - assert calls == 2 + # Assert event ordering; the watchdog detects deadlocks without + # imposing a one-second scheduling budget on loaded CI workers. + async with asyncio.timeout(10): + await cleaning_up.wait() + await response_sent.wait() + second = asyncio.create_task(_invoke(app)) + await second_started.wait() + cleanup.set() + await first + overloaded, _ = await _invoke(app) + assert overloaded[0]["status"] == 503 + assert calls == 2 finally: cleanup.set() second_release.set()