diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md index 5b9485ea8..84de4b544 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md @@ -186,7 +186,7 @@ Open pull requests, not this file, are the transient review view. | `WS-AUTH-001-12F1` | Merged | `codex/ws-auth-001-12f1-submission-policy-foundation` | #286 | Submission-policy PREP, replay, provenance, and audit custody foundation merged as `5a4186cc`; zero activation. | | `WS-AUTH-001-12F2` | Merged | `codex/ws-auth-001-12f2-manual-submission-policy` | #292 | Governed Project Manager append-only manual-draft create/update cutover merged as `81f281bd`. | | `WS-AUTH-001-12F3` | Merged; transitional | `codex/ws-auth-001-12f3-service-derivation` | #295 | Merged as `99c0aaf0`; authority/provenance is reused, while its separate inference entry point is removed at POL-04B. | -| `WS-AUTH-001-12I` | Proposed | - | - | Activates hidden unified compilation request/execute only. | +| `WS-AUTH-001-12I` | Implemented; review pending | `codex/ws-auth-001-12i-unified-compilation-activation` | - | Exact Project Manager request/recovery and fixed project-setup execution activation; POL remains hidden until 03B. | | `WS-AUTH-001-12F4` | Proposed | - | - | Activates approval of the stored unified pre-submit component; no inference. | | `WS-AUTH-001-12G` | Proposed | - | - | Activates deterministic stored post-submit projection; zero model calls. | | `WS-AUTH-001-12H` | Proposed | - | - | Activates only a complete approved unified guide lineage. | diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12I-unified-compilation-activation.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12I-unified-compilation-activation.md index 51d42515c..1dbe7ba8d 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12I-unified-compilation-activation.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12I-unified-compilation-activation.md @@ -1,45 +1,267 @@ # Chunk Contract: WS-AUTH-001-12I - Unified Compilation Authorization Activation -Status: Proposed after hidden WS-POL-003-03A; inactive. Risk: L1. +Status: Implemented and internally reviewed on the bounded branch. Risk: L1. ## Goal -Register and activate exactly two actions for immutable unified compilation: +Activate exactly two already-planned actions around the hidden immutable +unified-compilation parent: -- `project.guide_compilation.request` for covered Project Manager asynchronous - dispatch/recovery; -- `project.guide_compilation.execute` for fixed - `workstream.project.setup` execution and compilation-parent persistence. +- `project.guide_compilation.request` for a covered Project Manager to request + or recover one exact asynchronous compilation attempt; and +- `project.guide_compilation.execute` for only the fixed + `workstream.project.setup` service to admit provider execution and persist one + exact accepted result. + +This chunk installs AUTH's production implementation of the merged +`ProjectGuideCompilationAuthorizationPort`. It does not make the hidden POL +compilation workflow live; WS-POL-003-03B owns product orchestration and +composition after this activation merges. + +## Why this chunk exists + +POL-03A merged immutable attempt/result custody and a deny-only public AUTH +port. It deliberately cannot dispatch a model call or persist a compilation +until AUTH proves current human request authority, independent fixed-service +execution authority, and fresh transaction-bound final persistence authority. +The external-I/O gap must retain one attempt and provider idempotency key while +carrying no prepared handle or database transaction across the provider call. + +## Exact ownership and modular boundary + +- AUTH owns action/permission registration, evaluator rules, prepared handles, + decision evidence, fixed-service matrix membership, and the concrete port. +- PROJECTS/POL owns guide/setup/catalogue/attempt/result facts and every product + row. AUTH receives only the immutable public facts already defined in + `app.modules.authorization.api.project_guide_compilation`. +- The production implementation lives inside AUTH and imports no PROJECTS + model, repository, service, schema, or private type. +- POL continues importing AUTH only through `app.modules.authorization.api`. +- This chunk adds no cross-module private edge. The AUTH-003 ledger delta is + zero additions and zero removals; its validator remains authoritative. +- WS-POL-003-03B later wires the concrete AUTH port at the application + composition boundary. This chunk does not add a route, execution handler, Celery task, + product service, or alternate composition path. + +## Exact authority model + +### Human request/recovery + +`project.guide_compilation.request` maps only to the permission with the same +identifier. `authorization.policy` adds that permission only to +`AdminRole.PROJECT_MANAGER`; it is not a ProjectRole permission and is not +inherited by another admin role. It requires an active human actor profile, the +exact active identity link used for authentication, and a current covered +Project Manager AdminRoleGrant for the exact project. A system-wide or +different-project grant, another admin role, another identity link, and every +service identity deny. + +Preparation and consumption bind the complete +`ProjectGuideCompilationRequestFacts`: project, guide/version, source snapshot +and hash, setup run/generation, canonical input and guide-material hashes, +operation/request/idempotency UUIDs, both catalogue identities/versions/schema +versions/manifest hashes, agent identity/version, instruction version, and the +optional exact predecessor compilation. The request evidence resource is +exactly `project_guide_compilation_request`, its resource ID is the canonical +`operation_id`, and its selector is the exact project. Its canonical digest +binds the action/permission, actor profile, identity link, Project Manager +grant, and every request fact above. The public API may add only the +dependency-free request-digest helper required to share this canonical shape; +the frozen fact dataclasses and Protocol method shapes do not change. +Consumption records dispatch/recovery authority only; POL-03B will commit that +event atomically with reservation/recovery custody whose stored operation, +request, idempotency, and lineage values must match. It creates or mutates no +compilation product row in this chunk. + +### Fixed-service execution + +`project.guide_compilation.execute` maps only to the permission with the same +identifier. It is granted only by the static service matrix row for +`workstream.project.setup`. The service actor profile, its exact service +identity link, service identity registration, matrix row, action, and +permission must all be active/current. No human grant or admin role can satisfy +this action, and no other service can borrow it. + +Preflight is a fresh non-durable authorization decision over every request fact +plus exact attempt and provider-idempotency UUID. It occurs before future +provider I/O and does not create a prepared handle that survives that I/O. + +After accepted output exists, final preparation and consumption bind every +preflight fact plus the complete result and component hashes and the canonical +resource-context digest. The opaque handle is process-local, single-use, +non-dataclass, non-Pydantic, non-JSON, and bound to the exact actor/link/service, +action, facts, database session, transaction, and prepared generation. It is +never copied, reconstructed, persisted, logged, or placed in Celery. + +## Transaction and evidence boundary + +- Request consumption and its allowed authority evidence share the transaction + that POL-03B will use for durable dispatch/recovery custody. +- Execute preflight ends before provider I/O. No transaction or prepared handle + spans external I/O. +- Final execute consumption occurs in a newly opened transaction after POL has + reloaded and locked the exact attempt and current lineage. +- The allowed final decision event commits atomically with the immutable + compilation row and attempt transition in POL-03B. +- Rollback leaves no allowed evidence or protected product mutation. +- AUTH never receives raw guide text, provider output, canonical result JSON, + paths, URLs, credentials, prompts, reasoning, or Celery payloads. ## Allowed files -AUTH catalogue/permission/action owner, kernel/PREP/runtime/API composition, -narrow POL compilation authorization adapter/resource facts, one AUTH-owned -parity migration, focused authorization/integration tests, specifications, and -AUTH/POL memory. +```text +backend/app/modules/authorization/api/__init__.py +backend/app/modules/authorization/api/project_guide_compilation.py +backend/app/modules/authorization/admin_schemas.py +backend/app/modules/authorization/catalogue.py +backend/app/modules/authorization/guide_compilation.py +backend/app/modules/authorization/domain/** +backend/app/modules/authorization/kernel.py +backend/app/modules/authorization/models.py +backend/app/modules/authorization/policy.py +backend/app/modules/authorization/prepared.py +backend/app/modules/authorization/repository.py +backend/app/modules/authorization/runtime.py +backend/app/modules/audit/schemas.py +backend/alembic/versions/0063_guide_compilation_authority.py +backend/tests/architecture/test_authorization_boundary.py +backend/tests/authorization/guide_compilation/** +backend/tests/authorization/__init__.py +backend/tests/test_audit.py +backend/tests/test_alembic.py +backend/tests/test_auth.py +backend/tests/test_authorization.py +backend/tests/conftest.py +backend/tests/projects/guide_compilation/test_migration_contract.py +backend/scripts/authorization_boundary.py +.ci/behavior-ownership/auth/** +.ci/behavior-ownership/partition.v1.json +backend/scripts/behavior_ownership.py +backend/tests/test_behavior_ownership.py +backend/scripts/run_test_lanes.py +backend/scripts/run_isolated_tests.py +backend/scripts/validate_test_lane_evidence.py +backend/tests/test_ci_test_lanes.py +backend/tests/test_isolated_database_runner.py +backend/tests/test_merge_test_lane_evidence.py +backend/tests/test_test_lane_evidence.py +.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json +.github/workflows/backend.yml +scripts/test_lightweight_agent_gates.py +docs/spec_authorization_service.md +docs/operations_authorization_service.md +docs/operations_backend_testing.md +.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/** +.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/** +.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/STATUS.md +docs/roadmap_status.md +``` + +Edits to `authorization/api/project_guide_compilation.py` are limited to the +dependency-free request-resource digest helper described above; the public fact +fields and authorization Protocol method shapes are frozen. + +The migration identifier `0063_guide_compilation_authority` is valid only while +`0062_guide_compilation` remains the sole head after the implementation branch +rebases on then-current `main`. Tests in the broad historical files above may +change only for exact catalogue, SQL parity, migration topology, and existing +fixture registration. New behavioral proof belongs in the focused package. +Workflow/lane/ownership files may change only to add the exact focused tests and +90-percent materially changed AUTH surface gate; no existing gate may weaken. ## Not allowed -Compilation schema/validator/product writes, agent prompts/calls, policy -projection authority, broad compile permission, handles in Celery, ART/checker -behavior, or human/service authority inheritance. - -## Acceptance - -- PM request binds actor/link/grant, project/guide/source, setup run/generation, - operation/request/idempotency and records dispatch custody only. -- Execute binds fixed service profile/link/matrix, canonical input and both - catalogue hashes, setup run/generation, agent/instruction identity, prior - compilation when superseding, and attempt/provider-key identity. -- Execute supports fail-closed pre-I/O admission and fresh accepted-result-bound - final PREP. No handle or transaction spans provider I/O. -- Compilation authority cannot write sufficiency/artifact/pre/post canonical - projections; their separate actions remain mandatory. -- Cross-principal/action/resource/generation, stale, revoked, replay, copied, - wrong-session, and wrong-transaction cases deny without provider/product I/O. - -## Verification and review - -Typed/SQL/catalogue/matrix parity, all-pairs human/service denial, two-stage -PREP, POL-03A adapter integration, migration round trip, hosted coverage, and -all L1 tracks. Human focus: narrow parent custody around external I/O. +- Compilation schema, repository, validator, result, attempt, agent adapter, + prompt, provider call, setup orchestration, policy projection, or product + mutation changes. +- A route, live execution handler, Celery dispatch, serialized handle, raw + `AuthorizationContext` as durable authority, or transaction across I/O. +- ART, CHECKER, TASK, REV, CON, or COMP behavior. +- Broad compile/download/agent authority, human/service authority inheritance, + admin bypass, dynamic service lookup, compatibility alias, fallback, second + evaluator, second factory, or ART/POL-local authorization path. +- Activation of sufficiency, artifact-policy, pre-submit, post-submit, + approval, effective-policy, setup-ledger, guide-activation, submission, or + checker actions. +- New private AUTH imports by another module or any new general module-boundary + debt edge. + +## Acceptance criteria + +- Typed catalogue, SQL constraints/registries, evaluator ownership, action to + permission mapping, fixed-service matrix, runtime availability, and docs are + exactly in parity for the two actions and no others. +- Migration 0063 preserves 0062's existing execute audit/permission token + exactly once, adds the request permission/action/resource/evidence tokens + exactly once, adds both typed catalogue rows, and activates exactly the two + actions. Empty upgrade/downgrade/re-upgrade preserves one head and exact + parity; downgrade refuses once request or execute authority evidence exists. +- Covered Project Manager request prepare/consume succeeds only for the exact + active actor, link, grant, project, request identity, immutable lineage, + catalogues, and agent/instruction identity. +- Fixed `workstream.project.setup` preflight and final prepare/consume succeed + only for the exact active service profile/link/matrix row and complete facts. +- Human callers cannot execute; services cannot request; admin cannot + substitute; the binding/guide-reader/other services cannot use either action. +- Revoked/inactive/replaced actor, link, grant, service registration, service + matrix row, action, or permission denies. +- Cross-principal, cross-link, cross-action, cross-project, cross-guide, + cross-snapshot, cross-setup-run/generation, cross-catalogue, cross-agent, + cross-attempt, wrong provider key, stale predecessor, changed result/component + hash, wrong resource digest, copied handle, replayed handle, wrong session, + wrong transaction, rollback, and prepared-generation replacement all deny. +- AUTH-level denial records no allowed evidence and invokes no callback. Actual + provider/product side-effect ordering is deferred to POL-03B's composed + consumer proof because this activation chunk contains no product orchestration. + Rollback records no allowed evidence. +- Request-handle replay denies and retains the first allowed event UUID. POL-03B's + atomic operation/idempotency custody must ensure concurrently prepared request + handles commit at most one product mutation and therefore at most one allowed + event; AUTH does not create a second durable idempotency protocol. Execute + preflight is stateless and may be reevaluated only against freshly loaded + current authority. Final execute-handle replay denies and retains the first + allowed event UUID. Concurrent final handles rely on POL-03B's unique attempt + transition to commit at most one product mutation and allowed event. Copied + handles always deny and create no event. +- Public API leak/reachability proof shows the AUTH API reaches no PROJECTS or + private AUTH implementation, while the concrete adapter reaches no PROJECTS + private module. +- AUTH-003 and general modular-boundary debt do not grow. +- The materially changed AUTH surface is at least 90-percent covered and the + repository-wide hosted 78-percent floor remains unchanged. + +## Verification commands + +```text +cd backend && .venv/bin/python -m scripts.authorization_boundary validate --ledger ../.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/IMPORT_LEDGER.md +cd backend && .venv/bin/python -m scripts.test_structure_boundary validate --policy ../.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_POLICY.md --ledger ../.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json +PYTHONPATH=backend backend/.venv/bin/python backend/scripts/behavior_ownership.py validate +PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 PYTHONPATH=backend backend/.venv/bin/python -m pytest -q -p pytest_asyncio.plugin backend/tests/architecture/test_authorization_boundary.py backend/tests/authorization/guide_compilation +PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 PYTHONPATH=backend backend/.venv/bin/python -m pytest -q -p pytest_asyncio.plugin -p pytest_cov.plugin backend/tests/test_authorization.py backend/tests/authorization/guide_compilation --cov=app.modules.authorization --cov-branch --cov-report=term-missing --cov-fail-under=90 +PYTHONPATH=backend backend/.venv/bin/ruff check backend/app/modules/authorization backend/tests/authorization/guide_compilation backend/tests/architecture/test_authorization_boundary.py +python3 scripts/check_stale_authorization_docs.py +python3 scripts/check_markdown_links.py +git diff --check +``` + +Full semantic Backend lanes, repository coverage, accumulated subsystem +coverage, Agent Gates, and external review run on the exact pushed GitHub head. + +## Required reviewers + +- architecture +- security +- QA +- product/operations +- senior engineering +- CI integrity +- reuse/dedup +- test delta +- docs + +## Human review focus + +Review the separation between covered human request custody and independent +fixed-service execution, the two authorization points around provider I/O, the +complete result-bound final digest, process-local handle integrity, atomic +decision evidence, zero product activation, and zero private-edge growth. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12I-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12I-pr-trust-bundle.md new file mode 100644 index 000000000..b916e601e --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12I-pr-trust-bundle.md @@ -0,0 +1,156 @@ +# Workstream PR Trust Bundle + +## Chunk + +`WS-AUTH-001-12I` - Unified Compilation Authorization Activation + +## Goal + +Activate only `project.guide_compilation.request` for an exact-project Project +Manager and `project.guide_compilation.execute` for the fixed +`workstream.project.setup` service, while leaving POL's hidden compilation +workflow inactive until WS-POL-003-03B composes it. + +## Human-approved intent + +Continue AUTH-12 after the ART/AUTH prerequisites, preserve strict module +boundaries, avoid local full-suite execution, and use hosted GitHub Backend +lanes for repository-wide coverage. + +## What changed + +- Added exact request/execute catalogue, policy, kernel, PREP, audit, and SQL + parity plus migration `0063_guide_compilation_authority.py` (revision + `0063_compilation_authority`). +- Added the production AUTH implementation of the public compilation port. +- Added non-evidencing pre-provider authorization and fresh transaction-bound + final PREP with AUTH-verified result digest. +- Enforced exact-project PM grant selection and fixed-service isolation. +- Extracted bounded AUTH-internal helpers while shrinking recorded structural + debt and preserving the cross-module import ledger. +- Added focused runtime, actor-matrix, replay, strict-facts, migration, and + downgrade-refusal proof. +- Corrected the hosted schema fingerprint and strengthened migration `0063` so + both compilation permissions require exact action evidence, historical + permission-only execute evidence blocks upgrade, and every removed request + registry/resource reference blocks downgrade. + +## Why it changed + +POL-03B must not call a provider or persist an accepted compilation until AUTH +can prove the exact current human request and fixed-service execution authority. + +## Design chosen + +The existing opaque PREP protocol remains the sole durable authorization path. +Preflight validates the complete typed attempt context but issues no handle and +stages no evidence. Final persistence uses a new transaction and a single-use +handle whose result digest AUTH recomputes. POL-03B retains atomic product +idempotency custody; AUTH does not add a competing durable replay protocol. + +## Alternatives rejected + +- A normal PREP consume for preflight: it could commit allowed evidence before + provider I/O. +- Trusting the caller's final digest: it would not prove exact result facts. +- System-scoped PM fallback: compilation requests require the exact project. +- A second authorization protocol or POL-local evaluator. + +## Scope control + +No route, worker, provider call, prompt, product row, checker, ART, REV, task, +submission, or guide-activation behavior is added. Only the two 12I actions are +activated. The allowed-file contract was kept explicit. + +## Product behavior + +A covered PM may authorize dispatch/recovery for one immutable compilation +context. Only `workstream.project.setup` may pass exact preflight and authorize +accepted-result persistence. The workflow remains hidden until POL-03B wires +the port and product transaction. + +## Acceptance criteria proof + +- Exact PM project grant succeeds; system grant and actor/service substitutions + deny. +- Preflight binds lineage, catalogues, agent, attempt, and provider key without + a handle or evidence. +- Final result/component digest mismatch denies before PREP. +- Handles are opaque, transaction-bound, single-use, and replay-denying. +- Revoked services deny without allowed evidence. +- Migration roundtrip succeeds and retained request or execute evidence blocks + downgrade. + +## Tests/checks run + +```text +Focused adapter/domain tests: 16 passed +AUTH boundary plus focused non-DB tests: 65 passed (before final test additions) +PostgreSQL 0063 roundtrip and retained-evidence downgrade tests: passed +PostgreSQL compilation migration adoption/custody suite: 10 passed +Changed adapter coverage: 98.36%; hosted per-file AUTH enforcement coverage +and the complete AUTH-module coverage gate retain the 90% requirement +Ruff: passed +Authorization boundary: passed +Test-structure boundary: passed +Behavior ownership: passed +Stale authorization/Workstream wording: passed +Markdown links: passed +git diff --check: passed +``` + +Repository-wide tests and the 78% global floor run only in hosted GitHub +Backend lanes on the exact pushed head. + +## Test delta + +No tests were removed, skipped, weakened, or marked xfail. New focused tests +cover real-kernel positive and negative behavior rather than only mocks. + +## CI integrity + +No workflow, threshold, lint, typecheck, or failure-masking behavior was +weakened. Focused tests were added to the existing semantic lane and behavior +ownership manifests. + +## Reviewer results + +Architecture, security, QA, product/operations, senior engineering, CI +integrity, reuse/dedup, test-delta, and documentation reviews pass after their +findings were fixed. + +## External review + +The first hosted Backend run exposed a stale canonical schema fingerprint. The +second exact-head run proved that correction and then exposed missing +action-required SQL custody plus stale historical migration assertions. Those +failures were fixed without weakening CI. After current main was merged, the +next hosted run exposed two further historical assumptions: 0022 admitted later +12I evidence, and 0049 parity omitted later permission-registry additions. Both +were corrected, and the exact two PostgreSQL tests pass in a runner-owned +disposable database. Fresh GitHub Actions and a substantive CodeRabbit review +remain required on the next pushed exact head. + +## Remaining risks + +- POL-03B must consume these boundaries in the same transaction as its unique + operation/attempt transition; AUTH activation alone does not make the product + flow live. +- The AUTH-internal `domain`/`runtime` partition has a non-blocking layering + smell recorded by architecture review for later boundary recovery. + +## Follow-up work + +WS-POL-003-03B installs the live composition and provider/product ordering. +AUTH then resumes its approved post-12I sequence. + +## Human review focus + +- No preflight evidence or handle survives provider I/O. +- Exact-project PM selection when system and project grants coexist. +- Complete preflight and final digest binding. +- Only the two intended actions become active. + +## Human merge ownership + +- [ ] The user explicitly approves this specific PR for merge. diff --git a/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json b/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json index b008de104..956f267c0 100644 --- a/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json +++ b/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json @@ -2,11 +2,11 @@ "entries": [ { "capability": "unassigned_legacy_auth", - "content_sha256": "c774711d616a367d82fa8ce98de771e2f838c3bf5448b71a2d6e3fac2872e8a2", - "end_line": 1603, + "content_sha256": "2a5e5c4a07996ca7e0a5be65197324602b1a6783440f3e143832aa6a9353659f", + "end_line": 1600, "hard_limit": 1200, "kind": "production_file", - "observed_lines": 1603, + "observed_lines": 1600, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -26,11 +26,11 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "1d2fd1263fedb2444c1dbb8d1c00d23ff0b23fd591d95ada59e65e1177edebd1", - "end_line": 1703, + "content_sha256": "bf5ea8baf23e45ab71cd6a87d7fc48b3b8321b26fd616b2841d90fd90a18b1cc", + "end_line": 1694, "hard_limit": 1200, "kind": "production_file", - "observed_lines": 1703, + "observed_lines": 1694, "path": "backend/app/modules/authorization/runtime.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -38,63 +38,63 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "02a8da301e2540ce878a24ad11d7aad775f84b6687803702195f0ea885abc551", - "end_line": 1092, + "content_sha256": "17251cc63f24b1cd34969fc192848459d4800ab2877e85d9244e468b4c82dfb7", + "end_line": 1113, "hard_limit": 100, "kind": "production_function", - "observed_lines": 166, + "observed_lines": 126, "path": "backend/app/modules/authorization/catalogue.py", "qualified_symbol": "_index_service_actions", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 927 + "start_line": 988 }, { "capability": "unassigned_legacy_auth", - "content_sha256": "04c49daa0c7104ae9b8779732ab5211b5486288d923cb1e2dd288d70b6a74ba9", - "end_line": 606, + "content_sha256": "a7acb9f31de3a2b2e32c9e8419d1ea8546610f86dcd05f021c87ed340a134590", + "end_line": 604, "hard_limit": 100, "kind": "production_function", - "observed_lines": 226, + "observed_lines": 217, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": "AuthorizationService._prepare_prelocked", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 381 + "start_line": 388 }, { "capability": "unassigned_legacy_auth", - "content_sha256": "19967950790fb3ae806d921d9c0397f99aa0df710c0aaf452d9f86b5ac971e50", - "end_line": 1123, + "content_sha256": "152e3da2ea2843ecfe2c0cfd30a5ba38fe87e4bc03308e1b3ee02351dda4b7c7", + "end_line": 1115, "hard_limit": 100, "kind": "production_function", - "observed_lines": 181, + "observed_lines": 175, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": "AuthorizationService._require_prelocked", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 943 + "start_line": 941 }, { "capability": "unassigned_legacy_auth", - "content_sha256": "05ac23e6645d3dfde33a3101b7e76a395f953f4e2e5c6d66f67a97342f88e076", - "end_line": 1603, + "content_sha256": "8cb806aad12f5010d1e8875c4ba31eee43856a120e338d31a02d87e1ea30ad0c", + "end_line": 1600, "hard_limit": 100, "kind": "production_function", - "observed_lines": 126, + "observed_lines": 125, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": "AuthorizationService._stage_decision", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 1478 + "start_line": 1476 }, { "capability": "unassigned_legacy_auth", - "content_sha256": "1ec90236c1156d09d8772ffb25bd7163d43c43cef564cad5485e4bcd528da0f8", - "end_line": 750, + "content_sha256": "cb697c0c6e970a22d59a7a7c93a8be3fdc7a79933c1e47f29466db64ae8e038c", + "end_line": 802, "hard_limit": 100, "kind": "production_function", - "observed_lines": 285, + "observed_lines": 284, "path": "backend/app/modules/authorization/prepared.py", "qualified_symbol": "PreparedAuthorizationService._binding", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 466 + "start_line": 519 }, { "capability": "unassigned_legacy_auth", @@ -170,11 +170,11 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "a22db9cfc5dc48abeb980b4dab760748caffd9ef9894dc20e388ce10ba423f80", - "end_line": 14093, + "content_sha256": "82d11f09ce0a0684ec0eaa6dbb75266a8207060ee8cfdb406ac2a10b2a4913f6", + "end_line": 14088, "hard_limit": 1200, "kind": "test_file", - "observed_lines": 14093, + "observed_lines": 14088, "path": "backend/tests/test_alembic.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -206,11 +206,11 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "92892a4a247fb28fba6d0ed231c4f35255413f940d381d98ee3a5c4cafc210ec", - "end_line": 1807, + "content_sha256": "7717de94f55d517c03dbf8ca674cf4803f33d4d4412c5ea9b1fde3e3b57d199c", + "end_line": 1806, "hard_limit": 1200, "kind": "test_file", - "observed_lines": 1807, + "observed_lines": 1806, "path": "backend/tests/test_audit.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -218,11 +218,11 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "32b1d5439d6ef7288f73cf4faeb9c715702a20e56734ff7aedcaaf07d217670d", - "end_line": 7425, + "content_sha256": "16ebbaa1b66f08bf3bc395353f43505856aed1b937148f96b988377c028c4e39", + "end_line": 7424, "hard_limit": 1200, "kind": "test_file", - "observed_lines": 7425, + "observed_lines": 7424, "path": "backend/tests/test_auth.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -230,11 +230,11 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "b8e68072ce45dbcd6a7d684dbc5a7861f9d6eca6b108ca524c75f056bf4d3899", - "end_line": 13447, + "content_sha256": "dccb6b375f33912a43ae12fed6d27c385720b517878c59b947758a65430dfca1", + "end_line": 13439, "hard_limit": 1200, "kind": "test_file", - "observed_lines": 13447, + "observed_lines": 13439, "path": "backend/tests/test_authorization.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -339,46 +339,46 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "a2230b4ba19040c254ee6c079255bec019157bbdf1db8e61342732eb61a65b44", - "end_line": 6679, + "end_line": 6678, "hard_limit": 120, "kind": "test_function", "observed_lines": 237, "path": "backend/tests/test_alembic.py", "qualified_symbol": "test_actor_profile_lifecycle_constraint_and_trigger_parity", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 6443 + "start_line": 6442 }, { "capability": "unassigned_legacy_auth", "content_sha256": "2e0a21b0cabfd9848336b1ab916634e86b3ef444b0ed81a4b4bed3ac492a69cb", - "end_line": 7106, + "end_line": 7105, "hard_limit": 120, "kind": "test_function", "observed_lines": 225, "path": "backend/tests/test_alembic.py", "qualified_symbol": "test_actor_profile_lifecycle_downgrade_refuses_forward_evidence", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 6882 + "start_line": 6881 }, { "capability": "unassigned_legacy_auth", "content_sha256": "227911179d5c719984be3d8f6179fc494fcbab4d15e6cc3106aba4793d6a380f", - "end_line": 6858, + "end_line": 6857, "hard_limit": 120, "kind": "test_function", "observed_lines": 177, "path": "backend/tests/test_alembic.py", "qualified_symbol": "test_actor_profile_lifecycle_upgrade_refuses_dirty_rows", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 6682 + "start_line": 6681 }, { "capability": "unassigned_legacy_auth", - "content_sha256": "2c967a0f541f2a48001f2e0de812281687965f14c22a0dfd6d58b76a536c389c", - "end_line": 5710, + "content_sha256": "a65f146c03ec7406de43435a4f91267c13dd7ae7b8f2bcf730081ba22e0fe1c7", + "end_line": 5709, "hard_limit": 120, "kind": "test_function", - "observed_lines": 146, + "observed_lines": 145, "path": "backend/tests/test_alembic.py", "qualified_symbol": "test_authorization_action_evidence_constraints_and_guarded_downgrade", "removal_chunk": "WS-AUTH-003-CLOSE", @@ -387,14 +387,14 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "09ff96e6a32fdd56c810fe4cb872b035ed92412520bfbd6ca321bb2ab56db041", - "end_line": 5940, + "end_line": 5939, "hard_limit": 120, "kind": "test_function", "observed_lines": 131, "path": "backend/tests/test_alembic.py", "qualified_symbol": "test_fixed_service_identity_schema_mapping_and_guarded_downgrade", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 5810 + "start_line": 5809 }, { "capability": "unassigned_legacy_auth", @@ -411,14 +411,14 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "45eebcdaffabc34e517bc3e91b4881528585fbb7d57cd661020d111e97cc8e5b", - "end_line": 6139, + "end_line": 6138, "hard_limit": 120, "kind": "test_function", "observed_lines": 197, "path": "backend/tests/test_alembic.py", "qualified_symbol": "test_service_link_verification_timestamp_schema_and_guarded_downgrade", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 5943 + "start_line": 5942 }, { "capability": "unassigned_legacy_auth", @@ -554,11 +554,11 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "b6b644fa258c335a3381d287d52720fc99a6250b39abd4383c53c9201e348331", - "end_line": 256, + "content_sha256": "02ed6da571dafee7f7b6f0051fb17620ad3058f92c4e283dc5cde24d098d4c31", + "end_line": 255, "hard_limit": 120, "kind": "test_function", - "observed_lines": 132, + "observed_lines": 131, "path": "backend/tests/test_audit.py", "qualified_symbol": "test_action_aware_audit_input_enforces_mapping_and_action_availability", "removal_chunk": "WS-AUTH-003-CLOSE", @@ -567,130 +567,130 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "982f7c01fbc085454707e4b4491b9e79289af6203b7c2718635732d61ba8c9f1", - "end_line": 756, + "end_line": 755, "hard_limit": 120, "kind": "test_function", "observed_lines": 189, "path": "backend/tests/test_audit.py", "qualified_symbol": "test_authority_input_rejects_unbounded_or_inconsistent_evidence", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 568 + "start_line": 567 }, { "capability": "unassigned_legacy_auth", "content_sha256": "36820d7d6c9db7dac729710d8d87ab233d4882febd049e862961449550b52e8e", - "end_line": 1317, + "end_line": 1316, "hard_limit": 120, "kind": "test_function", "observed_lines": 210, "path": "backend/tests/test_audit.py", "qualified_symbol": "test_database_rejects_malformed_and_mutated_audit_rows", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 1108 + "start_line": 1107 }, { "capability": "unassigned_legacy_auth", "content_sha256": "44b9858d132d384cd10c6b8b8fae096ade7c3fced6780cc2c142e59b4162d97d", - "end_line": 3543, + "end_line": 3542, "hard_limit": 120, "kind": "test_function", "observed_lines": 280, "path": "backend/tests/test_auth.py", "qualified_symbol": "test_actor_admin_reads_hold_caller_and_grant_locks_through_disclosure", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 3264 + "start_line": 3263 }, { "capability": "unassigned_legacy_auth", "content_sha256": "b0cb3554dd7f531648e4202302059b8416ca7ed2fdcc6529460f0acb092b82de", - "end_line": 7405, + "end_line": 7404, "hard_limit": 120, "kind": "test_function", "observed_lines": 927, "path": "backend/tests/test_auth.py", "qualified_symbol": "test_actor_identity_link_lifecycle_real_postgres_concurrency", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 6479 + "start_line": 6478 }, { "capability": "unassigned_legacy_auth", "content_sha256": "15c7147c11fa1802f43480b38a3678ac1a2586320ae2f0013622f1d63b2a3806", - "end_line": 5962, + "end_line": 5961, "hard_limit": 120, "kind": "test_function", "observed_lines": 535, "path": "backend/tests/test_auth.py", "qualified_symbol": "test_actor_identity_link_lifecycle_real_postgres_matrix", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 5428 + "start_line": 5427 }, { "capability": "unassigned_legacy_auth", "content_sha256": "49243ef3a0b0b4b24e5033b3e0ad75eee17c7fc2abd2014224a595ed8c6c9ade", - "end_line": 6476, + "end_line": 6475, "hard_limit": 120, "kind": "test_function", "observed_lines": 491, "path": "backend/tests/test_auth.py", "qualified_symbol": "test_actor_profile_lifecycle_real_postgres_concurrency", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 5986 + "start_line": 5985 }, { "capability": "unassigned_legacy_auth", "content_sha256": "eca9eeff229ee37c103188f9a31e278773a69e96f2fd354662723f840cf8f16a", - "end_line": 5425, + "end_line": 5424, "hard_limit": 120, "kind": "test_function", "observed_lines": 482, "path": "backend/tests/test_auth.py", "qualified_symbol": "test_actor_profile_lifecycle_real_postgres_matrix", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 4944 + "start_line": 4943 }, { "capability": "unassigned_legacy_auth", "content_sha256": "e1fd679295947e120c6da520bd138a78a8e133e44f8dde91010de29354e88f5b", - "end_line": 3261, + "end_line": 3260, "hard_limit": 120, "kind": "test_function", "observed_lines": 485, "path": "backend/tests/test_auth.py", "qualified_symbol": "test_admin_bootstrap_replay_and_cross_revoke_are_concurrency_safe", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 2777 + "start_line": 2776 }, { "capability": "unassigned_legacy_auth", "content_sha256": "8ea087b906726103295a019967f8c38e42b55deb4e912fda3a41289fda23f35d", - "end_line": 4269, + "end_line": 4268, "hard_limit": 120, "kind": "test_function", "observed_lines": 647, "path": "backend/tests/test_auth.py", "qualified_symbol": "test_controlled_service_actor_provisioning_includes_project_setup_and_is_atomic", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 3623 + "start_line": 3622 }, { "capability": "unassigned_legacy_auth", "content_sha256": "d26732dd125f8c2062b5a4b45b59fbccd283ee04939a9da7be1576235524292f", - "end_line": 4680, + "end_line": 4679, "hard_limit": 120, "kind": "test_function", "observed_lines": 409, "path": "backend/tests/test_auth.py", "qualified_symbol": "test_service_actor_provisioning_failure_and_authority_races_are_atomic", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 4272 + "start_line": 4271 }, { "capability": "unassigned_legacy_auth", - "content_sha256": "e89a4e774b3445d3d1ad04bebeb643d8cc0517f91a588d0296e64434cc58e768", - "end_line": 2661, + "content_sha256": "a7e36b433ed775ab82e7ff92c013aab3a1cee14fbbed6f4ddace351ab936e24c", + "end_line": 2660, "hard_limit": 120, "kind": "test_function", - "observed_lines": 1042, + "observed_lines": 1041, "path": "backend/tests/test_auth.py", "qualified_symbol": "test_signed_tokens_bootstrap_and_admin_grant_lifecycle", "removal_chunk": "WS-AUTH-003-CLOSE", @@ -699,58 +699,58 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "951b44cc07e36002118fe93b7974e8d65851b0e2c3cec89a3031cbe42b014e2d", - "end_line": 8541, + "end_line": 8539, "hard_limit": 120, "kind": "test_function", "observed_lines": 140, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_actor_lifecycle_service_applies_success_and_guards_conflicts", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 8402 + "start_line": 8400 }, { "capability": "unassigned_legacy_auth", "content_sha256": "569084d6de89ff9eae71d526fc6c157aea7638f55ca93ad128b721fbda339be6", - "end_line": 9031, + "end_line": 9029, "hard_limit": 120, "kind": "test_function", "observed_lines": 122, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_admin_resource_digest_alone_rejects_substituted_role_and_disposition", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 8910 + "start_line": 8908 }, { "capability": "unassigned_legacy_auth", "content_sha256": "90b8b670b4d277209617cc1aa794188b4fb3cd9d894b69d5dc3d0adfc885f6ed", - "end_line": 9259, + "end_line": 9257, "hard_limit": 120, "kind": "test_function", "observed_lines": 132, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_admin_revoke_stages_complete_state_and_evidence", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 9128 + "start_line": 9126 }, { "capability": "unassigned_legacy_auth", "content_sha256": "78d9bf3df08e5633e9a75720b1e4bf5b7d7b24019bc392b4cb7496a9ac5e5e8e", - "end_line": 11093, + "end_line": 11091, "hard_limit": 120, "kind": "test_function", "observed_lines": 122, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_authorization_locks_refresh_cached_actor_lifecycle_state", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 10972 + "start_line": 10970 }, { "capability": "unassigned_legacy_auth", - "content_sha256": "c842e30cc5e616242252c2d0be622877bcc0ea0e6e165b50beec6452f8781b03", - "end_line": 2255, + "content_sha256": "aab80cce1a72714c4ec2e95b0596031e59a463459c69a4079627070f79edf349", + "end_line": 2252, "hard_limit": 120, "kind": "test_function", - "observed_lines": 330, + "observed_lines": 327, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_closed_permission_and_action_catalogue_is_exact_and_non_executable", "removal_chunk": "WS-AUTH-003-CLOSE", @@ -759,134 +759,134 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "df1df6ec2ba6aa55445e21cfcf4e4e3ad9664c9504313484a495b6a6df1e9047", - "end_line": 3488, + "end_line": 3486, "hard_limit": 120, "kind": "test_function", "observed_lines": 126, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_identity_link_lifecycle_route_preserves_outcome_transaction_contract", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 3363 + "start_line": 3361 }, { "capability": "unassigned_legacy_auth", "content_sha256": "05d1b020ecff0f9bc0a0567adc07f5b31a2f9dfb7828ae3ad34d4e1e7757797c", - "end_line": 8694, + "end_line": 8692, "hard_limit": 120, "kind": "test_function", "observed_lines": 151, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_identity_link_lifecycle_service_applies_success_and_guards_conflicts", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 8544 + "start_line": 8542 }, { "capability": "unassigned_legacy_auth", "content_sha256": "a45270573154ce2f298221169a3d55530059598bc976263feafc349c77c6ea46", - "end_line": 6346, + "end_line": 6344, "hard_limit": 120, "kind": "test_function", "observed_lines": 121, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_pre_submit_materializer_adapter_binds_every_fact_and_service", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 6226 + "start_line": 6224 }, { "capability": "unassigned_legacy_auth", "content_sha256": "c5d6d0d480ced964354915614f15202c0159bfbb281658da0d44186dffd17848", - "end_line": 7489, + "end_line": 7487, "hard_limit": 120, "kind": "test_function", "observed_lines": 142, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_prepared_actor_authority_crossed_mutations_complete_in_both_orders", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 7348 + "start_line": 7346 }, { "capability": "unassigned_legacy_auth", "content_sha256": "ebb76e63671195aa4d806ac602bfe58cb22bf82d00c8a70ab186785f3acd7f9b", - "end_line": 7830, + "end_line": 7828, "hard_limit": 120, "kind": "test_function", "observed_lines": 336, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_prepared_crosses_real_lifecycle_service_transactions", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 7495 + "start_line": 7493 }, { "capability": "unassigned_legacy_auth", "content_sha256": "9ea4fc0ddbab4c7262a43bc3f498ee1afea3318e9a0b6c93c87763f9f22aae9c", - "end_line": 7320, + "end_line": 7318, "hard_limit": 120, "kind": "test_function", "observed_lines": 518, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_prepared_postgresql_failure_and_cancellation_are_atomic", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 6803 + "start_line": 6801 }, { "capability": "unassigned_legacy_auth", "content_sha256": "46e0b031394da6fee856e48615732a17ff8d2276d9cedfb0ecaa3a6879410adb", - "end_line": 4693, + "end_line": 4691, "hard_limit": 120, "kind": "test_function", "observed_lines": 157, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_11c2_reads_require_exact_admin_context_and_role_allowlist", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 4537 + "start_line": 4535 }, { "capability": "unassigned_legacy_auth", "content_sha256": "e95d2a03e3829df89c43210a9de92efca31a25905c9648c3179d17b078386b17", - "end_line": 2654, + "end_line": 2653, "hard_limit": 120, "kind": "test_function", "observed_lines": 397, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_mutation_resources_and_prepared_scopes_are_closed", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 2258 + "start_line": 2257 }, { "capability": "unassigned_legacy_auth", "content_sha256": "105667302ed6e8f2fd16ea7e95d642e72e41514673e1152503536e0520f9c362", - "end_line": 10968, + "end_line": 10966, "hard_limit": 120, "kind": "test_function", "observed_lines": 204, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_read_permissions_have_postgresql_role_scope_matrix", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 10765 + "start_line": 10763 }, { "capability": "unassigned_legacy_auth", "content_sha256": "756b7f99f9a743284934b4d85ce263617710d9b8119792ccb4526a1de04070a1", - "end_line": 12470, + "end_line": 12468, "hard_limit": 120, "kind": "test_function", "observed_lines": 233, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_role_and_all_operation_mappings_commit_one_linked_pair", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 12238 + "start_line": 12236 }, { "capability": "unassigned_legacy_auth", - "content_sha256": "309c929c4021266138f129372af8d0de953d06fc5764d284587ef6260151fb57", - "end_line": 13447, + "content_sha256": "2e1d7db74ddb955b90a0ee12e4fb72b651a0f85d2746c76e09079c05b0b85252", + "end_line": 13439, "hard_limit": 120, "kind": "test_function", - "observed_lines": 687, + "observed_lines": 681, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_role_issue_postgresql_prep_binds_target_role_and_scope", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 12761 + "start_line": 12759 }, { "capability": "unassigned_legacy_auth", @@ -915,14 +915,14 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "05621e885ed2f88d0ba1a072c1f263fc923939f7ce755a514e9872112aa830a1", - "end_line": 11987, + "end_line": 11985, "hard_limit": 120, "kind": "test_function", "observed_lines": 163, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_service_actor_replay_fails_closed_on_committed_state_drift", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 11825 + "start_line": 11823 }, { "capability": "unassigned_legacy_auth", @@ -1167,74 +1167,74 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "98e4cd04125ed747aee2d97cafa0b8a244e03e17d27ed782a8a953611bb09147", - "end_line": 8461, + "end_line": 8460, "hard_limit": 100, "kind": "test_helper", "observed_lines": 191, "path": "backend/tests/test_alembic.py", "qualified_symbol": "_assert_actor_registry_unique_constraints", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 8271 + "start_line": 8270 }, { "capability": "unassigned_legacy_auth", "content_sha256": "817d69bed103a2803dfd7fe5b304adcfef329553213887f092371870921e28c2", - "end_line": 9831, + "end_line": 9830, "hard_limit": 100, "kind": "test_helper", "observed_lines": 319, "path": "backend/tests/test_alembic.py", "qualified_symbol": "_assert_artifact_fact_guards", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 9513 + "start_line": 9512 }, { "capability": "unassigned_legacy_auth", "content_sha256": "3e5ed6a3877dfefda909fe1c68c15a365bb620e783c69f4e87539ef920e47b46", - "end_line": 10226, + "end_line": 10225, "hard_limit": 100, "kind": "test_helper", "observed_lines": 108, "path": "backend/tests/test_alembic.py", "qualified_symbol": "_authority_audit_schema", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 10119 + "start_line": 10118 }, { "capability": "unassigned_legacy_auth", "content_sha256": "ebc0d8774563966998b4db10a048b778765a9e23109ba703506b95b7334df569", - "end_line": 11659, + "end_line": 11658, "hard_limit": 100, "kind": "test_helper", "observed_lines": 318, "path": "backend/tests/test_alembic.py", "qualified_symbol": "_exercise_admin_authority_guards", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 11342 + "start_line": 11341 }, { "capability": "unassigned_legacy_auth", "content_sha256": "3b6c08f3e3d1e37b51315c789a4e6ed27003869c56c40a557686359033fff1ff", - "end_line": 12458, + "end_line": 12457, "hard_limit": 100, "kind": "test_helper", "observed_lines": 191, "path": "backend/tests/test_alembic.py", "qualified_symbol": "_exercise_contributor_lineage_guards", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 12268 + "start_line": 12267 }, { "capability": "unassigned_legacy_auth", "content_sha256": "0122149c2718c63593230b51b0e1ea27536e311c7429202ce2a0c8364d2843a0", - "end_line": 13029, + "end_line": 13028, "hard_limit": 100, "kind": "test_helper", "observed_lines": 315, "path": "backend/tests/test_alembic.py", "qualified_symbol": "_exercise_project_role_migration", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 12715 + "start_line": 12714 }, { "capability": "unassigned_legacy_auth", @@ -1251,38 +1251,38 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "14a7f4c2f1dd2cf32b144e185550fdd6f14ab4b9d02781944aa808087daa9384", - "end_line": 9278, + "end_line": 9277, "hard_limit": 100, "kind": "test_helper", "observed_lines": 302, "path": "backend/tests/test_alembic.py", "qualified_symbol": "_seed_artifact_prior_head_runtime_rows", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 8977 + "start_line": 8976 }, { "capability": "unassigned_legacy_auth", "content_sha256": "3df48541781fbe3b968cd80daa10c88b297862e6d461adde8536a0930e3d0afb", - "end_line": 12147, + "end_line": 12146, "hard_limit": 100, "kind": "test_helper", "observed_lines": 160, "path": "backend/tests/test_alembic.py", "qualified_symbol": "_seed_contributor_prior_head", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 11988 + "start_line": 11987 }, { "capability": "unassigned_legacy_auth", "content_sha256": "1ca131d55649be7f826117fa9202300857df7a1c6f183662e9ca53dd5cbd264b", - "end_line": 8819, + "end_line": 8818, "hard_limit": 100, "kind": "test_helper", "observed_lines": 187, "path": "backend/tests/test_alembic.py", "qualified_symbol": "_seed_pre_provenance_runtime_rows", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 8633 + "start_line": 8632 }, { "capability": "unassigned_legacy_auth", @@ -1299,14 +1299,14 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "e1d84b95accf71abd4dada32db1e1786b9d0425920f60840098bdc446d03331c", - "end_line": 6671, + "end_line": 6670, "hard_limit": 100, "kind": "test_helper", "observed_lines": 221, "path": "backend/tests/test_alembic.py", "qualified_symbol": "test_actor_profile_lifecycle_constraint_and_trigger_parity.prove_guards", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 6451 + "start_line": 6450 }, { "capability": "unassigned_legacy_auth", @@ -1347,26 +1347,26 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "efca18e30f3e8e9d7b16310a956b427262dc92bdf13165146b58523b25ea2687", - "end_line": 522, + "end_line": 521, "hard_limit": 100, "kind": "test_helper", "observed_lines": 228, "path": "backend/tests/test_audit.py", "qualified_symbol": "_authority_event_matrix", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 295 + "start_line": 294 }, { "capability": "unassigned_legacy_auth", "content_sha256": "1a0a9f3e2be6965e29f76aa74272ccfa2fe4b99e4e0c3bde5ec8ba2c374b369b", - "end_line": 11301, + "end_line": 11299, "hard_limit": 100, "kind": "test_helper", "observed_lines": 169, "path": "backend/tests/test_authorization.py", "qualified_symbol": "_operation_success", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 11133 + "start_line": 11131 }, { "capability": "unassigned_legacy_auth", diff --git a/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/STATUS.md b/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/STATUS.md index 16fafcad6..aea95ef3d 100644 --- a/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/STATUS.md +++ b/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/STATUS.md @@ -29,8 +29,8 @@ determine transient implementation ownership. | Canonical CHECKER/POL post-submit registry | POL-01 | Present; remaining POL-002 work must be reframed as executor ownership, not inference | | POL-01 strict manifest | POL-02 | Merged PR #299 | | POL-02 adapter | POL-03A | Merged PR #301 | -| Hidden POL-03A compilation custody | AUTH-12I compilation request/execute activation | POL-03A merged PR #307; AUTH-12I not implemented | -| AUTH-12I | POL-03B authorized persistence | Not yet implemented | +| Hidden POL-03A compilation custody | AUTH-12I compilation request/execute activation | POL-03A merged PR #307; AUTH-12I implemented pending review | +| AUTH-12I | POL-03B authorized persistence | Implemented pending review; not merged | | Hidden POL-04A unified setup-service manifest | AUTH-12B2 setup-ledger activation | Not yet implemented | | Hidden POL-05A approval manifest | AUTH-12F4 approval activation | Not yet implemented | | Hidden POL-06A deterministic post manifest | AUTH-12G projection/approval activation | Not yet implemented | @@ -46,6 +46,6 @@ explicit allowed/not-allowed paths, runnable verification commands, and named reviewer tracks. They cannot authorize implementation in their current form. `WS-POL-003-03A` merged through PR #307 at `5e459a8f`. It installs hidden -compilation custody and the first public AUTH-capability consumer proof without -activating request/execute authority. AUTH-12I is the next dependency gate; -later chunks and AUTH gates remain planned and inactive. +compilation custody and the first public AUTH-capability consumer proof. AUTH-12I +now implements the exact request/execute activation on its review branch; POL-03B +remains blocked until that activation is reviewed and merged. diff --git a/.ci/behavior-ownership/auth/authorization-audit-domain.json b/.ci/behavior-ownership/auth/authorization-audit-domain.json new file mode 100644 index 000000000..aae95d650 --- /dev/null +++ b/.ci/behavior-ownership/auth/authorization-audit-domain.json @@ -0,0 +1,9 @@ +{ + "behavior_id": "auth.audit.domain", + "group": "auth", + "reason": "Closed immutable audit resource registry contains no executable callables.", + "reviewed_by": ["WS-AUTH-001-12I required reviewers"], + "schema": "workstream.behavior-ownership.v1", + "status": "structural_only", + "target": "backend/app/modules/authorization/domain/audit.py" +} diff --git a/.ci/behavior-ownership/auth/project-create-domain.json b/.ci/behavior-ownership/auth/project-create-domain.json new file mode 100644 index 000000000..1d0a9977a --- /dev/null +++ b/.ci/behavior-ownership/auth/project-create-domain.json @@ -0,0 +1,12 @@ +{ + "behavior_id": "auth.project_create.domain", + "boundaries": [], + "callables": ["app.modules.authorization.domain.project_create.ProjectCreateResourceContext.require_operation_identity"], + "group": "auth", + "outcomes": ["return", "mapped_error"], + "reviewed_by": ["WS-AUTH-003 incremental boundary reviewers"], + "schema": "workstream.behavior-ownership.v1", + "status": "reviewed", + "target": "backend/app/modules/authorization/domain/project_create.py", + "tests": ["backend/tests/test_authorization.py::test_project_mutation_resources_and_prepared_scopes_are_closed"] +} diff --git a/.ci/behavior-ownership/auth/project-guide-compilation-adapter.json b/.ci/behavior-ownership/auth/project-guide-compilation-adapter.json new file mode 100644 index 000000000..5f0446350 --- /dev/null +++ b/.ci/behavior-ownership/auth/project-guide-compilation-adapter.json @@ -0,0 +1,34 @@ +{ + "behavior_id": "auth.project_guide_compilation.adapter", + "boundaries": ["postgresql"], + "callables": [ + "app.modules.authorization.guide_compilation.ProjectGuideCompilationAuthorizationAdapter.__init__", + "app.modules.authorization.guide_compilation.ProjectGuideCompilationAuthorizationAdapter._assert_actor", + "app.modules.authorization.guide_compilation.ProjectGuideCompilationAuthorizationAdapter._assert_result_digest", + "app.modules.authorization.guide_compilation.ProjectGuideCompilationAuthorizationAdapter._invoke", + "app.modules.authorization.guide_compilation.ProjectGuideCompilationAuthorizationAdapter.authorize_execute_preflight", + "app.modules.authorization.guide_compilation.ProjectGuideCompilationAuthorizationAdapter.consume_execute_persist", + "app.modules.authorization.guide_compilation.ProjectGuideCompilationAuthorizationAdapter.consume_request", + "app.modules.authorization.guide_compilation.ProjectGuideCompilationAuthorizationAdapter.prepare_execute_persist", + "app.modules.authorization.guide_compilation.ProjectGuideCompilationAuthorizationAdapter.prepare_request", + "app.modules.authorization.guide_compilation._execute_context", + "app.modules.authorization.guide_compilation._input", + "app.modules.authorization.guide_compilation._request_context" + ], + "group": "auth", + "outcomes": ["return", "mapped_error"], + "reviewed_by": ["WS-AUTH-001-12I required reviewers"], + "schema": "workstream.behavior-ownership.v1", + "status": "reviewed", + "target": "backend/app/modules/authorization/guide_compilation.py", + "tests": [ + "backend/tests/authorization/guide_compilation/test_adapter_contract.py::test_request_prepare_and_consume_bind_the_exact_project_context", + "backend/tests/authorization/guide_compilation/test_adapter_contract.py::test_preflight_is_non_durable_and_final_digest_is_exact", + "backend/tests/authorization/guide_compilation/test_adapter_contract.py::test_actor_mismatch_denies_before_prepared_service_access", + "backend/tests/authorization/guide_compilation/test_adapter_contract.py::test_real_kernel_system_project_manager_grant_cannot_request_compilation", + "backend/tests/authorization/guide_compilation/test_adapter_contract.py::test_real_kernel_exact_project_manager_request_succeeds_and_replay_denies", + "backend/tests/authorization/guide_compilation/test_adapter_contract.py::test_real_kernel_actor_matrix_denies_without_evidence", + "backend/tests/authorization/guide_compilation/test_adapter_contract.py::test_real_kernel_preflight_is_non_evidencing_and_final_is_single_use", + "backend/tests/authorization/guide_compilation/test_adapter_contract.py::test_real_kernel_revoked_service_preflight_denies_without_evidence" + ] +} diff --git a/.ci/behavior-ownership/auth/project-guide-compilation-domain.json b/.ci/behavior-ownership/auth/project-guide-compilation-domain.json new file mode 100644 index 000000000..adb2d57dd --- /dev/null +++ b/.ci/behavior-ownership/auth/project-guide-compilation-domain.json @@ -0,0 +1,22 @@ +{ + "behavior_id": "auth.project_guide_compilation.domain", + "boundaries": [], + "callables": [ + "app.modules.authorization.domain.guide_compilation.ProjectGuideCompilationExecuteResourceContext.require_attempt_selector_and_phase", + "app.modules.authorization.domain.guide_compilation.ProjectGuideCompilationRequestResourceContext.require_operation_selector", + "app.modules.authorization.domain.guide_compilation.persisted_result_digest", + "app.modules.authorization.domain.guide_compilation.request_authority_digest" + ], + "group": "auth", + "outcomes": ["return", "mapped_error"], + "reviewed_by": ["WS-AUTH-001-12I required reviewers"], + "schema": "workstream.behavior-ownership.v1", + "status": "reviewed", + "target": "backend/app/modules/authorization/domain/guide_compilation.py", + "tests": [ + "backend/tests/authorization/guide_compilation/test_domain_contract.py::test_request_context_rejects_an_operation_selector_mismatch", + "backend/tests/authorization/guide_compilation/test_domain_contract.py::test_execute_context_requires_phase_appropriate_result_digest", + "backend/tests/authorization/guide_compilation/test_domain_contract.py::test_compilation_resource_contexts_reject_scalar_coercion", + "backend/tests/authorization/guide_compilation/test_domain_contract.py::test_request_digest_requires_a_grant_and_binds_it" + ] +} diff --git a/.ci/behavior-ownership/auth/project-guide-compilation-facts.json b/.ci/behavior-ownership/auth/project-guide-compilation-facts.json index e1ff9fdb5..e45199e8a 100644 --- a/.ci/behavior-ownership/auth/project-guide-compilation-facts.json +++ b/.ci/behavior-ownership/auth/project-guide-compilation-facts.json @@ -9,16 +9,20 @@ "app.modules.authorization.api.project_guide_compilation.ProjectGuideCompilationAuthorizationPort.prepare_request", "app.modules.authorization.api.project_guide_compilation.ProjectGuideCompilationRequestFacts.__post_init__", "app.modules.authorization.api.project_guide_compilation._validate_common", - "app.modules.authorization.api.project_guide_compilation.project_guide_compilation_execute_resource_digest" + "app.modules.authorization.api.project_guide_compilation.project_guide_compilation_execute_resource_digest", + "app.modules.authorization.api.project_guide_compilation.project_guide_compilation_facts_digest", + "app.modules.authorization.api.project_guide_compilation.project_guide_compilation_request_authority_digest", + "app.modules.authorization.api.project_guide_compilation.project_guide_compilation_request_resource_digest" ], "group": "auth", "outcomes": ["return", "mapped_error"], - "reviewed_by": ["WS-POL-003-03A required reviewers"], + "reviewed_by": ["WS-POL-003-03A and WS-AUTH-001-12I required reviewers"], "schema": "workstream.behavior-ownership.v1", "status": "reviewed", "target": "backend/app/modules/authorization/api/project_guide_compilation.py", "tests": [ "backend/tests/projects/guide_compilation/test_public_authorization.py::test_public_facts_reject_wrong_uuid_and_unbounded_token", - "backend/tests/projects/guide_compilation/test_repository_persistence.py::test_wrong_resource_authority_leaves_accepted_attempt_unpersisted" + "backend/tests/projects/guide_compilation/test_repository_persistence.py::test_wrong_resource_authority_leaves_accepted_attempt_unpersisted", + "backend/tests/authorization/guide_compilation/test_domain_contract.py::test_request_digest_requires_a_grant_and_binds_it" ] } diff --git a/.ci/behavior-ownership/auth/project-guide-compilation-prepared-domain.json b/.ci/behavior-ownership/auth/project-guide-compilation-prepared-domain.json new file mode 100644 index 000000000..280d5905e --- /dev/null +++ b/.ci/behavior-ownership/auth/project-guide-compilation-prepared-domain.json @@ -0,0 +1,15 @@ +{ + "behavior_id": "auth.project_guide_compilation.prepared_domain", + "boundaries": [], + "callables": [ + "app.modules.authorization.domain.prepared_compilation.parse_prepared_compilation", + "app.modules.authorization.domain.prepared_compilation.prepared_compilation_matches" + ], + "group": "auth", + "outcomes": ["return", "mapped_error"], + "reviewed_by": ["WS-AUTH-001-12I required reviewers"], + "schema": "workstream.behavior-ownership.v1", + "status": "reviewed", + "target": "backend/app/modules/authorization/domain/prepared_compilation.py", + "tests": ["backend/tests/authorization/guide_compilation/test_domain_contract.py::test_prepared_parser_round_trips_exact_request_and_rejects_bad_uuid"] +} diff --git a/.ci/behavior-ownership/auth/project-guide-compilation-service-domain.json b/.ci/behavior-ownership/auth/project-guide-compilation-service-domain.json new file mode 100644 index 000000000..58d70763c --- /dev/null +++ b/.ci/behavior-ownership/auth/project-guide-compilation-service-domain.json @@ -0,0 +1,15 @@ +{ + "behavior_id": "auth.project_guide_compilation.service_domain", + "boundaries": [], + "callables": [ + "app.modules.authorization.domain.prepared_service.is_project_setup_scope", + "app.modules.authorization.domain.prepared_service.project_setup_resource_matches" + ], + "group": "auth", + "outcomes": ["return"], + "reviewed_by": ["WS-AUTH-001-12I required reviewers"], + "schema": "workstream.behavior-ownership.v1", + "status": "reviewed", + "target": "backend/app/modules/authorization/domain/prepared_service.py", + "tests": ["backend/tests/authorization/guide_compilation/test_domain_contract.py::test_project_setup_scope_and_resource_guard_are_action_specific"] +} diff --git a/.ci/behavior-ownership/partition.v1.json b/.ci/behavior-ownership/partition.v1.json index a83f2e093..f9cc155da 100644 --- a/.ci/behavior-ownership/partition.v1.json +++ b/.ci/behavior-ownership/partition.v1.json @@ -348,6 +348,30 @@ "group": "auth", "target": "backend/app/modules/authorization/catalogue.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/domain/audit.py" + }, + { + "group": "auth", + "target": "backend/app/modules/authorization/domain/guide_compilation.py" + }, + { + "group": "auth", + "target": "backend/app/modules/authorization/domain/prepared_compilation.py" + }, + { + "group": "auth", + "target": "backend/app/modules/authorization/domain/prepared_service.py" + }, + { + "group": "auth", + "target": "backend/app/modules/authorization/domain/project_create.py" + }, + { + "group": "auth", + "target": "backend/app/modules/authorization/guide_compilation.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/kernel.py" @@ -765,7 +789,7 @@ "target": "backend/scripts/week2_api_e2e.py" } ], - "authority_digest": "b1e7516a8ceb1eccff47d177a665b5fe9c971b9cab9d420c828b7537f34e44b9", + "authority_digest": "980703d737c30d6579d96d01bb348116af9bfbc772a1d74ad15131d9e2388597", "protected_base_commit": "7676ce4347db0c9694962a9b587a20765e16eac6", "schema": "workstream.behavior-ownership-partition.v1" } diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 9c15145bd..a5fcc68b6 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -83,9 +83,11 @@ jobs: fail-fast: false matrix: lane: - - shared_foundations + - shared_foundations_a + - shared_foundations_b - schema_contracts_a - schema_contracts_b + - schema_contracts_c - project_lifecycle - task_lifecycle @@ -291,12 +293,19 @@ jobs: docker logs workstream-minio exit 1 - - name: Download shared foundations evidence + - name: Download shared foundations A evidence if: ${{ always() }} uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: - name: backend-lane-${{ github.sha }}-shared_foundations - path: backend/.ci/download/shared_foundations + name: backend-lane-${{ github.sha }}-shared_foundations_a + path: backend/.ci/download/shared_foundations_a + + - name: Download shared foundations B evidence + if: ${{ always() }} + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: backend-lane-${{ github.sha }}-shared_foundations_b + path: backend/.ci/download/shared_foundations_b - name: Download schema contracts A evidence if: ${{ always() }} @@ -312,6 +321,13 @@ jobs: name: backend-lane-${{ github.sha }}-schema_contracts_b path: backend/.ci/download/schema_contracts_b + - name: Download schema contracts C evidence + if: ${{ always() }} + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: backend-lane-${{ github.sha }}-schema_contracts_c + path: backend/.ci/download/schema_contracts_c + - name: Download project lifecycle evidence if: ${{ always() }} uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 @@ -353,7 +369,7 @@ jobs: set -euo pipefail shopt -s nullglob coverage_files=(.ci/test-lanes/run/.coverage.*) - test "${#coverage_files[@]}" -eq 5 + test "${#coverage_files[@]}" -eq 7 for source in "${coverage_files[@]}"; do destination="$(basename "${source}")" test -f "${source}" @@ -611,7 +627,7 @@ jobs: raise SystemExit("hosted evidence head drift") lanes = summary.get("lanes") - if not isinstance(lanes, list) or len(lanes) != 5: + if not isinstance(lanes, list) or len(lanes) != 7: raise SystemExit("invalid hosted lane inventory") collected: list[str] = [] completed: list[str] = [] @@ -680,9 +696,11 @@ jobs: start_epochs = [] for lane_name in ( - "shared_foundations", + "shared_foundations_a", + "shared_foundations_b", "schema_contracts_a", "schema_contracts_b", + "schema_contracts_c", "project_lifecycle", "task_lifecycle", ): diff --git a/backend/alembic/versions/0063_guide_compilation_authority.py b/backend/alembic/versions/0063_guide_compilation_authority.py new file mode 100644 index 000000000..df340cdbf --- /dev/null +++ b/backend/alembic/versions/0063_guide_compilation_authority.py @@ -0,0 +1,198 @@ +"""Activate exact unified guide-compilation authorization vocabulary. + +Revision ID: 0063_compilation_authority +Revises: 0062_guide_compilation +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0063_compilation_authority" +down_revision = "0062_guide_compilation" +branch_labels = depends_on = None + +_ACTION = "project.guide_compilation.request" +_EXECUTE_ACTION = "project.guide_compilation.execute" +_PERMISSION = _ACTION +_RESOURCE = "project_guide_compilation_request" + + +def _lock_audit_events() -> None: + op.execute("lock table audit_events in access exclusive mode") + + +def _refuse_permission_only_execute_evidence() -> None: + retained = ( + op.get_bind() + .execute( + sa.text( + "select exists(select 1 from audit_events " + "where permission_id=:execute_permission and action_id is null)" + ), + {"execute_permission": _EXECUTE_ACTION}, + ) + .scalar_one() + ) + if retained: + raise RuntimeError( + "cannot activate compilation authority over permission-only execute evidence" + ) + + +def _rewrite_constraint(name: str, marker: str, addition: str, *, add: bool) -> None: + connection = op.get_bind() + definition = connection.execute( + sa.text( + "select pg_get_constraintdef(oid) from pg_constraint " + "where conrelid='audit_events'::regclass and conname=:name" + ), + {"name": f"ck_audit_events_{name}"}, + ).scalar_one() + expanded = marker + addition + source, target = (marker, expanded) if add else (expanded, marker) + if definition.count(source) != 1 or (add and expanded in definition): + raise RuntimeError(f"unexpected compilation authority {name} registry") + op.drop_constraint(name, "audit_events", type_="check") + op.execute( + "alter table audit_events add constraint " + f"ck_audit_events_{name} {definition.replace(source, target, 1)}" + ) + + +def _rewrite_privacy_permission_registry(*, add: bool) -> None: + definition = ( + op.get_bind() + .execute( + sa.text( + "select pg_get_constraintdef(oid) from pg_constraint " + "where conrelid='audit_events'::regclass " + "and conname='ck_audit_events_authority_privacy_bounds'" + ) + ) + .scalar_one() + ) + marker = "('review.queue.override'::character varying)::text" + addition = ( + ", ('project.guide_compilation.request'::character varying)::text" + ", ('project.guide_compilation.execute'::character varying)::text" + ) + expanded = marker + addition + source, target = (marker, expanded) if add else (expanded, marker) + if definition.count(source) != 2 or (add and expanded in definition): + raise RuntimeError("unexpected compilation privacy permission registry") + op.drop_constraint("authority_privacy_bounds", "audit_events", type_="check") + op.execute( + "alter table audit_events add constraint " + f"ck_audit_events_authority_privacy_bounds {definition.replace(source, target)}" + ) + + +def _rewrite_action_evidence(*, add: bool) -> None: + connection = op.get_bind() + definition = connection.execute( + sa.text( + "select pg_get_constraintdef(oid) from pg_constraint " + "where conrelid='audit_events'::regclass " + "and conname='ck_audit_events_authorization_action_evidence'" + ) + ).scalar_one() + marker = ( + "(((action_id)::text = 'project.guide_compilation.execute'::text) AND " + "((permission_id)::text = 'project.guide_compilation.execute'::text))" + ) + token = ( + " OR (((action_id)::text = 'project.guide_compilation.request'::text) AND " + "((permission_id)::text = 'project.guide_compilation.request'::text))" + ) + permission_marker = "('project.effective_policy.read'::character varying)::text" + permission_tokens = ( + ", ('project.guide_compilation.request'::character varying)::text" + ", ('project.guide_compilation.execute'::character varying)::text" + ) + if add: + if ( + definition.count(marker) != 2 + or token in definition + or definition.count(permission_marker) != 1 + or permission_tokens in definition + ): + raise RuntimeError("unexpected compilation request action registry") + definition = definition.replace(marker, marker + token) + definition = definition.replace( + permission_marker, + permission_marker + permission_tokens, + 1, + ) + else: + if definition.count(token) != 2 or definition.count(permission_tokens) != 1: + raise RuntimeError("unexpected compilation request action registry") + definition = definition.replace(token, "") + definition = definition.replace(permission_tokens, "", 1) + op.drop_constraint("authorization_action_evidence", "audit_events", type_="check") + op.execute( + "alter table audit_events add constraint " + f"ck_audit_events_authorization_action_evidence {definition}" + ) + + +def upgrade() -> None: + _lock_audit_events() + _refuse_permission_only_execute_evidence() + _rewrite_constraint( + "authority_privacy_bounds", + "('project_guide_compilation_attempt'::character varying)::text", + ", ('project_guide_compilation_request'::character varying)::text", + add=True, + ) + _rewrite_privacy_permission_registry(add=True) + _rewrite_constraint( + "authority_registries", + "('project.guide_compilation.execute'::character varying)::text", + ", ('project.guide_compilation.request'::character varying)::text", + add=True, + ) + _rewrite_action_evidence(add=True) + + +def downgrade() -> None: + _lock_audit_events() + retained = ( + op.get_bind() + .execute( + sa.text( + "select exists(select 1 from audit_events " + "where action_id in (:request_action, :execute_action) " + "or permission_id=:request_action " + "or (target_ref_kind='permission_registry' " + "and target_ref_id in (:request_action, :execute_action)) " + "or (invalidation_target_kind='permission_registry' " + "and invalidation_target_ref in (:request_action, :execute_action)) " + "or resource_type=:request_resource)" + ), + { + "request_action": _ACTION, + "execute_action": _EXECUTE_ACTION, + "request_resource": _RESOURCE, + }, + ) + .scalar_one() + ) + if retained: + raise RuntimeError("cannot downgrade retained compilation authority") + _rewrite_action_evidence(add=False) + _rewrite_privacy_permission_registry(add=False) + _rewrite_constraint( + "authority_registries", + "('project.guide_compilation.execute'::character varying)::text", + ", ('project.guide_compilation.request'::character varying)::text", + add=False, + ) + _rewrite_constraint( + "authority_privacy_bounds", + "('project_guide_compilation_attempt'::character varying)::text", + ", ('project_guide_compilation_request'::character varying)::text", + add=False, + ) diff --git a/backend/app/modules/audit/schemas.py b/backend/app/modules/audit/schemas.py index e4db2bb0f..b7b7fbee9 100644 --- a/backend/app/modules/audit/schemas.py +++ b/backend/app/modules/audit/schemas.py @@ -36,7 +36,8 @@ """actor_profile actor_identity_link admin_role_grant project qualification_snapshot project_role_grant task submission review contribution compensation_award compensation_delivery operations audit_event project_create_operation project_submission_artifact_policy_mutation - pre_submit_checker_input""".split() + pre_submit_checker_input project_guide_compilation_request + project_guide_compilation_attempt""".split() ) _UUID_TARGET_KINDS = frozenset( { diff --git a/backend/app/modules/authorization/admin_schemas.py b/backend/app/modules/authorization/admin_schemas.py index 88ba62cad..c8edcb203 100644 --- a/backend/app/modules/authorization/admin_schemas.py +++ b/backend/app/modules/authorization/admin_schemas.py @@ -35,7 +35,7 @@ class PermissionDefinitionsResponse(BaseModel): model_config = _STRICT items: tuple[PermissionDefinitionResponse, ...] - total: Literal[71] + total: Literal[73] class AdminRoleDefinitionResponse(BaseModel): diff --git a/backend/app/modules/authorization/api/__init__.py b/backend/app/modules/authorization/api/__init__.py index a90e5e3f7..a0061b013 100644 --- a/backend/app/modules/authorization/api/__init__.py +++ b/backend/app/modules/authorization/api/__init__.py @@ -16,6 +16,9 @@ ProjectGuideCompilationExecutePreflightFacts, ProjectGuideCompilationRequestFacts, project_guide_compilation_execute_resource_digest, + project_guide_compilation_facts_digest, + project_guide_compilation_request_authority_digest, + project_guide_compilation_request_resource_digest, ) __all__ = ( @@ -38,6 +41,9 @@ "ProjectGuideCompilationExecutePreflightFacts", "ProjectGuideCompilationRequestFacts", "project_guide_compilation_execute_resource_digest", + "project_guide_compilation_facts_digest", + "project_guide_compilation_request_authority_digest", + "project_guide_compilation_request_resource_digest", "ResourceFacts", "ResourceValue", "action_id", diff --git a/backend/app/modules/authorization/api/project_guide_compilation.py b/backend/app/modules/authorization/api/project_guide_compilation.py index 49e39e284..bf5f9267a 100644 --- a/backend/app/modules/authorization/api/project_guide_compilation.py +++ b/backend/app/modules/authorization/api/project_guide_compilation.py @@ -93,9 +93,7 @@ class ProjectGuideCompilationExecutePreflightFacts(ProjectGuideCompilationReques @dataclass(frozen=True, slots=True, kw_only=True) -class ProjectGuideCompilationExecutePersistFacts( - ProjectGuideCompilationExecutePreflightFacts -): +class ProjectGuideCompilationExecutePersistFacts(ProjectGuideCompilationExecutePreflightFacts): """Exact accepted-result hashes consumed with immutable persistence.""" result_hash: str @@ -134,6 +132,75 @@ def project_guide_compilation_execute_resource_digest( return "sha256:" + hashlib.sha256(canonical).hexdigest() +def project_guide_compilation_request_resource_digest( + actor: ActorIdentityFacts, + grant_id: UUID, + facts: ProjectGuideCompilationRequestFacts, +) -> str: + """Hash the complete Project Manager request/recovery authority context.""" + if actor.actor_kind.value != "human" or actor.service_identity is not None: + raise ValueError("compilation requests require a human actor") + if not isinstance(grant_id, UUID): + raise ValueError("grant_id must be a UUID") + facts_digest = project_guide_compilation_facts_digest(facts) + return project_guide_compilation_request_authority_digest( + actor_profile_id=actor.actor_profile_id, + identity_link_id=actor.identity_link_id, + grant_id=grant_id, + project_id=facts.project_id, + operation_id=facts.operation_id, + request_facts_digest=facts_digest, + ) + + +def project_guide_compilation_facts_digest( + facts: ProjectGuideCompilationRequestFacts, +) -> str: + """Hash the complete dependency-free compilation facts envelope.""" + canonical = json.dumps( + { + "domain": "workstream.project_guide_compilation.facts.v1", + "facts": { + key: str(value) if isinstance(value, UUID) else value + for key, value in asdict(facts).items() + }, + }, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode() + return "sha256:" + hashlib.sha256(canonical).hexdigest() + + +def project_guide_compilation_request_authority_digest( + *, + actor_profile_id: UUID, + identity_link_id: UUID, + grant_id: UUID, + project_id: UUID, + operation_id: UUID, + request_facts_digest: str, +) -> str: + """Hash server-selected request authority with caller-owned facts.""" + canonical = json.dumps( + { + "action_id": "project.guide_compilation.request", + "permission_id": "project.guide_compilation.request", + "resource_type": "project_guide_compilation_request", + "resource_id": str(operation_id), + "scope_project_id": str(project_id), + "actor_profile_id": str(actor_profile_id), + "identity_link_id": str(identity_link_id), + "project_manager_grant_id": str(grant_id), + "request_facts_digest": request_facts_digest, + }, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode() + return "sha256:" + hashlib.sha256(canonical).hexdigest() + + PreparedHandleT = TypeVar("PreparedHandleT") diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index e6a2cbf07..6afc77645 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -38,6 +38,8 @@ class PermissionId(StrEnum): PROJECT_REVIEW_POLICY_MANAGE = "project.review_policy.manage" PROJECT_ROLE_GRANT_READ = "project.role_grant.read" PROJECT_ROLE_GRANT_MANAGE = "project.role_grant.manage" + PROJECT_GUIDE_COMPILATION_REQUEST = "project.guide_compilation.request" + PROJECT_GUIDE_COMPILATION_EXECUTE = "project.guide_compilation.execute" TASK_QUEUE_READ = "task.queue.read" TASK_CLAIM = "task.claim" SUBMISSION_CREATE = "submission.create" @@ -129,6 +131,8 @@ class ActionId(StrEnum): PROJECT_GUIDE_CREATE = "project.guide.create" PROJECT_GUIDE_UPDATE = "project.guide.update" PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE = "project.guide_source_snapshot.create" + PROJECT_GUIDE_COMPILATION_REQUEST = "project.guide_compilation.request" + PROJECT_GUIDE_COMPILATION_EXECUTE = "project.guide_compilation.execute" PROJECT_REVIEW_POLICY_UPDATE = "project.review_policy.update" PROJECT_REVISION_POLICY_UPDATE = "project.revision_policy.update" PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE = "project.guide_sufficiency_report.create" @@ -218,6 +222,7 @@ class ActionOwner(StrEnum): AUTH_12B2 = "WS-AUTH-001-12B2" AUTH_12C = "WS-AUTH-001-12C" AUTH_12D = "WS-AUTH-001-12D" + AUTH_12I = "WS-AUTH-001-12I" XINT_003_02B = "WS-XINT-003-02B" AUTH_12E = "WS-AUTH-001-12E" AUTH_12F = "WS-AUTH-001-12F" @@ -458,6 +463,16 @@ def _active( PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12D, ), + _active( + ActionId.PROJECT_GUIDE_COMPILATION_REQUEST, + PermissionId.PROJECT_GUIDE_COMPILATION_REQUEST, + ActionOwner.AUTH_12I, + ), + _active( + ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, + PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, + ActionOwner.AUTH_12I, + ), _active( ActionId.PROJECT_REVIEW_POLICY_UPDATE, PermissionId.PROJECT_REVIEW_POLICY_MANAGE, @@ -763,6 +778,8 @@ def _active( { PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, PermissionId.PROJECT_EFFECTIVE_POLICY_READ, + PermissionId.PROJECT_GUIDE_COMPILATION_REQUEST, + PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, PermissionId.OPERATIONS_TASK_START_OVERRIDE, PermissionId.OPERATIONS_SUBMISSION_GATE_REPAIR, PermissionId.OPERATIONS_CHECKER_RETRY, @@ -801,11 +818,11 @@ def _index_actions( ): raise RuntimeError("authorization action catalogue contains an invalid row") indexed = {definition.action_id: definition for definition in definitions} - if len(PERMISSION_IDS) != 71 or len(ACTION_IDS) != 100: + if len(PERMISSION_IDS) != 73 or len(ACTION_IDS) != 102: raise RuntimeError("authorization catalogue count mismatch") if len(indexed) != len(definitions) or set(indexed) != ACTION_IDS: raise RuntimeError("authorization action catalogue is incomplete") - if len(HISTORICAL_PERMISSION_IDS) != 49 or len(NEW_PERMISSION_IDS) != 22: + if len(HISTORICAL_PERMISSION_IDS) != 49 or len(NEW_PERMISSION_IDS) != 24: raise RuntimeError("authorization permission boundary mismatch") active_actions = { ActionId.ACTOR_PROFILE_READ_SELF, @@ -834,6 +851,8 @@ def _index_actions( ActionId.PROJECT_GUIDE_CREATE, ActionId.PROJECT_GUIDE_UPDATE, ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE, + ActionId.PROJECT_GUIDE_COMPILATION_REQUEST, + ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, ActionId.PROJECT_REVIEW_POLICY_UPDATE, ActionId.PROJECT_REVISION_POLICY_UPDATE, ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE, @@ -905,6 +924,7 @@ def _index_actions( ServiceIdentity.ARTIFACT_CHECKER_OUTPUT: frozenset({ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE}), ServiceIdentity.PROJECT_SETUP: frozenset( { + ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE, @@ -923,55 +943,49 @@ def _index_actions( ServiceIdentity.REVIEW_PROJECTION: frozenset({ActionId.REVIEW_PROJECTION_REBUILD}), } - -def _index_service_actions( - rows: dict[ServiceIdentity, frozenset[ActionId]], -) -> MappingProxyType[ServiceIdentity, frozenset[ActionId]]: - """Validate the exact fixed service matrix and return an immutable view.""" - expected_rows = { - ServiceIdentity.ARTIFACT_VERIFIER: frozenset({ActionId.ARTIFACT_VERIFICATION_EXECUTE}), - ServiceIdentity.ARTIFACT_PUT_RESOLVER: frozenset({ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE}), - ServiceIdentity.ARTIFACT_SCHEDULER: frozenset({ActionId.ARTIFACT_PENDING_WORK_SCAN}), - ServiceIdentity.ARTIFACT_BINDING: frozenset( - { - ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE, - ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, - ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE, - ActionId.ARTIFACT_REVIEW_EVIDENCE_BINDING_CREATE, - } - ), - ServiceIdentity.ARTIFACT_GUIDE_READER: frozenset({ActionId.ARTIFACT_GUIDE_SOURCE_READ}), - ServiceIdentity.ARTIFACT_MATERIALIZER: frozenset( - { - ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, - ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, - ActionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE, - } - ), - ServiceIdentity.ARTIFACT_CHECKER_OUTPUT: frozenset( - {ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE} - ), - ServiceIdentity.PROJECT_SETUP: frozenset( - { - ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE, - ActionId.PROJECT_SETUP_RUN_UPDATE, - } +_EXPECTED_SERVICE_ACTION_MEMBERSHIPS = frozenset( + (identity, action) + for identity, action in ( + (ServiceIdentity.ARTIFACT_VERIFIER, ActionId.ARTIFACT_VERIFICATION_EXECUTE), + (ServiceIdentity.ARTIFACT_PUT_RESOLVER, ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE), + (ServiceIdentity.ARTIFACT_SCHEDULER, ActionId.ARTIFACT_PENDING_WORK_SCAN), + (ServiceIdentity.ARTIFACT_BINDING, ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE), + (ServiceIdentity.ARTIFACT_BINDING, ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE), + (ServiceIdentity.ARTIFACT_BINDING, ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE), + (ServiceIdentity.ARTIFACT_BINDING, ActionId.ARTIFACT_REVIEW_EVIDENCE_BINDING_CREATE), + (ServiceIdentity.ARTIFACT_GUIDE_READER, ActionId.ARTIFACT_GUIDE_SOURCE_READ), + ( + ServiceIdentity.ARTIFACT_MATERIALIZER, + ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, ), - ServiceIdentity.REVIEW_PREFERENCE_EXPIRY: frozenset( - {ActionId.REVIEW_PREFERENCE_EXPIRY_RUN} + ( + ServiceIdentity.ARTIFACT_MATERIALIZER, + ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, ), - ServiceIdentity.REVIEW_LEASE_EXPIRY: frozenset({ActionId.REVIEW_LEASE_EXPIRY_RUN}), - ServiceIdentity.REVIEW_AUTHORITY_INVALIDATION_RECONCILIATION: frozenset( - {ActionId.REVIEW_RECONCILE_RUN} + (ServiceIdentity.ARTIFACT_MATERIALIZER, ActionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE), + (ServiceIdentity.ARTIFACT_CHECKER_OUTPUT, ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE), + (ServiceIdentity.PROJECT_SETUP, ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE), + (ServiceIdentity.PROJECT_SETUP, ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN), + (ServiceIdentity.PROJECT_SETUP, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE), + (ServiceIdentity.PROJECT_SETUP, ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE), + (ServiceIdentity.PROJECT_SETUP, ActionId.PROJECT_SETUP_RUN_UPDATE), + (ServiceIdentity.REVIEW_PREFERENCE_EXPIRY, ActionId.REVIEW_PREFERENCE_EXPIRY_RUN), + (ServiceIdentity.REVIEW_LEASE_EXPIRY, ActionId.REVIEW_LEASE_EXPIRY_RUN), + ( + ServiceIdentity.REVIEW_AUTHORITY_INVALIDATION_RECONCILIATION, + ActionId.REVIEW_RECONCILE_RUN, ), - ServiceIdentity.REVIEW_RECONCILIATION: frozenset({ActionId.REVIEW_RECONCILE_RUN}), - ServiceIdentity.REVIEW_ARTIFACT_REFERENCE_RECONCILIATION: frozenset( - {ActionId.REVIEW_ARTIFACT_REFERENCE_RECONCILE} + (ServiceIdentity.REVIEW_RECONCILIATION, ActionId.REVIEW_RECONCILE_RUN), + ( + ServiceIdentity.REVIEW_ARTIFACT_REFERENCE_RECONCILIATION, + ActionId.REVIEW_ARTIFACT_REFERENCE_RECONCILE, ), - ServiceIdentity.REVIEW_PROJECTION: frozenset({ActionId.REVIEW_PROJECTION_REBUILD}), - } + (ServiceIdentity.REVIEW_PROJECTION, ActionId.REVIEW_PROJECTION_REBUILD), + ) +) + + +def _index_service_actions(rows: dict[ServiceIdentity, frozenset[ActionId]]) -> MappingProxyType[ServiceIdentity, frozenset[ActionId]]: expected_metadata = { ActionId.ARTIFACT_VERIFICATION_EXECUTE: ( PermissionId.ARTIFACT_VERIFICATION_EXECUTE, @@ -1025,6 +1039,10 @@ def _index_service_actions( PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12E, ), + ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE: ( + PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, + ActionOwner.AUTH_12I, + ), ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE: ( PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, ActionOwner.AUTH_12F3, @@ -1060,7 +1078,9 @@ def _index_service_actions( } if set(rows) != SERVICE_IDENTITIES: raise RuntimeError("service action matrix identity mismatch") - if rows != expected_rows: + if any(not actions for actions in rows.values()): + raise RuntimeError("service action matrix row mismatch") + if frozenset((i, a) for i, actions in rows.items() for a in actions) != _EXPECTED_SERVICE_ACTION_MEMBERSHIPS: raise RuntimeError("service action matrix row mismatch") if not FUTURE_INTENT_REQUIRED_ACTIONS.isdisjoint( action for actions in rows.values() for action in actions @@ -1079,6 +1099,7 @@ def _index_service_actions( ActionId.ARTIFACT_GUIDE_SOURCE_READ, ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, + ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, } else ActionAvailability.PLANNED diff --git a/backend/app/modules/authorization/domain/__init__.py b/backend/app/modules/authorization/domain/__init__.py new file mode 100644 index 000000000..a7b0c53a0 --- /dev/null +++ b/backend/app/modules/authorization/domain/__init__.py @@ -0,0 +1 @@ +"""AUTH-owned domain rules and immutable internal facts.""" diff --git a/backend/app/modules/authorization/domain/audit.py b/backend/app/modules/authorization/domain/audit.py new file mode 100644 index 000000000..8e74d90ba --- /dev/null +++ b/backend/app/modules/authorization/domain/audit.py @@ -0,0 +1,17 @@ +"""Privacy-bounded audit classification shared by AUTH capabilities.""" + +from __future__ import annotations + +CONTEXT_DIGEST_RESOURCE_TYPES = ( + "artifact_put_attempt", + "artifact_verification_job", + "artifact_pending_work", + "guide_source_binding", + "guide_source_read", + "pre_submit_checker_input", + "project_diagnostic", + "project_policy_read", + "project_active_guide_read", + "project_guide_compilation_request", + "project_guide_compilation_attempt", +) diff --git a/backend/app/modules/authorization/domain/guide_compilation.py b/backend/app/modules/authorization/domain/guide_compilation.py new file mode 100644 index 000000000..1ab02c4ee --- /dev/null +++ b/backend/app/modules/authorization/domain/guide_compilation.py @@ -0,0 +1,103 @@ +"""Internal resource contexts for unified Project Guide compilation authority.""" + +from __future__ import annotations + +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from app.modules.authorization.api import ( + project_guide_compilation_request_authority_digest, +) +from app.modules.authorization.catalogue import ActionId + +_STRICT_FROZEN = ConfigDict(extra="forbid", frozen=True, strict=True) + + +class ProjectGuideCompilationRequestResourceContext(BaseModel): + """Canonical Project Manager compilation request/recovery authority facts.""" + + model_config = _STRICT_FROZEN + resource_type: Literal["project_guide_compilation_request"] + resource_id: UUID + scope_project_id: UUID + guide_id: UUID + source_snapshot_id: UUID + setup_run_id: UUID + setup_generation: int = Field(ge=1) + operation_id: UUID + request_id: UUID + idempotency_key: UUID + request_facts_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + @model_validator(mode="after") + def require_operation_selector(self): + if self.resource_id != self.operation_id: + raise ValueError("compilation request resource must match operation") + return self + + +class ProjectGuideCompilationExecuteResourceContext(BaseModel): + """Canonical fixed-service compilation preflight or persistence facts.""" + + model_config = _STRICT_FROZEN + resource_type: Literal["project_guide_compilation_attempt"] + resource_id: UUID + scope_project_id: UUID + guide_id: UUID + source_snapshot_id: UUID + setup_run_id: UUID + setup_generation: int = Field(ge=1) + attempt_id: UUID + provider_idempotency_key: UUID + phase: Literal["preflight", "persist"] + request_facts_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + result_resource_digest: str | None = Field(default=None, pattern=r"^sha256:[0-9a-f]{64}$") + + @model_validator(mode="after") + def require_attempt_selector_and_phase(self): + if self.resource_id != self.attempt_id: + raise ValueError("compilation execute resource must match attempt") + if (self.phase == "persist") != (self.result_resource_digest is not None): + raise ValueError("compilation persist phase requires exact result digest") + return self + + +CompilationResourceContext = ( + ProjectGuideCompilationRequestResourceContext | ProjectGuideCompilationExecuteResourceContext +) +COMPILATION_RESOURCE_BY_ACTION = { + ActionId.PROJECT_GUIDE_COMPILATION_REQUEST: ProjectGuideCompilationRequestResourceContext, + ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE: ProjectGuideCompilationExecuteResourceContext, +} + + +def persisted_result_digest(resource: object) -> str | None: + """Return the public exact-result digest only for final persistence.""" + if ( + isinstance(resource, ProjectGuideCompilationExecuteResourceContext) + and resource.phase == "persist" + ): + return resource.result_resource_digest + return None + + +def request_authority_digest( + resource: object, + *, + actor_profile_id: UUID, + identity_link_id: UUID, + grant_id: UUID | None, +) -> str | None: + """Return the grant-bound digest for one allowed human request.""" + if not isinstance(resource, ProjectGuideCompilationRequestResourceContext) or grant_id is None: + return None + return project_guide_compilation_request_authority_digest( + actor_profile_id=actor_profile_id, + identity_link_id=identity_link_id, + grant_id=grant_id, + project_id=resource.scope_project_id, + operation_id=resource.operation_id, + request_facts_digest=resource.request_facts_digest, + ) diff --git a/backend/app/modules/authorization/domain/prepared_compilation.py b/backend/app/modules/authorization/domain/prepared_compilation.py new file mode 100644 index 000000000..6f6ca167f --- /dev/null +++ b/backend/app/modules/authorization/domain/prepared_compilation.py @@ -0,0 +1,64 @@ +"""Prepared-capability parsing and equality for guide compilation.""" + +from __future__ import annotations + +from collections.abc import Mapping +from uuid import UUID + +from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.domain.guide_compilation import ( + CompilationResourceContext, + ProjectGuideCompilationExecuteResourceContext, + ProjectGuideCompilationRequestResourceContext, +) +from app.modules.authorization.runtime import authorization_resource_digest +from app.modules.authorization.runtime import PreparedAuthorizationHandleInvalid + +_CONTEXT_BY_ACTION = { + ActionId.PROJECT_GUIDE_COMPILATION_REQUEST: ProjectGuideCompilationRequestResourceContext, + ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE: ProjectGuideCompilationExecuteResourceContext, +} +_UUID_FIELDS = ( + "resource_id", + "scope_project_id", + "guide_id", + "source_snapshot_id", + "setup_run_id", + "operation_id", + "request_id", + "idempotency_key", + "attempt_id", + "provider_idempotency_key", +) + + +def parse_prepared_compilation( + action_id: ActionId, request_value: Mapping[str, object] +) -> dict[str, object]: + """Parse exact compilation facts when the action belongs to this capability.""" + context_type = _CONTEXT_BY_ACTION.get(action_id) + if context_type is None: + return {} + try: + value = dict(request_value) + for field in _UUID_FIELDS: + if field in value: + value[field] = UUID(str(value[field])) + resource = context_type.model_validate(value) + except (TypeError, ValueError) as exc: + raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") from exc + return { + "guide_compilation_context": resource.model_dump(mode="json"), + "guide_compilation_resource_digest": authorization_resource_digest(resource), + } + + +def prepared_compilation_matches( + context: dict | None, + digest: str | None, + resource: CompilationResourceContext, +) -> bool: + """Require exact final facts and digest equality.""" + return context == resource.model_dump(mode="json") and ( + digest == authorization_resource_digest(resource) + ) diff --git a/backend/app/modules/authorization/domain/prepared_service.py b/backend/app/modules/authorization/domain/prepared_service.py new file mode 100644 index 000000000..50736fc28 --- /dev/null +++ b/backend/app/modules/authorization/domain/prepared_service.py @@ -0,0 +1,61 @@ +"""Pure resource guards for fixed-service prepared authorization.""" + +from __future__ import annotations + +from uuid import UUID + +from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.domain.guide_compilation import ( + ProjectGuideCompilationExecuteResourceContext, +) +from app.modules.authorization.runtime import ( + AuthorizationResourceContext, + PreparedAuthorityScope, + PreparedAuthorityScopeKind, + ProjectGuideSufficiencyMutationResourceContext, + ProjectSubmissionArtifactPolicyMutationResourceContext, +) + +_PROJECT_SETUP_ACTIONS = frozenset( + { + ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, + ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, + } +) + + +def is_project_setup_scope(action_id: ActionId, scope: PreparedAuthorityScope) -> bool: + """Return whether a fixed setup action has one exact project scope.""" + return ( + action_id in _PROJECT_SETUP_ACTIONS + and scope.kind is PreparedAuthorityScopeKind.PROJECT + and scope.project_id is not None + ) + + +def project_setup_resource_matches( + action_id: ActionId, + resource: AuthorizationResourceContext, + project_id: UUID | None, +) -> bool | None: + """Validate setup-service facts, or return None for non-setup actions.""" + if action_id is ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN: + return ( + isinstance(resource, ProjectGuideSufficiencyMutationResourceContext) + and resource.execution_kind == "setup_service" + and resource.scope_project_id == project_id + ) + if action_id is ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE: + return ( + isinstance(resource, ProjectSubmissionArtifactPolicyMutationResourceContext) + and resource.execution_kind == "setup_service" + and resource.target_kind == "derive" + and resource.scope_project_id == project_id + ) + if action_id is ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE: + return ( + isinstance(resource, ProjectGuideCompilationExecuteResourceContext) + and resource.scope_project_id == project_id + ) + return None diff --git a/backend/app/modules/authorization/domain/project_create.py b/backend/app/modules/authorization/domain/project_create.py new file mode 100644 index 000000000..d4a156893 --- /dev/null +++ b/backend/app/modules/authorization/domain/project_create.py @@ -0,0 +1,22 @@ +"""Internal resource context for prepared project creation authority.""" + +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class ProjectCreateResourceContext(BaseModel): + """Server-owned facts for one system-scoped project creation.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + resource_type: Literal["project_create"] + resource_id: UUID + requested_project_id: UUID + operation_generation: int = Field(ge=1) + + @model_validator(mode="after") + def require_operation_identity(self): + if self.resource_id == self.requested_project_id: + raise ValueError("project creation operation must not impersonate project identity") + return self diff --git a/backend/app/modules/authorization/guide_compilation.py b/backend/app/modules/authorization/guide_compilation.py new file mode 100644 index 000000000..b158342c1 --- /dev/null +++ b/backend/app/modules/authorization/guide_compilation.py @@ -0,0 +1,222 @@ +"""Production AUTH adapter for unified Project Guide compilation custody.""" + +from __future__ import annotations + +from uuid import UUID + +from app.modules.authorization.api import ( + ActorIdentityFacts, + AuthorizationDenied as BoundaryAuthorizationDenied, + PreparedAuthorizationInvalid, + ProjectGuideCompilationExecutePersistFacts, + ProjectGuideCompilationExecutePreflightFacts, + ProjectGuideCompilationRequestFacts, + project_guide_compilation_execute_resource_digest, + project_guide_compilation_facts_digest, +) +from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.domain.guide_compilation import ( + ProjectGuideCompilationExecuteResourceContext, + ProjectGuideCompilationRequestResourceContext, +) +from app.modules.authorization.kernel import AuthorizationService +from app.modules.authorization.prepared import ( + PreparedAuthorizationHandle, + PreparedAuthorizationService, +) +from app.modules.authorization.runtime import ( + AuthorizationDenied as KernelAuthorizationDenied, + HumanAuthorizationContext, + PreparedAuthorizationInput, + PreparedAuthorityScope, + PreparedAuthorityScopeKind, + PreparedAuthorizationHandleInvalid, + PreparedAuthorizationUnsupported, + ServiceAuthorizationContext, +) + + +def _request_context( + facts: ProjectGuideCompilationRequestFacts, +) -> ProjectGuideCompilationRequestResourceContext: + return ProjectGuideCompilationRequestResourceContext( + resource_type="project_guide_compilation_request", + resource_id=facts.operation_id, + scope_project_id=facts.project_id, + guide_id=facts.guide_id, + source_snapshot_id=facts.source_snapshot_id, + setup_run_id=facts.setup_run_id, + setup_generation=facts.setup_generation, + operation_id=facts.operation_id, + request_id=facts.request_id, + idempotency_key=facts.idempotency_key, + request_facts_digest=project_guide_compilation_facts_digest(facts), + ) + + +def _execute_context( + facts: ProjectGuideCompilationExecutePreflightFacts, + *, + phase: str, +) -> ProjectGuideCompilationExecuteResourceContext: + result_digest = ( + facts.resource_context_digest + if isinstance(facts, ProjectGuideCompilationExecutePersistFacts) + else None + ) + return ProjectGuideCompilationExecuteResourceContext( + resource_type="project_guide_compilation_attempt", + resource_id=facts.attempt_id, + scope_project_id=facts.project_id, + guide_id=facts.guide_id, + source_snapshot_id=facts.source_snapshot_id, + setup_run_id=facts.setup_run_id, + setup_generation=facts.setup_generation, + attempt_id=facts.attempt_id, + provider_idempotency_key=facts.provider_idempotency_key, + phase=phase, + request_facts_digest=project_guide_compilation_facts_digest(facts), + result_resource_digest=result_digest, + ) + + +def _input(resource, idempotency_key: UUID) -> PreparedAuthorizationInput: + return PreparedAuthorizationInput( + idempotency_key=idempotency_key, + request_value=resource.model_dump(mode="json"), + ) + + +class ProjectGuideCompilationAuthorizationAdapter: + """Bind the public compilation port to the existing AUTH kernel and PREP.""" + + def __init__( + self, + authorization: AuthorizationService, + prepared: PreparedAuthorizationService, + ) -> None: + if prepared._authorization is not authorization: + raise TypeError("compilation adapter requires one authorization composition") + self._authorization = authorization + self._prepared = prepared + + def _assert_actor(self, actor: ActorIdentityFacts) -> None: + context = self._authorization._context + if ( + actor.actor_profile_id != context.actor_profile_id + or actor.identity_link_id != context.identity_link_id + or ( + isinstance(context, ServiceAuthorizationContext) + and actor.service_identity != context.service_identity.value + ) + or ( + isinstance(context, HumanAuthorizationContext) + and actor.service_identity is not None + ) + ): + raise BoundaryAuthorizationDenied("compilation authority denied") + + @staticmethod + def _assert_result_digest( + actor: ActorIdentityFacts, facts: ProjectGuideCompilationExecutePersistFacts + ) -> None: + if facts.resource_context_digest != project_guide_compilation_execute_resource_digest( + actor, facts + ): + raise BoundaryAuthorizationDenied("compilation authority denied") + + @staticmethod + async def _invoke(operation): + try: + return await operation + except PreparedAuthorizationHandleInvalid as exc: + raise PreparedAuthorizationInvalid("prepared compilation authority is invalid") from exc + except (PreparedAuthorizationUnsupported, KernelAuthorizationDenied) as exc: + raise BoundaryAuthorizationDenied("compilation authority denied") from exc + + async def prepare_request( + self, *, actor: ActorIdentityFacts, facts: ProjectGuideCompilationRequestFacts + ) -> PreparedAuthorizationHandle: + self._assert_actor(actor) + resource = _request_context(facts) + return await self._invoke( + self._prepared.prepare( + ActionId.PROJECT_GUIDE_COMPILATION_REQUEST, + _input(resource, facts.idempotency_key), + PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, project_id=facts.project_id + ), + ) + ) + + async def consume_request( + self, + *, + handle: PreparedAuthorizationHandle, + actor: ActorIdentityFacts, + facts: ProjectGuideCompilationRequestFacts, + ) -> UUID: + self._assert_actor(actor) + resource = _request_context(facts) + decision = await self._invoke( + self._prepared.consume( + handle, + ActionId.PROJECT_GUIDE_COMPILATION_REQUEST, + _input(resource, facts.idempotency_key), + resource, + ) + ) + return decision.decision_id + + async def authorize_execute_preflight( + self, *, actor: ActorIdentityFacts, facts: ProjectGuideCompilationExecutePreflightFacts + ) -> None: + self._assert_actor(actor) + resource = _execute_context(facts, phase="preflight") + await self._invoke( + self._prepared.preflight( + ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, + _input(resource, facts.idempotency_key), + PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=facts.project_id, + ), + resource, + ) + ) + + async def prepare_execute_persist( + self, *, actor: ActorIdentityFacts, facts: ProjectGuideCompilationExecutePersistFacts + ) -> PreparedAuthorizationHandle: + self._assert_actor(actor) + self._assert_result_digest(actor, facts) + resource = _execute_context(facts, phase="persist") + return await self._invoke( + self._prepared.prepare( + ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, + _input(resource, facts.idempotency_key), + PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, project_id=facts.project_id + ), + ) + ) + + async def consume_execute_persist( + self, + *, + handle: PreparedAuthorizationHandle, + actor: ActorIdentityFacts, + facts: ProjectGuideCompilationExecutePersistFacts, + ) -> UUID: + self._assert_actor(actor) + self._assert_result_digest(actor, facts) + resource = _execute_context(facts, phase="persist") + decision = await self._invoke( + self._prepared.consume( + handle, + ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, + _input(resource, facts.idempotency_key), + resource, + ) + ) + return decision.decision_id diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index ad618c3ea..2dfcb4c72 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -23,6 +23,12 @@ ActionId, PermissionId, ) +from app.modules.authorization.domain import guide_compilation as compilation +from app.modules.authorization.domain.audit import CONTEXT_DIGEST_RESOURCE_TYPES +from app.modules.authorization.domain.prepared_service import ( + is_project_setup_scope, + project_setup_resource_matches, +) from app.modules.authorization.policy import ACTIVE_GUIDE_ADMIN_ROLES from app.modules.authorization.repository import AdminAuthorizationRepository from app.modules.authorization.schemas import AdminRole @@ -101,6 +107,7 @@ ActionId.PROJECT_GUIDE_CREATE, ActionId.PROJECT_GUIDE_UPDATE, ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE, + ActionId.PROJECT_GUIDE_COMPILATION_REQUEST, ActionId.PROJECT_REVIEW_POLICY_UPDATE, ActionId.PROJECT_REVISION_POLICY_UPDATE, ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE, @@ -384,7 +391,6 @@ async def _prepare_prelocked( action_id: ActionId, scope: PreparedAuthorityScope, ) -> _PrelockedAuthority: - """Lock, validate, and seal one closed authority plan without caller facts.""" self._validate_prepared_consumer(consumer_token) if self._session.in_nested_transaction(): raise TypeError("prelocked authority requires one root transaction") @@ -394,8 +400,7 @@ async def _prepare_prelocked( action = ACTION_BY_ID.get(action_id) if isinstance(action_id, ActionId) else None if action is None: raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.UNKNOWN_ACTION) - context = self._context - grant = None + context, grant = self._context, None if isinstance(context, ServiceAuthorizationContext): if action_id not in SERVICE_ACTIONS_BY_IDENTITY[context.service_identity]: raise PreparedAuthorizationUnsupported( @@ -404,17 +409,8 @@ async def _prepare_prelocked( if action.availability is not ActionAvailability.ACTIVE: raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.ACTION_UNAVAILABLE) expected_resource = _ARTIFACT_INTERNAL_RESOURCES.get(action_id) - project_setup_sufficiency = ( - action_id is ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN - and scope.kind is PreparedAuthorityScopeKind.PROJECT - and scope.project_id is not None - ) - project_setup_submission_policy = ( - action_id is ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE - and scope.kind is PreparedAuthorityScopeKind.PROJECT - and scope.project_id is not None - ) - if not (project_setup_sufficiency or project_setup_submission_policy) and ( + project_setup_action = is_project_setup_scope(action_id, scope) + if not project_setup_action and ( expected_resource is None or scope.kind is not PreparedAuthorityScopeKind.ARTIFACT_INTERNAL or scope.artifact_resource_type != expected_resource[0] @@ -435,11 +431,7 @@ async def _prepare_prelocked( transaction=transaction, context=context, action_id=action_id, - scope_project_id=( - scope.project_id - if project_setup_sufficiency or project_setup_submission_policy - else None - ), + scope_project_id=scope.project_id if project_setup_action else None, matched_grant_id=None, matched_grant_scope_project_id=None, matched_grant_status=None, @@ -526,12 +518,18 @@ async def _prepare_prelocked( action.permission_id, scope_project_id=scope.project_id, for_update=True, - allowed_roles=frozenset({AdminRole.PROJECT_MANAGER}), + allowed_roles=frozenset({AdminRole.PROJECT_MANAGER}), exact_project_scope=action_id is ActionId.PROJECT_GUIDE_COMPILATION_REQUEST, ) if grant is None: raise PreparedAuthorizationUnsupported( AuthorizationDenialCode.PERMISSION_NOT_GRANTED ) + if action_id is ActionId.PROJECT_GUIDE_COMPILATION_REQUEST and ( + grant.scope_type != "project" or grant.scope_project_id != str(scope.project_id) + ): + raise PreparedAuthorizationUnsupported( + AuthorizationDenialCode.PERMISSION_NOT_GRANTED + ) elif action_id is ActionId.PROJECT_CREATE: if not isinstance(context, HumanAuthorizationContext): raise PreparedAuthorizationUnsupported( @@ -983,28 +981,20 @@ async def _require_prelocked( and action_id not in SERVICE_ACTIONS_BY_IDENTITY[context.service_identity] ): denial = AuthorizationDenialCode.PERMISSION_NOT_GRANTED - if action_id is ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN: - if denial is None and ( - not isinstance(resource_context, ProjectGuideSufficiencyMutationResourceContext) - or resource_context.execution_kind != "setup_service" - or resource_context.scope_project_id != authority.scope_project_id - ): - denial = AuthorizationDenialCode.RESOURCE_GUARD_DENIED - elif action_id is ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE: - if denial is None and ( - not isinstance( - resource_context, ProjectSubmissionArtifactPolicyMutationResourceContext - ) - or resource_context.execution_kind != "setup_service" - or resource_context.target_kind != "derive" - or resource_context.scope_project_id != authority.scope_project_id - ): - denial = AuthorizationDenialCode.RESOURCE_GUARD_DENIED - elif denial is None and ( - expected_resource is None - or not isinstance(resource_context, expected_resource[1]) - or resource_context.resource_type != authority.artifact_resource_type - or resource_context.resource_id != authority.artifact_resource_id + setup_match = project_setup_resource_matches( + action_id, resource_context, authority.scope_project_id + ) + if denial is None and setup_match is False: + denial = AuthorizationDenialCode.RESOURCE_GUARD_DENIED + elif ( + denial is None + and setup_match is None + and ( + expected_resource is None + or not isinstance(resource_context, expected_resource[1]) + or resource_context.resource_type != authority.artifact_resource_type + or resource_context.resource_id != authority.artifact_resource_id + ) ): denial = AuthorizationDenialCode.RESOURCE_GUARD_DENIED if denial is None: @@ -1034,7 +1024,9 @@ async def _require_prelocked( matched_project_id = authority.scope_project_id elif action_id in _GUIDE_BOUND_PROJECT_MANAGER_MUTATIONS: denial = self._lifecycle_denial(context) - expected = PROJECT_MUTATION_RESOURCE_BY_ACTION.get(action_id) + expected = PROJECT_MUTATION_RESOURCE_BY_ACTION.get( + action_id + ) or compilation.COMPILATION_RESOURCE_BY_ACTION.get(action_id) if denial is None and action.availability is not ActionAvailability.ACTIVE: denial = AuthorizationDenialCode.ACTION_UNAVAILABLE if denial is None and (expected is None or not isinstance(resource_context, expected)): @@ -1135,6 +1127,12 @@ async def _complete_decision( revalidated: bool, ) -> AuthorizationDecision: """Construct, evidence, and enforce one canonical authorization decision.""" + resource_digest = compilation.request_authority_digest( + resource_context, + actor_profile_id=context.actor_profile_id, + identity_link_id=context.identity_link_id, + grant_id=matched_grant_id if denial is None else None, + ) or authorization_resource_digest(resource_context) decision = AuthorizationDecision( decision_id=uuid4(), action_id=action.action_id if action is not None else None, @@ -1143,7 +1141,7 @@ async def _complete_decision( denial_code=denial, resource_type=resource_context.resource_type, resource_id=resource_context.resource_id, - resource_context_digest=authorization_resource_digest(resource_context), + resource_context_digest=resource_digest, matched_authority_kind=matched_kind, matched_grant_id=matched_grant_id, matched_scope_project_id=matched_project_id, @@ -1524,12 +1522,21 @@ async def _stage_decision( audit_resource_id = str(resource_context.resource_id) target_ref_kind = "project" target_ref_id = str(resource_context.scope_project_id) - elif isinstance(resource_context, PreSubmitCheckerInputResourceContext): - audit_project_id = str(resource_context.project_id) + elif isinstance( + resource_context, + ( + PreSubmitCheckerInputResourceContext, + compilation.ProjectGuideCompilationRequestResourceContext, + compilation.ProjectGuideCompilationExecuteResourceContext, + ), + ): + project_id = getattr(resource_context, "project_id", None) or getattr( + resource_context, "scope_project_id" + ) + audit_project_id = str(project_id) audit_resource_type = resource_context.resource_type audit_resource_id = str(resource_context.resource_id) - target_ref_kind = "project" - target_ref_id = str(resource_context.project_id) + target_ref_kind, target_ref_id = "project", str(project_id) elif decision.action_id in _GUIDE_BOUND_PROJECT_MANAGER_MUTATIONS: if resource_context is not None: project_id = self._resource_project_id(resource_context) @@ -1540,17 +1547,7 @@ async def _stage_decision( target_ref_kind = "project" target_ref_id = str(project_id) after_facts: dict[str, object] = {"allowed": decision.allowed} - if decision.resource_type in { - "artifact_put_attempt", - "artifact_verification_job", - "artifact_pending_work", - "guide_source_binding", - "guide_source_read", - "pre_submit_checker_input", - "project_diagnostic", - "project_policy_read", - "project_active_guide_read", - } or decision.action_id in { + if decision.resource_type in CONTEXT_DIGEST_RESOURCE_TYPES or decision.action_id in { ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, ActionId.PROJECT_CREATE, *_GUIDE_BOUND_PROJECT_MANAGER_MUTATIONS, diff --git a/backend/app/modules/authorization/policy.py b/backend/app/modules/authorization/policy.py index e5b173d1e..2618c539b 100644 --- a/backend/app/modules/authorization/policy.py +++ b/backend/app/modules/authorization/policy.py @@ -63,6 +63,7 @@ PermissionId.PROJECT_UPDATE, PermissionId.PROJECT_ARCHIVE, PermissionId.PROJECT_GUIDE_MANAGE, + PermissionId.PROJECT_GUIDE_COMPILATION_REQUEST, PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, PermissionId.PROJECT_TASK_MANAGE, PermissionId.PROJECT_REVIEW_POLICY_MANAGE, diff --git a/backend/app/modules/authorization/prepared.py b/backend/app/modules/authorization/prepared.py index 218123772..15e5eb9d9 100644 --- a/backend/app/modules/authorization/prepared.py +++ b/backend/app/modules/authorization/prepared.py @@ -22,6 +22,16 @@ AuthorizationService, ) from app.modules.authorization.repository import AdminAuthorizationRepository +from app.modules.authorization.domain.guide_compilation import ( + COMPILATION_RESOURCE_BY_ACTION, + ProjectGuideCompilationExecuteResourceContext, + ProjectGuideCompilationRequestResourceContext, +) +from app.modules.authorization.domain.prepared_compilation import ( + parse_prepared_compilation, + prepared_compilation_matches, +) +from app.modules.authorization.domain.prepared_service import project_setup_resource_matches from app.modules.authorization.runtime import ( ActorSelfResourceContext, ActorKind, @@ -155,6 +165,8 @@ class _PreparedAuthorizationBinding: submission_policy_resource_digest: str | None = None exact_artifact_context: dict | None = None exact_artifact_resource_digest: str | None = None + guide_compilation_context: dict | None = None + guide_compilation_resource_digest: str | None = None @dataclass(slots=True) @@ -334,6 +346,35 @@ async def prepare( self._issued[handle] = _Issuance(binding, transaction, authority) return handle + async def preflight( + self, + action_id: ActionId, + caller_input: PreparedAuthorizationInput, + scope: PreparedAuthorityScope, + resource: AuthorizationResourceContext, + ) -> None: + """Revalidate current authority without issuing a handle or staging evidence.""" + self._root_transaction() + binding = self._binding(action_id, caller_input, scope) + if self._scope_from_resource(action_id, resource) != scope or not prepared_compilation_matches( + binding.guide_compilation_context, + binding.guide_compilation_resource_digest, + resource, + ): + raise PreparedAuthorizationHandleInvalid("invalid prepared authorization preflight") + authority = await self._authorization._prepare_prelocked( + self._consumer_token, action_id, scope + ) + try: + if project_setup_resource_matches( + action_id, resource, authority.scope_project_id + ) is not True: + raise PreparedAuthorizationUnsupported( + AuthorizationDenialCode.RESOURCE_GUARD_DENIED + ) + finally: + self._authorization._discard_prelocked(authority) + async def consume( self, handle: PreparedAuthorizationHandle, @@ -390,6 +431,18 @@ async def consume( final_resource_context, PreSubmitCheckerInputResourceContext ) and not _exact_artifact_binding_matches(issuance.binding, final_resource_context): raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") + if isinstance( + final_resource_context, + ( + ProjectGuideCompilationRequestResourceContext, + ProjectGuideCompilationExecuteResourceContext, + ), + ) and not prepared_compilation_matches( + issuance.binding.guide_compilation_context, + issuance.binding.guide_compilation_resource_digest, + final_resource_context, + ): + raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") self._issued[handle] = _CONSUMED return await self._authorization._require_prelocked( self._consumer_token, @@ -481,10 +534,8 @@ def _binding( policy_mutation_predecessor_id = None policy_mutation_guide_status = None sufficiency: dict[str, object] = {} - submission_policy_context: dict | None = None - submission_policy_resource_digest: str | None = None - exact_artifact_context: dict | None = None - exact_artifact_resource_digest: str | None = None + submission_policy_context = submission_policy_resource_digest = exact_artifact_context = exact_artifact_resource_digest = None + compilation_binding = parse_prepared_compilation(action_id, caller_input.request_value) if action_id is ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE: try: value = dict(caller_input.request_value) @@ -747,6 +798,7 @@ def _binding( submission_policy_resource_digest=submission_policy_resource_digest, exact_artifact_context=exact_artifact_context, exact_artifact_resource_digest=exact_artifact_resource_digest, + **compilation_binding, ) @staticmethod @@ -791,7 +843,9 @@ def _scope_from_resource( role=role, grant_id=grant_id, ) - expected_project_mutation = PROJECT_MUTATION_RESOURCE_BY_ACTION.get(action_id) + expected_project_mutation = PROJECT_MUTATION_RESOURCE_BY_ACTION.get( + action_id + ) or COMPILATION_RESOURCE_BY_ACTION.get(action_id) if action_id in { ActionId.PROJECT_GUIDE_CREATE, ActionId.PROJECT_GUIDE_UPDATE, diff --git a/backend/app/modules/authorization/repository.py b/backend/app/modules/authorization/repository.py index 9987a2ee1..c02b86462 100644 --- a/backend/app/modules/authorization/repository.py +++ b/backend/app/modules/authorization/repository.py @@ -339,6 +339,7 @@ async def find_effective_grant( *, scope_project_id: UUID | None, system_scope_only: bool = False, + exact_project_scope: bool = False, for_update: bool = False, allowed_roles: frozenset[AdminRole] | None = None, ) -> AdminRoleGrant | None: @@ -350,7 +351,14 @@ async def find_effective_grant( and (allowed_roles is None or role in allowed_roles) ] scope_guard = AdminRoleGrant.scope_type == "system" - if not system_scope_only and scope_project_id is not None: + if exact_project_scope: + if scope_project_id is None: + raise ValueError("exact project scope requires one project identifier") + scope_guard = and_( + AdminRoleGrant.scope_type == "project", + AdminRoleGrant.scope_project_id == str(scope_project_id), + ) + elif not system_scope_only and scope_project_id is not None: scope_guard = or_( scope_guard, and_( diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index 399c887a7..496adfab7 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -10,12 +10,17 @@ from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator, model_validator from app.core.hashing import canonical_json_hash +from app.modules.authorization.domain.guide_compilation import ( + ProjectGuideCompilationExecuteResourceContext, + ProjectGuideCompilationRequestResourceContext, + persisted_result_digest, +) +from app.modules.authorization.domain.project_create import ProjectCreateResourceContext from app.modules.actors.service_identities import ServiceIdentity from app.modules.authorization.catalogue import ActionId, PermissionId from app.modules.authorization.schemas import AdminRole, AdminScope, ProjectRole _STRICT_FROZEN = ConfigDict(extra="forbid", frozen=True, strict=True) - PROJECT_DIAGNOSTIC_TARGET_KIND_BY_ACTION = { ActionId.PROJECT_SETUP_RUN_READ: "setup_run", ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST: "sufficiency_report_collection", @@ -456,24 +461,6 @@ def require_active_bundle_shape(self): return self -class ProjectCreateResourceContext(BaseModel): - """Server-owned facts for one system-scoped project creation.""" - - model_config = _STRICT_FROZEN - - resource_type: Literal["project_create"] - resource_id: UUID - requested_project_id: UUID - operation_generation: int = Field(ge=1) - - @model_validator(mode="after") - def require_operation_identity(self): - """Keep the idempotent operation distinct from the future project.""" - if self.resource_id == self.requested_project_id: - raise ValueError("project creation operation must not impersonate project identity") - return self - - class ProjectGuideMutationResourceContext(BaseModel): """Canonical draft-guide facts for create or update.""" @@ -1504,6 +1491,8 @@ class PreSubmitCheckerInputResourceContext(PreSubmitCheckerInputPreparationConte | ProjectPostSubmitCheckerPolicyMutationResourceContext | ProjectSetupRunMutationResourceContext | ProjectGuideActivationResourceContext + | ProjectGuideCompilationRequestResourceContext + | ProjectGuideCompilationExecuteResourceContext | ActorAuthorizationContextResourceContext | ActorProfileAdminReadResourceContext | ActorIdentityLinkAdminReadResourceContext @@ -1533,14 +1522,14 @@ class PreSubmitCheckerInputResourceContext(PreSubmitCheckerInputPreparationConte def authorization_resource_digest(resource: AuthorizationResourceContext) -> str: - """Bind a decision to every scalar fact in its typed resource context.""" + if exact_digest := persisted_result_digest(resource): + return exact_digest return canonical_json_hash( {"resource_context": resource.model_dump(mode="json", exclude_none=True)} ) def authorization_resource_selector_id(resource_type: str, raw_id: str) -> UUID: - """Return a bounded UUID selector for missing-resource decision evidence.""" try: return UUID(raw_id) except (TypeError, ValueError, AttributeError): @@ -1600,6 +1589,8 @@ class AuthorizationDecision(BaseModel): "project_policy_mutation_request", "project_guide_sufficiency_mutation", "project_submission_artifact_policy_mutation", + "project_guide_compilation_request", + "project_guide_compilation_attempt", "actor_identity_link", "system", "permission_catalogue", diff --git a/backend/scripts/behavior_ownership.py b/backend/scripts/behavior_ownership.py index 67152a4c7..8a45c9c9f 100644 --- a/backend/scripts/behavior_ownership.py +++ b/backend/scripts/behavior_ownership.py @@ -89,6 +89,16 @@ POL_03A_DECLARATIVE_MODEL_TARGET = ( "backend/app/modules/projects/guide_compilation/models.py" ) +AUTH_12I_TARGETS = frozenset( + { + "backend/app/modules/authorization/domain/audit.py", + "backend/app/modules/authorization/domain/guide_compilation.py", + "backend/app/modules/authorization/domain/prepared_compilation.py", + "backend/app/modules/authorization/domain/prepared_service.py", + "backend/app/modules/authorization/domain/project_create.py", + "backend/app/modules/authorization/guide_compilation.py", + } +) class BehaviorOwnershipError(RuntimeError): @@ -234,6 +244,7 @@ def _validate_additive_partition_transition( | MODULE_BOUNDARY_FOUNDATION_TARGETS | TASK_BOUNDARY_FOUNDATION_TARGETS | POL_03A_CALLABLE_TARGETS + | AUTH_12I_TARGETS ) expected_additions = (approved_additions & additions) - set(trusted_targets) if POL_03A_DECLARATIVE_MODEL_TARGET in additions: diff --git a/backend/scripts/run_isolated_tests.py b/backend/scripts/run_isolated_tests.py index c113a3a5e..6173cd3ac 100644 --- a/backend/scripts/run_isolated_tests.py +++ b/backend/scripts/run_isolated_tests.py @@ -34,7 +34,7 @@ MINIO_ENDPOINT_ENV = "WORKSTREAM_TEST_MINIO_ENDPOINT" MINIO_ACCESS_KEY = "workstream-minio" MINIO_SECRET_KEY = "workstream-minio-secret-key" -S3_TRAFFIC_LANE = "shared_foundations" +S3_TRAFFIC_LANES = frozenset(("shared_foundations_a", "shared_foundations_b")) S3_TRAFFIC_BUCKET = "workstream-artifacts" LANE_RE = re.compile(r"[a-z][a-z0-9_]{0,62}") BUCKET_RE = re.compile(r"[a-z0-9](?:[a-z0-9.-]{1,61}[a-z0-9])?") @@ -199,7 +199,7 @@ def _minio_namespace(lane: str, suffix: str) -> tuple[str, str]: lane_component = lane.replace("_", "-") bucket = ( S3_TRAFFIC_BUCKET - if lane == S3_TRAFFIC_LANE + if lane in S3_TRAFFIC_LANES else f"workstream-ci-{lane_component}-{suffix}" ) if len(bucket) > 63 or BUCKET_RE.fullmatch(bucket) is None: diff --git a/backend/scripts/run_test_lanes.py b/backend/scripts/run_test_lanes.py index 09567eea4..76d8fb07a 100644 --- a/backend/scripts/run_test_lanes.py +++ b/backend/scripts/run_test_lanes.py @@ -40,7 +40,12 @@ SCHEMA_VERSION = 1 ADMIN_RUNNER_MODULE = "tests/test_isolated_database_runner.py" PARTITIONED_SCHEMA_MODULE = "tests/test_alembic.py" -PARTITIONED_SCHEMA_LANES = ("schema_contracts_a", "schema_contracts_b") +PARTITIONED_SCHEMA_LANES = ( + "schema_contracts_a", + "schema_contracts_b", + "schema_contracts_c", +) +PARTITIONED_SHARED_LANES = ("shared_foundations_a", "shared_foundations_b") ORDINARY_KIND = "ordinary_isolated" ADMIN_KIND = "admin_runner_self_test" ADMIN_REDACTING_WRAPPER = """ @@ -80,73 +85,76 @@ class TestLane: requires_postgres: bool = True +SHARED_FOUNDATION_MODULES = ( + "tests/test_actor_legacy_classification.py", + "tests/test_actor_migration_tools.py", + "tests/test_agent_runtime.py", + "tests/test_api_contract_e2e.py", + "tests/test_api_controls.py", + "tests/test_app.py", + "tests/test_artifact_architecture.py", + "tests/architecture/test_authorization_boundary.py", + "tests/architecture/test_module_boundaries.py", + "tests/architecture/test_test_structure_boundary.py", + "tests/test_artifact_authorization.py", + "tests/test_artifact_internal_authorization.py", + "tests/test_artifact_cleanup_wiring.py", + "tests/test_checker_materialization.py", + "tests/test_artifact_preparation.py", + "tests/test_artifact_store_conformance.py", + "tests/test_artifact_verification.py", + "tests/test_artifacts.py", + "tests/test_assertion_helpers.py", + "tests/test_aws_credential_isolation.py", + "tests/test_ci_test_lanes.py", + "tests/test_config.py", + "tests/test_compensation.py", + "tests/test_contributions.py", + "tests/test_coverage_contract.py", + "tests/test_external_service_adapters.py", + "tests/test_guide_artifacts.py", + "tests/test_guide_bindings.py", + "tests/test_guide_setup.py", + "tests/test_guide_formats.py", + "tests/test_guide_extraction.py", + "tests/test_guide_images.py", + "tests/test_guide_xlsx.py", + "tests/test_guide_docx.py", + "tests/test_guide_extractor_dependencies.py", + "tests/test_guide_ooxml.py", + "tests/test_guide_pdf.py", + "tests/test_guide_pptx.py", + "tests/test_local_artifact_store.py", + "tests/test_merge_test_lane_evidence.py", + "tests/test_mutation_policy.py", + "tests/test_s3_artifact_store.py", + "tests/test_submission_archive.py", + "tests/test_submission_change_gate.py", + "tests/test_submission_manifest.py", + "tests/test_test_lane_evidence.py", + "tests/test_actors.py", + "tests/test_api_rate_controls.py", + "tests/test_audit.py", + "tests/test_auth.py", + "tests/test_authorization.py", + "tests/authorization/guide_compilation/test_adapter_contract.py", + "tests/authorization/guide_compilation/test_domain_contract.py", + "tests/authorization/guide_compilation/test_migration_contract.py", + "tests/test_behavior_ownership.py", + "tests/test_artifact_admission.py", + "tests/test_submission_bundle_admission.py", + "tests/test_artifact_operator_api.py", + "tests/test_artifact_recovery.py", + "tests/test_db_session.py", + "tests/test_outbox.py", + "tests/test_policy_identity_lineage.py", + "tests/test_project_policy_mutations.py", + "tests/test_review_authorization_contracts.py", +) + LANES = ( - TestLane( - "shared_foundations", - ( - "tests/test_actor_legacy_classification.py", - "tests/test_actor_migration_tools.py", - "tests/test_agent_runtime.py", - "tests/test_api_contract_e2e.py", - "tests/test_api_controls.py", - "tests/test_app.py", - "tests/test_artifact_architecture.py", - "tests/architecture/test_authorization_boundary.py", - "tests/architecture/test_module_boundaries.py", - "tests/architecture/test_test_structure_boundary.py", - "tests/test_artifact_authorization.py", - "tests/test_artifact_internal_authorization.py", - "tests/test_artifact_cleanup_wiring.py", - "tests/test_checker_materialization.py", - "tests/test_artifact_preparation.py", - "tests/test_artifact_store_conformance.py", - "tests/test_artifact_verification.py", - "tests/test_artifacts.py", - "tests/test_assertion_helpers.py", - "tests/test_aws_credential_isolation.py", - "tests/test_ci_test_lanes.py", - "tests/test_config.py", - "tests/test_compensation.py", - "tests/test_contributions.py", - "tests/test_coverage_contract.py", - "tests/test_external_service_adapters.py", - "tests/test_guide_artifacts.py", - "tests/test_guide_bindings.py", - "tests/test_guide_setup.py", - "tests/test_guide_formats.py", - "tests/test_guide_extraction.py", - "tests/test_guide_images.py", - "tests/test_guide_xlsx.py", - "tests/test_guide_docx.py", - "tests/test_guide_extractor_dependencies.py", - "tests/test_guide_ooxml.py", - "tests/test_guide_pdf.py", - "tests/test_guide_pptx.py", - "tests/test_local_artifact_store.py", - "tests/test_merge_test_lane_evidence.py", - "tests/test_mutation_policy.py", - "tests/test_s3_artifact_store.py", - "tests/test_submission_archive.py", - "tests/test_submission_change_gate.py", - "tests/test_submission_manifest.py", - "tests/test_test_lane_evidence.py", - "tests/test_actors.py", - "tests/test_api_rate_controls.py", - "tests/test_audit.py", - "tests/test_auth.py", - "tests/test_authorization.py", - "tests/test_behavior_ownership.py", - "tests/test_artifact_admission.py", - "tests/test_submission_bundle_admission.py", - "tests/test_artifact_operator_api.py", - "tests/test_artifact_recovery.py", - "tests/test_db_session.py", - "tests/test_outbox.py", - "tests/test_policy_identity_lineage.py", - "tests/test_project_policy_mutations.py", - "tests/test_review_authorization_contracts.py", - ), - ), + TestLane(PARTITIONED_SHARED_LANES[0], SHARED_FOUNDATION_MODULES), + TestLane(PARTITIONED_SHARED_LANES[1], SHARED_FOUNDATION_MODULES), TestLane( "schema_contracts_a", ( @@ -156,6 +164,7 @@ class TestLane: ), ), TestLane("schema_contracts_b", (PARTITIONED_SCHEMA_MODULE,)), + TestLane("schema_contracts_c", (PARTITIONED_SCHEMA_MODULE,)), TestLane( "project_lifecycle", ( @@ -182,6 +191,8 @@ class TestLane: ), ), ) + + @dataclass class ActiveLane: key: str @@ -325,9 +336,8 @@ def _restore_uuid4() -> None: except (OSError, ValueError): continue for name, value in tuple(vars(module).items()): - if ( - value is _deterministic_uuid4 - and not (module is sys.modules.get(__name__) and name == "_deterministic_uuid4") + if value is _deterministic_uuid4 and not ( + module is sys.modules.get(__name__) and name == "_deterministic_uuid4" ): setattr(module, name, original) _ORIGINAL_UUID4 = None @@ -352,7 +362,7 @@ def discover_test_modules(tests_dir: Path = TESTS_DIR, root: Path = ROOT) -> tup continue path = current / name if path.is_symlink() or not path.is_file(): - raise LaneError("symlinked_test_module") + raise LaneError("symlinked_test_module") try: modules.append(path.relative_to(root).as_posix()) except ValueError as exc: @@ -390,28 +400,32 @@ def validate_lane_inventory( declared = [module for lane in lanes for module in lane.modules] if any(not _safe_module_path(module) for module in declared): raise LaneError("invalid_lane_module") - duplicates = { - module: count for module, count in Counter(declared).items() if count != 1 - } - expected_duplicates = ( - {PARTITIONED_SCHEMA_MODULE: len(PARTITIONED_SCHEMA_LANES)} - if PARTITIONED_SCHEMA_MODULE in discovered - else {} - ) - if duplicates != expected_duplicates: - raise LaneError(f"duplicate_lane_modules:{','.join(duplicates)}") missing = sorted(set(discovered) - set(declared)) foreign = sorted(set(declared) - set(discovered)) if missing: raise LaneError(f"missing_lane_modules:{','.join(missing)}") if foreign: raise LaneError(f"foreign_lane_modules:{','.join(foreign)}") + duplicates = {module: count for module, count in Counter(declared).items() if count != 1} + expected_duplicates = { + module: len(PARTITIONED_SHARED_LANES) + for module in SHARED_FOUNDATION_MODULES + if module in discovered + } + if PARTITIONED_SCHEMA_MODULE in discovered: + expected_duplicates[PARTITIONED_SCHEMA_MODULE] = len(PARTITIONED_SCHEMA_LANES) + if duplicates != expected_duplicates: + raise LaneError(f"duplicate_lane_modules:{','.join(duplicates)}") def _tree_sha() -> str: value = subprocess.run( - ["git", "rev-parse", "HEAD"], cwd=ROOT, check=True, text=True, - stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ["git", "rev-parse", "HEAD"], + cwd=ROOT, + check=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, ).stdout.strip() if SHA_RE.fullmatch(value) is None: raise LaneError("invalid_tree_sha") @@ -423,7 +437,9 @@ def _read_nodes(path: Path, *, allow_empty: bool = False) -> list[str]: values = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: raise LaneError("invalid_node_evidence") from exc - if (not allow_empty and not values) or any(not isinstance(value, str) or "::" not in value for value in values): + if (not allow_empty and not values) or any( + not isinstance(value, str) or "::" not in value for value in values + ): raise LaneError("invalid_node_evidence") return values @@ -436,7 +452,14 @@ def _module_from_node(node_id: str) -> str: def _plugin_args() -> list[str]: - return ["-p", "pytest_asyncio.plugin", "-p", "pytest_cov.plugin", "-p", "scripts.run_test_lanes"] + return [ + "-p", + "pytest_asyncio.plugin", + "-p", + "pytest_cov.plugin", + "-p", + "scripts.run_test_lanes", + ] def _collection_environment( @@ -484,7 +507,9 @@ def collect_nodes( deselected_nodes = _read_nodes(deselected, allow_empty=True) if result.returncode == 0: expected = set(modules) - if len(nodes) != len(set(nodes)) or any(_module_from_node(node) not in expected for node in nodes): + if len(nodes) != len(set(nodes)) or any( + _module_from_node(node) not in expected for node in nodes + ): raise LaneError("invalid_collected_nodes") if set(_module_from_node(node) for node in nodes) != expected: raise LaneError("zero_collected_module") @@ -504,9 +529,14 @@ def build_manifest(tree_sha: str, nodes: list[str]) -> dict[str, Any]: def lane_for_node(node: str) -> str: module = _module_from_node(node) candidates = lanes_by_module[module] + partition_lanes: tuple[str, ...] | None = None if module == PARTITIONED_SCHEMA_MODULE: - if tuple(candidates) != PARTITIONED_SCHEMA_LANES: - raise LaneError("invalid_schema_partition_lanes") + partition_lanes = PARTITIONED_SCHEMA_LANES + elif module in SHARED_FOUNDATION_MODULES: + partition_lanes = PARTITIONED_SHARED_LANES + if partition_lanes is not None: + if tuple(candidates) != partition_lanes: + raise LaneError("invalid_partition_lanes") digest = hashlib.sha256(node.encode("utf-8")).digest() return candidates[digest[0] % len(candidates)] if len(candidates) != 1: @@ -575,28 +605,55 @@ def lane_command( tree_sha: str, ) -> list[str]: pytest_command = [ - sys.executable, "-m", "pytest", "-q", *_plugin_args(), "--cov=app", "--cov-report=", - "--durations=25", *nodes, + sys.executable, + "-m", + "pytest", + "-q", + *_plugin_args(), + "--cov=app", + "--cov-report=", + "--durations=25", + *nodes, ] return [ - sys.executable, str(ISOLATED_RUNNER), "--metadata-json", - str(metadata_dir / f"{lane.name}.database.json"), "--lane", lane.name, - "--tree-sha", tree_sha, "--timeout-seconds", f"{timeout_seconds:g}", - "--", *pytest_command, + sys.executable, + str(ISOLATED_RUNNER), + "--metadata-json", + str(metadata_dir / f"{lane.name}.database.json"), + "--lane", + lane.name, + "--tree-sha", + tree_sha, + "--timeout-seconds", + f"{timeout_seconds:g}", + "--", + *pytest_command, ] def admin_runner_command(nodes: list[str]) -> list[str]: """Run isolation-runner self-tests directly, never inside an owned database.""" pytest_command = [ - sys.executable, "-m", "pytest", "-q", *_plugin_args(), "--cov=app", - "--cov-report=", "--durations=25", *nodes, + sys.executable, + "-m", + "pytest", + "-q", + *_plugin_args(), + "--cov=app", + "--cov-report=", + "--durations=25", + *nodes, ] return [sys.executable, "-c", ADMIN_REDACTING_WRAPPER, *pytest_command] def _prepare_outputs(metadata_dir: Path, summary_json: Path) -> None: - if metadata_dir.exists() or metadata_dir.is_symlink() or summary_json.exists() or summary_json.is_symlink(): + if ( + metadata_dir.exists() + or metadata_dir.is_symlink() + or summary_json.exists() + or summary_json.is_symlink() + ): raise LaneError("invalid_lane_outputs") if not metadata_dir.parent.is_dir() or not summary_json.parent.is_dir(): raise LaneError("invalid_lane_outputs") @@ -630,14 +687,20 @@ def _print_redacted_exception(exc: BaseException) -> None: def _finish_unit(active: ActiveLane, exit_code: int, elapsed: float) -> dict[str, Any]: active.log.flush() active.log.close() - collected = _read_nodes(active.evidence_path.with_name(f"{active.key}.collected.jsonl"), allow_empty=True) - completed = _read_nodes(active.evidence_path.with_name(f"{active.key}.completed.jsonl"), allow_empty=True) - skipped = _read_nodes(active.evidence_path.with_name(f"{active.key}.skipped.jsonl"), allow_empty=True) - deselected = _read_nodes(active.evidence_path.with_name(f"{active.key}.deselected.jsonl"), allow_empty=True) + collected = _read_nodes( + active.evidence_path.with_name(f"{active.key}.collected.jsonl"), allow_empty=True + ) + completed = _read_nodes( + active.evidence_path.with_name(f"{active.key}.completed.jsonl"), allow_empty=True + ) + skipped = _read_nodes( + active.evidence_path.with_name(f"{active.key}.skipped.jsonl"), allow_empty=True + ) + deselected = _read_nodes( + active.evidence_path.with_name(f"{active.key}.deselected.jsonl"), allow_empty=True + ) return { - "collection_exit_code": 0 - if sorted(collected) == sorted(active.expected_nodes) - else 1, + "collection_exit_code": 0 if sorted(collected) == sorted(active.expected_nodes) else 1, "collected_nodes": collected, "completed_nodes": completed, "coverage_path": active.coverage_path, @@ -658,9 +721,17 @@ def _combine_coverage(sources: list[Path], destination: Path) -> None: shutil.copyfile(regular[0], destination) return result = subprocess.run( - [sys.executable, "-m", "coverage", "combine", "--data-file", str(destination), - *(str(path) for path in regular)], - cwd=ROOT, check=False, + [ + sys.executable, + "-m", + "coverage", + "combine", + "--data-file", + str(destination), + *(str(path) for path in regular), + ], + cwd=ROOT, + check=False, ) if result.returncode != 0: raise LaneError("lane_coverage_combine_failed") @@ -672,13 +743,9 @@ def _finalize_lane( evidence_path = metadata_dir / f"{lane.name}.json" isolation_path = metadata_dir / f"{lane.name}.database.json" coverage_path = metadata_dir / f".coverage.{lane.name}" - execution_exit_code = _aggregate_exit_codes( - [unit["execution_exit_code"] for unit in units] - ) + execution_exit_code = _aggregate_exit_codes([unit["execution_exit_code"] for unit in units]) ordinary_coverage = [ - unit["coverage_path"] - for unit in units - if unit["execution_kind"] == ORDINARY_KIND + unit["coverage_path"] for unit in units if unit["execution_kind"] == ORDINARY_KIND ] if execution_exit_code == 0 and not ordinary_coverage: raise LaneError("missing_ordinary_lane_coverage") @@ -700,7 +767,9 @@ def _finalize_lane( evidence = { "collected_nodes": sorted(node for unit in units for node in unit["collected_nodes"]), "completed_nodes": sorted(node for unit in units for node in unit["completed_nodes"]), - "deselected_nodes": sorted(set(node for unit in units for node in unit["deselected_nodes"])), + "deselected_nodes": sorted( + set(node for unit in units for node in unit["deselected_nodes"]) + ), "isolation_metadata_file": isolation_path.name if isolation_is_regular else None, "isolation_metadata_sha256": ( _sha256(isolation_path.read_bytes()) if isolation_is_regular else None @@ -719,7 +788,8 @@ def _finalize_lane( ), "coverage_file": coverage_path.name, "coverage_sha256": _sha256(coverage_path.read_bytes()) - if coverage_path.is_file() and not coverage_path.is_symlink() else None, + if coverage_path.is_file() and not coverage_path.is_symlink() + else None, "elapsed_seconds": round(max(unit["elapsed_seconds"] for unit in units), 3), "evidence_file": evidence_path.name, "evidence_sha256": _sha256(evidence_path.read_bytes()), @@ -742,9 +812,7 @@ def _timing_summary(lanes: list[dict[str, Any]]) -> dict[str, float]: raise LaneError("invalid_lane_timing_inventory") elapsed = [row["elapsed_seconds"] for row in lanes] if any( - not isinstance(value, (int, float)) - or isinstance(value, bool) - or value < 0 + not isinstance(value, (int, float)) or isinstance(value, bool) or value < 0 for value in elapsed ): raise LaneError("invalid_lane_timing") @@ -785,21 +853,42 @@ def run_lanes( for lane in LANES: lane_nodes = [row["nodeid"] for row in manifest["nodes"] if row["lane"] == lane.name] evidence_path = metadata_dir / f"{lane.name}.json" - evidence_path.write_bytes(_json_bytes({ - "collected_nodes": lane_nodes, "completed_nodes": [], "deselected_nodes": [], - "isolation_metadata_file": None, "isolation_metadata_sha256": None, - "skipped_nodes": [], - })) - lane_rows.append({ - "collection_exit_code": 0, "coverage_file": None, "coverage_sha256": None, - "elapsed_seconds": 0.0, "evidence_file": evidence_path.name, - "evidence_sha256": _sha256(evidence_path.read_bytes()), "execution_exit_code": None, - "interrupted": False, "name": lane.name, - }) - summary = {"canonical_node_count": len(nodes), "elapsed_seconds": 0.0, - "head_sha": tree_sha, "lanes": lane_rows, "manifest_file": manifest_path.name, - "manifest_sha256": manifest_digest, "mode": "collect", "schema_version": SCHEMA_VERSION, - **_timing_summary(lane_rows)} + evidence_path.write_bytes( + _json_bytes( + { + "collected_nodes": lane_nodes, + "completed_nodes": [], + "deselected_nodes": [], + "isolation_metadata_file": None, + "isolation_metadata_sha256": None, + "skipped_nodes": [], + } + ) + ) + lane_rows.append( + { + "collection_exit_code": 0, + "coverage_file": None, + "coverage_sha256": None, + "elapsed_seconds": 0.0, + "evidence_file": evidence_path.name, + "evidence_sha256": _sha256(evidence_path.read_bytes()), + "execution_exit_code": None, + "interrupted": False, + "name": lane.name, + } + ) + summary = { + "canonical_node_count": len(nodes), + "elapsed_seconds": 0.0, + "head_sha": tree_sha, + "lanes": lane_rows, + "manifest_file": manifest_path.name, + "manifest_sha256": manifest_digest, + "mode": "collect", + "schema_version": SCHEMA_VERSION, + **_timing_summary(lane_rows), + } summary_json.write_bytes(_json_bytes(summary)) return 0 execution_lanes = ( @@ -813,9 +902,7 @@ def run_lanes( raise LaneError("missing_admin_database_url") active: dict[str, ActiveLane] = {} - unit_results: dict[str, list[dict[str, Any]]] = { - lane.name: [] for lane in execution_lanes - } + unit_results: dict[str, list[dict[str, Any]]] = {lane.name: [] for lane in execution_lanes} started = time.monotonic() stopping = False run_error: BaseException | None = None @@ -839,20 +926,31 @@ def run_lanes( coverage_path = metadata_dir / f".coverage.unit.{key}" if kind == ADMIN_KIND: command = admin_runner_command(unit_nodes) - env = admin_runner_environment( - lane, metadata_dir, coverage_path, key, tree_sha - ) + env = admin_runner_environment(lane, metadata_dir, coverage_path, key, tree_sha) else: - command = lane_command(lane, unit_nodes, metadata_dir, timeout_seconds, tree_sha) + command = lane_command( + lane, unit_nodes, metadata_dir, timeout_seconds, tree_sha + ) env = lane_environment(lane, metadata_dir, coverage_path, key, tree_sha) process = subprocess.Popen( - command, cwd=ROOT, env=env, stdout=log, stderr=subprocess.STDOUT, + command, + cwd=ROOT, + env=env, + stdout=log, + stderr=subprocess.STDOUT, start_new_session=True, ) active[key] = ActiveLane( - key, lane, kind, tuple(unit_nodes), process, log, log_path, + key, + lane, + kind, + tuple(unit_nodes), + process, + log, + log_path, metadata_dir / f"{key}.json", - coverage_path, time.monotonic(), + coverage_path, + time.monotonic(), ) while active: now = time.monotonic() @@ -867,7 +965,10 @@ def run_lanes( item.timed_out = True item.interrupted_at = now _signal_lane(item, signal.SIGINT) - elif item.interrupted_at is not None and now - item.interrupted_at >= CLEANUP_GRACE_SECONDS: + elif ( + item.interrupted_at is not None + and now - item.interrupted_at >= CLEANUP_GRACE_SECONDS + ): _signal_lane(item, signal.SIGKILL) code = item.process.poll() if code is None: @@ -891,9 +992,7 @@ def run_lanes( item.interrupted_at = now _signal_lane(item, signal.SIGKILL) code = item.process.wait() - unit_results[item.lane.name].append( - _finish_unit(item, code, now - item.started_at) - ) + unit_results[item.lane.name].append(_finish_unit(item, code, now - item.started_at)) del active[key] signal.signal(signal.SIGINT, old_int) signal.signal(signal.SIGTERM, old_term) @@ -901,28 +1000,32 @@ def run_lanes( for lane in execution_lanes: if unit_results[lane.name]: continue - unit_results[lane.name].append({ - "collection_exit_code": 1, - "collected_nodes": [], - "completed_nodes": [], - "coverage_path": metadata_dir / f".coverage.unit.{lane.name}", - "deselected_nodes": [], - "elapsed_seconds": 0.0, - "execution_kind": ORDINARY_KIND, - "execution_exit_code": 1, - "interrupted": True, - "skipped_nodes": [], - }) + unit_results[lane.name].append( + { + "collection_exit_code": 1, + "collected_nodes": [], + "completed_nodes": [], + "coverage_path": metadata_dir / f".coverage.unit.{lane.name}", + "deselected_nodes": [], + "elapsed_seconds": 0.0, + "execution_kind": ORDINARY_KIND, + "execution_exit_code": 1, + "interrupted": True, + "skipped_nodes": [], + } + ) results = { lane.name: _finalize_lane(lane, unit_results[lane.name], metadata_dir) for lane in execution_lanes if unit_results[lane.name] } summary = { - "canonical_node_count": len(nodes), "elapsed_seconds": round(time.monotonic() - started, 3), + "canonical_node_count": len(nodes), + "elapsed_seconds": round(time.monotonic() - started, 3), "head_sha": tree_sha, "lanes": [results[lane.name] for lane in execution_lanes if lane.name in results], - "manifest_file": manifest_path.name, "manifest_sha256": manifest_digest, + "manifest_file": manifest_path.name, + "manifest_sha256": manifest_digest, "mode": "run" if selected_lane is None else "lane", "schema_version": SCHEMA_VERSION, } @@ -935,11 +1038,15 @@ def run_lanes( slowest_lane_seconds=elapsed, ) summary_json.write_bytes(_json_bytes(summary)) - return 0 if ( - run_error is None - and len(results) == len(execution_lanes) - and all(row["execution_exit_code"] == 0 for row in results.values()) - ) else 1 + return ( + 0 + if ( + run_error is None + and len(results) == len(execution_lanes) + and all(row["execution_exit_code"] == 0 for row in results.values()) + ) + else 1 + ) def main() -> int: diff --git a/backend/scripts/validate_test_lane_evidence.py b/backend/scripts/validate_test_lane_evidence.py index f7daae90c..b773cbb74 100644 --- a/backend/scripts/validate_test_lane_evidence.py +++ b/backend/scripts/validate_test_lane_evidence.py @@ -20,11 +20,13 @@ SCHEMA_VERSION = 1 -LANE_COUNT = 5 +LANE_COUNT = 7 SHA_RE = re.compile(r"^[0-9a-f]{40}$") DIGEST_RE = re.compile(r"^[0-9a-f]{64}$") LANE_RE = re.compile(r"^[a-z][a-z0-9_]*$") ADMIN_RUNNER_MODULE = "tests/test_isolated_database_runner.py" +SHARED_S3_LANES = frozenset(("shared_foundations_a", "shared_foundations_b")) +SHARED_S3_BUCKET = "workstream-artifacts" ORDINARY_KIND = "ordinary_isolated" ADMIN_KIND = "admin_runner_self_test" DATABASE_IDENTIFIER_RE = re.compile(r"^[a-z][a-z0-9_]*$") @@ -416,7 +418,7 @@ def validate_evidence( all_collected: list[str] = [] all_completed: list[str] = [] - isolation_namespaces: list[tuple[str, str, str, str]] = [] + isolation_namespaces: list[tuple[str, str, str, str, str]] = [] coverage_files: list[str] = [] isolation_files: list[str] = [] lane_elapsed_seconds: list[float] = [] @@ -564,7 +566,13 @@ def validate_evidence( or ".." in PurePosixPath(isolation["minio_prefix"]).parts ): raise EvidenceError("invalid_isolation_metadata") - isolation_namespaces.append(tuple(isolation[field] for field in namespace_fields)) + if (name in SHARED_S3_LANES) != ( + isolation["minio_bucket"] == SHARED_S3_BUCKET + ): + raise EvidenceError("invalid_isolation_metadata") + isolation_namespaces.append( + (name, *(isolation[field] for field in namespace_fields)) + ) all_collected.extend(collected) all_completed.extend(completed) @@ -572,11 +580,33 @@ def validate_evidence( raise EvidenceError("global_collection_reconciliation_failed") if mode == "run" and Counter(all_completed) != Counter(canonical_ids): raise EvidenceError("global_completion_reconciliation_failed") - if mode == "run" and any( - len({namespace[index] for namespace in isolation_namespaces}) != LANE_COUNT - for index in range(4) - ): - raise EvidenceError("shared_isolation_namespace") + if mode == "run": + database_names = {namespace[1] for namespace in isolation_namespaces} + database_roles = {namespace[2] for namespace in isolation_namespaces} + minio_prefixes = {namespace[4] for namespace in isolation_namespaces} + bucket_owners: dict[str, set[str]] = {} + for lane_name, _database, _role, bucket, _prefix in isolation_namespaces: + bucket_owners.setdefault(bucket, set()).add(lane_name) + expected_bucket_owners = { + SHARED_S3_BUCKET: set(SHARED_S3_LANES), + **{ + bucket: owners + for bucket, owners in bucket_owners.items() + if bucket != SHARED_S3_BUCKET + }, + } + if ( + len(database_names) != LANE_COUNT + or len(database_roles) != LANE_COUNT + or len(minio_prefixes) != LANE_COUNT + or bucket_owners != expected_bucket_owners + or any( + len(owners) != 1 + for bucket, owners in bucket_owners.items() + if bucket != SHARED_S3_BUCKET + ) + ): + raise EvidenceError("shared_isolation_namespace") if mode == "run" and ( len(set(coverage_files)) != LANE_COUNT or len(set(isolation_files)) != LANE_COUNT ): diff --git a/backend/tests/authorization/__init__.py b/backend/tests/authorization/__init__.py new file mode 100644 index 000000000..3f49651b0 --- /dev/null +++ b/backend/tests/authorization/__init__.py @@ -0,0 +1 @@ +"""Focused authorization capability tests.""" diff --git a/backend/tests/authorization/guide_compilation/__init__.py b/backend/tests/authorization/guide_compilation/__init__.py new file mode 100644 index 000000000..d088222a2 --- /dev/null +++ b/backend/tests/authorization/guide_compilation/__init__.py @@ -0,0 +1 @@ +"""Unified guide-compilation authorization tests.""" diff --git a/backend/tests/authorization/guide_compilation/test_adapter_contract.py b/backend/tests/authorization/guide_compilation/test_adapter_contract.py new file mode 100644 index 000000000..52bf40a40 --- /dev/null +++ b/backend/tests/authorization/guide_compilation/test_adapter_contract.py @@ -0,0 +1,496 @@ +"""Focused behavior proof for the unified compilation AUTH adapter.""" + +from __future__ import annotations + +from dataclasses import asdict +from types import SimpleNamespace +from uuid import uuid4 + +import pytest + +from app.modules.authorization.api import ( + ActorIdentityFacts, + ActorKind as PublicActorKind, + AuthorizationDenied as BoundaryAuthorizationDenied, + PreparedAuthorizationInvalid, + ProjectGuideCompilationExecutePersistFacts, + ProjectGuideCompilationExecutePreflightFacts, + ProjectGuideCompilationRequestFacts, + project_guide_compilation_execute_resource_digest, + project_guide_compilation_facts_digest, +) +from app.modules.authorization.catalogue import ActionId, PermissionId +from app.modules.authorization.kernel import AuthorizationService +from app.modules.authorization.prepared import PreparedAuthorizationService +from app.modules.authorization.repository import AdminAuthorizationRepository +from app.modules.authorization.guide_compilation import ( + ProjectGuideCompilationAuthorizationAdapter, +) +from app.modules.authorization.runtime import ( + ActorKind, + ActorStatus, + HumanAuthorizationContext, + IdentityLinkStatus, + ServiceAuthorizationContext, +) +from app.modules.actors.service_identities import ServiceIdentity +from app.modules.audit.schemas import AuthorityAuditEventInput + + +def _request() -> ProjectGuideCompilationRequestFacts: + digest = "sha256:" + "a" * 64 + return ProjectGuideCompilationRequestFacts( + project_id=uuid4(), + guide_id=uuid4(), + guide_version="v1", + source_snapshot_id=uuid4(), + source_snapshot_hash=digest, + canonical_input_hash=digest, + guide_material_hash=digest, + setup_run_id=uuid4(), + setup_generation=1, + operation_id=uuid4(), + request_id=uuid4(), + idempotency_key=uuid4(), + pre_catalogue_id="pre", + pre_catalogue_version="v1", + pre_catalogue_schema_version="v1", + pre_catalogue_manifest_hash=digest, + post_catalogue_id="post", + post_catalogue_version="v1", + post_catalogue_schema_version="v1", + post_catalogue_manifest_hash=digest, + agent_identity="agent", + agent_version="v1", + instruction_version="v1", + ) + + +def _actor() -> ActorIdentityFacts: + return ActorIdentityFacts(uuid4(), uuid4(), PublicActorKind.HUMAN) + + +class _Prepared: + def __init__(self) -> None: + self.calls: list[tuple] = [] + self.handle = object() + self.event_id = uuid4() + + async def prepare(self, *args): + self.calls.append(("prepare", *args)) + return self.handle + + async def preflight(self, *args): + self.calls.append(("preflight", *args)) + + async def consume(self, *args): + self.calls.append(("consume", *args)) + return SimpleNamespace(decision_id=self.event_id) + + +class _Session: + def __init__(self) -> None: + self.root = SimpleNamespace(is_active=True) + self.sync_session = self + + def get_transaction(self): + return self.root + + def in_nested_transaction(self) -> bool: + return False + + +class _Repository: + def __init__(self, *, link_status: str = "active") -> None: + self.link_status = link_status + + async def lock_request_actor(self, identity_link_id, actor_profile_id): + return ( + SimpleNamespace( + id=str(identity_link_id), + actor_profile_id=str(actor_profile_id), + status=self.link_status, + ), + SimpleNamespace( + id=str(actor_profile_id), + actor_kind="service", + status="active", + service_identity=ServiceIdentity.PROJECT_SETUP.value, + ), + ) + + +class _Evidence: + def __init__(self) -> None: + self.events: list[AuthorityAuditEventInput] = [] + + async def add_authority_event(self, event: AuthorityAuditEventInput) -> None: + self.events.append(event) + + +def _adapter( + actor: ActorIdentityFacts, +) -> tuple[ProjectGuideCompilationAuthorizationAdapter, _Prepared]: + context = HumanAuthorizationContext( + actor_profile_id=actor.actor_profile_id, + actor_kind=ActorKind.HUMAN, + actor_status=ActorStatus.ACTIVE, + identity_link_id=actor.identity_link_id, + identity_link_status=IdentityLinkStatus.ACTIVE, + request_id=uuid4(), + correlation_id=uuid4(), + ) + prepared = _Prepared() + authorization = SimpleNamespace(_context=context) + prepared._authorization = authorization + return ProjectGuideCompilationAuthorizationAdapter(authorization, prepared), prepared + + +def _runtime_context_for(actor_kind: ActorKind, service_identity: ServiceIdentity | None): + common = dict( + actor_profile_id=uuid4(), actor_status=ActorStatus.ACTIVE, + identity_link_id=uuid4(), identity_link_status=IdentityLinkStatus.ACTIVE, + request_id=uuid4(), correlation_id=uuid4(), + ) + if actor_kind is ActorKind.SERVICE: + return ServiceAuthorizationContext( + actor_kind=actor_kind, service_identity=service_identity, **common + ) + return HumanAuthorizationContext(actor_kind=actor_kind, **common) + + +@pytest.mark.asyncio +async def test_request_prepare_and_consume_bind_the_exact_project_context() -> None: + actor, facts = _actor(), _request() + adapter, prepared = _adapter(actor) + handle = await adapter.prepare_request(actor=actor, facts=facts) + event_id = await adapter.consume_request(handle=handle, actor=actor, facts=facts) + assert handle is prepared.handle + assert event_id == prepared.event_id + assert [call[1] for call in prepared.calls] == [ + ActionId.PROJECT_GUIDE_COMPILATION_REQUEST, + prepared.handle, + ] + resource = prepared.calls[0][2].request_value + assert resource["resource_id"] == str(facts.operation_id) + assert resource["scope_project_id"] == str(facts.project_id) + + +@pytest.mark.asyncio +async def test_preflight_is_non_durable_and_final_digest_is_exact() -> None: + actor, base = _actor(), _request() + adapter, prepared = _adapter(actor) + preflight = ProjectGuideCompilationExecutePreflightFacts( + **asdict(base), attempt_id=uuid4(), provider_idempotency_key=uuid4() + ) + await adapter.authorize_execute_preflight(actor=actor, facts=preflight) + assert [call[0] for call in prepared.calls] == ["preflight"] + preflight_input, preflight_resource = prepared.calls[0][2], prepared.calls[0][4] + assert preflight_input.request_value == preflight_resource.model_dump(mode="json") + assert preflight_resource.attempt_id == preflight.attempt_id + assert preflight_resource.provider_idempotency_key == preflight.provider_idempotency_key + assert preflight_resource.request_facts_digest == project_guide_compilation_facts_digest( + preflight + ) + digest = "sha256:" + "b" * 64 + values = { + **asdict(preflight), + **{ + "result_hash": digest, + "sufficiency_component_hash": digest, + "artifact_policy_component_hash": digest, + "requirement_inventory_component_hash": digest, + "pre_submit_policy_component_hash": digest, + "post_submit_policy_component_hash": digest, + "capability_suggestions_component_hash": digest, + "setup_notes_component_hash": digest, + }, + } + provisional = ProjectGuideCompilationExecutePersistFacts( + **values, resource_context_digest=digest + ) + exact = project_guide_compilation_execute_resource_digest(actor, provisional) + persist = ProjectGuideCompilationExecutePersistFacts(**values, resource_context_digest=exact) + prepared.calls.clear() + await adapter.prepare_execute_persist(actor=actor, facts=persist) + assert prepared.calls[0][2].request_value["result_resource_digest"] == exact + + wrong = ProjectGuideCompilationExecutePersistFacts( + **values, resource_context_digest="sha256:" + "c" * 64 + ) + prepared.calls.clear() + with pytest.raises(BoundaryAuthorizationDenied): + await adapter.prepare_execute_persist(actor=actor, facts=wrong) + assert prepared.calls == [] + + +@pytest.mark.asyncio +async def test_actor_mismatch_denies_before_prepared_service_access() -> None: + actor, facts = _actor(), _request() + adapter, prepared = _adapter(actor) + wrong = ActorIdentityFacts(uuid4(), actor.identity_link_id, PublicActorKind.HUMAN) + with pytest.raises(BoundaryAuthorizationDenied): + await adapter.prepare_request(actor=wrong, facts=facts) + assert prepared.calls == [] + + +@pytest.mark.asyncio +async def test_real_kernel_system_project_manager_grant_cannot_request_compilation() -> None: + context = HumanAuthorizationContext( + actor_profile_id=uuid4(), + actor_kind=ActorKind.HUMAN, + actor_status=ActorStatus.ACTIVE, + identity_link_id=uuid4(), + identity_link_status=IdentityLinkStatus.ACTIVE, + request_id=uuid4(), + correlation_id=uuid4(), + ) + session = _Session() + + class SystemGrantRepository: + async def lock_request_actor(self, identity_link_id, actor_profile_id): + return ( + SimpleNamespace( + id=str(identity_link_id), + actor_profile_id=str(actor_profile_id), + status="active", + ), + SimpleNamespace(id=str(actor_profile_id), actor_kind="human", status="active"), + ) + + async def find_effective_grant(self, *_args, **kwargs): + assert kwargs["exact_project_scope"] is True + return SimpleNamespace( + id=uuid4(), status="active", scope_type="system", scope_project_id=None + ) + + repository = SystemGrantRepository() + authorization = AuthorizationService( + session, + context, + admin_repository=repository, # type: ignore[arg-type] + ) + prepared = PreparedAuthorizationService( + session, + context, + authorization, + repository, # type: ignore[arg-type] + ) + adapter = ProjectGuideCompilationAuthorizationAdapter(authorization, prepared) + actor = ActorIdentityFacts( + context.actor_profile_id, context.identity_link_id, PublicActorKind.HUMAN + ) + with pytest.raises(BoundaryAuthorizationDenied): + await adapter.prepare_request(actor=actor, facts=_request()) + + +@pytest.mark.asyncio +async def test_real_kernel_exact_project_manager_request_succeeds_and_replay_denies() -> None: + context = HumanAuthorizationContext( + actor_profile_id=uuid4(), actor_kind=ActorKind.HUMAN, + actor_status=ActorStatus.ACTIVE, identity_link_id=uuid4(), + identity_link_status=IdentityLinkStatus.ACTIVE, + request_id=uuid4(), correlation_id=uuid4(), + ) + session, grant_id = _Session(), uuid4() + + class ExactGrantRepository: + async def lock_request_actor(self, identity_link_id, actor_profile_id): + return ( + SimpleNamespace(id=str(identity_link_id), actor_profile_id=str(actor_profile_id), status="active"), + SimpleNamespace(id=str(actor_profile_id), actor_kind="human", status="active"), + ) + + async def find_effective_grant(self, *_args, **kwargs): + project_id = kwargs["scope_project_id"] + assert kwargs["exact_project_scope"] is True + return SimpleNamespace( + id=grant_id, status="active", scope_type="project", + scope_project_id=str(project_id), + ) + + repository = ExactGrantRepository() + authorization = AuthorizationService( + session, context, admin_repository=repository # type: ignore[arg-type] + ) + evidence = _Evidence() + authorization._audit = evidence # type: ignore[assignment] + prepared = PreparedAuthorizationService( + session, context, authorization, repository # type: ignore[arg-type] + ) + adapter = ProjectGuideCompilationAuthorizationAdapter(authorization, prepared) + actor = ActorIdentityFacts( + context.actor_profile_id, context.identity_link_id, PublicActorKind.HUMAN + ) + facts = _request() + handle = await adapter.prepare_request(actor=actor, facts=facts) + event_id = await adapter.consume_request(handle=handle, actor=actor, facts=facts) + assert [event.event_id for event in evidence.events] == [event_id] + with pytest.raises(PreparedAuthorizationInvalid): + await adapter.consume_request(handle=handle, actor=actor, facts=facts) + + +@pytest.mark.parametrize( + ("actor_kind", "service_identity", "method"), + [ + (ActorKind.HUMAN, None, "execute"), + (ActorKind.SERVICE, ServiceIdentity.PROJECT_SETUP, "request"), + (ActorKind.SERVICE, ServiceIdentity.ARTIFACT_BINDING, "execute"), + ], +) +@pytest.mark.asyncio +async def test_real_kernel_actor_matrix_denies_without_evidence( + actor_kind, service_identity, method +) -> None: + context = _runtime_context_for(actor_kind, service_identity) + session, repository = _Session(), _Repository() + authorization = AuthorizationService( + session, context, admin_repository=repository # type: ignore[arg-type] + ) + evidence = _Evidence() + authorization._audit = evidence # type: ignore[assignment] + prepared = PreparedAuthorizationService( + session, context, authorization, repository # type: ignore[arg-type] + ) + adapter = ProjectGuideCompilationAuthorizationAdapter(authorization, prepared) + public_kind = PublicActorKind(actor_kind.value) + actor = ActorIdentityFacts( + context.actor_profile_id, context.identity_link_id, public_kind, + service_identity.value if service_identity else None, + ) + with pytest.raises(BoundaryAuthorizationDenied): + if method == "request": + await adapter.prepare_request(actor=actor, facts=_request()) + else: + await adapter.authorize_execute_preflight( + actor=actor, + facts=ProjectGuideCompilationExecutePreflightFacts( + **asdict(_request()), attempt_id=uuid4(), provider_idempotency_key=uuid4() + ), + ) + assert evidence.events == [] + + +@pytest.mark.asyncio +async def test_real_kernel_preflight_is_non_evidencing_and_final_is_single_use() -> None: + context = ServiceAuthorizationContext( + actor_profile_id=uuid4(), + actor_kind=ActorKind.SERVICE, + actor_status=ActorStatus.ACTIVE, + identity_link_id=uuid4(), + identity_link_status=IdentityLinkStatus.ACTIVE, + service_identity=ServiceIdentity.PROJECT_SETUP, + request_id=uuid4(), + correlation_id=uuid4(), + ) + session, repository = _Session(), _Repository() + authorization = AuthorizationService( + session, + context, + admin_repository=repository, # type: ignore[arg-type] + ) + evidence = _Evidence() + authorization._audit = evidence # type: ignore[assignment] + prepared = PreparedAuthorizationService( + session, + context, + authorization, + repository, # type: ignore[arg-type] + ) + adapter = ProjectGuideCompilationAuthorizationAdapter(authorization, prepared) + actor = ActorIdentityFacts( + context.actor_profile_id, + context.identity_link_id, + PublicActorKind.SERVICE, + ServiceIdentity.PROJECT_SETUP.value, + ) + base = _request() + preflight = ProjectGuideCompilationExecutePreflightFacts( + **asdict(base), attempt_id=uuid4(), provider_idempotency_key=uuid4() + ) + await adapter.authorize_execute_preflight(actor=actor, facts=preflight) + await adapter.authorize_execute_preflight(actor=actor, facts=preflight) + assert evidence.events == [] + assert prepared._issued == {} + + digest = "sha256:" + "b" * 64 + result = dict( + result_hash=digest, + sufficiency_component_hash=digest, + artifact_policy_component_hash=digest, + requirement_inventory_component_hash=digest, + pre_submit_policy_component_hash=digest, + post_submit_policy_component_hash=digest, + capability_suggestions_component_hash=digest, + setup_notes_component_hash=digest, + ) + provisional = ProjectGuideCompilationExecutePersistFacts( + **asdict(preflight), **result, resource_context_digest=digest + ) + facts = ProjectGuideCompilationExecutePersistFacts( + **asdict(preflight), + **result, + resource_context_digest=project_guide_compilation_execute_resource_digest( + actor, provisional + ), + ) + handle = await adapter.prepare_execute_persist(actor=actor, facts=facts) + event_id = await adapter.consume_execute_persist(handle=handle, actor=actor, facts=facts) + assert [event.event_id for event in evidence.events] == [event_id] + with pytest.raises(PreparedAuthorizationInvalid): + await adapter.consume_execute_persist(handle=handle, actor=actor, facts=facts) + assert [event.event_id for event in evidence.events] == [event_id] + + +@pytest.mark.asyncio +async def test_real_kernel_revoked_service_preflight_denies_without_evidence() -> None: + context = ServiceAuthorizationContext( + actor_profile_id=uuid4(), + actor_kind=ActorKind.SERVICE, + actor_status=ActorStatus.ACTIVE, + identity_link_id=uuid4(), + identity_link_status=IdentityLinkStatus.ACTIVE, + service_identity=ServiceIdentity.PROJECT_SETUP, + request_id=uuid4(), + correlation_id=uuid4(), + ) + session, repository = _Session(), _Repository(link_status="revoked") + authorization = AuthorizationService( + session, + context, + admin_repository=repository, # type: ignore[arg-type] + ) + evidence = _Evidence() + authorization._audit = evidence # type: ignore[assignment] + prepared = PreparedAuthorizationService( + session, + context, + authorization, + repository, # type: ignore[arg-type] + ) + adapter = ProjectGuideCompilationAuthorizationAdapter(authorization, prepared) + actor = ActorIdentityFacts( + context.actor_profile_id, + context.identity_link_id, + PublicActorKind.SERVICE, + ServiceIdentity.PROJECT_SETUP.value, + ) + preflight = ProjectGuideCompilationExecutePreflightFacts( + **asdict(_request()), attempt_id=uuid4(), provider_idempotency_key=uuid4() + ) + with pytest.raises(BoundaryAuthorizationDenied): + await adapter.authorize_execute_preflight(actor=actor, facts=preflight) + assert evidence.events == [] + + +@pytest.mark.asyncio +async def test_exact_project_repository_scope_requires_a_project_identifier() -> None: + """Never fall back to a system grant for an incomplete exact-project query.""" + repository = AdminAuthorizationRepository(SimpleNamespace()) # type: ignore[arg-type] + with pytest.raises(ValueError, match="exact project scope requires"): + await repository.find_effective_grant( + uuid4(), + PermissionId.PROJECT_GUIDE_COMPILATION_REQUEST, + scope_project_id=None, + exact_project_scope=True, + ) diff --git a/backend/tests/authorization/guide_compilation/test_domain_contract.py b/backend/tests/authorization/guide_compilation/test_domain_contract.py new file mode 100644 index 000000000..e58317bb6 --- /dev/null +++ b/backend/tests/authorization/guide_compilation/test_domain_contract.py @@ -0,0 +1,153 @@ +"""Focused domain proof for compilation resource and PREP guards.""" + +from dataclasses import asdict +from uuid import uuid4 + +import pytest +from pydantic import ValidationError + +from app.modules.authorization.catalogue import ( + ActionId, + SERVICE_ACTIONS_BY_IDENTITY, + ServiceIdentity, + _index_service_actions, +) +from app.modules.authorization.api import project_guide_compilation_request_resource_digest +from app.modules.authorization.domain.guide_compilation import ( + ProjectGuideCompilationExecuteResourceContext, + ProjectGuideCompilationRequestResourceContext, + persisted_result_digest, + request_authority_digest, +) +from app.modules.authorization.domain.prepared_compilation import ( + parse_prepared_compilation, + prepared_compilation_matches, +) +from app.modules.authorization.domain.prepared_service import ( + is_project_setup_scope, + project_setup_resource_matches, +) +from app.modules.authorization.guide_compilation import _execute_context, _request_context +from app.modules.authorization.runtime import ( + PreparedAuthorityScope, + PreparedAuthorityScopeKind, + PreparedAuthorizationHandleInvalid, + SystemResourceContext, +) + +from .test_adapter_contract import _actor, _request + + +def test_fixed_service_matrix_rejects_an_empty_identity_row() -> None: + rows = dict(SERVICE_ACTIONS_BY_IDENTITY) + rows[ServiceIdentity.PROJECT_SETUP] = frozenset() + + with pytest.raises(RuntimeError, match="service action matrix row mismatch"): + _index_service_actions(rows) + + +def test_request_context_rejects_an_operation_selector_mismatch() -> None: + resource = _request_context(_request()) + with pytest.raises(ValidationError, match="must match operation"): + ProjectGuideCompilationRequestResourceContext( + **{**resource.model_dump(), "resource_id": uuid4()} + ) + + +def test_execute_context_requires_phase_appropriate_result_digest() -> None: + request = _request() + values = {**asdict(request), "attempt_id": uuid4(), "provider_idempotency_key": uuid4()} + from app.modules.authorization.api import ProjectGuideCompilationExecutePreflightFacts + + resource = _execute_context(ProjectGuideCompilationExecutePreflightFacts(**values), phase="preflight") + with pytest.raises(ValidationError, match="requires exact result digest"): + ProjectGuideCompilationExecuteResourceContext( + **{**resource.model_dump(), "result_resource_digest": "sha256:" + "a" * 64} + ) + + +def test_request_digest_requires_a_grant_and_binds_it() -> None: + actor, facts = _actor(), _request() + resource = _request_context(facts) + assert request_authority_digest( + resource, + actor_profile_id=actor.actor_profile_id, + identity_link_id=actor.identity_link_id, + grant_id=None, + ) is None + grant_id = uuid4() + first = request_authority_digest( + resource, + actor_profile_id=actor.actor_profile_id, + identity_link_id=actor.identity_link_id, + grant_id=grant_id, + ) + second = request_authority_digest( + resource, + actor_profile_id=actor.actor_profile_id, + identity_link_id=actor.identity_link_id, + grant_id=uuid4(), + ) + assert first != second + assert first == project_guide_compilation_request_resource_digest(actor, grant_id, facts) + + +def test_prepared_parser_round_trips_exact_request_and_rejects_bad_uuid() -> None: + resource = _request_context(_request()) + binding = parse_prepared_compilation( + ActionId.PROJECT_GUIDE_COMPILATION_REQUEST, resource.model_dump(mode="json") + ) + assert prepared_compilation_matches( + binding["guide_compilation_context"], + binding["guide_compilation_resource_digest"], + resource, + ) + assert parse_prepared_compilation(ActionId.PROJECT_CREATE, {}) == {} + with pytest.raises(PreparedAuthorizationHandleInvalid, match="invalid prepared"): + parse_prepared_compilation( + ActionId.PROJECT_GUIDE_COMPILATION_REQUEST, + {**resource.model_dump(mode="json"), "guide_id": "invalid"}, + ) + + +def test_project_setup_scope_and_resource_guard_are_action_specific() -> None: + facts = _request() + from app.modules.authorization.api import ProjectGuideCompilationExecutePreflightFacts + + execute = _execute_context( + ProjectGuideCompilationExecutePreflightFacts( + **asdict(facts), attempt_id=uuid4(), provider_idempotency_key=uuid4() + ), + phase="preflight", + ) + scope = PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, project_id=facts.project_id + ) + assert is_project_setup_scope(ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, scope) + assert project_setup_resource_matches( + ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, execute, facts.project_id + ) + assert not project_setup_resource_matches( + ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, execute, uuid4() + ) + unrelated = SystemResourceContext(resource_type="system", resource_id="workstream:system") + assert project_setup_resource_matches(ActionId.PROJECT_CREATE, unrelated, None) is None + assert persisted_result_digest(execute) is None + + +def test_compilation_resource_contexts_reject_scalar_coercion() -> None: + attempt_id = uuid4() + with pytest.raises(ValidationError): + ProjectGuideCompilationExecuteResourceContext( + resource_type="project_guide_compilation_attempt", + resource_id=attempt_id, + scope_project_id=uuid4(), + guide_id=uuid4(), + source_snapshot_id=uuid4(), + setup_run_id=uuid4(), + setup_generation="1", + attempt_id=attempt_id, + provider_idempotency_key=uuid4(), + phase="preflight", + request_facts_digest="sha256:" + "a" * 64, + ) diff --git a/backend/tests/authorization/guide_compilation/test_migration_contract.py b/backend/tests/authorization/guide_compilation/test_migration_contract.py new file mode 100644 index 000000000..daea3a0a1 --- /dev/null +++ b/backend/tests/authorization/guide_compilation/test_migration_contract.py @@ -0,0 +1,240 @@ +"""PostgreSQL topology proof for AUTH compilation migration 0063.""" + +import asyncio +from pathlib import Path +from uuid import uuid4 + +from alembic import command +from alembic.config import Config +import asyncpg +import pytest + +pytestmark = pytest.mark.postgres_schema_contract + + +def _config() -> Config: + root = Path(__file__).resolve().parents[3] + config = Config(str(root / "alembic.ini")) + config.set_main_option("script_location", str(root / "alembic")) + return config + + +async def _registry_state(database_url: str) -> tuple[str, int, int, int]: + connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) + try: + head = await connection.fetchval("select version_num from alembic_version") + action = await connection.fetchval( + "select count(*) from pg_constraint where conrelid='audit_events'::regclass " + "and conname='ck_audit_events_authority_registries' and " + "pg_get_constraintdef(oid) like '%project.guide_compilation.request%'" + ) + evidence = await connection.fetchval( + "select count(*) from pg_constraint where conrelid='audit_events'::regclass " + "and conname='ck_audit_events_authorization_action_evidence' and " + "pg_get_constraintdef(oid) like '%project.guide_compilation.request%'" + ) + resource = await connection.fetchval( + "select count(*) from pg_constraint where conrelid='audit_events'::regclass " + "and conname='ck_audit_events_authority_privacy_bounds' and " + "pg_get_constraintdef(oid) like '%project_guide_compilation_request%'" + ) + return head, action, evidence, resource + finally: + await connection.close() + + +async def _insert_authority_evidence(database_url: str, action: str) -> str: + event_id = str(uuid4()) + connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) + try: + await connection.execute( + "insert into audit_events " + "(id,entity_type,entity_id,event_type,actor_id,actor_roles,claim_snapshot," + "auth_source,is_dev_auth,event_payload,event_domain,event_version,actor_ref_kind," + "request_id,correlation_id,permission_id,action_id,reason,denial_code,after_facts) " + "values($1,'authorization_decision',$1,'SensitiveAuthorizationDenied'," + "'workstream:system:bootstrap','[]'::json,'{}'::json,'local_authority',false," + "'{}'::json,'authority',1,'system_principal',$2,$3,$4,$4," + "'authorization_evaluation','permission_not_granted','{\"allowed\": false}'::json)", + event_id, + str(uuid4()), + str(uuid4()), + action, + ) + return event_id + finally: + await connection.close() + + +async def _insert_permission_without_action(database_url: str, permission: str) -> str: + event_id = str(uuid4()) + connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) + try: + await connection.execute( + "insert into audit_events " + "(id,entity_type,entity_id,event_type,actor_id,actor_roles,claim_snapshot," + "auth_source,is_dev_auth,event_payload,event_domain,event_version,actor_ref_kind," + "request_id,correlation_id,permission_id,reason,denial_code,after_facts) " + "values($1,'authorization_decision',$1,'SensitiveAuthorizationDenied'," + "'workstream:system:bootstrap','[]'::json,'{}'::json,'local_authority',false," + "'{}'::json,'authority',1,'system_principal',$2,$3,$4," + "'authorization_evaluation','permission_not_granted','{\"allowed\": false}'::json)", + event_id, + str(uuid4()), + str(uuid4()), + permission, + ) + return event_id + finally: + await connection.close() + + +async def _insert_compilation_registry_reference( + database_url: str, permission: str +) -> str: + event_id = str(uuid4()) + connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) + try: + await connection.execute( + "insert into audit_events " + "(id,entity_type,entity_id,event_type,actor_id,actor_roles,claim_snapshot," + "auth_source,is_dev_auth,event_payload,event_domain,event_version,actor_ref_kind," + "request_id,correlation_id,permission_id,action_id,target_ref_kind,target_ref_id," + "reason,after_facts) values($1,'authorization_decision',$1," + "'SensitiveAuthorizationAllowed','workstream:system:bootstrap','[]'::json," + "'{}'::json,'local_authority',false,'{}'::json,'authority',1," + "'system_principal',$2,$3,'actor.profile.read_self','actor.profile.read_self'," + "'permission_registry',$4," + "'authorization_evaluation','{\"allowed\": true}'::json)", + event_id, + str(uuid4()), + str(uuid4()), + permission, + ) + return event_id + finally: + await connection.close() + + +async def _remove_authority_evidence(database_url: str, event_id: str) -> None: + connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) + try: + async with connection.transaction(): + await connection.execute("lock table audit_events in access exclusive mode") + await connection.execute( + "alter table audit_events disable trigger audit_events_reject_update_delete" + ) + await connection.execute("delete from audit_events where id=$1", event_id) + await connection.execute( + "alter table audit_events enable trigger audit_events_reject_update_delete" + ) + finally: + await connection.close() + + +def test_0063_empty_round_trip_preserves_exact_request_registries( + isolated_database_env: str, migration_lock +) -> None: + config = _config() + with migration_lock(): + try: + command.downgrade(config, "0062_guide_compilation") + assert asyncio.run(_registry_state(isolated_database_env)) == ( + "0062_guide_compilation", + 0, + 0, + 0, + ) + command.upgrade(config, "0063_compilation_authority") + assert asyncio.run(_registry_state(isolated_database_env)) == ( + "0063_compilation_authority", + 1, + 1, + 1, + ) + finally: + command.upgrade(config, "head") + + +@pytest.mark.parametrize( + "permission", + ["project.guide_compilation.request", "project.guide_compilation.execute"], +) +def test_0063_compilation_permissions_require_exact_action_evidence( + isolated_database_env: str, migration_lock, permission: str +) -> None: + with migration_lock(): + with pytest.raises(asyncpg.CheckViolationError): + asyncio.run( + _insert_permission_without_action(isolated_database_env, permission) + ) + + +def test_0063_refuses_historical_permission_only_execute_evidence( + isolated_database_env: str, migration_lock +) -> None: + config = _config() + event_id = "" + with migration_lock(): + try: + command.downgrade(config, "0062_guide_compilation") + event_id = asyncio.run( + _insert_permission_without_action( + isolated_database_env, + "project.guide_compilation.execute", + ) + ) + with pytest.raises( + RuntimeError, + match="permission-only execute evidence", + ): + command.upgrade(config, "0063_compilation_authority") + assert asyncio.run(_registry_state(isolated_database_env))[0] == ( + "0062_guide_compilation" + ) + finally: + if event_id: + asyncio.run(_remove_authority_evidence(isolated_database_env, event_id)) + command.upgrade(config, "head") + + +@pytest.mark.parametrize( + "permission", + ["project.guide_compilation.request", "project.guide_compilation.execute"], +) +def test_0063_downgrade_refuses_compilation_permission_registry_reference( + isolated_database_env: str, migration_lock, permission: str +) -> None: + config = _config() + event_id = "" + with migration_lock(): + try: + event_id = asyncio.run( + _insert_compilation_registry_reference( + isolated_database_env, + permission, + ) + ) + with pytest.raises(RuntimeError, match="cannot downgrade retained"): + command.downgrade(config, "0062_guide_compilation") + finally: + if event_id: + asyncio.run(_remove_authority_evidence(isolated_database_env, event_id)) + command.upgrade(config, "head") + + +@pytest.mark.parametrize( + "action", ["project.guide_compilation.request", "project.guide_compilation.execute"] +) +def test_0063_refuses_downgrade_after_retained_compilation_authority( + isolated_database_env: str, migration_lock, action: str +) -> None: + config = _config() + with migration_lock(): + try: + command.upgrade(config, "head") + asyncio.run(_insert_authority_evidence(isolated_database_env, action)) + with pytest.raises(RuntimeError, match="cannot downgrade retained"): + command.downgrade(config, "0062_guide_compilation") + finally: + command.upgrade(config, "head") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 0cc19409f..f2bcccb4f 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -21,7 +21,7 @@ from scripts.run_isolated_tests import LOOPBACK, NAME_RE, ROLE_RE DDL_LOCK_DIRECTORY = Path("/tmp") -EXPECTED_PUBLIC_SCHEMA_SHA256 = "50e35074611d76d676ebd87c370cc87b77bf0f56346b1a6c421a8676ca830595" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "d311629237dea2163a76fa474aa3873a9343f05255ccd85e63357b4a1eb0d75c" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", diff --git a/backend/tests/projects/guide_compilation/test_migration_contract.py b/backend/tests/projects/guide_compilation/test_migration_contract.py index 1583f6e4c..37107c586 100644 --- a/backend/tests/projects/guide_compilation/test_migration_contract.py +++ b/backend/tests/projects/guide_compilation/test_migration_contract.py @@ -1,4 +1,4 @@ -"""Alembic topology and downgrade custody for migration 0062.""" +"""Alembic topology and downgrade custody for compilation persistence.""" from __future__ import annotations @@ -57,10 +57,10 @@ async def _schema_state(database_url: str) -> tuple[str, bool, int, int, int, in await connection.close() -def test_0062_empty_round_trip_restores_exact_hidden_schema( +def test_0062_empty_round_trip_restores_exact_current_schema( isolated_database_env: str, migration_lock ) -> None: - """An empty 0062 downgrade/re-upgrade restores its tables and four guards.""" + """An empty 0062 downgrade/re-upgrade restores the current schema head.""" config = _config() with migration_lock(): try: @@ -73,16 +73,17 @@ def test_0062_empty_round_trip_restores_exact_hidden_schema( 0, 0, ) - finally: command.upgrade(config, "0062_guide_compilation") - assert asyncio.run(_schema_state(isolated_database_env)) == ( - "0062_guide_compilation", - True, - 4, - 1, - 1, - 1, - ) + assert asyncio.run(_schema_state(isolated_database_env)) == ( + "0062_guide_compilation", + True, + 4, + 1, + 1, + 1, + ) + finally: + command.upgrade(config, "head") def test_0062_nonempty_attempt_blocks_downgrade( @@ -107,5 +108,5 @@ async def seed_attempt() -> None: ): command.downgrade(_config(), "0061_submission_admission") assert asyncio.run(_schema_state(isolated_database_env))[0] == ( - "0062_guide_compilation" + "0063_compilation_authority" ) diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index d3df0d37b..d0d5cdd49 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -73,7 +73,7 @@ snapshot_existing_service_rows, ) -HEAD_REVISION = "0062_guide_compilation" +HEAD_REVISION = "0063_compilation_authority" pytestmark = pytest.mark.postgres_schema_contract @@ -5605,11 +5605,11 @@ def test_authorization_action_evidence_constraints_and_guarded_downgrade( ActionOwner.XINT_002_07, ActionOwner.XINT_003_08A, ActionOwner.XINT_003_08B, + ActionOwner.AUTH_12I, } ), ) ) - action_event = asyncio.run(_insert_authorization_action_event(isolated_database_env)) with pytest.raises( RuntimeError, @@ -5617,7 +5617,6 @@ def test_authorization_action_evidence_constraints_and_guarded_downgrade( ): command.downgrade(config, "0020_canonical_actor_profile") asyncio.run(_remove_authorization_action_events(isolated_database_env, [action_event])) - permission_event = asyncio.run( _insert_authorization_action_event(isolated_database_env) ) @@ -5767,13 +5766,13 @@ def test_bootstrap_admin_grant_schema_is_immutable_and_guarded( ActionOwner.XINT_002_07, ActionOwner.XINT_003_08A, ActionOwner.XINT_003_08B, + ActionOwner.AUTH_12I, } ), ) ) command.downgrade(config, "0021_auth_action_evidence") command.upgrade(config, "0022_bootstrap_admin_grants") - proof = asyncio.run(_exercise_admin_authority_guards(isolated_database_env)) assert proof == { "service_target_rejected": True, @@ -13571,15 +13570,14 @@ def test_xint003_02c_rev_auth_readiness_schema_and_roundtrip( repeated = asyncio.run(_xint003_02c_readiness_state(isolated_database_env)) finally: command.upgrade(config, "head") - additions = " OR " + " OR ".join(_xint003_02c_pair_token(*pair) for pair in _XINT003_02C_ACTIONS) - compilation_addition = " OR " + _xint003_02c_pair_token( - "project.guide_compilation.execute", "project.guide_compilation.execute") assert prior["profiles"] == upgraded["profiles"] == 0 assert upgraded["action_definition"].count(additions) == 2 - assert upgraded["action_definition"].count(compilation_addition) == 2 - assert upgraded["action_definition"].replace(additions, "").replace( - compilation_addition, "") == prior["action_definition"] + without_later_actions = upgraded["action_definition"].replace(additions, "") + for action in ("project.guide_compilation.execute", "project.guide_compilation.request"): + assert upgraded["action_definition"].count(" OR " + _xint003_02c_pair_token(action, action)) == 2 + without_later_actions = without_later_actions.replace(" OR " + _xint003_02c_pair_token(action, action), "").replace(f", ('{action}'::character varying)::text", "") + assert without_later_actions == prior["action_definition"] historical_identities = (*FROZEN_SERVICE_IDENTITY_VALUES, ServiceIdentity.PROJECT_SETUP.value) assert prior["identity_values"] == historical_identities assert upgraded["identity_values"] == (*historical_identities, *_XINT003_02C_IDENTITIES) @@ -14050,15 +14048,12 @@ async def _remove_xint003_02a_immutable_policies(database_url: str, ids: dict[st engine = create_async_engine(database_url) try: async with engine.begin() as connection: - has_lineage = bool( - await connection.scalar( - text( - "select exists(select 1 from information_schema.columns " - "where table_schema='public' and table_name='project_guides' " - "and column_name='selected_review_policy_id')" - ) - ) + lineage_query = text( + "select exists(select 1 from information_schema.columns " + "where table_schema='public' and table_name='project_guides' " + "and column_name='selected_review_policy_id')" ) + has_lineage = bool(await connection.scalar(lineage_query)) for table in ( "projects", "project_guides", diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index 8eac4fe6b..36beebea2 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -131,7 +131,6 @@ def test_action_aware_audit_input_enforces_mapping_and_action_availability() -> ) assert denied.action_id is ActionId.ARTIFACT_BINDING_READ assert denied.permission_id is PermissionId.ARTIFACT_BINDING_READ - with pytest.raises(ValidationError, match="action permission"): _authority_input( AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED, @@ -196,9 +195,9 @@ def test_action_aware_audit_input_enforces_mapping_and_action_availability() -> ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE, ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, ActionId.PROJECT_GUIDE_SUFFICIENCY_WARNINGS_ACKNOWLEDGE, + ActionId.PROJECT_GUIDE_COMPILATION_REQUEST, ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, ActionId.PROJECT_READ, - ActionId.PROJECT_REVIEW_POLICY_UPDATE, - ActionId.PROJECT_REVISION_POLICY_UPDATE, + ActionId.PROJECT_REVIEW_POLICY_UPDATE, ActionId.PROJECT_REVISION_POLICY_UPDATE, ActionId.PROJECT_ROLE_GRANT_LIST, ActionId.PROJECT_ROLE_GRANT_READ, ActionId.PROJECT_ROLE_GRANT_ISSUE, diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index df705d5a3..eb7d9ea14 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -2142,8 +2142,7 @@ async def assert_failed_admin_read(path: str) -> None: headers=admin_headers, ) assert permissions.status_code == definitions.status_code == 200 - assert permissions.json()["total"] == 71 - assert len(permissions.json()["items"]) == 71 + assert (permissions.json()["total"], len(permissions.json()["items"])) == (73, 73) assert definitions.json()["total"] == 5 assert [item["role"] for item in definitions.json()["items"]] == [ "access_administrator", @@ -2445,7 +2444,7 @@ async def assert_failed_admin_read(path: str) -> None: ), ] assert [response.status_code for response in system_audit_reads] == [200] * 6 - assert system_audit_reads[0].json()["total"] == 71 + assert system_audit_reads[0].json()["total"] == 73 assert system_audit_reads[1].json()["total"] == 5 assert system_audit_reads[2].json()["total"] == 2 assert system_audit_reads[3].json()["total"] == 1 diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index a7a8e803e..a9e15113a 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -1951,7 +1951,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> artifact.verification.execute artifact.pending_work.scan artifact.put_attempt.resolve artifact.guide_source.read artifact.checker_input.materialize artifact.checker_output.write artifact.review_packet.materialize - review.queue.override""".split() + review.queue.override project.guide_compilation.request project.guide_compilation.execute""".split() ) expected = { "actor.profile.read_self": ("actor.profile.read_self", "WS-AUTH-001-07B"), @@ -2038,10 +2038,9 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> "project.create": ("project.create", "WS-AUTH-001-12C"), "project.guide.create": ("project.guide.manage", "WS-AUTH-001-12D"), "project.guide.update": ("project.guide.manage", "WS-AUTH-001-12D"), - "project.guide_source_snapshot.create": ( - "project.guide.manage", - "WS-AUTH-001-12D", - ), + "project.guide_source_snapshot.create": ("project.guide.manage", "WS-AUTH-001-12D"), + "project.guide_compilation.request": ("project.guide_compilation.request", "WS-AUTH-001-12I"), + "project.guide_compilation.execute": ("project.guide_compilation.execute", "WS-AUTH-001-12I"), "project.review_policy.update": ( "project.review_policy.manage", "WS-XINT-003-02B", @@ -2096,7 +2095,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> assert {item.value for item in HISTORICAL_PERMISSION_IDS} == historical_permissions assert {item.value for item in NEW_PERMISSION_IDS} == new_permissions assert {item.value for item in PERMISSION_IDS} == historical_permissions | new_permissions - assert len(ACTION_IDS) == len(ACTION_DEFINITIONS) == len(ACTION_BY_ID) == 100 + assert len(ACTION_IDS) == len(ACTION_DEFINITIONS) == len(ACTION_BY_ID) == 102 assert set(ACTION_BY_ID) == ACTION_IDS assert {definition.owner for definition in ACTION_DEFINITIONS} == set(ActionOwner) assert { @@ -2130,6 +2129,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> ActionId.PROJECT_GUIDE_CREATE, ActionId.PROJECT_GUIDE_UPDATE, ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE, + ActionId.PROJECT_GUIDE_COMPILATION_REQUEST, ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, ActionId.PROJECT_REVIEW_POLICY_UPDATE, ActionId.PROJECT_REVISION_POLICY_UPDATE, ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE, @@ -2150,8 +2150,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, ActionId.PROJECT_ACTIVE_GUIDE_READ, ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, - ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE, - ActionId.ARTIFACT_GUIDE_SOURCE_READ, + ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE, ActionId.ARTIFACT_GUIDE_SOURCE_READ, ActionId.ARTIFACT_VERIFICATION_EXECUTE, ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, @@ -2237,7 +2236,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> definition.availability is ActionAvailability.ACTIVE for definition in ACTION_DEFINITIONS ) - == 52 + == 54 ) assert ( sum( @@ -2246,15 +2245,15 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> ) == 48 ) - assert resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id is ( - PermissionId.ACTOR_PROFILE_READ_SELF - ) + assert resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id is PermissionId.ACTOR_PROFILE_READ_SELF with pytest.raises(ValueError, match="not active"): resolve_executable_action(ActionId.REVIEW_QUEUE_READ) with pytest.raises(TypeError): ACTION_BY_ID[ActionId.ACTOR_PROFILE_READ_SELF] = ACTION_DEFINITIONS[0] + + def test_project_mutation_resources_and_prepared_scopes_are_closed() -> None: """Bind every planned project mutation to one typed system/project scope.""" project_id, guide_id, snapshot_id, report_id = (uuid4() for _ in range(4)) @@ -2705,9 +2704,7 @@ def test_fixed_service_action_matrix_and_activation_are_exact_and_immutable() -> expected = { ServiceIdentity.ARTIFACT_VERIFIER: {"artifact.verification.execute"}, ServiceIdentity.ARTIFACT_PUT_RESOLVER: {"artifact.put_attempt.resolve"}, - ServiceIdentity.ARTIFACT_SCHEDULER: { - "artifact.pending_work.scan", - }, + ServiceIdentity.ARTIFACT_SCHEDULER: {"artifact.pending_work.scan"}, ServiceIdentity.ARTIFACT_BINDING: { "artifact.guide_source.binding.create", "artifact.submission.binding.create", @@ -2722,6 +2719,7 @@ def test_fixed_service_action_matrix_and_activation_are_exact_and_immutable() -> }, ServiceIdentity.ARTIFACT_CHECKER_OUTPUT: {"artifact.checker_output.write"}, ServiceIdentity.PROJECT_SETUP: { + "project.guide_compilation.execute", "project.guide_sufficiency.run", "project.submission_artifact_policy.derive", "project.post_submit_checker_policy.derive", @@ -2741,7 +2739,7 @@ def test_fixed_service_action_matrix_and_activation_are_exact_and_immutable() -> identity: {action.value for action in actions} for identity, actions in SERVICE_ACTIONS_BY_IDENTITY.items() } == expected - assert sum(map(len, SERVICE_ACTIONS_BY_IDENTITY.values())) == 22 + assert sum(map(len, SERVICE_ACTIONS_BY_IDENTITY.values())) == 23 assert FUTURE_INTENT_REQUIRED_ACTIONS == { ActionId.REVIEW_FINDING_EVIDENCE_INGEST, ActionId.REVIEW_FINDING_RESPONSE_EVIDENCE_INGEST, @@ -2762,6 +2760,7 @@ def test_fixed_service_action_matrix_and_activation_are_exact_and_immutable() -> ) for action in project_setup_actions } == { + ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE: (PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, ActionOwner.AUTH_12I, ActionAvailability.ACTIVE), ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN: ( PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12E, @@ -2802,12 +2801,12 @@ def test_submission_artifact_policy_draft_actions_have_exact_child_owners() -> N assert approval.owner is ActionOwner.AUTH_12F assert approval.availability is ActionAvailability.PLANNED active_internal = { - ActionId.ARTIFACT_VERIFICATION_EXECUTE, - ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + ActionId.ARTIFACT_VERIFICATION_EXECUTE, ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE, ActionId.ARTIFACT_GUIDE_SOURCE_READ, + ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, } @@ -2913,7 +2912,7 @@ def test_art_custody_documentation_matches_the_independent_activation_fixture() assert "does not grant Operator" in operations assert "verification retry remains independently gated" in operations assert ( - "71 PermissionIds, 100 ActionIds, 48 active actions, and\n52 planned actions" in operations + "73 PermissionIds, 102 ActionIds, 54 active actions, and\n48 planned actions" in operations ) @@ -3036,8 +3035,8 @@ def test_administrative_role_policy_and_definition_responses_are_exact() -> None artifact.verification_job.retry artifact.recovery_attempt.read artifact.audit.read audit.read""".split(), AdminRole.PROJECT_MANAGER: """project.create project.read project.setup_diagnostic.read - project.effective_policy.read project.update project.archive - project.guide.manage project.effective_policy.manage project.task.manage + project.effective_policy.read project.update project.archive project.guide.manage + project.guide_compilation.request project.effective_policy.manage project.task.manage project.review_policy.manage project.role_grant.read project.role_grant.manage artifact.guide_source.ingest review.queue.inspect contribution.read_project compensation.award.read @@ -3069,9 +3068,8 @@ def test_administrative_role_policy_and_definition_responses_are_exact() -> None for permission in ADMIN_ROLE_PERMISSIONS[AdminRole.AUDIT_AUTHORITY] ) - permission_response = AdminRoleGrantService.permission_definitions() - role_response = AdminRoleGrantService.role_definitions() - assert permission_response.total == 71 + permission_response, role_response = AdminRoleGrantService.permission_definitions(), AdminRoleGrantService.role_definitions() + assert permission_response.total == 73 assert [item.permission_id.value for item in permission_response.items] == sorted( permission.value for permission in PermissionId ) @@ -3945,16 +3943,16 @@ async def find_effective_grant( scope_project_id, for_update, allowed_roles, + exact_project_scope=False, ): assert actor_profile_id == self.context.actor_profile_id assert permission_id is self.permission_id assert scope_project_id is not None assert for_update is True assert allowed_roles == frozenset({AdminRole.PROJECT_MANAGER}) - if self.grant is None or self.grant.scope_project_id not in { - None, - scope_project_id, - }: + if self.grant is None or self.grant.scope_project_id not in {None, scope_project_id}: + return None + if exact_project_scope and self.grant.scope_project_id != scope_project_id: return None return self.grant @@ -12763,13 +12761,7 @@ async def test_project_role_issue_postgresql_prep_binds_target_role_and_scope( authorization_factory, monkeypatch, ) -> None: - caller_id, caller_link_id, target_id, target_link_id, project_id = ( - uuid4(), - uuid4(), - uuid4(), - uuid4(), - uuid4(), - ) + caller_id, caller_link_id, target_id, target_link_id, project_id = (uuid4(), uuid4(), uuid4(), uuid4(), uuid4()) manager_grant_id = uuid4() bootstrap_grant_id = uuid4() now = datetime.now(UTC) diff --git a/backend/tests/test_ci_test_lanes.py b/backend/tests/test_ci_test_lanes.py index 5cc39b278..a37ad675b 100644 --- a/backend/tests/test_ci_test_lanes.py +++ b/backend/tests/test_ci_test_lanes.py @@ -23,11 +23,18 @@ def test_committed_lanes_cover_recursive_inventory_exactly_once() -> None: runner.validate_lane_inventory(discovered) assigned = [module for lane in LANES for module in lane.modules] - assert len(LANES) == 5 + assert len(LANES) == 7 assert all(lane.requires_postgres for lane in LANES) - assert Counter(assigned)[runner.PARTITIONED_SCHEMA_MODULE] == 2 + assert Counter(assigned)[runner.PARTITIONED_SCHEMA_MODULE] == 3 assert all( - count == (2 if module == runner.PARTITIONED_SCHEMA_MODULE else 1) + count + == ( + len(runner.PARTITIONED_SCHEMA_LANES) + if module == runner.PARTITIONED_SCHEMA_MODULE + else 2 + if module in runner.SHARED_FOUNDATION_MODULES + else 1 + ) for module, count in Counter(assigned).items() ) assert set(assigned) == set(discovered) @@ -59,12 +66,16 @@ def test_measured_hotspots_have_explicit_semantic_owners() -> None: "tests/test_review_queue_persistence.py", "tests/test_tasks.py", } + shared_a = modules_by_lane[runner.PARTITIONED_SHARED_LANES[0]] + shared_b = modules_by_lane[runner.PARTITIONED_SHARED_LANES[1]] + assert shared_a == shared_b == set(runner.SHARED_FOUNDATION_MODULES) assert { "tests/test_alembic.py", "tests/test_database_reset.py", runner.ADMIN_RUNNER_MODULE, } == modules_by_lane["schema_contracts_a"] assert {"tests/test_alembic.py"} == modules_by_lane["schema_contracts_b"] + assert {"tests/test_alembic.py"} == modules_by_lane["schema_contracts_c"] assert { "tests/test_actors.py", "tests/test_artifact_admission.py", @@ -72,7 +83,7 @@ def test_measured_hotspots_have_explicit_semantic_owners() -> None: "tests/test_authorization.py", "tests/test_guide_artifacts.py", "tests/test_mutation_policy.py", - } <= modules_by_lane["shared_foundations"] + } <= shared_a def test_discovery_is_recursive_and_lexically_canonical(tmp_path: Path) -> None: @@ -107,9 +118,13 @@ def test_discovery_rejects_symlinks(tmp_path: Path, kind: str) -> None: @pytest.mark.parametrize( ("mutation", "error"), - (("missing", "missing_lane_modules"), ("duplicate", "duplicate_lane_modules"), - ("foreign", "foreign_lane_modules"), ("unsafe", "invalid_lane_module"), - ("name", "invalid_lane_names")), + ( + ("missing", "missing_lane_modules"), + ("duplicate", "duplicate_lane_modules"), + ("foreign", "foreign_lane_modules"), + ("unsafe", "invalid_lane_module"), + ("name", "invalid_lane_names"), + ), ) def test_inventory_fails_closed(mutation: str, error: str) -> None: discovered = runner.discover_test_modules() @@ -117,6 +132,7 @@ def test_inventory_fails_closed(mutation: str, error: str) -> None: first = lanes[0] if mutation == "missing": lanes[0] = replace(first, modules=first.modules[1:]) + lanes[1] = replace(lanes[1], modules=lanes[1].modules[1:]) elif mutation == "duplicate": lanes[0] = replace(first, modules=(*first.modules, lanes[1].modules[0])) elif mutation == "foreign": @@ -144,7 +160,7 @@ def test_manifest_contains_sorted_exact_node_ids() -> None: def test_manifest_classifies_only_runner_self_tests_as_admin_kind() -> None: - ordinary = f"{LANES[1].modules[0]}::test_migration" + ordinary = f"{runner.PARTITIONED_SCHEMA_MODULE}::test_migration" admin = f"{runner.ADMIN_RUNNER_MODULE}::test_admin_owner" rows = runner.build_manifest("a" * 40, sorted((ordinary, admin)))["nodes"] @@ -156,10 +172,7 @@ def test_manifest_classifies_only_runner_self_tests_as_admin_kind() -> None: def test_alembic_nodes_partition_deterministically_across_schema_lanes() -> None: - nodes = [ - f"{runner.PARTITIONED_SCHEMA_MODULE}::test_migration_{index}" - for index in range(100) - ] + nodes = [f"{runner.PARTITIONED_SCHEMA_MODULE}::test_migration_{index}" for index in range(100)] first = runner.build_manifest("a" * 40, nodes) second = runner.build_manifest("a" * 40, list(reversed(nodes))) @@ -168,9 +181,20 @@ def test_alembic_nodes_partition_deterministically_across_schema_lanes() -> None assert first_by_node == second_by_node assert set(first_by_node.values()) == set(runner.PARTITIONED_SCHEMA_LANES) - assert all( - lane in runner.PARTITIONED_SCHEMA_LANES for lane in first_by_node.values() - ) + assert all(lane in runner.PARTITIONED_SCHEMA_LANES for lane in first_by_node.values()) + + +def test_shared_nodes_partition_deterministically_across_shared_lanes() -> None: + module = runner.SHARED_FOUNDATION_MODULES[0] + nodes = [f"{module}::test_shared_{index}" for index in range(100)] + + first = runner.build_manifest("a" * 40, nodes) + second = runner.build_manifest("a" * 40, list(reversed(nodes))) + first_by_node = {row["nodeid"]: row["lane"] for row in first["nodes"]} + second_by_node = {row["nodeid"]: row["lane"] for row in second["nodes"]} + + assert first_by_node == second_by_node + assert set(first_by_node.values()) == set(runner.PARTITIONED_SHARED_LANES) def test_manifest_has_no_exclusion_escape_hatch() -> None: @@ -178,12 +202,14 @@ def test_manifest_has_no_exclusion_escape_hatch() -> None: manifest = runner.build_manifest("a" * 40, [admin]) assert "excluded_modules" not in manifest - assert manifest["nodes"] == [{ - "execution_kind": runner.ADMIN_KIND, - "lane": "schema_contracts_a", - "module": runner.ADMIN_RUNNER_MODULE, - "nodeid": admin, - }] + assert manifest["nodes"] == [ + { + "execution_kind": runner.ADMIN_KIND, + "lane": "schema_contracts_a", + "module": runner.ADMIN_RUNNER_MODULE, + "nodeid": admin, + } + ] def test_deterministic_uuid_nodeids_match_across_full_subset_and_repeat( @@ -275,9 +301,15 @@ def test_lane_environment_uses_private_evidence_and_coverage(tmp_path: Path) -> env = runner.lane_environment(LANES[0], tmp_path, coverage) assert env["COVERAGE_FILE"] == str(coverage.resolve()) - paths = [Path(env[name]) for name in ( - runner.COLLECTED_ENV, runner.COMPLETED_ENV, runner.SKIPPED_ENV, runner.DESELECTED_ENV, - )] + paths = [ + Path(env[name]) + for name in ( + runner.COLLECTED_ENV, + runner.COMPLETED_ENV, + runner.SKIPPED_ENV, + runner.DESELECTED_ENV, + ) + ] assert len(set(paths)) == 4 assert all(path.parent == tmp_path for path in paths) @@ -287,7 +319,9 @@ def test_admin_runner_environment_retains_only_admin_database_url( ) -> None: monkeypatch.setenv(runner.ADMIN_ENV, "postgresql+asyncpg://admin:secret@localhost/postgres") monkeypatch.setenv("WORKSTREAM_DATABASE_URL", "postgresql+asyncpg://app:secret@localhost/app") - monkeypatch.setenv("WORKSTREAM_TEST_DATABASE_URL", "postgresql+asyncpg://test:secret@localhost/test") + monkeypatch.setenv( + "WORKSTREAM_TEST_DATABASE_URL", "postgresql+asyncpg://test:secret@localhost/test" + ) lane = next(lane for lane in LANES if lane.name == "schema_contracts_a") env = runner.admin_runner_environment(lane, tmp_path, tmp_path / ".coverage", "admin") @@ -391,18 +425,20 @@ def test_failed_lane_preserves_evidence_without_isolation_metadata( ) -> None: """A provisioning failure remains observable before metadata exists.""" lane = LANES[0] - units = [{ - "collection_exit_code": 1, - "collected_nodes": [], - "completed_nodes": [], - "coverage_path": tmp_path / ".coverage.unit.missing", - "deselected_nodes": [], - "elapsed_seconds": 1.0, - "execution_kind": runner.ORDINARY_KIND, - "execution_exit_code": 2, - "interrupted": False, - "skipped_nodes": [], - }] + units = [ + { + "collection_exit_code": 1, + "collected_nodes": [], + "completed_nodes": [], + "coverage_path": tmp_path / ".coverage.unit.missing", + "deselected_nodes": [], + "elapsed_seconds": 1.0, + "execution_kind": runner.ORDINARY_KIND, + "execution_exit_code": 2, + "interrupted": False, + "skipped_nodes": [], + } + ] row = runner._finalize_lane(lane, units, tmp_path) evidence = json.loads((tmp_path / row["evidence_file"]).read_text()) @@ -440,8 +476,15 @@ def test_collect_only_writes_raw_digest_bound_validator_schema( summary = json.loads(summary_path.read_text(encoding="utf-8")) manifest_bytes = (metadata / summary["manifest_file"]).read_bytes() assert set(summary) == { - "aggregate_runner_seconds", "canonical_node_count", "elapsed_seconds", "head_sha", - "lanes", "manifest_file", "manifest_sha256", "mode", "schema_version", + "aggregate_runner_seconds", + "canonical_node_count", + "elapsed_seconds", + "head_sha", + "lanes", + "manifest_file", + "manifest_sha256", + "mode", + "schema_version", "slowest_lane_seconds", } assert summary["mode"] == "collect" @@ -533,10 +576,11 @@ def fake_run(*_args, **kwargs): def test_timing_summary_is_derived_from_exact_declared_lanes() -> None: - lanes = [{"elapsed_seconds": value} for value in (1.125, 2.25, 0.5, 3.75, 1.0)] + elapsed = (1.125, 2.25, 0.5, 3.75, 1.0, 0.75, 0.625) + lanes = [{"elapsed_seconds": value} for value in elapsed] assert runner._timing_summary(lanes) == { - "aggregate_runner_seconds": 8.625, + "aggregate_runner_seconds": 10.0, "slowest_lane_seconds": 3.75, } with pytest.raises(LaneError, match="invalid_lane_timing_inventory"): @@ -564,10 +608,10 @@ def test_finalized_lanes_leave_one_public_coverage_file_per_lane(tmp_path: Path) "collection_exit_code": 0, "completed_nodes": [f"{lane.modules[0]}::test_one"], "coverage_path": source, - "deselected_nodes": [], - "elapsed_seconds": float(index + 1), - "execution_kind": runner.ORDINARY_KIND, - "execution_exit_code": 0, + "deselected_nodes": [], + "elapsed_seconds": float(index + 1), + "execution_kind": runner.ORDINARY_KIND, + "execution_exit_code": 0, "interrupted": False, "skipped_nodes": [], } @@ -629,8 +673,7 @@ def test_unexpected_runner_failure_force_kills_and_records_every_lane( ) -> None: """Cleanup preserves four-lane evidence and the orchestration traceback.""" lanes = tuple( - LaneDefinition(f"lane_{index}", (f"tests/test_{index}.py",)) - for index in range(4) + LaneDefinition(f"lane_{index}", (f"tests/test_{index}.py",)) for index in range(4) ) modules = tuple(lane.modules[0] for lane in lanes) nodes = [f"{module}::test_one" for module in modules] @@ -669,8 +712,7 @@ def test_partial_startup_failure_records_exactly_four_failed_lanes( ) -> None: """A process-launch failure cannot erase lanes that never started.""" lanes = tuple( - LaneDefinition(f"lane_{index}", (f"tests/test_{index}.py",)) - for index in range(4) + LaneDefinition(f"lane_{index}", (f"tests/test_{index}.py",)) for index in range(4) ) modules = tuple(lane.modules[0] for lane in lanes) nodes = [f"{module}::test_one" for module in modules] diff --git a/backend/tests/test_isolated_database_runner.py b/backend/tests/test_isolated_database_runner.py index c59f7618c..77fb634ef 100644 --- a/backend/tests/test_isolated_database_runner.py +++ b/backend/tests/test_isolated_database_runner.py @@ -64,20 +64,25 @@ async def observed(*_): return "0015" def test_lane_namespaces_bind_real_s3_traffic_and_separate_other_lanes() -> None: - """Only the S3 lane receives the application's hardcoded integration bucket.""" - s3_bucket, s3_prefix = runner._minio_namespace("shared_foundations", "012345abcdef") + """Only shared lanes receive the application's hardcoded integration bucket.""" + shared_a = runner._minio_namespace("shared_foundations_a", "012345abcdef") + shared_b = runner._minio_namespace("shared_foundations_b", "012345abcdef") control_bucket, control_prefix = runner._minio_namespace( "project_lifecycle", "012345abcdef" ) execution_bucket, execution_prefix = runner._minio_namespace( "task_lifecycle", "fedcba543210" ) - assert (s3_bucket, s3_prefix) == ( + assert shared_a == ( "workstream-artifacts", - "ci/shared_foundations/012345abcdef", + "ci/shared_foundations_a/012345abcdef", ) - assert len({s3_bucket, control_bucket, execution_bucket}) == 3 - assert len({s3_prefix, control_prefix, execution_prefix}) == 3 + assert shared_b == ( + "workstream-artifacts", + "ci/shared_foundations_b/012345abcdef", + ) + assert len({shared_a[0], control_bucket, execution_bucket}) == 3 + assert len({shared_a[1], shared_b[1], control_prefix, execution_prefix}) == 4 with pytest.raises(runner.RunnerError, match="invalid_lane"): runner._minio_namespace("../foreign", "012345abcdef") @@ -179,7 +184,8 @@ async def delete_bucket(self, **_kwargs): @pytest.mark.parametrize( ("lane", "expected_bucket"), [ - ("shared_foundations", "workstream-artifacts"), + ("shared_foundations_a", "workstream-artifacts"), + ("shared_foundations_b", "workstream-artifacts"), ("schema_contracts", "workstream-ci-schema-contracts-012345abcdef"), ("project_lifecycle", "workstream-ci-project-lifecycle-012345abcdef"), ("task_lifecycle", "workstream-ci-task-lifecycle-012345abcdef"), @@ -202,14 +208,15 @@ def test_lane_namespaces_do_not_collide_across_lanes_or_runner_suffixes() -> Non namespaces = { runner._minio_namespace(lane, suffix) for lane in ( - "shared_foundations", + "shared_foundations_a", + "shared_foundations_b", "schema_contracts", "project_lifecycle", "task_lifecycle", ) for suffix in ("012345abcdef", "fedcba543210") } - assert len(namespaces) == 8 + assert len(namespaces) == 10 with pytest.raises(runner.RunnerError, match="invalid_lane"): runner._minio_namespace("project_lifecycle", "not-hex") with pytest.raises(runner.RunnerError, match="invalid_minio_namespace"): diff --git a/backend/tests/test_merge_test_lane_evidence.py b/backend/tests/test_merge_test_lane_evidence.py index a45fd642b..0abe77753 100644 --- a/backend/tests/test_merge_test_lane_evidence.py +++ b/backend/tests/test_merge_test_lane_evidence.py @@ -91,8 +91,10 @@ def test_merge_bundles_emits_complete_run_summary(tmp_path: Path) -> None: summary = json.loads((tmp_path / "summary.json").read_text(encoding="utf-8")) assert summary["mode"] == "run" assert [row["name"] for row in summary["lanes"]] == [lane.name for lane in LANES] - assert summary["aggregate_runner_seconds"] == 15.0 - assert summary["slowest_lane_seconds"] == 5.0 + assert summary["aggregate_runner_seconds"] == sum( + float(index) for index in range(1, len(LANES) + 1) + ) + assert summary["slowest_lane_seconds"] == float(len(LANES)) assert len(list((tmp_path / "merged").glob(".coverage.*"))) == len(LANES) diff --git a/backend/tests/test_test_lane_evidence.py b/backend/tests/test_test_lane_evidence.py index b0db69660..007712352 100644 --- a/backend/tests/test_test_lane_evidence.py +++ b/backend/tests/test_test_lane_evidence.py @@ -21,9 +21,11 @@ REAL_COLLECT_CURRENT_NODES = validator._collect_current_nodes HEAD = "a" * 40 LANES = ( - "shared_foundations", + "shared_foundations_a", + "shared_foundations_b", "schema_contracts_a", "schema_contracts_b", + "schema_contracts_c", "project_lifecycle", "task_lifecycle", ) @@ -80,7 +82,11 @@ def _bundle(tmp_path: Path, mode: str = "run") -> tuple[Path, Path, dict]: "database_provisioned": True, "database_role": f"role_{lane}", "lane": lane, - "minio_bucket": f"bucket-{lane.replace('_', '-')}", + "minio_bucket": ( + validator.SHARED_S3_BUCKET + if lane in validator.SHARED_S3_LANES + else f"bucket-{lane.replace('_', '-')}" + ), "minio_cleanup_complete": True, "minio_prefix": f"prefix/{lane}", "minio_probe_complete": True, @@ -118,8 +124,8 @@ def _bundle(tmp_path: Path, mode: str = "run") -> tuple[Path, Path, dict]: } ) summary = { - "aggregate_runner_seconds": 5.0, - "canonical_node_count": 6, + "aggregate_runner_seconds": float(len(LANES)), + "canonical_node_count": len(nodes), "elapsed_seconds": 2.0, "head_sha": HEAD, "lanes": lane_rows, @@ -142,7 +148,7 @@ def exact_head(monkeypatch: pytest.MonkeyPatch) -> None: "_collect_current_nodes", lambda _root, _head: sorted( [ - *(f"tests/test_{index}.py::test_ok" for index in range(5)), + *(f"tests/test_{index}.py::test_ok" for index in range(len(LANES))), "tests/test_isolated_database_runner.py::test_admin_custody", ] ), @@ -322,6 +328,42 @@ def test_rejects_recorded_database_environment_or_shared_coverage(tmp_path: Path validator.validate_evidence(metadata, summary_path, tmp_path) +@pytest.mark.parametrize( + ("target_index", "source_index", "field", "message"), + ( + (2, 0, "minio_bucket", "invalid_isolation_metadata"), + (1, 0, "minio_prefix", "shared_isolation_namespace"), + ), +) +def test_rejects_unapproved_shared_minio_custody( + tmp_path: Path, + target_index: int, + source_index: int, + field: str, + message: str, +) -> None: + metadata, summary_path, summary = _bundle(tmp_path) + + def isolation_for(index: int) -> tuple[dict, Path, dict, dict]: + lane = summary["lanes"][index] + evidence_path = metadata / lane["evidence_file"] + evidence = json.loads(evidence_path.read_text()) + isolation_path = metadata / evidence["isolation_metadata_file"] + isolation = json.loads(isolation_path.read_text()) + return lane, evidence_path, evidence, isolation + + source = isolation_for(source_index)[3] + lane, evidence_path, evidence, isolation = isolation_for(target_index) + isolation[field] = source[field] + isolation_path = metadata / evidence["isolation_metadata_file"] + evidence["isolation_metadata_sha256"] = _write(isolation_path, isolation) + lane["evidence_sha256"] = _write(evidence_path, evidence) + _write(summary_path, summary) + + with pytest.raises(validator.EvidenceError, match=message): + validator.validate_evidence(metadata, summary_path, tmp_path) + + @pytest.mark.parametrize( ("field", "value", "message"), [ diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index ddffd4b97..4b8d61652 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -714,8 +714,9 @@ reconciliation uses migration `0036`. The REV transfer adds no migration. The ART transfer does not grant Operator authority; its `OPERATOR` suffix denotes only future activation custody, and verification retry remains independently gated from read/status actions. -Catalogue totals are 71 PermissionIds, 100 ActionIds, 48 active actions, and -52 planned actions. AUTH-11C2 activates three current effective-policy and +Catalogue totals are 73 PermissionIds, 102 ActionIds, 54 active actions, and +48 planned actions. AUTH-12I adds and activates only the unified compilation +request/execute pair. AUTH-11C2 activates three current effective-policy and active-guide reads in addition to AUTH-11C1's six diagnostic reads. The exact route mapping is in `docs/spec_authorization_service.md`. WS-XINT-002-04A activates Project Manager guide-source ingest, and WS-XINT-002-04B activates @@ -749,8 +750,9 @@ actions. Planned actions can record bounded denial evidence but cannot record an allowed decision through the typed writer. The historical permission set remains exactly 49 values. The post-`0020` set -contains exactly 27 values, including `review.queue.override`, -`project.setup_diagnostic.read`, and `project.effective_policy.read`; do not derive +contains exactly 24 values, including `review.queue.override`, the two +compilation permissions, `project.setup_diagnostic.read`, and +`project.effective_policy.read`; do not derive historical status from identifier prefixes. All submission/review rows remain planned. Initial and revision submission share `submission.create`, and no revision-specific permission or preparation action exists. @@ -895,7 +897,7 @@ actor, project, role, and cause event before a consumer changes product state. Revoking one role must leave the other project roles and all AdminRoleGrants unchanged. -The closed registry now has fourteen fixed-service identities and twenty-two +The closed registry now has fourteen fixed-service identities and twenty-three matrix memberships: seven ART identities, project setup, and six exact REV identities. Missing provisioned rows deny without stopping the application. The REV actions remain unavailable, so registry membership alone grants no @@ -903,12 +905,14 @@ authority. Do not create a shared review service or a database service-grant table. Historically, AUTH-12B extended the registry to an eighth identity, -`workstream.project.setup`, with exactly four static memberships: +`workstream.project.setup`, now with exactly five static memberships: `project.guide_sufficiency.run`, +`project.guide_compilation.execute`, `project.submission_artifact_policy.derive`, `project.post_submit_checker_policy.derive`, and `project.setup_run.update`. -AUTH-12E activates `project.guide_sufficiency.run`, and AUTH-12F3 activates -`project.submission_artifact_policy.derive`; the other two memberships remain +AUTH-12E activates `project.guide_sufficiency.run`, AUTH-12F3 activates +`project.submission_artifact_policy.derive`, and AUTH-12I activates +`project.guide_compilation.execute`; the remaining two memberships remain planned and unavailable. Each active action can be resolved for this fixed service only by an internal command carrying exact setup-run, expected-step, task/correlation, project, guide, snapshot, generation, stale @@ -1376,6 +1380,13 @@ successor and supersedes its exact hash-selected predecessor atomically. 12F3 activates derive only for the fixed `workstream.project.setup` service; approve remains planned. Operators must not treat the shared schema as wider activation. +Migration `0063_compilation_authority` admits the request action, permission, +and resource while preserving the earlier execute vocabulary. Empty downgrade +to `0062_guide_compilation` is supported, but any allowed or denied audit +evidence for either compilation action intentionally blocks downgrade. Do not +delete authority evidence to force rollback; retain revision 0063 or apply an +explicitly reviewed evidence-retention migration. + ## Draft review and revision policy authorization The guide-bound review-policy and revision-policy `PUT` routes require a UUID diff --git a/docs/operations_backend_testing.md b/docs/operations_backend_testing.md index 30b0746c1..1b5edcb23 100644 --- a/docs/operations_backend_testing.md +++ b/docs/operations_backend_testing.md @@ -41,7 +41,7 @@ unset WORKSTREAM_TEST_ADMIN_DATABASE_URL ``` Run both phases for the legacy sequential local diagnostic. Hosted CI instead -uses five independent matrix jobs, one per semantic lane, with a 20-minute lane +uses seven independent matrix jobs, one per semantic lane, with a 20-minute lane limit and a separate fail-closed fan-in job. The runner removes the admin URL before child launch, overwrites both child database URLs, @@ -84,7 +84,7 @@ If provisioning fails, confirm the local PostgreSQL provisioning credential can ## Hosted semantic-lane full-suite proof -The required GitHub check remains `Backend / test`. Five matrix jobs each own a +The required GitHub check remains `Backend / test`. Seven matrix jobs each own a digest-pinned PostgreSQL service container, a digest-pinned MinIO container, and exactly one dependency lane. A step-level curl health loop admits MinIO before collection. This is semantic fan-out, not arbitrary test-count sharding: @@ -92,20 +92,22 @@ lane ownership remains repository-defined and exact. The lanes are balanced by measured dependency ownership: `project_lifecycle` owns project tests, `task_lifecycle` owns task and checker tests, -`schema_contracts_a` and `schema_contracts_b` deterministically partition exact +`schema_contracts_a`, `schema_contracts_b`, and `schema_contracts_c` +deterministically partition exact node IDs from the measured 12-minute `test_alembic.py` hotspot; -`schema_contracts_a` also owns reset and isolated-runner contracts, and -`shared_foundations` owns the remaining authorization, artifact, API, and -infrastructure tests. Every non-partitioned module belongs to exactly one lane; -every collected test node, including each Alembic node, belongs to exactly one -lane. +`schema_contracts_a` also owns reset and isolated-runner contracts. The +`shared_foundations_a` and `shared_foundations_b` lanes deterministically +partition exact node IDs from the remaining authorization, artifact, API, and +infrastructure modules. Every collected test node, including each Alembic and +shared-foundation node, belongs to exactly one lane. Each matrix job binds its checkout to `GITHUB_SHA`, installs and asserts exact Ruff `0.15.22`, runs lint and docstrings, starts MinIO, and validates the full canonical inventory before executing its one lane. Each lane receives a distinct -runner-created database and role plus a distinct MinIO bucket/prefix custody -record. `shared_foundations` owns the actual `workstream-artifacts` test bucket -and a unique run prefix; other lanes create, probe, and remove distinct buckets. +runner-created database and role plus a distinct MinIO prefix custody record. +Both shared-foundation jobs run in separate MinIO containers, use the actual +`workstream-artifacts` test bucket, and receive distinct run prefixes; other +lanes create, probe, and remove distinct buckets. The isolated-runner self-tests remain in the canonical manifest as the explicit `admin_runner_self_test` execution kind. The lane orchestrator runs only those nodes directly with the admin URL while stripping application database URLs; @@ -122,14 +124,14 @@ Each matrix job uploads a fixed-name artifact bound to GitHub's checked-out PR merge-tree SHA, containing its manifest, lane evidence, isolation record, and coverage data. The final `test` job runs with `if: always()`, downloads available diagnostic bundles, then rejects any failed, cancelled, or skipped matrix result before fan-in. Fan-in -accepts exactly the five declared lane directories, +accepts exactly the seven declared lane directories, requires byte-identical manifests and heads, verifies every bound digest, and rejects symlinks or surplus lanes. After fan-in, independent validation rejects missing, duplicated, foreign, deselected, unexpectedly skipped, interrupted, or partially completed nodes. It also binds the exact head, manifest, per-lane isolation metadata, evidence, -and coverage-file SHA-256 digests. Only then are exactly five regular, +and coverage-file SHA-256 digests. Only then are exactly seven regular, non-symlink coverage files copied byte-for-byte for one literal `coverage combine`. The 78 percent global floor and every protected 90 percent subsystem floor remain blocking. The real API contract drill remains a separate @@ -139,7 +141,7 @@ isolated invocation inside the final required job. Each lane uploads one seven-day bundle, and the final job uploads the reconciled `.ci/test-lanes` tree. Its summary -records the exact head, canonical node count, five lane results, elapsed time, +records the exact head, canonical node count, seven lane results, elapsed time, and raw-file digests. Per-lane evidence records collected, completed, skipped, and deselected exact node IDs plus the bound resource-isolation metadata and coverage digest. Resource metadata is mode `0600`, omits credentials, and proves diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 2c4d08460..fce7c98c5 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -163,6 +163,8 @@ project.effective_policy.read project.update project.archive project.guide.manage +project.guide_compilation.request +project.guide_compilation.execute project.effective_policy.manage project.task.manage project.review_policy.manage @@ -236,12 +238,13 @@ registration, hidden ART behavior/resource composition, then dedicated AUTH evaluator integration and activation. ART never writes availability. AUTH-12, AUTH-14, and AUTH-15 are not alternate artifact activation paths. -These are 71 approved `PermissionId` values. `ActionId` values are a separate +These are 73 approved `PermissionId` values. `ActionId` values are a separate closed registry layer and are not included in that permission count. AUTH-05A's typed and PostgreSQL audit registry accepts the exact historical 49. The three approved Operator recovery identifiers, 16 artifact identifiers, `review.queue.override`, and the two AUTH-11A read-only project inspection -permissions are the exact 22 post-`0020` permissions. AUTH-07A, AUTH-11A, and +permissions plus the two compilation permissions are the exact 24 post-`0020` +permissions. AUTH-07A, AUTH-11A, and WS-XINT-002-01 add their matching typed/SQL audit parity without making them executable. @@ -250,8 +253,9 @@ and 41 planned rows before AUTH-12A. AUTH-12A added eighteen planned project-mutation rows, producing the historical 96-row state of 37 active and 59 planned. Later project-mutation and ART activation chunks advanced the pre-02C state to 45 active and 51 planned. WS-XINT-003-02C adds four planned -REV rows, and AUTH-12E activates three existing rows, producing the current -100-row state of 48 active and 52 planned. +REV rows. Subsequent approved activations advanced the pre-12I catalogue to 52 +active and 48 planned rows. AUTH-12I adds and activates the two compilation +request/execute rows, producing 102 rows: 54 active and 48 planned. AUTH-10A added five project-role read/manage rows; AUTH-10B owns and activates the three reads, while AUTH-10C owns and activates the two reason-bound, idempotent project-role mutations. AUTH-11A adds eleven @@ -548,7 +552,7 @@ closed: | `workstream.artifact.guide_reader` | `artifact.guide_source.read` | | `workstream.artifact.materializer` | `artifact.pre_submit.checker_input.materialize`, `artifact.post_submit.checker_input.materialize`, `artifact.review_packet.materialize` | | `workstream.artifact.checker_output` | `artifact.checker_output.write` | -| `workstream.project.setup` | `project.guide_sufficiency.run`, `project.submission_artifact_policy.derive`, `project.post_submit_checker_policy.derive`, `project.setup_run.update` | +| `workstream.project.setup` | `project.guide_compilation.execute`, `project.guide_sufficiency.run`, `project.submission_artifact_policy.derive`, `project.post_submit_checker_policy.derive`, `project.setup_run.update` | | `workstream.review.preference_expiry` | `review.preference_expiry.run` | | `workstream.review.lease_expiry` | `review.lease_expiry.run` | | `workstream.review.authority_invalidation_reconciliation` | `review.reconcile.run` | @@ -570,10 +574,11 @@ facts because `TaskAssignment` uses a new immutable ID for replacement rather than a separate generation counter. `workstream.project.setup` was the eighth fixed identity when AUTH-12B merged; -02C expands the current registry to fourteen identities. AUTH-12E activates only -`project.guide_sufficiency.run` for the exact internal setup-service command; -the other three project-setup actions and all six REV rows remain planned and -unavailable. Registration makes the +02C expands the current registry to fourteen identities. AUTH-12E activates +`project.guide_sufficiency.run`, AUTH-12F3 activates policy derivation, and +AUTH-12I activates exact unified compilation execution; the remaining two +project-setup actions and all six REV rows remain planned and unavailable. +Registration makes the identity selectable by the existing controlled provisioning route but creates no ActorProfile, ActorIdentityLink, role, grant, or executable authority by itself; migration `0043_project_setup_service` only expands the closed database @@ -1090,6 +1095,19 @@ outside any prepared handle; persistence obtains fresh authority. The fixed `workstream.project.setup` service may resolve only the run action internally with exact setup custody and no matched human grant. +Unified guide compilation separates human dispatch from provider execution. +`project.guide_compilation.request` requires a current exact-project Project +Manager grant; system-scoped grants do not substitute. Transaction-bound PREP +binds the actor, identity link, matched grant, immutable guide/setup lineage, +catalogue manifests, agent/instruction versions, and operation/request facts. +`project.guide_compilation.execute` belongs only to `workstream.project.setup`. +Its pre-provider check validates the complete typed attempt and provider key +without issuing a handle or writing authorization evidence. After provider I/O, fresh +PREP recomputes and verifies the complete result/component digest and commits +its allowed event only with POL-03B's immutable result transition. AUTH-12I +activates these authority boundaries only; it exposes no route, dispatches no +execution task, calls no provider, and does not make the hidden POL workflow live. + | ActionId | PermissionId | Activation owner | |---|---|---| | `project.create` (active) | `project.create` | `WS-AUTH-001-12C` | @@ -1100,6 +1118,8 @@ with exact setup custody and no matched human grant. | `project.revision_policy.update` (active) | `project.review_policy.manage` | `WS-XINT-003-02B` | | `project.guide_sufficiency_report.create` (active) | `project.guide.manage` | `WS-AUTH-001-12E` | | `project.guide_sufficiency.run` (active) | `project.guide.manage` | `WS-AUTH-001-12E` | +| `project.guide_compilation.request` (active) | `project.guide_compilation.request` | `WS-AUTH-001-12I` | +| `project.guide_compilation.execute` (active) | `project.guide_compilation.execute` | `WS-AUTH-001-12I` | | `project.guide_sufficiency.warnings.acknowledge` (active) | `project.guide.manage` | `WS-AUTH-001-12E` | | `project.submission_artifact_policy.create` (active) | `project.effective_policy.manage` | `WS-AUTH-001-12F2` | | `project.submission_artifact_policy.derive` (active) | `project.effective_policy.manage` | `WS-AUTH-001-12F3` | diff --git a/scripts/test_lightweight_agent_gates.py b/scripts/test_lightweight_agent_gates.py index dd3c9affa..a3650395e 100644 --- a/scripts/test_lightweight_agent_gates.py +++ b/scripts/test_lightweight_agent_gates.py @@ -107,9 +107,12 @@ def test_backend_uses_distributed_semantic_lanes_and_stable_fan_in(self) -> None self.assertNotIn("pull_request_review:", workflow) self.assertIn("cancel-in-progress: true", workflow) self.assertIn("matrix:\n lane:", workflow) - self.assertEqual(workflow.count(" - shared_foundations"), 1) + self.assertNotIn(" - shared_foundations\n", workflow) + self.assertEqual(workflow.count(" - shared_foundations_a\n"), 1) + self.assertEqual(workflow.count(" - shared_foundations_b\n"), 1) self.assertEqual(workflow.count(" - schema_contracts_a"), 1) self.assertEqual(workflow.count(" - schema_contracts_b"), 1) + self.assertEqual(workflow.count(" - schema_contracts_c"), 1) self.assertIn(" test:\n if: ${{ always() }}\n needs: lanes", workflow) self.assertIn("Require every semantic lane", workflow) self.assertIn("python -m scripts.merge_test_lane_evidence", workflow)