diff --git a/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json b/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json index 35ce913dd..bd6f69118 100644 --- a/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json +++ b/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json @@ -134,11 +134,11 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "4c55b5f9067f5cd54860370d8f37543e519d4afd782ee637fa5e4d4120e3c1ce", - "end_line": 3058, + "content_sha256": "3043402c35ebc6881418f55da007441ea1ebd8f5c22202d44b0db58a4889be12", + "end_line": 2951, "hard_limit": 1200, "kind": "test_file", - "observed_lines": 3058, + "observed_lines": 2951, "path": "backend/tests/test_artifact_admission.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -170,11 +170,11 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "a9fba3af25d8712261eea1b0f2de783b190c0c82f4e5ed8844e023c15c32371f", - "end_line": 1625, + "content_sha256": "28ff3e8f72805284a444f4b52dfdf5c19f09266b7a2565586f8c6539886e7e34", + "end_line": 1610, "hard_limit": 1200, "kind": "test_file", - "observed_lines": 1625, + "observed_lines": 1610, "path": "backend/tests/test_default_pre_submit_execution.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -194,39 +194,39 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "2148d2efe5122aa384163692940a3ac558031374168fcb2230536dc5d2288351", - "end_line": 2706, + "content_sha256": "81194689462162ba65ef8d41049e29c887d305bd9adf4bac60b7860f70ea1df0", + "end_line": 2599, "hard_limit": 120, "kind": "test_function", - "observed_lines": 134, + "observed_lines": 121, "path": "backend/tests/test_artifact_admission.py", "qualified_symbol": "test_checker_output_requires_exact_active_fixed_service_identity", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 2573 + "start_line": 2479 }, { "capability": "unassigned_legacy_auth", "content_sha256": "e9f4d7d65b3794e1642401465a6f2c5a4ae179865c1a4113f97b8494ae0e1241", - "end_line": 2439, + "end_line": 2345, "hard_limit": 120, "kind": "test_function", "observed_lines": 199, "path": "backend/tests/test_artifact_admission.py", "qualified_symbol": "test_guide_admission_consumes_real_project_manager_prep_atomically", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 2241 + "start_line": 2147 }, { "capability": "unassigned_legacy_auth", "content_sha256": "76e5cd8871311fb09405aa4c2fb280ff8722be235764e4eb3c6907f83c31fb2c", - "end_line": 2238, + "end_line": 2144, "hard_limit": 120, "kind": "test_function", "observed_lines": 157, "path": "backend/tests/test_artifact_admission.py", "qualified_symbol": "test_guide_admission_derives_three_scopes_without_provider_evidence", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 2082 + "start_line": 1988 }, { "capability": "unassigned_legacy_auth", @@ -554,11 +554,11 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "82bfaa06687cf11b51fb3018f3e0f0c2dcde7819a86e9ada5177888537d25e9f", - "end_line": 995, + "content_sha256": "e51894c0b79d58b60d2dd8f17ebe319b794fc2ae92d8ea4e1223202929511753", + "end_line": 980, "hard_limit": 120, "kind": "test_function", - "observed_lines": 553, + "observed_lines": 538, "path": "backend/tests/test_default_pre_submit_execution.py", "qualified_symbol": "test_effective_evidence_workflow_persists_once_and_replays_exactly", "removal_chunk": "WS-AUTH-003-CLOSE", @@ -603,26 +603,26 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "10292b6ed6f73baae351efbcb13102da0bd21eea2926587cef48e784baa95803", - "end_line": 363, + "end_line": 368, "hard_limit": 100, "kind": "test_helper", "observed_lines": 104, "path": "backend/tests/project_create_fixtures.py", "qualified_symbol": "seed_authorized_project", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 260 + "start_line": 265 }, { "capability": "unassigned_legacy_auth", - "content_sha256": "8c55ccd6549044a155547e4fa203c7d6e645452f4e45ba72ff7cd80350983117", - "end_line": 587, + "content_sha256": "583f52665e3c9d74e04dbfee577a53e5f5bf316f97b9211c918ead0b4507284a", + "end_line": 491, "hard_limit": 100, "kind": "test_helper", - "observed_lines": 247, + "observed_lines": 158, "path": "backend/tests/test_artifact_admission.py", "qualified_symbol": "_seed_checker_output_relationships", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 341 + "start_line": 334 }, { "capability": "unassigned_legacy_auth", diff --git a/.ci/behavior-ownership/partition.v1.json b/.ci/behavior-ownership/partition.v1.json index 57c5563bf..fd4385efe 100644 --- a/.ci/behavior-ownership/partition.v1.json +++ b/.ci/behavior-ownership/partition.v1.json @@ -852,6 +852,10 @@ "group": "lifecycle", "target": "backend/app/modules/projects/api/locked_policy.py" }, + { + "group": "lifecycle", + "target": "backend/app/modules/projects/api/policy_lineage.py" + }, { "group": "lifecycle", "target": "backend/app/modules/projects/api/post_policy.py" @@ -1030,15 +1034,15 @@ }, { "group": "lifecycle", - "target": "backend/app/modules/projects/locked_policy_repository.py" + "target": "backend/app/modules/projects/locked_policy_projection.py" }, { "group": "lifecycle", - "target": "backend/app/modules/projects/models.py" + "target": "backend/app/modules/projects/locked_policy_repository.py" }, { "group": "lifecycle", - "target": "backend/app/modules/projects/policy_lineage.py" + "target": "backend/app/modules/projects/models.py" }, { "group": "lifecycle", @@ -1317,7 +1321,7 @@ "target": "backend/scripts/validate_test_lane_evidence.py" } ], - "authority_digest": "7ffc098471cf45d88cfe75cf5405ef14dec93bec644fcb8ef8abd69e3efa0e14", + "authority_digest": "54a2edb9588f599931d277f5243561f912b5c8830f4d3e74d3d692b2786bf281", "protected_base_commit": "7676ce4347db0c9694962a9b587a20765e16eac6", "schema": "workstream.behavior-ownership-partition.v1" } diff --git a/.commitrail/INDEX.md b/.commitrail/INDEX.md index c9819044c..d70554c5d 100644 --- a/.commitrail/INDEX.md +++ b/.commitrail/INDEX.md @@ -7,12 +7,12 @@ for current product capability. | Initiative | Durable disposition | Next usable boundary | |---|---|---| | [WS-MCP-002](initiatives/WS-MCP-002/OVERVIEW.md) | Planned | Caller-token design and local profile proof recorded; continue WS-MCP-002-01 with selected schema verification and independent one-tool packaging | -| [WS-ARCH-001](initiatives/WS-ARCH-001/OVERVIEW.md) | Planned | Canonical ARCH-04A delivered; Automatic unified setup delivered; POL-05/06 manager review and separate approvals delivered; POL-07B internal checker phase service delivered; CP06 validation delivered; CP07 hidden activation/binding delivered; AUTH-12H live manager authority delivered; CP08 task-attempt lineage next | +| [WS-ARCH-001](initiatives/WS-ARCH-001/OVERVIEW.md) | Planned | Canonical ARCH-04A delivered; Automatic unified setup delivered; POL-05/06 manager review and separate approvals delivered; POL-07B internal checker phase service delivered; CP06 validation delivered; CP07 hidden activation/binding delivered; AUTH-12H live manager authority delivered; ARCH-03A complete internal guide context delivered; CP08 lineage and minimal writers next | | [WS-ART-001](initiatives/WS-ART-001/OVERVIEW.md) | Planned | Exact post-submit materialization after guide/checker contracts | -| [WS-AUTH-001](initiatives/WS-AUTH-001/OVERVIEW.md) | Planned | Unavailable shared-dispatcher contracts after delivered CP05; POL-04B consumes completed finalization authority; AUTH-12F4 supplies proposal authority; POL-05B public composition delivered; AUTH-12G post-policy authority delivered; POL-06B public composition delivered; POL-07B internal phase service delivered; CP06 validation delivered; CP07 hidden activation/binding delivered; AUTH-12H live manager authority delivered; CP08 task-attempt lineage next | -| [WS-CON-001](initiatives/WS-CON-001/OVERVIEW.md) | Planned | CP06 selected-policy validation delivered; CP07 hidden activation/binding delivered; AUTH-12H live manager authority delivered; CP08 task-attempt lineage next | +| [WS-AUTH-001](initiatives/WS-AUTH-001/OVERVIEW.md) | Planned | Unavailable shared-dispatcher contracts after delivered CP05; POL-04B consumes completed finalization authority; AUTH-12F4 supplies proposal authority; POL-05B public composition delivered; AUTH-12G post-policy authority delivered; POL-06B public composition delivered; POL-07B internal phase service delivered; CP06 validation delivered; CP07 hidden activation/binding delivered; AUTH-12H live manager authority delivered; ARCH-03A complete internal guide context delivered; CP08 lineage and minimal writers next | +| [WS-CON-001](initiatives/WS-CON-001/OVERVIEW.md) | Planned | CP06 selected-policy validation delivered; CP07 hidden activation/binding delivered; AUTH-12H live manager authority delivered; ARCH-03A complete internal guide context delivered; CP08 lineage and minimal writers next | | [WS-AUTH-003](initiatives/WS-AUTH-003/OVERVIEW.md) | Planned | Repair touched capabilities through `authorization.api` | -| [WS-POL-003](initiatives/WS-POL-003/OVERVIEW.md) | Planned | Unified setup, separate draft proposals and guide document intake delivered; Hidden proposal review/correction/pre-policy approval custody delivered; AUTH-12F4 proposal authority delivered; POL-05B public review/approval/manual correction dispatch delivered; POL-06A hidden post-policy projection/read/approval/correction delivered; AUTH-12G live authority delivered; POL-06B public access and automatic derivation delivered; POL-07A ART pre-submit attempt recovery delivered; POL-07B internal phase service delivered; CP06 validation delivered; CP07 hidden activation/binding delivered; AUTH-12H live manager authority delivered; CP08 task-attempt lineage next | +| [WS-POL-003](initiatives/WS-POL-003/OVERVIEW.md) | Planned | Unified setup, separate draft proposals and guide document intake delivered; Hidden proposal review/correction/pre-policy approval custody delivered; AUTH-12F4 proposal authority delivered; POL-05B public review/approval/manual correction dispatch delivered; POL-06A hidden post-policy projection/read/approval/correction delivered; AUTH-12G live authority delivered; POL-06B public access and automatic derivation delivered; POL-07A ART pre-submit attempt recovery delivered; POL-07B internal phase service delivered; CP06 validation delivered; CP07 hidden activation/binding delivered; AUTH-12H live manager authority delivered; ARCH-03A complete internal guide context delivered; CP08 lineage and minimal writers next | | [WS-REV-001](initiatives/WS-REV-001/OVERVIEW.md) | Planned | Shared acceptance/source and existing fence foundations; human hidden review work remains independently dependency-gated | | [WS-QUAL-002](initiatives/WS-QUAL-002/OVERVIEW.md) | Planned | Populate subsystem ownership before changed-line mutation work | | [WS-QUAL-003](initiatives/WS-QUAL-003/OVERVIEW.md) | Planned | Audit and prune test proof, add missing safety cases, decompose oversized test modules | diff --git a/.commitrail/initiatives/WS-ARCH-001/OVERVIEW.md b/.commitrail/initiatives/WS-ARCH-001/OVERVIEW.md index 55408bf2e..a85423b78 100644 --- a/.commitrail/initiatives/WS-ARCH-001/OVERVIEW.md +++ b/.commitrail/initiatives/WS-ARCH-001/OVERVIEW.md @@ -8,14 +8,14 @@ Exact pre-cutover work record: [`STATUS.md`](pre-cutover/STATUS.md), [`planning/chunk contracts`](pre-cutover/chunks/). - Disposition: Planned -- Completed boundary: through 02H, [CP05](WS-ARCH-001-CP05.md), [CP06](WS-ARCH-001-CP06.md), [CP07](WS-ARCH-001-CP07.md), and +- Completed boundary: through 02H, [CP05](WS-ARCH-001-CP05.md), [CP06](WS-ARCH-001-CP06.md), [CP07](WS-ARCH-001-CP07.md), [ARCH-03A](WS-ARCH-001-03A.md), and [ARCH-04A consolidation](WS-ARCH-001-04A.md) canonical post-submit contracts/conformance. - Intent: keep product modules behind explicit ports and composition roots. - Current boundary: one CHECKERS catalogue, compiler/parser and implementation per checker ID serve active policy consumers; production post-submit phase execution remains unavailable. -- Next usable boundary: CP08 task-attempt lineage, then ARCH-03A complete internal - guide facts after delivered CP07 activation and AUTH-12H live manager authority. POL-04B unified setup, POL-05/06 +- Next usable boundary: CP08 task-attempt lineage and minimal writers after + completed ARCH-03A internal guide context, following delivered CP07 activation and AUTH-12H live manager authority. POL-04B unified setup, POL-05/06 manager operations and POL-07B internal phase composition are delivered. The production post-submit phase remains unavailable until its separately sequenced ART/CHECKER/AUTH execution boundaries land. @@ -40,6 +40,6 @@ Exact pre-cutover work record: [`STATUS.md`](pre-cutover/STATUS.md), CP09 physical removal follows zero legacy consumers, including checker/public Submission cutover; it is not on the `allow_review` critical path. - Consolidated ARCH-04A supplies one current catalogue, immutable phase contracts and - registered structural conformance, not durable runs. ARCH-03A-03C then ARCH-04B-04F build project/task readiness, + registered structural conformance, not durable runs. CP08, then ARCH-03B/03C and ARCH-04B-04F build task readiness, post-submit checker/materialization, remediation, and `allow_review` before final public 02I cutover and later REV admission. diff --git a/.commitrail/initiatives/WS-ARCH-001/WS-ARCH-001-03A.md b/.commitrail/initiatives/WS-ARCH-001/WS-ARCH-001-03A.md new file mode 100644 index 000000000..7b9662182 --- /dev/null +++ b/.commitrail/initiatives/WS-ARCH-001/WS-ARCH-001-03A.md @@ -0,0 +1,334 @@ +# WS-ARCH-001-03A — Complete active and frozen guide context + +- Initiative: WS-ARCH-001 +- Durable disposition: Complete +- Intended merge outcome: the existing PROJECTS internal context port resolves + the complete approved, activated guide graph for new work or exact frozen work. + +## Intent + +Give TASK one canonical source for the complete guide-bound policy context. +An active guide supplies a new task lock; an existing task resolves its exact +stored guide/source/pre-policy selectors without adopting a newer guide or CON +publication. This is an internal port, not a new HTTP surface or task writer. + +## Current behavior and sequence correction + +Main `ad0e6b68` includes CP07 activation and AUTH-12H live manager authority. +`ProjectLockedPolicyContextPort` and `ProjectLockedPolicyRepository` currently +resolve only guide/source/effective/pre-submit facts, without activation, +post-submit, review/revision or contribution-policy custody. ART admission and +pre-submit evidence already consume this port. Some downstream fixtures mark a +guide active by disabling custody triggers instead of executing activation. + +The user approved ARCH-03A before CP08 after reviewing these concrete defects: +CP08 prohibited every writer needed to satisfy its required lineage constraints; +03A's CP08 prerequisite was artificial; and contribution-only schema tests cannot +prove a complete human revision rebase while old Submission foreign keys still +point to mutable Task context. CP08 will add its fields and minimal existing +writers together after this port. Full authorized revision preparation and its +immutable-context FK replacement remain with the later revision operation. +Retained work must never acquire invented lineage from a current selector. + +## Bounded change + +### Allowed + +- `backend/app/modules/projects/api/locked_policy.py` +- `backend/app/modules/projects/policy_lineage.py` -> `backend/app/modules/projects/api/policy_lineage.py` (canonical owner move, plus affected imports and exact ownership registration) and exports: extend the + existing immutable facts and port with an active-for-new-work read; retain the + exact frozen selector operation with one canonical result contract. +- `backend/app/modules/projects/locked_policy_repository.py` and a small same-owner + context projection helper if needed; existing PROJECTS adapter root wiring. +- Existing activation, proposal/finalization and post-policy custody readers: + narrowly reuse their exact validation for active/superseded guide reads, + refresh cached ORM values, and preserve draft-only mutation guards. +- The existing post-policy body parser and its CHECKERS/activation callers: + separate retained schema/hash custody from live catalogue eligibility; preserve + current execution and activation rejection behavior without a second parser. +- `backend/app/modules/tasks/service.py`: preserve the current-catalogue checks + inherited by existing screening/ready helpers from the shared parser; remove + its uncalled pre-Submission helper. No new TASK writer or cutover belongs here. +- Exact affected ART consumers only if a required type adaptation is necessary; + no ART business/authority behavior. Existing TASK selector persistence is not + changed in this chunk. +- Focused PROJECTS contract/PostgreSQL tests and affected downstream shared + fixtures, ART/AUTH regression tests, exact test-lane/behavior registrations and + genuinely shrinking structural-debt measurements if required. +- Current guide architecture/operations/data-model documentation, roadmap and + initiative navigation; adopted ARCH03A/CP08/03B sequence contracts. Preserve + main's MCP row and unrelated ongoing work. + +### Not allowed + +New HTTP endpoints, AUTH grants/actions, migrations or Task/Assignment/Submission +lineage fields/writers; CON selection/evaluation; checker execution; provider +calls; a new guide activation operation; compatibility aliases or a partial +context fallback; historical evidence fabrication or retained-data deletion; +claiming complete human revision rebase or introducing activation chronology. + +## Design and decisions + +1. Extend `ProjectLockedPolicyContextPort` with `lock_active_policy_context` for + an exact project. Both active and frozen reads share the same complete loader. + Frozen lookup continues to use the exact existing guide/version/source and + effective/pre-submit IDs/hashes. These select one immutable activated guide; + they do not select current CON or silently substitute another setup. +2. Require CP07 activation custody, its exact post-policy projection/approval, + unified finalization and separate pre-policy approval, canonical stored + bodies, selected review/revision policies and same-project contribution + identity. Reuse existing owner validation rather than another compiler or + policy-selection algorithm. Active reads require the sole active guide; + frozen reads admit active/superseded exact custody, including later CON + retirement, without rerunning current eligibility. +3. Extend the current fact type with required immutable activation receipt and + canonical artifact/post-submit/review/revision bodies. Catalogue facts are the + exact saved ID/version/schema-version/manifest-hash tuples in that receipt, + compared with the compilation attempt; full historical catalogue bodies are + not persisted and must not be reconstructed from the current registry. + The receipt supplies exact setup/finalization/result/component identities, + approval custody and contribution-policy selectors. Existing direct pre-policy + projections remain validated against that same receipt for ART consumption. + The immutable result independently binds its post-submit body hash to the + selected activation target; canonical serialization alone is insufficient. + Review and revision bodies independently match their receipt-selected hashes + through `require_complete_policy`. The result carries the required stored + review semantics format from the selected row; never infer a format or try + alternative hashes. Separate substitutions of human-review mode and revision + limits must reject, with positive retained-format and guard-removal proofs. + Move the sole pure `policy_lineage.py` owner into PROJECTS `api/` because + public contracts cannot import private modules. Update its affected imports + and exact ownership partition together; remove the old path without an alias. + The digest implementation and persisted evidence remain unchanged. + No ORM, session, mutable body, raw guide content or provider handle escapes. +4. Use actual CP07 operation ID, per-guide activation generation and timestamp. + Do not rename per-guide generation as project-wide chronology. The old planned + activation-sequence field has no source and is deferred with revision semantics. +5. Require a caller root transaction; never commit or mutate product state. + Lock Project first and discover the candidate guide without a Guide row lock. + Read its exact activation target, then reuse the canonical order: Attempt -> + Request -> Guide -> exact source/setup/compilation/projections and approval + custody -> finalization -> post-policy custody -> review/revision policies. + Re-fetch and validate the guide and activation receipt after waiting. Project + fencing stabilizes active selection; exact frozen selection never changes its + target. Refresh reused custody rows rather than trusting the identity map. + Never hold Guide while newly acquiring Attempt. Existing ART TASK/actor/link + locks remain before PROJECT; this reader acquires no AUTH locks. +6. Reuse `policy_lineage.require_complete_policy` and its persisted-format-aware + digest for exact review/revision bodies. Do not call activation-time + `validate_activation_ready`, consult current CON eligibility, or apply today's + automated-acceptance availability as a historical-read condition. Persisted + business policy/hash formats are evidence, not a second implementation. + Mutation callers retain their existing draft-only defaults; only this complete + context read supplies active/superseded eligibility. +7. Replace affected fabricated-positive guide fixtures with real saved approval, + CON publication and CP07 activation. Retain deliberate unbound/corrupt fixtures + only as negative or migration-preservation tests. No guard disabling to make a + positive complete-context test pass. + +## Acceptance criteria + +- Active and exact frozen reads return the same complete immutable graph for a + valid guide; frozen reads remain exact after a successor guide or CON retirement. +- Missing activation, either approval, finalization/source/catalogue mismatch, + foreign or substituted selectors, invalid bodies/hashes and unsupported + lifecycle states deny with the existing bounded context-unavailable error. +- A new active read returns the successor while the old frozen request still + returns its original graph; a newer publication alone selects nothing. +- Active/superseded context reads cannot broaden proposal/approval mutation + eligibility. Tests retain valid draft operations and active mutation denials. +- Real PostgreSQL readers refresh preloaded rows and serialize against guide + replacement/archival in both orders, with observed waiter/blocker evidence. +- Existing ART consumers use the sole strengthened port; no second partial path + or compatibility fixtures remain in the affected scope. + +## Risk and review routing + +- Risk: L1. +- Plan: architecture/reuse and security/QA feasibility reviews before code. +- Implementation: architecture/reuse, security, QA/test-delta, docs/product-ops; + CI-integrity for test ownership and final hosted proof. +- Human focus: complete source custody, frozen versus active selection, caller + transaction/lock order, no task/HTTP scope expansion, and corrected sequence. + +## Evidence + +Lead runs relevant unit and real isolated PostgreSQL tests, Ruff, module/AUTH +boundaries, Markdown links, stale wording, Commitrail records, exact inventories +and hosted complete coverage. New/materially changed modules remain at least 90%. +Current migration head is `0023_guide_activation_custody`; no migration changes. +Required falsification: +remove the activation receipt/ledger digest check and prove the otherwise-valid +activation-custody negative fails; substitute one exact receipt/policy selector while preserving all +other valid fields; and remove row refresh to expose a stale identity-map read. +Full suite and aggregate coverage remain hosted. Local evidence records exact +head and resource cleanup; no private guide documents or live providers are used. + + +## Exact implementation and proof map + +Product edit paths (unused paths need no change): + +- `backend/app/modules/projects/api/locked_policy.py` +- `backend/app/modules/projects/policy_lineage.py` -> `backend/app/modules/projects/api/policy_lineage.py` (canonical owner move, plus affected imports and exact ownership registration) +- `backend/app/modules/projects/api/__init__.py` +- `backend/app/modules/projects/locked_policy_repository.py` +- `backend/app/modules/projects/locked_policy_projection.py` (same-owner canonical projection) +- `backend/app/modules/projects/guide_activation/custody.py` +- `backend/app/modules/projects/guide_compilation/proposal_repository.py` +- `backend/app/modules/projects/guide_compilation/repository.py` +- `backend/app/modules/projects/guide_compilation/approval_custody.py` +- `backend/app/modules/projects/post_policy/repository.py` +- `backend/app/modules/projects/post_policy/custody.py` +- `backend/app/modules/projects/post_submit_policy.py` +- `backend/app/modules/checkers/service.py` (preserve live catalogue validation at execution) +- `backend/app/modules/tasks/service.py` (preserve affected live caller guards only) +- `backend/app/modules/projects/repository.py` + +The composition root and ART callers already consume the port and are inspection +scope, not new business behavior. `policy_lineage.py` is a reused unchanged owner. +Any additional product file requires an explicit contract correction first. + +Test edits are limited to `backend/tests/` paths below: + +- `projects/test_locked_policy_contract.py`, `projects/test_locked_policy_context.py` +- `projects/test_locked_policy_custody.py`, `projects/test_locked_policy_concurrency.py` +- `projects/locked_policy_fixtures.py` (shared real activation fixture) +- `projects/guide_activation/source_fixtures.py`, `projects/guide_activation/pg_support.py` +- `projects/guide_activation/test_successor.py` (extract reusable successor setup) +- `projects/unified_policy_fixtures.py`, `project_create_fixtures.py` +- `pre_submit_test_helpers.py`, `test_default_pre_submit_execution.py` +- `test_pre_submit_attempt_recovery.py`, `test_artifact_admission.py`, `test_artifact_recovery.py` +- `test_pre_submit_attempt_lock_order.py`, `test_pre_submit_related_lock_order.py` +- `authorization/contribution_policies/test_cross_owner_lock_order.py` +- `test_checkers.py`, `checkers/post_submit/test_compiled_policy.py` (saved parsing versus live validation) +- `test_ci_lane_catalogue.py` (exact owner set maintenance) +- `test_tasks.py` (catalogue-rollout screening/ready atomic denial) +- `test_review_lease_persistence.py` (reuse real guide-bound publication) +- `test_pre_submit_attempt_migration.py`, `migration_fixtures.py` + (isolate the 0021 retained-evidence boundary from later 0023 activation custody) + +Replace the affected complete-context positives, not every historical fixture. +The existing `activation_case` and real AUTH `guide_activation.pg_support.activate` +provide valid controls. Shared ART packet policy customization remains explicit. +No fake activation, disabled guide guards, or incomplete semantics may support a +positive complete-context proof. Existing deliberate SQL corruption fixtures may +remain only for negative or retention tests. + +Named verification: + +| Test | Required behavior and discriminating control | +| --- | --- | +| `test_active_and_frozen_context_are_complete` | Real finalization, separate approvals, CON publication and CP07 activation; compare every returned body and exact receipt/catalogue identity with stored source. | +| `test_frozen_context_survives_successor_and_retirement` | Activate a genuine successor; active read returns successor, frozen read returns original; retire CON version and preserve frozen facts. | +| `test_catalogue_rollout_preserves_context_but_blocks_new_activation` | Change current catalogue after saving exact custody; historical and active reads retain their bodies, while new activation denies and rolls back. Restoring current-registry validation inside the parser must make this test fail. | +| `test_catalogue_rollout_blocks_task_transition_without_writes` | Real HTTP screening/release denies the obsolete installed catalogue; independent PostgreSQL reads prove unchanged task fields and audit rows. Removing the live checks must make the exact cases fail. | +| `test_canonical_policy_sidecars_deny_before_manual_execution[catalogue-crossed]` | Valid saved policy cannot execute against a different installed catalogue; denial precedes lifecycle changes, audit writes and checker invocation. | +| `test_project_context_contract_does_not_cycle_agent_port_import` | Import the agent port in a fresh interpreter without relying on test collection order. | +| `test_new_publication_does_not_reselect_context` | Publish a new CON version while guide binding remains unchanged; both reads retain exact activation binding. | +| `test_context_rejects_missing_or_substituted_custody` | Parameterized missing activation/pre approval/post approval/finalization, crossed project or policy, catalogue mismatch, invalid body/hash and lifecycle; start with valid activated graph and alter only the selected boundary through controlled read corruption where DB forbids direct mutation. Assert bounded context error and no mutation. | +| `test_context_result_is_deeply_immutable` | Attempt nested receipt/body changes and show source/result identities cannot be mutated. | +| `test_context_rejects_substituted_post_policy_body` | Preserve valid activated facts and receipt, replace only the canonical post-submit body, and assert the specific activation mismatch. Removing the body-hash check must make this assertion fail. | +| `test_context_rejects_substituted_review_body` | Change only `human_review_required` to false with the receipt unchanged; require the semantic digest mismatch. Removing review validation independently must expose the substitution. | +| `test_context_rejects_substituted_revision_body` | Change only `max_revision_rounds` to 999 with the receipt unchanged; require the semantic digest mismatch. Removing revision validation independently must expose the substitution. | +| `test_context_binds_explicit_review_format` | Consistent public values for stored review formats and supported modes construct successfully; changing only the declared supported format rejects. This proves value consistency, not activation authority. | +| `test_context_preserves_persisted_review_semantics` | Format-aware stored review/revision hashes match exact bodies; incomplete semantics and altered human-review mode deny. Preserve retained policy hash identities, without adding runtime compatibility code. | +| `test_context_read_does_not_allow_activated_proposal_mutations` | Read valid active/superseded guides, then invoke proposal approve/correct defaults and require denial; valid draft operations still pass. | +| `test_context_refreshes_preloaded_custody` | Preload rows, change the test-visible cached state, reread persisted exact state; removing the required refresh makes the exact assertion fail. | +| `test_context_serializes_guide_replacement` | Reader-first and activation-first transactions, observed waiter/blocker PIDs, no Guide/Attempt inversion, successor freshness after wait. | +| `test_context_serializes_project_archival` | Reader-first and writer-first Project lifecycle change; frozen read cannot return stale active Project state. | +| `test_context_and_finalization_share_lock_order` | Real finalization replay on an activated target and context read in both orders: finalizer denies state normally; no deadlock, partial writes or hidden provider calls. Observe database locks before releasing participants. | + +ART admission, execution/recovery and real-AUTH cross-owner lock regressions run +through the strengthened port with real activation fixtures. Retain their original +ownership, replay, rollback and actual-ZIP assertions. Contract-only fake-session +coverage of the superseded partial reader is replaced by complete-graph tests. + +Required test-of-the-test probes: remove the activation receipt/ledger digest +comparison and require its otherwise-valid negative to fail; substitute one receipt selector +while keeping every other field valid; remove required fresh-read behavior and +require its named stale-cache test to fail. Record exact modified guard and test +output, then restore before the review candidate. Missing activation is also tested +separately; it cannot supply the exact target for this port and has no fallback. + +Lead commands: `pytest` on the four locked-policy modules plus affected ART/AUTH +modules through `backend/scripts/run_isolated_tests.py`; Ruff on touched Python; +`python3 scripts/check_commitrail_records.py --base-ref origin/main`; +`python3 scripts/check_markdown_links.py`; stale wording/authorization/artifact +contract checks; module/AUTH boundary validators; hosted complete test/coverage +lanes. Exact inventory edits may touch `backend/scripts/test_lane_catalogue.py`, +`backend/scripts/behavior_ownership.py`, `.ci/behavior-ownership/partition.v1.json`, +`.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json` and the existing +`.ci/behavior-ownership/lifecycle/project-guide-compilation-repository.json` only +when changed ownership or shrinking measurements require it, never weaker gates. + +Current documentation edits are README, `docs/roadmap_status.md`, +`docs/architecture_data_model.md`, `docs/architecture_checker_framework.md`, +`docs/operations_project_operating_manual.md`, ARCH/AUTH/CON/POL overviews, +`.commitrail/INDEX.md`, and adopted ARCH/AUTH planning dependency tables. Preserve +main's MCP entries. Assess local sheet exports only if present. + + +## Implementation outcome and proof custody + +ARCH-03A replaces the partial context reader with one complete active/frozen +loader and immutable result. Existing ART callers keep the same port and exact +request selectors. Shared approval reads retain effective/pre-policy locks and +refresh exact rows; mutation callers retain draft-only defaults. Exact-source +selection is extracted inside the existing finalization repository, keeping its +method below the existing structural limit without changing the limit. + +Affected ART fixtures now create real guide/source/finalization, separate +approvals, selected review/revision policies, published CON policy and real AUTH +activation. The old trigger-disabled positive activation helper is replaced, +and the partial-reader fake-session tests are replaced by complete graph tests. +Required JSON-value, failure, row-lock, immutable-history, authority, real-ZIP, +replay and rollback assertions remain. One artifact receipt assertion now targets +its exact put attempt instead of assuming how many guide documents exist. + +Local discriminating probes executed in an owned isolated PostgreSQL database: +removing the activation digest comparison made the named `activation_digest` +negative fail with DID NOT RAISE; removing activation-ledger refresh made the +preloaded-custody test fail; adding Guide-before-Attempt locking made the +finalization/context test fail with an actual PostgreSQL deadlock. All mutations +were restored. These are guard-specific test-of-the-test results, not claims of +production defects in the restored candidate. Shared final verification and +hosted aggregate custody belong to the PR trust bundle. + +The approved plan was reviewed at `936080ee` by architecture/reuse and security/QA. +Its lock-order, catalogue-identity, exact-scope/proof and sequence findings were +resolved before implementation. This record describes its intended merged +outcome; CP08 is the next bounded change and is not implemented here. No local +spreadsheet exports are present, so no XLSX or CSV update applies. + + +Review corrections separate saved post-policy schema/hash/sidecar custody from +live catalogue eligibility. The saved catalogue tuple is bound to the immutable +proposal/attempt; activation and execution retain explicit current-catalogue +validation. No optional bypass flag or second parser is introduced. The public +facts keep their typed, validated receipt while deferring its runtime import to +construction, removing a collection-order-dependent cycle through the agent port. +Exact ownership registries include the projection and tests without changing +thresholds or weakening equality. The orphaned trigger-disabled Project helper +and stale sequence wording are removed within this affected scope. + + +Shared-caller reconciliation preserves current-catalogue validation in both +existing TASK screening and locked-context helpers, including the latter's ready +transition and reads. Their intentional custody/readiness separation belongs to +CP08's port cutover. The uncalled `_validate_locked_post_submit_policy_context` +helper is removed; it supplied no live Submission protection and has no callers +or tests to preserve. A PROJECTS active-context read supplies saved facts, not +permission or execution eligibility for a new task. CP08 requires CHECKERS-owned +pre/post installed-capability validation before its initial lineage/status writes. + + +Downstream fixture repairs reuse the real guide-bound published CON version for +ReviewLease prerequisites instead of publishing a second active policy. Checker +output admission compares attempt/content/replica/receipt counts with the exact +starting graph; denial adds nothing and admit/replay adds one prepared attempt. +The 0021 retention tests execute only that revision's unchanged upgrade/downgrade +bodies in transactions, preserving later 0023 activation rows, authority events +and the Alembic head marker. They still prove byte-exact retained evidence, +absence of invented new fields, downgrade refusal and result bounds. No +production migration or retained data is changed by this fixture reconciliation. diff --git a/.commitrail/initiatives/WS-ARCH-001/planning/CHUNK_MAP.md b/.commitrail/initiatives/WS-ARCH-001/planning/CHUNK_MAP.md index 8c22083b9..001e5e87a 100644 --- a/.commitrail/initiatives/WS-ARCH-001/planning/CHUNK_MAP.md +++ b/.commitrail/initiatives/WS-ARCH-001/planning/CHUNK_MAP.md @@ -9,11 +9,11 @@ work accounting. Foundations through 02H and CP04B are complete; none restart. | `WS-ARCH-001-02I` | Admission-only public API/dispatch cutover and removal of legacy route reachability; physical economic cleanup remains CP09 after zero consumers | L1 | Deferred after 02H plus split 03/04/05 remediation, revision, checker-output and REV admission prerequisites | | [WS-ARCH-001-CP05](../WS-ARCH-001-CP05.md) | AUTH exact ContributionPolicy activation | L1 | Complete; exact policy authority delivered in PR #387 | | [WS-ARCH-001-CP06](../WS-ARCH-001-CP06.md) | CON guide-activation/revision policy-validation port | L1 | Complete; explicit version/purpose validation and shared current resource fences | -| [WS-ARCH-001-CP07](../WS-ARCH-001-CP07.md) | PROJECTS hidden activation/binding and replacement readiness guard | L1 | Complete; AUTH-12H live manager authority delivered; next CP08 task-attempt lineage, then ARCH-03A complete internal guide facts | -| [WS-ARCH-001-CP08](chunks/WS-ARCH-001-CP08-task-attempt-policy-lineage.md) | TASK/Assignment/Submission policy-lineage schema and public facts | L1 | Proposed foundation after CP07; no commands | +| [WS-ARCH-001-CP07](../WS-ARCH-001-CP07.md) | PROJECTS hidden activation/binding and replacement readiness guard | L1 | Complete; AUTH-12H live manager authority delivered; ARCH-03A complete internal guide facts delivered; next CP08 lineage and minimal writers | +| [WS-ARCH-001-CP08](chunks/WS-ARCH-001-CP08-task-attempt-policy-lineage.md) | TASK/Assignment/Submission policy-lineage schema, public facts and minimal existing writers | L1 | Planned after ARCH-03A; schema and minimal writers change together | | [WS-ARCH-001-CP09](chunks/WS-ARCH-001-CP09-legacy-economic-removal.md) | Physical retired economic-path cleanup coordination | L1 | Planned after all legacy consumers, including CHECKERS/public 02I, are replaced; not an allow_review dependency | -| [WS-ARCH-001-03A](chunks/WS-ARCH-001-03A-project-current-generation-api.md) | PROJECT current approved unified-generation public facts | L1 | Planned after AUTH-12H, CP07, and CP08; may reuse CP07 public guide fact but cannot duplicate its write | -| [WS-ARCH-001-03B](chunks/WS-ARCH-001-03B-task-assignment-api.md) | TASK readiness, claim, assignment and locked-context public commands/facts | L1 | Sole behavior owner after 03A and CP08; consumes CP08 fields/facts to write Task -> Assignment -> Submission lineage | +| [WS-ARCH-001-03A](chunks/WS-ARCH-001-03A-project-current-generation-api.md) | PROJECT current approved unified-generation public facts | L1 | Complete; one active/frozen context port reuses activation custody; CP08 next | +| [WS-ARCH-001-03B](chunks/WS-ARCH-001-03B-task-assignment-api.md) | TASK readiness, claim, assignment and locked-context public commands/facts | L1 | Planned after 03A and CP08; remaining queues, invalidation and broader projections; CP08 owns the minimal lineage writers | | [WS-ARCH-001-03C](chunks/WS-ARCH-001-03C-auth-task-readiness.md) | Exact task/assignment action activation, routing and invalidation proof | L1 | Planned after 03A/03B, CP08 and AUTH-OUTBOX-02; replacement precedes physical cleanup | | [WS-ARCH-001-04A](../WS-ARCH-001-04A.md) | CHECKER post-submit contract and registered evaluator conformance | L1 | Complete canonical catalogue, phase facts and structural conformance; consumed by delivered POL-04B and POL-07B | | [WS-ARCH-001-04B](chunks/WS-ARCH-001-04B-art-post-submit-materialization.md) | ART exact verified Submission materialization | L1 | Planned after 04A, POL-07, 03C and merged 02H | diff --git a/.commitrail/initiatives/WS-ARCH-001/planning/PLAN.md b/.commitrail/initiatives/WS-ARCH-001/planning/PLAN.md index 8e81881bc..b322ab06c 100644 --- a/.commitrail/initiatives/WS-ARCH-001/planning/PLAN.md +++ b/.commitrail/initiatives/WS-ARCH-001/planning/PLAN.md @@ -27,9 +27,9 @@ checker-remediation boundary before public Submission cutover. | POL-06B | POL-06A, AUTH-12G | PROJECTS live post-policy configuration, zero evaluator calls | | POL-07 | POL-06B, ARCH-04A, merged ART pre executor | One facade over ART pre and CHECKERS post contracts; no new persistence | | [AUTH-12H](../../WS-AUTH-001/WS-AUTH-001-12H.md) | POL-07, CP07, merged AUTH-12B2 | Complete: exact manager authority and internal composition for CP07; ARCH-03A completes internal guide facts; HTTP exposure remains pending | -| CP08 | CP07 | TASK-owned policy-lineage fields and public facts, no readiness commands | -| ARCH-03A | AUTH-12H, CP08 | PROJECTS current active-generation public facts | -| ARCH-03B | ARCH-03A, CP08 | TASK readiness/claim/assignment/Submission command lineage | +| [ARCH-03A](../WS-ARCH-001-03A.md) | AUTH-12H, CP07 | Complete active and exact frozen PROJECTS guide facts before CP08 | +| CP08 | ARCH-03A | TASK initial-attempt lineage schema, public facts and minimal existing writers together | +| ARCH-03B | ARCH-03A, CP08 | Remaining TASK queues, projections and assignment invalidation | | ARCH-03C | ARCH-03B, AUTH-OUTBOX-02 | AUTH exact task/assignment activation and integrated readiness proof | | CP09 (later cleanup coordination) | All legacy consumers replaced, including CHECKER and public 02I path | Physical economic deletion; not on the allow_review critical path | | ARCH-04B | ARCH-04A, POL-07, ARCH-03C, merged ARCH-02H | ART exact stored Submission materialization | @@ -57,8 +57,9 @@ queues or decisions, so this extension adds no REV-admission dependency cycle. CP05 and ARCH-04A have independent prerequisites. POL-04B consumes the corrected ARCH-04A catalogue/schema before producing approval-eligible generations. Owners may work concurrently if allowed paths do not overlap; shared catalogue/schema -changes must be serialized or rebased, not implemented twice. CP08 can proceed -after CP07 while policy setup finishes; it does not activate claims. Subsequent +changes must be serialized or rebased, not implemented twice. ARCH-03A completes the existing internal guide-context port after AUTH-12H. +CP08 then adds its schema and minimal existing writers together; ARCH-03B retains +queues/invalidation and broader projections. Subsequent PR-sized contracts name exact files, public types, current migration head and runnable proof before implementation; they refine this design, not create a new permission requirement. @@ -124,7 +125,7 @@ guide; they never become permissive fallback checks. - CON validates that the explicit expected policy version matches the active aggregate's current published selector under lock; PROJECTS binds it on activation. Existing work retains its frozen version. CP08 owns TASK lineage - schema, ARCH-03B writes it. Neither CON + schema and minimal existing writers together after ARCH-03A. ARCH-03B owns remaining task surfaces. Neither CON nor AUTH calls back into PROJECTS activation. - ARCH-04B/04D/04F replace historical ART-06A, XINT-06B/AUTH-14 and XINT-05C respectively; those old plans do not open parallel implementation lanes. diff --git a/.commitrail/initiatives/WS-ARCH-001/planning/chunks/WS-ARCH-001-03A-project-current-generation-api.md b/.commitrail/initiatives/WS-ARCH-001/planning/chunks/WS-ARCH-001-03A-project-current-generation-api.md index 8875d0cf8..8ac506c2e 100644 --- a/.commitrail/initiatives/WS-ARCH-001/planning/chunks/WS-ARCH-001-03A-project-current-generation-api.md +++ b/.commitrail/initiatives/WS-ARCH-001/planning/chunks/WS-ARCH-001-03A-project-current-generation-api.md @@ -1,7 +1,8 @@ # Chunk Contract: WS-ARCH-001-03A PROJECT Current Generation API -Status: non-executable planning skeleton after AUTH-12H and CP08; POL-08 cleanup remains -later. Risk: L1. Outcome: PROJECTS exposes immutable current approved unified +Adopted implementation contract: [ARCH-03A](../../WS-ARCH-001-03A.md). + +Status: Complete; CP08 is next; CP09 cleanup remains later. Risk: L1. Outcome: PROJECTS exposes immutable current approved unified guide, its exact guide-bound ContributionPolicyVersion, setup, pre-submit and post-submit identities/hashes through its public API. @@ -25,17 +26,16 @@ deny. Return contribution, review and revision policy lineage alongside both checker policies, not just a ContributionPolicy identifier. CP07 already owns activation writes and its response; do not implement them again here. -The complete public internal fact graph includes guide ID/version/activation -sequence; source snapshot/setup/compilation and result/component identities; +The complete public internal fact graph includes guide ID/version and CP07 activation operation, per-guide generation and timestamp; source snapshot/setup/compilation and result/component identities; artifact/effective/pre/post policy IDs, canonical hashes, required locked bodies -and catalogue snapshots; review/revision ID-generation-hash triples; and the +and persisted catalogue identity/version/schema/manifest-hash tuples; review/revision ID-generation-hash triples; and the guide-bound ContributionPolicy version plus activation provenance. Fields use canonical typed immutable values and are not automatically public HTTP fields. Historical resolution accepts the caller's exact locked selectors without reading TASK internals or consulting global CON selection. -Before implementation, replace this skeleton with a current-main contract that -enumerates exact files, commands, migration head and reviewers. +The adopted implementation contract above enumerates exact files, commands, +migration head, future proof and reviewers. Acceptance: one transaction-bound port returns only canonical immutable facts; guide activation has validated and bound one same-project published, complete, @@ -49,4 +49,4 @@ reviews: architecture, security, product/ops, QA, senior, reuse and test delta. ## Merge state -- Outcome on merge: `planned` +- Outcome on merge: `Complete` diff --git a/.commitrail/initiatives/WS-ARCH-001/planning/chunks/WS-ARCH-001-03B-task-assignment-api.md b/.commitrail/initiatives/WS-ARCH-001/planning/chunks/WS-ARCH-001-03B-task-assignment-api.md index d56deaa2c..5679f44ea 100644 --- a/.commitrail/initiatives/WS-ARCH-001/planning/chunks/WS-ARCH-001-03B-task-assignment-api.md +++ b/.commitrail/initiatives/WS-ARCH-001/planning/chunks/WS-ARCH-001-03B-task-assignment-api.md @@ -11,31 +11,17 @@ assignment transaction, not a second claim implementation. This skeleton still owns the missing contribution-policy attempt locks, queues, broader projections and invalidation behavior below; it is not completed by that repair. -The TASK readiness command inherits the ContributionPolicyVersion already -bound to the active Project Guide and locks it once as -`WorkstreamTask.locked_contribution_policy_version_id` before the task becomes -claimable. The later claim command performs no CON lookup: it copies that exact -locked identifier to -`TaskAssignment.submitter_contribution_policy_version_id` inside the TASK-owned -assignment transaction. TASK does not select, evaluate, or own -ContributionPolicy rules. `SubmissionCreationCommand` stamps the assignment's -current attempt version as immutable -`Submission.contribution_policy_version_id`. +The reconciled CP08 chunk owns contribution-policy fields and the minimal +existing screening/claim/Submission copy paths together after ARCH-03A. This +chunk consumes those complete frozen attempt facts; it must not reimplement the +writers or select current CON policy during ordinary claim. Preserve the +screening-time lock and existing authority/transaction ownership. -Preserve the existing readiness transition boundary: draft-to-screening may -stamp the guide-bound version; screening-to-READY verifies that persisted -complete lock. Neither READY nor claim selects a current CON version. A stale -lineage means a mismatch within the attempt's locked context, not merely a -new policy publication elsewhere. Remove retired economic reads/writes from -these replacement commands and their public projections before ARCH-03C; -physical columns remain until CP09 proves all other consumers are gone. - -Allowed: `backend/app/modules/tasks/api/**`, the smallest TASKS-owned -claim/assignment/service extraction, focused TASK tests, composition adapters, -`backend/app/modules/tasks/router.py` for deny-only route declarations, -boundary ledgers and initiative evidence/status. Not allowed: project-policy -evaluation, checker planning, artifact custody, AUTH decisions, legacy -eligibility fallback, public route cutover or revision semantics. +Allowed: the smallest remaining TASK queue, assignment invalidation and public +facts/projection changes, focused tests, composition adapters, deny-only route +declarations, boundary ledgers and current documentation. Not allowed: duplicate +lineage writers, project-policy evaluation, checker planning, ART custody, AUTH +decisions, compatibility paths, public cutover or human revision semantics. Declare the missing ready queue and replacement task surfaces against hidden owner commands; ARCH-03C owns their exact activation and live route switch. diff --git a/.commitrail/initiatives/WS-ARCH-001/planning/chunks/WS-ARCH-001-CP08-task-attempt-policy-lineage.md b/.commitrail/initiatives/WS-ARCH-001/planning/chunks/WS-ARCH-001-CP08-task-attempt-policy-lineage.md index bf54bed6c..441d98651 100644 --- a/.commitrail/initiatives/WS-ARCH-001/planning/chunks/WS-ARCH-001-CP08-task-attempt-policy-lineage.md +++ b/.commitrail/initiatives/WS-ARCH-001/planning/chunks/WS-ARCH-001-CP08-task-attempt-policy-lineage.md @@ -1,54 +1,75 @@ -# Chunk Contract: WS-ARCH-001-CP08 — Task Attempt Policy Lineage Foundation +# Chunk Contract: WS-ARCH-001-CP08 — Initial Attempt Policy Lineage -Status: proposed non-executable skeleton after CP07. Risk: L1. +Status: planned after ARCH-03A. Risk: L1. -TASKS adds its public immutable facts and persistence constraints for -`WorkstreamTask.locked_contribution_policy_version_id`, +## Reconciled boundary + +The user-approved [ARCH-03A reconciliation](../../WS-ARCH-001-03A.md) replaces +the former schema-only split. The old contract required lineage before claim +and Submission while prohibiting every writer of that lineage. ARCH-03A first +completes the existing PROJECTS active/frozen context port; CP08 then changes +schema and the minimal existing TASK writers together. + +CP08 owns `WorkstreamTask.locked_contribution_policy_version_id`, `TaskAssignment.submitter_contribution_policy_version_id`, and -`Submission.contribution_policy_version_id`. It adds no readiness, claim, -assignment-creation, Submission-creation, or revision command behavior. - -ARCH-03B remains the sole behavior owner: readiness writes the Task lock, claim -copies it to TaskAssignment, and Submission creation stamps the assignment's -attempt version. Ordinary claim performs no CON lookup. ReviewLease later -copies only the Submission stamp. Human `needs_revision` remains the sole -controlled same-Task/TaskAssignment rebase boundary for the next attempt. - -This chunk owns only TASK aggregate schema, repository persistence, immutable -public facts, and constraint tests; -it imports no PROJECTS, CON, AUTH, ART, CHECKERS, or REV internals. - -Explicitly define when a draft Task may lack the lock and require the complete -lineage before claimability/assignment/Submission. Preserve all other locked -guide, intake, post-submit, review and revision facts; this field addition is -not permission to overwrite an existing context or infer policy for retained -work. Resolve migration strategy from the actual current baseline and retained -rows; missing lineage must not be fabricated to satisfy a non-null constraint. -The bounded implementation records that inventory and fails closed if the -required source evidence is absent. - -Use same-project relational keys for the policy and stable Task/Assignment/ -Submission identity. Enforce creation-time equality to the locked assignment -in the database and make the Submission policy stamp immutable thereafter. -Do not foreign-key an immutable Submission's policy to its mutable Assignment -current-policy value: a later human revision must rebase the continuing Task -and Assignment without rewriting prior Submissions. Likewise closed historical -assignments must not be forced to follow a later Task lock. Current-attempt -equality guards must permit the complete authorized atomic rebase while -rejecting partial or unrelated updates. Reuse existing lineage/immutability -guards before proposing another attempt entity. - -Prove exact same-project lineage constraints, immutable Submission stamp, -round-trip public facts, missing/foreign identifier denial, migration parity, -and no new readiness/claim command or CON lookup. Run real PostgreSQL constraint -tests that directly insert a mismatched stamp, rebase the continuing Task and -Assignment while preserving old Submissions/closed assignments, create the -next Submission with the new stamp, and attempt to rewrite prior evidence. -Run boundary -checks and hosted coverage; architecture, security, QA and -product/operations review the field/state boundary. ARCH-03B supplies the -later command and race proof, not this schema foundation. - -## Merge state - -- Outcome on merge: `planned` +`Submission.contribution_policy_version_id`, their immutable public facts, +relational constraints, and the canonical screening/claim/Submission copy paths. +It reuses `TaskService` screening/context locking, `AuthorizedTaskCommands.claim`, +`TaskSubmissionContextPort`, and `TaskSubmissionCreationService/build_submission`. +Replace affected obsolete private policy/payment context paths with the existing +PROJECTS port; no parallel command or compatibility branch. + +Screening takes the complete active guide context once. This PROJECTS read +supplies exact custody, not readiness authority. Before initial lineage or a +screening/ready state is written, TASK must validate that installed CHECKERS +capabilities can execute both saved plans. Reuse +`EffectivePreSubmissionPlanningPort.compile_effective_plan` for pre-submit and +CHECKERS public `CompiledPostSubmitPolicy.validate_catalogue` for post-submit, +using the installed catalogue built from registered implementations at the +composition root. No new readiness workflow or parallel parser is needed. This validation performs no +checker execution or provider call. Fail before lineage, status, assignment or +audit writes. Keep historical reads independent of current availability; do not +make their success sufficient authority to advance new work. + +Ready/claim validate the exact frozen context, not a current CON selector. Claim copies the task stamp; +Submission copies the exact active assignment stamp. Ordinary claim/Submission +perform no CON lookup. Keep authorization and atomic operation ownership intact. +ARCH-03B retains queues, invalidation and broader task projections; ARCH-03C +retains their later exact authorization/public cutover. + +## Persistence and retained evidence + +A draft task may lack a complete lock; new claimable tasks, assignments and +Submissions may not. Use same-project policy and stable Task/Assignment/contributor +keys. Bind new Submission stamps to exact assignment values at creation and make +them immutable. Do not FK a frozen Submission to a mutable current assignment +policy value, or force closed assignments to follow later task context. + +Inventory the actual migration baseline. A current policy or present-day guide +binding is not proof of an old attempt. Any derivation needs exact immutable +original locked-context and CP07 receipt/temporal custody; otherwise refuse the +upgrade without modifying or deleting retained evidence. Do not add nullable +execution paths to keep incomplete earlier development code operational. + +Human `needs_revision` remains the only future complete-context rebase boundary. +Its actual revision-preparation owner must replace existing Submission-to-mutable- +Task context FKs and supply authority/audit proof. CP08 may test only narrow +policy-selector schema capability while retaining prior Submission/closed +Assignment stamps; this is not proof of a complete authorized guide rebase. + +## Verification and ownership + +Before implementation enumerate exact files, constraints, migration head, +retained-data handling and affected consumers in the combined CP08 record. +Prove real screening/claim/Submission copy paths; missing/foreign/mismatched +lineage rejection; no CON lookup at claim/Submission; immutable prior evidence; +caller rollback; concurrency and migration preservation/refusal. Prove that a +catalogue rollout leaves frozen PROJECTS reads intact while screening/ready +refuses new work atomically for unavailable pre-submit or post-submit capabilities. Account for +Submission's current staged flush-before-artifact-linkage transaction when +designing immediate versus deferred constraints. Use a discriminating faulty +stamp mutation, not merely invalid fixtures rejected by earlier guards. + +Required reviews: architecture/reuse, security, QA/test-delta, product/operations +and CI-integrity for migration/test registration. No new checker execution, +acceptance, compensation fulfillment, or revision operation belongs here. diff --git a/.commitrail/initiatives/WS-AUTH-001/OVERVIEW.md b/.commitrail/initiatives/WS-AUTH-001/OVERVIEW.md index 23868e3ae..9043c0010 100644 --- a/.commitrail/initiatives/WS-AUTH-001/OVERVIEW.md +++ b/.commitrail/initiatives/WS-AUTH-001/OVERVIEW.md @@ -21,8 +21,8 @@ Historical pre-cutover work records: [`STATUS.md`](pre-cutover/STATUS.md), - Public post-policy composition: POL-06B delivered using existing AUTH-12G. - Completed activation boundary: AUTH-12H exact-project manager authority for CP07 complete-guide activation/binding, with live-authority replay. -- Next usable boundary: CP08 task-attempt lineage, then ARCH-03A complete internal - guide facts. POL-07B internal phase composition is delivered. +- Next usable boundary: CP08 task-attempt lineage and minimal writers after + completed ARCH-03A internal guide context. POL-07B internal phase composition is delivered. Unavailable dispatcher contracts remain a separate contribution boundary. - Governing source: `docs/spec_authorization_service.md`, authorization code, migrations, and tests. @@ -55,7 +55,7 @@ POL-04B/05A/05B and AUTH-12F4 supply live unified setup and public manager proposal review, pre-submit approval and manual correction dispatch. 1. CP07 complete-guide activation/binding and AUTH-12H live authority are delivered. - CP08 supplies task-attempt lineage before ARCH-03A complete internal guide facts. + ARCH-03A supplies complete internal guide facts before CP08 lineage and minimal writers. 2. ARCH-03B/03C replace broad AUTH-13 and ARCH-04D replaces AUTH-14/XINT-06B. AUTH-OUTBOX-01/02 bracket hidden CON-02B dispatch; ARCH-04E2 activates only the proven TASK routing handler before ARCH-04E3 live composition. diff --git a/.commitrail/initiatives/WS-AUTH-001/planning/PLAN.md b/.commitrail/initiatives/WS-AUTH-001/planning/PLAN.md index e448999a2..7d5567425 100644 --- a/.commitrail/initiatives/WS-AUTH-001/planning/PLAN.md +++ b/.commitrail/initiatives/WS-AUTH-001/planning/PLAN.md @@ -14,10 +14,12 @@ AUTH-13/14 cutovers are not additional implementation work. phase composition are delivered. - [AUTH-12H](../WS-AUTH-001-12H.md) delivers exact-project manager authority after completed POL-07B phase composition, CP06 validation and CP07 guide binding. - CP08 task-attempt lineage and ARCH-03A complete internal guide facts follow; + ARCH-03A complete internal guide context is delivered; CP08 lineage and its + minimal existing writers are next; HTTP activation exposure remains pending. - CP05 owns exact ContributionPolicy-action activation after merged CP04B. -- ARCH-03B owns task behavior and ARCH-03C owns its exact activation. +- CP08 owns the minimal lineage writers; ARCH-03B owns remaining queues, + invalidation and projections, and ARCH-03C owns their exact activation. [AUTH-13 is superseded](chunks/WS-AUTH-001-13-task-assignment-cutover.md). - ARCH-04D alone activates post-submit materialization/output and CHECKERS execution/finalization after ARCH-04B/04B2/04C; historical AUTH-14 and XINT-06B diff --git a/.commitrail/initiatives/WS-CON-001/OVERVIEW.md b/.commitrail/initiatives/WS-CON-001/OVERVIEW.md index 3e7c58b0e..53859aaa1 100644 --- a/.commitrail/initiatives/WS-CON-001/OVERVIEW.md +++ b/.commitrail/initiatives/WS-CON-001/OVERVIEW.md @@ -9,7 +9,8 @@ and the [capability ledger](../../../docs/roadmap_status.md). immutable ContributionRecords and optional project-policy-driven compensation awards without coupling lifecycle truth to an economic provider. -- Next usable boundary: CP08 task-attempt lineage after completed +- Next usable boundary: CP08 lineage and minimal writers after completed + ARCH-03A internal guide context, [CP07 activation/binding](../WS-ARCH-001/WS-ARCH-001-CP07.md) and [AUTH-12H live authority](../WS-AUTH-001/WS-AUTH-001-12H.md), before task readiness. - Governing sources: `docs/spec_contribution_compensation.md`, @@ -40,8 +41,9 @@ the hidden policy behavior. 1. Completed CP06 validates the expected version against the active policy's current published selector for new guide activation, without reselecting existing frozen work; CP07 supplies hidden PROJECTS - activation/binding, and AUTH-12H supplies its live manager authority. CP08 supplies lineage fields; - ARCH-03B locks/copies them through TaskAssignment and Submission. CP09 removes the replaced legacy + activation/binding, and AUTH-12H supplies its live manager authority. ARCH-03A completes the internal context port; + CP08 adds lineage fields and existing Task/Assignment/Submission writers together. + ARCH-03B retains queues, invalidation and broader projections. CP09 removes the replaced legacy economic path only after all consumers are replaced, including CHECKERS and public Submission cutover; it does not block canonical `allow_review`. 2. Add CON-03C ContributionRecord/CompensationAward persistence after the diff --git a/.commitrail/initiatives/WS-POL-003/OVERVIEW.md b/.commitrail/initiatives/WS-POL-003/OVERVIEW.md index 041b067df..0241f183f 100644 --- a/.commitrail/initiatives/WS-POL-003/OVERVIEW.md +++ b/.commitrail/initiatives/WS-POL-003/OVERVIEW.md @@ -27,8 +27,8 @@ Exact pre-cutover work record: [`STATUS.md`](pre-cutover/STATUS.md), Production post-submit phase execution remains unavailable. - Intent: compile one locked guide and its policies into authoritative, versioned project behavior without circular subsystem authority. -- Next usable boundary: CP08 task-attempt lineage, then ARCH-03A complete internal - guide facts after completed CP07 activation/binding and +- Next usable boundary: CP08 task-attempt lineage and minimal writers after + completed ARCH-03A internal guide context, following completed CP07 activation/binding and [AUTH-12H live authority](../WS-AUTH-001/WS-AUTH-001-12H.md). POL-07B consumes the completed POL-07A ART attempt prerequisite and ARCH-04A value contracts. POL-06B public policy review and automatic derivation use completed diff --git a/README.md b/README.md index e3a39150b..1e7fd5075 100644 --- a/README.md +++ b/README.md @@ -556,6 +556,14 @@ before product resources, binds the complete activation digest and rechecks live authority on replay. Composition without an authority adapter still denies. Public activation wiring and downstream task/revision integration remain pending. +ARCH-03A completes the existing internal PROJECTS context port. New work selects +one active activated guide; existing work resolves its exact frozen guide and +policy selectors, including after a successor or contribution-policy retirement. +The result includes the saved activation receipt, both checker-policy bodies, +artifact/effective policy, review/revision semantics and recorded catalogue +identities. It never substitutes current policies or reruns inference. CP08 next +adds task-attempt lineage fields and their minimal existing writers together. + ## v0.1 Success Standard Workstream v0.1 succeeds only when the complete lifecycle defined at the top of diff --git a/backend/app/modules/checkers/service.py b/backend/app/modules/checkers/service.py index 1c6e3aae0..659f5997f 100644 --- a/backend/app/modules/checkers/service.py +++ b/backend/app/modules/checkers/service.py @@ -42,7 +42,9 @@ EffectiveProjectSubmissionArtifactPolicy, PreSubmitCheckerPolicy, ) -from app.modules.checkers.api.post_submit_catalogue import CompiledPostSubmitPolicy +from app.modules.checkers.api.post_submit_catalogue import ( + CompiledPostSubmitPolicy, current_post_submit_catalogue, +) from app.modules.checkers.api.post_submit import ExpectedPostSubmitContext, ObservedPostSubmitContext from app.modules.projects.post_submit_policy import ( parse_locked_post_submit_checker_policy_body, @@ -364,6 +366,7 @@ async def _load_locked_post_submit_policy( guide_version=locked_version, policy_hash=locked_hash, ) + locked_policy.validate_catalogue(current_post_submit_catalogue()) except ValueError as exc: raise CheckerPolicyInvalid("locked post-submit checker policy hash is invalid") from exc try: diff --git a/backend/app/modules/projects/api/locked_policy.py b/backend/app/modules/projects/api/locked_policy.py index 5950fcde2..88291a94e 100644 --- a/backend/app/modules/projects/api/locked_policy.py +++ b/backend/app/modules/projects/api/locked_policy.py @@ -5,9 +5,14 @@ from dataclasses import dataclass import hashlib import json -from typing import Literal, Mapping, Protocol, get_args +from typing import TYPE_CHECKING, Literal, Mapping, Protocol, get_args from uuid import UUID +from app.modules.projects.api.policy_lineage import ReviewSemanticsFormat, require_complete_policy + +if TYPE_CHECKING: + from .guide_activation import GuideActivationReceipt + ProjectLockedPolicyGuideStatus = Literal["active", "superseded"] ProjectLockedPolicyEffectiveStatus = Literal["approved", "superseded"] ProjectLockedPolicyPreSubmitStatus = Literal["compiled", "superseded"] @@ -118,6 +123,12 @@ class ProjectLockedPolicyContextFacts: pre_submit_policy_status: ProjectLockedPolicyPreSubmitStatus pre_submit_compiler_version: str compiled_pre_submit_bundle: CanonicalJsonObject + activation_receipt: GuideActivationReceipt + artifact_policy: CanonicalJsonObject + compiled_post_submit_policy: CanonicalJsonObject + review_policy: CanonicalJsonObject + review_semantics_format: ReviewSemanticsFormat + revision_policy: CanonicalJsonObject def __post_init__(self) -> None: """Reject lifecycle values outside the closed historical sets.""" @@ -137,11 +148,78 @@ def __post_init__(self) -> None: or not self.pre_submit_compiler_version.strip() ): raise ValueError("project locked policy facts are invalid") + if not all( + isinstance(value, CanonicalJsonObject) + for value in ( + self.effective_policy, + self.compiled_pre_submit_bundle, + self.artifact_policy, + self.compiled_post_submit_policy, + self.review_policy, + self.revision_policy, + ) + ): + raise ValueError("project locked policy bodies must be canonical immutable values") + from .guide_activation import GuideActivationReceipt + + receipt = GuideActivationReceipt.model_validate( + self.activation_receipt.model_dump(mode="json") + ) + target, upstream = receipt.command.target.proposal, receipt.command.target.upstream + if ( + ( + self.project_id, + self.guide_id, + self.guide_version, + self.source_snapshot_id, + self.source_snapshot_hash, + ) + != ( + target.project_id, + target.guide_id, + target.guide_version, + target.source_snapshot_id, + target.source_snapshot_hash, + ) + or ( + self.effective_policy_id, + self.effective_policy_hash, + self.pre_submit_policy_id, + self.pre_submit_policy_bundle_hash, + ) + != ( + upstream.effective_policy_id, + upstream.effective_policy_hash, + upstream.pre_submit_policy_id, + upstream.pre_submit_bundle_hash, + ) + or self.effective_policy.sha256 != self.effective_policy_hash + or self.compiled_pre_submit_bundle.sha256 != self.pre_submit_policy_bundle_hash + or self.artifact_policy.sha256 != target.artifact_policy_hash + or self.compiled_post_submit_policy.sha256 != receipt.command.target.policy_hash + ): + raise ValueError("project locked policy facts differ from activation") + require_complete_policy( + kind="review", status="complete", policy_hash=receipt.command.review.policy_hash, + semantic_values=json.loads(self.review_policy.value), + review_semantics_format=self.review_semantics_format, + ) + require_complete_policy( + kind="revision", status="complete", policy_hash=receipt.command.revision.policy_hash, + semantic_values=json.loads(self.revision_policy.value), + ) + object.__setattr__(self, "activation_receipt", receipt) class ProjectLockedPolicyContextPort(Protocol): """Transaction-bound PROJECT capability for exact locked policy facts.""" + async def lock_active_policy_context( + self, + project_id: UUID, + ) -> ProjectLockedPolicyContextFacts: + """Lock the sole active complete guide for a new task context.""" + async def lock_locked_policy_context( self, request: ProjectLockedPolicyContextRequest, diff --git a/backend/app/modules/projects/policy_lineage.py b/backend/app/modules/projects/api/policy_lineage.py similarity index 100% rename from backend/app/modules/projects/policy_lineage.py rename to backend/app/modules/projects/api/policy_lineage.py diff --git a/backend/app/modules/projects/guide_activation/custody.py b/backend/app/modules/projects/guide_activation/custody.py index 3822beeaf..cbc25a07e 100644 --- a/backend/app/modules/projects/guide_activation/custody.py +++ b/backend/app/modules/projects/guide_activation/custody.py @@ -67,7 +67,7 @@ async def load_guide_activation(session, guide): record = await session.scalar( select(GuideMutationIdempotencyRecord).where( GuideMutationIdempotencyRecord.operation_id == guide.activation_operation_id, - ) + ).execution_options(populate_existing=True) ) if record is None: raise ValueError("guide activation binding unavailable") diff --git a/backend/app/modules/projects/guide_compilation/approval_custody.py b/backend/app/modules/projects/guide_compilation/approval_custody.py index 5b0f9c19d..e74a82ee2 100644 --- a/backend/app/modules/projects/guide_compilation/approval_custody.py +++ b/backend/app/modules/projects/guide_compilation/approval_custody.py @@ -42,7 +42,7 @@ async def load_approval_custody(session, policy_id: str | None) -> ApprovalCusto .where( ProjectGuideProposalApproval.artifact_policy_id == policy_id, ) - .with_for_update() + .with_for_update().execution_options(populate_existing=True) ) if operation is None: return None @@ -51,25 +51,25 @@ async def load_approval_custody(session, policy_id: str | None) -> ApprovalCusto .where( SubmissionPolicyMutationIdempotencyRecord.operation_id == operation.operation_id, ) - .with_for_update() + .with_for_update().execution_options(populate_existing=True) ) effective = await session.get( - EffectiveProjectSubmissionArtifactPolicy, operation.effective_policy_id + EffectiveProjectSubmissionArtifactPolicy, operation.effective_policy_id, populate_existing=True, with_for_update=True ) - pre = await session.get(PreSubmitCheckerPolicy, operation.pre_submit_policy_id) + pre = await session.get(PreSubmitCheckerPolicy, operation.pre_submit_policy_id, populate_existing=True, with_for_update=True) if reservation is None or effective is None or pre is None: raise ValueError("approval custody is incomplete") successor = await session.scalar( select(ProjectGuideProposalApproval).where( ProjectGuideProposalApproval.prior_approval_operation_id == operation.operation_id, - ) + ).execution_options(populate_existing=True) ) if successor is not None: - next_policy = await session.get(SubmissionArtifactPolicy, successor.artifact_policy_id) + next_policy = await session.get(SubmissionArtifactPolicy, successor.artifact_policy_id, populate_existing=True) next_effective = await session.get( - EffectiveProjectSubmissionArtifactPolicy, successor.effective_policy_id + EffectiveProjectSubmissionArtifactPolicy, successor.effective_policy_id, populate_existing=True ) - next_pre = await session.get(PreSubmitCheckerPolicy, successor.pre_submit_policy_id) + next_pre = await session.get(PreSubmitCheckerPolicy, successor.pre_submit_policy_id, populate_existing=True) if ( next_policy is None or next_effective is None diff --git a/backend/app/modules/projects/guide_compilation/proposal_repository.py b/backend/app/modules/projects/guide_compilation/proposal_repository.py index 5ce0a4162..32cc443dc 100644 --- a/backend/app/modules/projects/guide_compilation/proposal_repository.py +++ b/backend/app/modules/projects/guide_compilation/proposal_repository.py @@ -56,14 +56,17 @@ class GuideProposalRepository: def __init__(self, session: AsyncSession) -> None: self.session = session - async def lock(self, selection: GuideProposalSelection) -> LockedGuideProposal: + async def lock( + self, selection: GuideProposalSelection, *, + allowed_guide_statuses: frozenset[str] = frozenset({"draft"}), + ) -> LockedGuideProposal: """Resolve only the selected compilation, including retained predecessors.""" compilation = await self.session.scalar( select(ProjectGuideCompilation).where( ProjectGuideCompilation.id == selection.compilation_id, ProjectGuideCompilation.project_id == str(selection.project_id), ProjectGuideCompilation.guide_id == str(selection.guide_id), - ) + ).execution_options(populate_existing=True) ) if compilation is None: raise GuideProposalError("proposal_unavailable") @@ -77,7 +80,8 @@ async def lock(self, selection: GuideProposalSelection) -> LockedGuideProposal: compilation.attempt_id, exact_setup=True, ) - require_lineage(view, command, require_current=False) + require_lineage(view, command, require_current=False, + allowed_guide_statuses=allowed_guide_statuses) finalization = await self.session.scalar( select(ProjectGuideSetupFinalization) .where( diff --git a/backend/app/modules/projects/guide_compilation/repository.py b/backend/app/modules/projects/guide_compilation/repository.py index 9e628cba5..6eb99c6ce 100644 --- a/backend/app/modules/projects/guide_compilation/repository.py +++ b/backend/app/modules/projects/guide_compilation/repository.py @@ -155,28 +155,9 @@ async def lock_finalization( if guide is None or guide.project_id != str(command.project_id): raise GuideCompilationIntegrityError("finalization guide unavailable") await self._session.refresh(guide) - snapshot = await projects.lock_latest_guide_source_snapshot( - str(command.project_id), guide.id, guide.version + setup, snapshot = await self._lock_finalization_source( + projects, command, guide, exact_setup=exact_setup, ) - setup = await projects.lock_latest_project_setup_run( - str(command.project_id), guide.id, guide.version - ) - if exact_setup: - # Downstream review can select retained custody explicitly. The - # finalizer still uses only the latest source via the default path. - setup = await projects.lock_project_setup_run(str(command.setup_run_id)) - if setup is not None: - snapshot = await self._session.scalar( - select(GuideSourceSnapshot) - .where(GuideSourceSnapshot.id == setup.source_snapshot_id) - .with_for_update() - .execution_options(populate_existing=True) - ) - if setup is None or snapshot is None: - raise GuideCompilationIntegrityError("finalization setup unavailable") - await self._session.refresh(snapshot) - # Re-fetch the latest setup so a waiting session never uses a cached pre-state. - await self._session.refresh(setup) compilation = await self._session.scalar( select(ProjectGuideCompilation) .where(ProjectGuideCompilation.id == command.compilation_id) @@ -235,6 +216,33 @@ async def lock_finalization( await load_approval_custody(self._session, policy.id if policy else None), ) + async def _lock_finalization_source(self, projects, command, guide, *, exact_setup): + """Resolve the explicitly retained source or the finalizer's latest source.""" + if exact_setup: + # Downstream review can select retained custody explicitly. The + # finalizer still uses only the latest source via the default path. + setup = await projects.lock_project_setup_run(str(command.setup_run_id)) + snapshot = None + if setup is not None: + snapshot = await self._session.scalar( + select(GuideSourceSnapshot) + .where(GuideSourceSnapshot.id == setup.source_snapshot_id) + .with_for_update() + .execution_options(populate_existing=True) + ) + else: + snapshot = await projects.lock_latest_guide_source_snapshot( + str(command.project_id), guide.id, guide.version + ) + setup = await projects.lock_latest_project_setup_run( + str(command.project_id), guide.id, guide.version + ) + if setup is None or snapshot is None: + raise GuideCompilationIntegrityError("finalization setup unavailable") + await self._session.refresh(snapshot) + await self._session.refresh(setup) + return setup, snapshot + async def persist_finalization(self, row, setup) -> None: """Insert custody then close the setup using the database transaction clock.""" self._session.add(row) @@ -302,7 +310,7 @@ async def request_operation_for_attempt( ) if lock: statement = statement.with_for_update() - operation = await self._session.scalar(statement) + operation = await self._session.scalar(statement.execution_options(populate_existing=True)) if operation is None: raise GuideCompilationIntegrityError("compilation request custody is missing") return operation diff --git a/backend/app/modules/projects/locked_policy_projection.py b/backend/app/modules/projects/locked_policy_projection.py new file mode 100644 index 000000000..162167b12 --- /dev/null +++ b/backend/app/modules/projects/locked_policy_projection.py @@ -0,0 +1,77 @@ +"""Detach complete validated PROJECTS custody into the sole immutable port result.""" + +from uuid import UUID + +from app.modules.projects.api.locked_policy import ( + CanonicalJsonObject, + ProjectLockedPolicyContextFacts, +) +from app.modules.projects.guide_compilation.approval_custody import approved_projection_digest +from app.modules.projects.api.policy_lineage import ( + ReviewPolicySemantics, + RevisionPolicySemantics, + require_complete_policy, +) + + +def _policy_body(kind, row, selection, guide): + if row is None or ( + row.project_id != guide.project_id + or row.guide_version != guide.version + or (row.id, row.policy_generation, row.policy_hash) + != (str(selection.policy_id), selection.generation, selection.policy_hash) + ): + raise ValueError("selected policy unavailable") + model = ReviewPolicySemantics if kind == "review" else RevisionPolicySemantics + values = {name: getattr(row, name) for name in model.model_fields} + options = {"review_semantics_format": row.semantics_format} if kind == "review" else {} + require_complete_policy( + kind=kind, + status=row.semantics_status, + policy_hash=row.policy_hash, + semantic_values=values, + **options, + ) + return CanonicalJsonObject.from_mapping(model.model_validate(values).model_dump(mode="json")) + + +def complete_context(locked, post_policy, post_custody, receipt, review, revision): + """Require exact activation and each approved output, without rerunning eligibility.""" + view = locked.view + command = receipt.command + if ( + command.target != post_custody.target + or command.target.proposal != locked.target + or post_custody.approval is None + or post_custody.approval.operation_id != command.post_approval_operation_id + or post_custody.approval.output_digest != command.post_approval_output_digest + ): + raise ValueError("activation post-policy custody mismatch") + approved_projection_digest(view) + approval = view.approval_custody + snapshot = CanonicalJsonObject.from_mapping(view.snapshot.manifest_json) + if snapshot.sha256 != locked.target.source_snapshot_hash: + raise ValueError("source manifest hash mismatch") + return ProjectLockedPolicyContextFacts( + project_id=locked.target.project_id, + guide_id=locked.target.guide_id, + guide_version=view.guide.version, + guide_status=view.guide.status, + source_snapshot_id=UUID(view.snapshot.id), + source_snapshot_hash=view.snapshot.bundle_hash, + effective_policy_id=UUID(approval.effective.id), + effective_policy_hash=approval.effective.effective_policy_hash, + effective_policy_status=approval.effective.lifecycle_status, + effective_policy=CanonicalJsonObject.from_mapping(approval.effective.effective_policy), + pre_submit_policy_id=UUID(approval.pre.id), + pre_submit_policy_bundle_hash=approval.pre.compiled_bundle_hash, + pre_submit_policy_status=approval.pre.lifecycle_status, + pre_submit_compiler_version=approval.pre.compiler_version, + compiled_pre_submit_bundle=CanonicalJsonObject.from_mapping(approval.pre.compiled_bundle), + activation_receipt=receipt, + artifact_policy=CanonicalJsonObject.from_mapping(view.policy.policy_body), + compiled_post_submit_policy=CanonicalJsonObject.from_mapping(post_policy.policy_body), + review_policy=_policy_body("review", review, command.review, view.guide), + review_semantics_format=review.semantics_format, + revision_policy=_policy_body("revision", revision, command.revision, view.guide), + ) diff --git a/backend/app/modules/projects/locked_policy_repository.py b/backend/app/modules/projects/locked_policy_repository.py index 53b2e81c1..3b29e0013 100644 --- a/backend/app/modules/projects/locked_policy_repository.py +++ b/backend/app/modules/projects/locked_policy_repository.py @@ -1,143 +1,111 @@ -"""PROJECT persistence for exact task-locked policy lineage.""" +"""Complete active or exact frozen guide context under the caller's transaction.""" -from __future__ import annotations - -from collections.abc import Mapping -from typing import Any from uuid import UUID from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.modules.projects.api import ( - CanonicalJsonObject, ProjectLockedPolicyContextFacts, ProjectLockedPolicyContextRequest, ProjectLockedPolicyContextUnavailable, + ProjectGuideSetupFinalizationError, ) -from app.modules.projects.models import ( - EffectiveProjectSubmissionArtifactPolicy, - GuideSourceSnapshot, - PreSubmitCheckerPolicy, - Project, - ProjectGuide, -) +from app.modules.projects.api.guide_proposals import GuideProposalError +from app.modules.projects.api.post_policy import PostPolicySelection +from app.modules.projects.guide_activation.custody import load_guide_activation +from app.modules.projects.guide_compilation.repository import GuideCompilationIntegrityError +from app.modules.projects.locked_policy_projection import complete_context +from app.modules.projects.models import Project, ProjectGuide +from app.modules.projects.post_policy.repository import PostPolicyRepository +from app.modules.projects.repository import ProjectRepository class ProjectLockedPolicyRepository: - """Resolve one exact historical locked-policy context under PROJECT locks.""" + """Resolve one complete graph without current CON or catalogue selection.""" def __init__(self, session: AsyncSession) -> None: self._session = session + async def lock_active_policy_context(self, project_id: UUID) -> ProjectLockedPolicyContextFacts: + """Select the sole active guide while retaining the Project fence.""" + return await self._resolve(project_id, None) + async def lock_locked_policy_context( self, request: ProjectLockedPolicyContextRequest ) -> ProjectLockedPolicyContextFacts: - """Lock, validate, and return the exact selected policy lineage.""" + """Resolve only the stored selectors, including a superseded guide.""" + return await self._resolve(request.project_id, request) - async def lock_by_id(model: Any, identifier: UUID) -> Any: - return await self._session.scalar( - select(model) - .where(model.id == str(identifier)) - .with_for_update() - .execution_options(populate_existing=True) - ) + async def _resolve(self, project_id, request): + if not self._session.in_transaction() or self._session.in_nested_transaction(): + raise ProjectLockedPolicyContextUnavailable("project_locked_policy_context_changed") + try: + # Reads must neither flush the caller's pending writes nor commit them. + with self._session.no_autoflush: + return await self._load(project_id, request) + except ( + ValueError, + TypeError, + GuideProposalError, + ProjectGuideSetupFinalizationError, + GuideCompilationIntegrityError, + ) as exc: + raise ProjectLockedPolicyContextUnavailable( + "project_locked_policy_context_changed" + ) from exc - project = await lock_by_id(Project, request.project_id) - guide = await self._session.scalar( - select(ProjectGuide) + async def _load(self, project_id, request): + project = await self._session.scalar( + select(Project) .where( - ProjectGuide.project_id == str(request.project_id), - ProjectGuide.version == request.guide_version, + Project.id == str(project_id), ) .with_for_update() .execution_options(populate_existing=True) ) - snapshot = await lock_by_id(GuideSourceSnapshot, request.source_snapshot_id) - effective_policy = await lock_by_id( - EffectiveProjectSubmissionArtifactPolicy, request.effective_policy_id - ) - pre_submit_policy = await lock_by_id(PreSubmitCheckerPolicy, request.pre_submit_policy_id) - if ( - project is None - or project.status != "active" - or guide is None - or guide.status not in {"active", "superseded"} - or snapshot is None - or effective_policy is None - or effective_policy.lifecycle_status not in {"approved", "superseded"} - or pre_submit_policy is None - or pre_submit_policy.lifecycle_status not in {"compiled", "superseded"} - or pre_submit_policy.compiler_version is None - or pre_submit_policy.compiled_bundle is None - or pre_submit_policy.compiled_bundle_hash is None - ): - raise ProjectLockedPolicyContextUnavailable("project_locked_policy_context_changed") - values = ( - snapshot.manifest_json, - effective_policy.effective_policy, - pre_submit_policy.compiled_bundle, + if project is None or project.status != "active": + raise ValueError("active project unavailable") + selection = select(ProjectGuide).where(ProjectGuide.project_id == str(project_id)) + selection = selection.where( + ProjectGuide.version == request.guide_version + if request + else ProjectGuide.status == "active" ) - if not all(isinstance(value, Mapping) for value in values): - raise ProjectLockedPolicyContextUnavailable("project_locked_policy_context_changed") - try: - canonical_snapshot = CanonicalJsonObject.from_mapping(snapshot.manifest_json) - canonical_effective = CanonicalJsonObject.from_mapping( - effective_policy.effective_policy - ) - canonical_pre_submit = CanonicalJsonObject.from_mapping( - pre_submit_policy.compiled_bundle - ) - except (TypeError, ValueError) as exc: - raise ProjectLockedPolicyContextUnavailable( - "project_locked_policy_context_changed" - ) from exc - expected = ( - guide.project_id == str(request.project_id), - guide.version == request.guide_version, - snapshot.project_id == str(request.project_id), - snapshot.guide_id == guide.id, - snapshot.guide_version == request.guide_version, - snapshot.bundle_hash == request.source_snapshot_hash, - effective_policy.project_id == str(request.project_id), - effective_policy.guide_id == guide.id, - effective_policy.guide_version == request.guide_version, - effective_policy.source_snapshot_id == str(request.source_snapshot_id), - effective_policy.source_snapshot_hash == request.source_snapshot_hash, - effective_policy.effective_policy_hash == request.effective_policy_hash, - pre_submit_policy.project_id == str(request.project_id), - pre_submit_policy.guide_id == guide.id, - pre_submit_policy.guide_version == request.guide_version, - pre_submit_policy.source_snapshot_id == str(request.source_snapshot_id), - pre_submit_policy.source_snapshot_hash == request.source_snapshot_hash, - pre_submit_policy.effective_policy_id == str(request.effective_policy_id), - pre_submit_policy.effective_policy_hash == request.effective_policy_hash, - pre_submit_policy.compiled_bundle_hash == request.pre_submit_policy_bundle_hash, - canonical_snapshot.sha256 == request.source_snapshot_hash, - canonical_effective.sha256 == request.effective_policy_hash, - canonical_pre_submit.sha256 == request.pre_submit_policy_bundle_hash, + # Do not take Guide before Attempt: finalization owns Attempt -> Guide. + guide = ( + await self._session.scalars(selection.execution_options(populate_existing=True)) + ).one_or_none() + if guide is None: + raise ValueError("selected guide unavailable") + receipt = await load_guide_activation(self._session, guide) + target = receipt.command.target + locked, post_policy, post_custody = await PostPolicyRepository(self._session).lock_policy( + PostPolicySelection( + project_id=project_id, + guide_id=target.proposal.guide_id, + compilation_id=target.proposal.compilation_id, + policy_id=target.policy_id, + ), + allowed_guide_statuses=frozenset({"active", "superseded"}), ) - if not all(expected): - raise ProjectLockedPolicyContextUnavailable("project_locked_policy_context_changed") - try: - return ProjectLockedPolicyContextFacts( - project_id=request.project_id, - guide_id=UUID(guide.id), - guide_version=guide.version, - guide_status=guide.status, - source_snapshot_id=request.source_snapshot_id, - source_snapshot_hash=snapshot.bundle_hash, - effective_policy_id=request.effective_policy_id, - effective_policy_hash=effective_policy.effective_policy_hash, - effective_policy_status=effective_policy.lifecycle_status, - effective_policy=canonical_effective, - pre_submit_policy_id=request.pre_submit_policy_id, - pre_submit_policy_bundle_hash=pre_submit_policy.compiled_bundle_hash, - pre_submit_policy_status=pre_submit_policy.lifecycle_status, - pre_submit_compiler_version=pre_submit_policy.compiler_version, - compiled_pre_submit_bundle=canonical_pre_submit, - ) - except (TypeError, ValueError) as exc: - raise ProjectLockedPolicyContextUnavailable( - "project_locked_policy_context_changed" - ) from exc + guide = locked.view.guide + refreshed = await load_guide_activation(self._session, guide) + if refreshed != receipt or (request is None and guide.status != "active"): + raise ValueError("selected activation changed") + projects = ProjectRepository(self._session) + review = await projects.lock_review_policy(guide.project_id, guide.version) + revision = await projects.lock_revision_policy(guide.project_id, guide.version) + facts = complete_context(locked, post_policy, post_custody, refreshed, review, revision) + if request is not None and request != ProjectLockedPolicyContextRequest( + project_id=facts.project_id, + guide_version=facts.guide_version, + source_snapshot_id=facts.source_snapshot_id, + source_snapshot_hash=facts.source_snapshot_hash, + effective_policy_id=facts.effective_policy_id, + effective_policy_hash=facts.effective_policy_hash, + pre_submit_policy_id=facts.pre_submit_policy_id, + pre_submit_policy_bundle_hash=facts.pre_submit_policy_bundle_hash, + ): + raise ValueError("frozen context selectors differ") + return facts diff --git a/backend/app/modules/projects/policy_mutation_service.py b/backend/app/modules/projects/policy_mutation_service.py index 17caf469f..b3182c07e 100644 --- a/backend/app/modules/projects/policy_mutation_service.py +++ b/backend/app/modules/projects/policy_mutation_service.py @@ -25,7 +25,7 @@ authorization_resource_digest, ) from app.modules.projects.models import ProjectGuide, ReviewPolicy, RevisionPolicy -from app.modules.projects.policy_lineage import ( +from app.modules.projects.api.policy_lineage import ( ReviewPolicySemantics, RevisionPolicySemantics, policy_digest, diff --git a/backend/app/modules/projects/post_policy/custody.py b/backend/app/modules/projects/post_policy/custody.py index 38b6fa734..c65585566 100644 --- a/backend/app/modules/projects/post_policy/custody.py +++ b/backend/app/modules/projects/post_policy/custody.py @@ -11,6 +11,7 @@ from app.modules.authorization.api.post_policy import PostPolicyAuthorityReceipt from app.modules.projects.api.guide_proposals import GuideProposalError from app.modules.projects.api.post_policy import PostPolicyReceipt, PostPolicyTarget +from app.modules.checkers.api.post_submit_catalogue import current_post_submit_catalogue from app.modules.projects.post_submit_policy import parse_locked_post_submit_checker_policy_body from .models import PostPolicyOperation @@ -85,7 +86,7 @@ def operation_receipt(operation: PostPolicyOperation) -> PostPolicyReceipt: async def load_post_policy_custody(session, policy) -> PostPolicyCustody: """Require complete projection/approval/supersession evidence, even for reads.""" operations = {row.kind: row for row in await session.scalars( - select(PostPolicyOperation).where(PostPolicyOperation.policy_id == policy.id).with_for_update() + select(PostPolicyOperation).where(PostPolicyOperation.policy_id == policy.id).with_for_update().execution_options(populate_existing=True) )} projection = operations.get("derive") if projection is None or policy.projection_operation_id != projection.operation_id: @@ -95,6 +96,14 @@ async def load_post_policy_custody(session, policy) -> PostPolicyCustody: policy.policy_body, project_id=policy.project_id, guide_version=policy.guide_version, policy_hash=policy.policy_hash or "", ) + if ( + compiled.catalogue_id, compiled.catalogue_source_version, + compiled.catalogue_schema_version, compiled.catalogue_manifest_sha256, + ) != ( + target.proposal.post_catalogue_id, target.proposal.post_catalogue_version, + target.proposal.post_catalogue_schema_version, target.proposal.post_catalogue_manifest_hash, + ): + raise GuideProposalError("proposal_unavailable") compiled.validate_sidecars(required_checkers=policy.required_checkers, warning_checkers=policy.warning_checkers, blocking_severities=policy.blocking_severities) @@ -126,7 +135,7 @@ async def load_post_policy_custody(session, policy) -> PostPolicyCustody: ): raise GuideProposalError("proposal_unavailable") if policy.lifecycle_status == "superseded": - successor = await session.get(PostPolicyOperation, policy.supersession_operation_id) + successor = await session.get(PostPolicyOperation, policy.supersession_operation_id, populate_existing=True) if successor is None: raise GuideProposalError("proposal_unavailable") receipt = operation_receipt(successor) @@ -214,6 +223,7 @@ def validate_activation_post_policy( guide_version=post_submit_checker_policy.guide_version, policy_hash=post_submit_checker_policy.policy_hash or "", ) + parsed_post_submit_policy.validate_catalogue(current_post_submit_catalogue()) except ValueError as exc: raise ValueError("post-submit checker policy hash is invalid") from exc try: diff --git a/backend/app/modules/projects/post_policy/repository.py b/backend/app/modules/projects/post_policy/repository.py index 0f29d46ac..415f74357 100644 --- a/backend/app/modules/projects/post_policy/repository.py +++ b/backend/app/modules/projects/post_policy/repository.py @@ -20,11 +20,11 @@ def __init__(self, session): self.session = session self.proposals = GuideProposalRepository(session) - async def lock_proposal(self, selection): + async def lock_proposal(self, selection, *, allowed_guide_statuses=frozenset({"draft"})): return await self.proposals.lock(GuideProposalSelection( project_id=selection.project_id, guide_id=selection.guide_id, compilation_id=selection.compilation_id, - )) + ), allowed_guide_statuses=allowed_guide_statuses) async def require_current_upstream(self, locked): """A finalized draft alone is insufficient; require the approved chain tip.""" @@ -48,8 +48,8 @@ async def latest_policy(self, guide_id): .order_by(PostPolicyOperation.target_json["proposal"]["setup_generation"].as_integer().desc()) .limit(1).with_for_update().execution_options(populate_existing=True))).one_or_none() - async def lock_policy(self, selection): - locked = await self.lock_proposal(selection) + async def lock_policy(self, selection, *, allowed_guide_statuses=frozenset({"draft"})): + locked = await self.lock_proposal(selection, allowed_guide_statuses=allowed_guide_statuses) policy = await self.session.scalar(select(PostSubmitCheckerPolicy).where( PostSubmitCheckerPolicy.id == str(selection.policy_id), PostSubmitCheckerPolicy.project_id == str(selection.project_id), diff --git a/backend/app/modules/projects/post_submit_policy.py b/backend/app/modules/projects/post_submit_policy.py index 54e1f429d..4db8fe36d 100644 --- a/backend/app/modules/projects/post_submit_policy.py +++ b/backend/app/modules/projects/post_submit_policy.py @@ -119,7 +119,7 @@ def parse_locked_post_submit_checker_policy_body( guide_version: str, policy_hash: str, ) -> CompiledPostSubmitPolicy: - """Reject unsupported bodies and validate exact current policy identity.""" + """Validate the saved schema, guide identity and body hash independently of live eligibility.""" if not isinstance(body, dict): raise ValueError("locked post-submit checker policy body is missing") policy = CompiledPostSubmitPolicy.model_validate_json(json.dumps(body)) @@ -127,7 +127,6 @@ def parse_locked_post_submit_checker_policy_body( raise ValueError("locked post-submit checker policy guide context mismatch") if policy.policy_hash != policy_hash: raise ValueError("locked post-submit checker policy hash is invalid") - policy.validate_catalogue(current_post_submit_catalogue()) return policy diff --git a/backend/app/modules/projects/repository.py b/backend/app/modules/projects/repository.py index 9cd8a5106..960c5330c 100644 --- a/backend/app/modules/projects/repository.py +++ b/backend/app/modules/projects/repository.py @@ -830,7 +830,7 @@ async def lock_review_policy(self, project_id: str, guide_version: str) -> Revie ProjectGuide.project_id == project_id, ProjectGuide.version == guide_version, ) - .with_for_update(of=ReviewPolicy) + .with_for_update(of=ReviewPolicy).execution_options(populate_existing=True) ) async def lock_revision_policy( @@ -854,7 +854,7 @@ async def lock_revision_policy( ProjectGuide.project_id == project_id, ProjectGuide.version == guide_version, ) - .with_for_update(of=RevisionPolicy) + .with_for_update(of=RevisionPolicy).execution_options(populate_existing=True) ) async def add_review_policy_version( diff --git a/backend/app/modules/projects/service.py b/backend/app/modules/projects/service.py index 2b66e8820..90455d66b 100644 --- a/backend/app/modules/projects/service.py +++ b/backend/app/modules/projects/service.py @@ -32,7 +32,7 @@ ReviewPolicy, SubmissionArtifactPolicy, ) -from app.modules.projects.policy_lineage import require_complete_policy +from app.modules.projects.api.policy_lineage import require_complete_policy from app.modules.projects.repository import ProjectRepository, ProjectRepositoryIntegrityError from app.modules.projects.schemas import ( ActiveGuideReadResponse, diff --git a/backend/app/modules/tasks/service.py b/backend/app/modules/tasks/service.py index 9fcf60e44..54c84a890 100644 --- a/backend/app/modules/tasks/service.py +++ b/backend/app/modules/tasks/service.py @@ -16,6 +16,7 @@ PreSubmitCheckerCompilerError, validate_compiled_pre_submit_checker_bundle, ) +from app.modules.checkers.api.post_submit_catalogue import current_post_submit_catalogue from app.modules.checkers.gate_queue import PreReviewGateQueueError, enqueue_pre_review_gate from app.modules.checkers.pre_review_gate import ( find_submission_requester_provenance, @@ -967,6 +968,7 @@ async def _load_active_policy_context( guide_version=checker_policy.guide_version, policy_hash=checker_policy.policy_hash, ) + parsed_checker_policy.validate_catalogue(current_post_submit_catalogue()) except ValueError as exc: raise TaskProjectNotReady("active post-submit checker policy hash is invalid") from exc try: @@ -1121,6 +1123,7 @@ async def _load_locked_task_context(self, task: WorkstreamTask) -> LockedTaskCon guide_version=task.locked_post_submit_checker_policy_version or "", policy_hash=task.locked_post_submit_checker_policy_hash or "", ) + parsed_post_submit_body.validate_catalogue(current_post_submit_catalogue()) except ValueError as exc: raise TaskLockedContextInvalid( "task locked post-submit checker policy body is invalid", @@ -1675,45 +1678,6 @@ def _ensure_locked_context(self, task: WorkstreamTask) -> None: if missing: raise TaskTransitionBlocked(f"task missing locked context: {', '.join(missing)}") - async def _validate_locked_post_submit_policy_context(self, task: WorkstreamTask) -> None: - """Validate the task's locked post-submit checker policy before submission. - - Args: - task: Task whose locked post-submit policy context should be - verified before a submission row can be created. - - Raises: - TaskProjectNotReady: If the locked post-submit policy row is - missing, mismatched, or no longer hashes to the stamped value. - """ - policy = await self._project_repo.get_post_submit_checker_policy_by_id( - task.locked_post_submit_checker_policy_id or "" - ) - if ( - policy is None - or policy.project_id != task.project_id - or policy.guide_version != task.locked_post_submit_checker_policy_version - or policy.guide_version != task.locked_guide_version - or policy.policy_hash != task.locked_post_submit_checker_policy_hash - ): - raise TaskProjectNotReady("locked post-submit checker policy is invalid") - try: - parsed_policy = parse_locked_post_submit_checker_policy_body( - task.locked_post_submit_checker_policy_body, - project_id=task.project_id, - guide_version=task.locked_post_submit_checker_policy_version or "", - policy_hash=task.locked_post_submit_checker_policy_hash or "", - ) - if policy.policy_body != parsed_policy.policy_body: - raise ValueError("persisted post-submit policy body differs from lock") - parsed_policy.validate_sidecars( - required_checkers=policy.required_checkers, - warning_checkers=policy.warning_checkers, - blocking_severities=policy.blocking_severities, - ) - except ValueError as exc: - raise TaskProjectNotReady("locked post-submit checker policy hash is invalid") from exc - async def _change_task_status( self, actor: ActorContext, diff --git a/backend/scripts/behavior_ownership.py b/backend/scripts/behavior_ownership.py index 7639cd2ae..5a13870bf 100644 --- a/backend/scripts/behavior_ownership.py +++ b/backend/scripts/behavior_ownership.py @@ -161,6 +161,14 @@ "backend/app/modules/projects/contribution_policy.py", } ) +ARCH_03A_GUIDE_CONTEXT_TARGETS = frozenset({ + "backend/app/modules/projects/locked_policy_projection.py", + "backend/app/modules/projects/api/policy_lineage.py", +}) +ARCH_03A_GUIDE_CONTEXT_REMOVED_TARGETS = frozenset({ + "backend/app/modules/projects/policy_lineage.py", +}) + ARCH_CP07_GUIDE_ACTIVATION_TARGETS = frozenset({ "backend/app/adapters/projects/contribution_validation.py", "backend/app/modules/projects/api/guide_activation.py", @@ -512,6 +520,22 @@ def _read_json(path: Path, error: str) -> Any: raise BehaviorOwnershipError(error) from exc +def _validate_policy_lineage_relocation(trusted_targets: set[str], current_targets: set[str]) -> None: + """Permit only the one-way relocation of the sole canonical policy hash owner.""" + old = "backend/app/modules/projects/policy_lineage.py" + new = "backend/app/modules/projects/api/policy_lineage.py" + before = (old in trusted_targets, new in trusted_targets) + after = (old in current_targets, new in current_targets) + if before == after == (False, False): + return + if (before, after) not in { + ((True, False), (True, False)), + ((True, False), (False, True)), + ((False, True), (False, True)), + }: + raise BehaviorOwnershipError("untrusted_partition_change") + + def _validate_additive_partition_transition( current: dict[str, Any], trusted: Any, @@ -544,13 +568,14 @@ def _validate_additive_partition_transition( raise BehaviorOwnershipError("invalid_trusted_partition") current_assignments = current["assignments"] current_by_target = {item["target"]: item for item in current_assignments} + _validate_policy_lineage_relocation(set(trusted_targets), set(current_by_target)) removed = set(trusted_targets) - set(current_by_target) retained_trusted = [ item for item in trusted_assignments if item["target"] not in removed ] if ( trusted_targets != sorted(trusted_targets) - or removed - (V01_BASELINE_REMOVED_TARGETS | POL_03B_REMOVED_TARGETS | POL_04B_REMOVED_TARGETS | POL_05A_REMOVED_TARGETS) + or removed - (V01_BASELINE_REMOVED_TARGETS | POL_03B_REMOVED_TARGETS | POL_04B_REMOVED_TARGETS | POL_05A_REMOVED_TARGETS | ARCH_03A_GUIDE_CONTEXT_REMOVED_TARGETS) or [current_by_target[item["target"]] for item in retained_trusted] != retained_trusted ): @@ -593,6 +618,7 @@ def _validate_additive_partition_transition( | ARCH_CP05_POLICY_AUTH_TARGETS | ARCH_CP06_SELECTED_POLICY_TARGETS | ARCH_CP07_GUIDE_ACTIVATION_TARGETS + | ARCH_03A_GUIDE_CONTEXT_TARGETS | V01_BASELINE_ADDED_TARGETS | TASK_PROJECT_AUTHORITY_TARGETS ) diff --git a/backend/scripts/test_lane_catalogue.py b/backend/scripts/test_lane_catalogue.py index 565604df9..1b0c7aee5 100644 --- a/backend/scripts/test_lane_catalogue.py +++ b/backend/scripts/test_lane_catalogue.py @@ -313,6 +313,8 @@ class TestLane: "tests/projects/guide_compilation/test_repository_persistence.py", "tests/projects/test_locked_policy_context.py", "tests/projects/test_locked_policy_contract.py", + "tests/projects/test_locked_policy_custody.py", + "tests/projects/test_locked_policy_concurrency.py", "tests/projects/test_activation_readiness.py", "tests/projects/test_policy_read_composition.py", "tests/projects/test_active_guide_read_composition.py", diff --git a/backend/tests/checkers/post_submit/test_compiled_policy.py b/backend/tests/checkers/post_submit/test_compiled_policy.py index a5e92062e..60a0d15f1 100644 --- a/backend/tests/checkers/post_submit/test_compiled_policy.py +++ b/backend/tests/checkers/post_submit/test_compiled_policy.py @@ -26,13 +26,16 @@ def compile_selection(*, required=(), warning=()): def parse(body, *, digest=None): - return parse_locked_post_submit_checker_policy_body( + parsed = parse_locked_post_submit_checker_policy_body( body, project_id=str(PROJECT), guide_version="v1", policy_hash=canonical_json_hash(body) if digest is None else digest, ) + parsed.validate_catalogue(current_post_submit_catalogue()) + return parsed + def test_canonical_compile_parse_and_derived_lists(): policy = request().policy diff --git a/backend/tests/migration_fixtures.py b/backend/tests/migration_fixtures.py index fa0a57e5e..5ddf30a2f 100644 --- a/backend/tests/migration_fixtures.py +++ b/backend/tests/migration_fixtures.py @@ -21,16 +21,27 @@ def current_schema_revision(): async def run_guarded_revision_downgrade(database_url: str, revision: str) -> None: """Exercise a retained-data guard directly, without earlier guards masking it.""" + await _run_revision_body(database_url, revision, "downgrade") + + +async def run_scoped_revision_upgrade(database_url: str, revision: str) -> None: + """Restore one tested revision body without traversing later schema owners.""" + await _run_revision_body(database_url, revision, "upgrade") + + +async def _run_revision_body(database_url: str, revision: str, direction: str) -> None: + """Run the unchanged owned migration in one PostgreSQL transaction.""" from alembic.migration import MigrationContext from alembic.operations import Operations from alembic.script import ScriptDirectory - downgrade = ScriptDirectory.from_config(_config()).get_revision(revision).module.downgrade + module = ScriptDirectory.from_config(_config()).get_revision(revision).module + operation = getattr(module, direction) engine = create_async_engine(database_url) def run(connection): with Operations.context(MigrationContext.configure(connection)): - downgrade() + operation() try: async with engine.begin() as connection: diff --git a/backend/tests/pre_submit_test_helpers.py b/backend/tests/pre_submit_test_helpers.py index 0b884f474..113e4d2c8 100644 --- a/backend/tests/pre_submit_test_helpers.py +++ b/backend/tests/pre_submit_test_helpers.py @@ -114,25 +114,17 @@ async def approved_pre_submit_fixture(factory, namespace, *, guide_version): from app.interfaces.project_agents import SubmissionArtifactPolicyProposal from app.modules.checkers.catalogue import build_pre_submission_checker_catalogue from app.modules.checkers.api import EffectivePreSubmissionPlanLineage - from app.modules.projects.api.guide_proposals import GuideProposalSelection from app.modules.projects.models import ProjectGuide, PostSubmitCheckerPolicy - from tests.projects.unified_policy_fixtures import create_standalone_unified_policy, _approval_context - from tests.projects.guide_compilation.proposals.pg_support import seed_selected_review_revision_inputs + from tests.projects.unified_policy_fixtures import create_standalone_unified_policy values, effective, pre = await create_standalone_unified_policy( - factory, namespace, guide_version=guide_version, include_post_policy=True, + factory, namespace, guide_version=guide_version, artifact_proposal=SubmissionArtifactPolicyProposal( maximum_file_size_bytes=1_000_000, maximum_package_size_bytes=5_000_000, required_artifacts=("task.toml",), required_evidence=("results",), attestation_terms=("rights_confirmed",), ), ) - actor, _grant, compilation = await _approval_context( - factory, str(values["project"]), str(values["guide"]), effective["submission_artifact_policy_id"], - ) - await seed_selected_review_revision_inputs(factory, GuideProposalSelection( - project_id=values["project"], guide_id=values["guide"], compilation_id=compilation.id, - ), actor) lineage = EffectivePreSubmissionPlanLineage( project_id=values["project"], guide_id=values["guide"], guide_version=guide_version, source_snapshot_id=values["snapshot"], source_snapshot_hash=effective["source_snapshot_hash"], diff --git a/backend/tests/project_create_fixtures.py b/backend/tests/project_create_fixtures.py index 3821a35ca..c4c8d6cf5 100644 --- a/backend/tests/project_create_fixtures.py +++ b/backend/tests/project_create_fixtures.py @@ -132,7 +132,14 @@ async def suspend_historical_product_custody( async def seed_active_guide_for_downstream_test( session_factory, *, project_id: str, guide_id: str, ) -> dict: - """Seed a downstream prerequisite; no activation or approval flow is exercised.""" + """Activate separately approved downstream guide fixtures through real AUTH/CP07.""" + from app.modules.authorization.api import ActorIdentityFacts, ActorKind + from app.modules.projects.api.post_policy import PostPolicyReceipt + from app.modules.projects.models import PostSubmitCheckerPolicy + from app.modules.projects.post_policy.models import PostPolicyOperation + from tests.projects.guide_activation.pg_support import activation_command, publish_policy + from tests.authorization.guide_activation.pg_support import activate + async with session_factory() as session: link = await session.scalar(select(ActorIdentityLink).where( ActorIdentityLink.issuer == "flow-test", @@ -140,28 +147,26 @@ async def seed_active_guide_for_downstream_test( )) if link is None: raise RuntimeError("downstream guide fixture requires an admitted actor") - guide = await session.get(ProjectGuide, guide_id) - project = await session.get(Project, project_id) - assert guide is not None and project is not None and guide.project_id == project.id - now = datetime.now(UTC) - async with suspend_historical_product_custody(session, table="projects", - triggers=("project_activation_custody",)), suspend_historical_product_custody(session, table="project_guides", - triggers=("guide_mutation_product_custody", "guide_lineage_lifecycle_guard")): - for prior in await session.scalars(select(ProjectGuide).where( - ProjectGuide.project_id == project_id, ProjectGuide.status == "active")): - prior.status = "superseded" - prior.superseded_at = now - await session.flush() - guide.status = "active" - guide.approved_by = link.actor_profile_id - guide.effective_at = now - project.status = "active" - await session.flush() - seeded = {"guide": {"id": guide.id, "version": guide.version, - "status": guide.status, "approved_by": guide.approved_by, - "effective_at": guide.effective_at.isoformat()}} - await session.commit() - return seeded + actor = ActorIdentityFacts(UUID(link.actor_profile_id), UUID(link.id), ActorKind.HUMAN) + operation = (await session.scalars(select(PostPolicyOperation).join( + PostSubmitCheckerPolicy, + PostSubmitCheckerPolicy.approval_operation_id == PostPolicyOperation.operation_id, + ).where(PostSubmitCheckerPolicy.guide_id == guide_id, + PostSubmitCheckerPolicy.project_id == project_id, + PostSubmitCheckerPolicy.lifecycle_status == "approved"))).one() + approved = PostPolicyReceipt.model_validate(operation.receipt_json) + previous = await session.scalar(select(ProjectGuide).where( + ProjectGuide.project_id == project_id, ProjectGuide.status == "active")) + predecessor = { + "expected_previous_active_guide_id": UUID(previous.id) if previous else None, + "expected_previous_active_guide_generation": previous.mutation_generation if previous else None, + } + _, policy = await publish_policy(session_factory, UUID(project_id)) + command = (await activation_command(session_factory, approved, policy)).model_copy(update=predecessor) + receipt = await activate(session_factory, actor, command) + return {"guide": {"id": guide_id, "version": receipt.command.target.proposal.guide_version, + "status": "active", "approved_by": str(actor.actor_profile_id), + "effective_at": receipt.effective_at.isoformat()}} async def grant_system_project_manager( @@ -433,13 +438,3 @@ async def grant_fixture_admin_role(session, actor_id, *, role="project_manager", session.add(grant) await session.flush() return grant - - -async def activate_retained_project_for_test(connection, project_id): - """Arrange only the retained Project prerequisite in an owned test database.""" - async with suspend_historical_product_custody( - connection, table="projects", triggers=("project_activation_custody",), - ): - await connection.execute( - text("update projects set status='active' where id=:project"), {"project": str(project_id)}, - ) diff --git a/backend/tests/projects/guide_activation/source_fixtures.py b/backend/tests/projects/guide_activation/source_fixtures.py index 8f454eb3c..dd0b1612b 100644 --- a/backend/tests/projects/guide_activation/source_fixtures.py +++ b/backend/tests/projects/guide_activation/source_fixtures.py @@ -34,7 +34,7 @@ from tests.projects.guide_compilation.proposals.pg_support import seed_review_actor -async def create_compiled_guide(factory, values, actor, *, version="v1"): +async def create_compiled_guide(factory, values, actor, *, version="v1", artifact_proposal=None): """Create declarations with real AUTH, then execute/project/finalize scripted findings.""" async with factory() as session, session.begin(): resolved = ResolvedActor( @@ -88,6 +88,8 @@ async def create_compiled_guide(factory, values, actor, *, version="v1"): update={"material": manifest} ) outcome = result() + if artifact_proposal is not None: + outcome = outcome.model_copy(update={"submission_artifact_policy": artifact_proposal}) refs = tuple( GuideEvidenceRef( source_item_id=item.source_item_id, @@ -112,11 +114,24 @@ async def create_compiled_guide(factory, values, actor, *, version="v1"): @asynccontextmanager -async def source_case(url): +async def source_case(url, *, namespace=None, guide_version="v1", artifact_proposal=None): """An authorized draft Project plus a genuinely created complete guide source.""" engine = create_async_engine(url) factory = async_sessionmaker(engine, expire_on_commit=False) try: + if namespace is not None: + from app.modules.artifacts.models import ArtifactStorageNamespace + async with factory() as session, session.begin(): + existing = await session.get(ArtifactStorageNamespace, "primary") + if existing is None: + session.add(ArtifactStorageNamespace( + id="primary", backend=namespace.backend, adapter=namespace.adapter, + provider_profile=namespace.provider_profile, + namespace_descriptor=namespace.namespace_descriptor, + namespace_fingerprint=namespace.namespace_fingerprint, + )) + else: + assert existing.namespace_fingerprint == namespace.namespace_fingerprint values = ids() async with factory() as session, session.begin(): await seed_authorized_project( @@ -136,7 +151,8 @@ async def source_case(url): ).one() values.update(actor=UUID(service_id), link=UUID(link_id)) actor, grant = await seed_review_actor(factory, values["project"]) - values, finalization = await create_compiled_guide(factory, values, actor) + values, finalization = await create_compiled_guide(factory, values, actor, version=guide_version, + artifact_proposal=artifact_proposal) yield values, factory, finalization, actor, grant finally: await engine.dispose() diff --git a/backend/tests/projects/locked_policy_fixtures.py b/backend/tests/projects/locked_policy_fixtures.py new file mode 100644 index 000000000..2e09b0dd1 --- /dev/null +++ b/backend/tests/projects/locked_policy_fixtures.py @@ -0,0 +1,28 @@ +"""Real activated guide context shared by complete-context proofs.""" + +from contextlib import asynccontextmanager + +from app.modules.projects.api import ProjectLockedPolicyContextRequest +from tests.authorization.guide_activation.pg_support import activate +from tests.projects.guide_activation.pg_support import activation_case + + +def frozen_request(receipt): + target, upstream = receipt.command.target.proposal, receipt.command.target.upstream + return ProjectLockedPolicyContextRequest( + project_id=target.project_id, + guide_version=target.guide_version, + source_snapshot_id=target.source_snapshot_id, + source_snapshot_hash=target.source_snapshot_hash, + effective_policy_id=upstream.effective_policy_id, + effective_policy_hash=upstream.effective_policy_hash, + pre_submit_policy_id=upstream.pre_submit_policy_id, + pre_submit_policy_bundle_hash=upstream.pre_submit_bundle_hash, + ) + + +@asynccontextmanager +async def activated_context(url): + async with activation_case(url) as (factory, command, actor, grant, world, policy): + receipt = await activate(factory, actor, command) + yield factory, receipt, actor, grant, world, policy diff --git a/backend/tests/projects/review_policy/test_activation.py b/backend/tests/projects/review_policy/test_activation.py index 27283ebe7..1e757b4f2 100644 --- a/backend/tests/projects/review_policy/test_activation.py +++ b/backend/tests/projects/review_policy/test_activation.py @@ -5,7 +5,7 @@ import pytest -from app.modules.projects.policy_lineage import ( +from app.modules.projects.api.policy_lineage import ( ReviewPolicySemantics, RevisionPolicySemantics, policy_digest, diff --git a/backend/tests/projects/review_policy/test_migration.py b/backend/tests/projects/review_policy/test_migration.py index d11ec0f16..80e0108f0 100644 --- a/backend/tests/projects/review_policy/test_migration.py +++ b/backend/tests/projects/review_policy/test_migration.py @@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.exc import DBAPIError -from app.modules.projects.policy_lineage import ( +from app.modules.projects.api.policy_lineage import ( ReviewPolicySemantics, policy_digest, require_complete_policy, diff --git a/backend/tests/projects/review_policy/test_mutation.py b/backend/tests/projects/review_policy/test_mutation.py index 5ea417d8b..65ec3e86b 100644 --- a/backend/tests/projects/review_policy/test_mutation.py +++ b/backend/tests/projects/review_policy/test_mutation.py @@ -107,7 +107,7 @@ async def test_legacy_response_recovery_defaults_true_and_retains_v1_hash(): old = result.response.model_dump( mode="json", exclude={"human_review_required", "semantics_format"} ) - from app.modules.projects.policy_lineage import ReviewPolicySemantics, policy_digest + from app.modules.projects.api.policy_lineage import ReviewPolicySemantics, policy_digest old["policy_hash"] = policy_digest( "review", diff --git a/backend/tests/projects/review_policy/test_semantics.py b/backend/tests/projects/review_policy/test_semantics.py index 85a5f7fb8..f03f03d86 100644 --- a/backend/tests/projects/review_policy/test_semantics.py +++ b/backend/tests/projects/review_policy/test_semantics.py @@ -6,7 +6,7 @@ from pydantic import ValidationError from app.core.hashing import canonical_json_hash -from app.modules.projects.policy_lineage import ( +from app.modules.projects.api.policy_lineage import ( ReviewPolicySemantics, policy_digest, require_complete_policy, diff --git a/backend/tests/projects/test_locked_policy_concurrency.py b/backend/tests/projects/test_locked_policy_concurrency.py new file mode 100644 index 000000000..a481ca710 --- /dev/null +++ b/backend/tests/projects/test_locked_policy_concurrency.py @@ -0,0 +1,278 @@ +"""Observed PostgreSQL ordering of complete context reads and existing writers.""" + +import asyncio +from uuid import uuid4 + +import pytest +from sqlalchemy import select, text, update + +from app.modules.projects.api import ( + ProjectGuideSetupFinalizationCommand, + ProjectGuideSetupFinalizationError, + ProjectLockedPolicyContextUnavailable, +) +from app.modules.projects.guide_compilation.finalization import GuideCompilationFinalizationService +from app.modules.projects.guide_compilation.models import ProjectGuideCompilationAttempt +from app.modules.authorization.project_setup_finalization import SetupFinalizationAuthorization +from app.modules.projects.locked_policy_repository import ProjectLockedPolicyRepository +from app.modules.projects.models import Project, PreSubmitCheckerPolicy +from tests.auth_concurrency_support import wait_for_named_database_lock +from tests.projects.locked_policy_fixtures import activated_context, frozen_request + + +async def _name(session, label): + await session.execute( + text("select set_config('application_name', :label, true)"), {"label": label} + ) + return await session.scalar(text("select pg_backend_pid()")) + + +@pytest.mark.parametrize("reader_first", (True, False)) +async def test_context_serializes_project_archival(clean_postgres_database, reader_first): + async with activated_context(clean_postgres_database) as (factory, receipt, *_): + project_id = receipt.contribution.project_id + task = None + async with factory() as reader, factory() as writer: + try: + # Preserve a strong reference to an active cached row for writer-first proof. + cached = await reader.get(Project, str(project_id)) + assert cached.status == "active" + name = "arch03a-archive-" + uuid4().hex + reader_pid = await _name(reader, name if not reader_first else name + "-reader") + writer_pid = await _name(writer, name if reader_first else name + "-writer") + owner = ProjectLockedPolicyRepository(reader) + change = ( + update(Project).where(Project.id == str(project_id)).values(status="archived") + ) + if reader_first: + facts = await owner.lock_locked_policy_context(frozen_request(receipt)) + task = asyncio.create_task(writer.execute(change)) + await wait_for_named_database_lock( + clean_postgres_database, + name, + expected_waiter_pid=writer_pid, + expected_blocker_pid=reader_pid, + ) + assert not task.done() + await reader.commit() + await asyncio.wait_for(task, 15) + await writer.commit() + assert facts.activation_receipt == receipt + else: + await writer.execute(change) + task = asyncio.create_task( + owner.lock_locked_policy_context(frozen_request(receipt)) + ) + await wait_for_named_database_lock( + clean_postgres_database, + name, + expected_waiter_pid=reader_pid, + expected_blocker_pid=writer_pid, + ) + await writer.commit() + with pytest.raises(ProjectLockedPolicyContextUnavailable): + await asyncio.wait_for(task, 15) + assert cached.status == "archived" + await reader.rollback() + async with factory() as observer, observer.begin(): + with pytest.raises(ProjectLockedPolicyContextUnavailable): + await ProjectLockedPolicyRepository(observer).lock_active_policy_context( + project_id + ) + finally: + if task is not None: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + await reader.rollback() + await writer.rollback() + + +@pytest.mark.parametrize("reader_first", (True, False)) +async def test_context_and_finalization_share_lock_order( + clean_postgres_database, monkeypatch, reader_first +): + async with activated_context(clean_postgres_database) as (factory, receipt, *_): + target = receipt.command.target.proposal + command = ProjectGuideSetupFinalizationCommand( + project_id=target.project_id, + guide_id=target.guide_id, + setup_run_id=target.setup_run_id, + setup_generation=target.setup_generation, + compilation_id=target.compilation_id, + ) + locked, release = asyncio.Event(), asyncio.Event() + tasks = [] + async with factory() as reader, factory() as finalizer: + first, second = (reader, finalizer) if reader_first else (finalizer, reader) + original = first.scalar + + async def pause_after_attempt(*args, **kwargs): + row = await original(*args, **kwargs) + if isinstance(row, ProjectGuideCompilationAttempt) and not locked.is_set(): + locked.set() + await asyncio.wait_for(release.wait(), 20) + return row + + monkeypatch.setattr(first, "scalar", pause_after_attempt) + first_name, second_name = ( + "arch03a-first-" + uuid4().hex, + "arch03a-waiter-" + uuid4().hex, + ) + first_pid, second_pid = await _name(first, first_name), await _name(second, second_name) + + async def read(): + try: + facts = await ProjectLockedPolicyRepository(reader).lock_locked_policy_context( + frozen_request(receipt) + ) + assert facts.activation_receipt == receipt + await reader.commit() + return "read" + finally: + await reader.rollback() + + async def finalize(): + try: + with pytest.raises(ProjectGuideSetupFinalizationError) as denied: + await GuideCompilationFinalizationService( + finalizer, + SetupFinalizationAuthorization(finalizer), + ).finalize(command) + assert denied.value.code == "source_state_unavailable" + return "denied_active_finalization" + finally: + await finalizer.rollback() + + try: + tasks.append(asyncio.create_task(read() if reader_first else finalize())) + await asyncio.wait_for(locked.wait(), 15) + tasks.append(asyncio.create_task(finalize() if reader_first else read())) + await wait_for_named_database_lock( + clean_postgres_database, + second_name, + expected_waiter_pid=second_pid, + expected_blocker_pid=first_pid, + ) + release.set() + outcomes = await asyncio.wait_for(asyncio.gather(*tasks), 20) + assert set(outcomes) == {"read", "denied_active_finalization"} + finally: + release.set() + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + await reader.rollback() + await finalizer.rollback() + + +async def test_context_retains_pre_policy_row_lock(clean_postgres_database): + async with activated_context(clean_postgres_database) as (factory, receipt, *_): + task = None + async with factory() as reader, factory() as contender: + try: + pid = await _name(reader, "arch03a-pre-reader-" + uuid4().hex) + name = "arch03a-pre-waiter-" + uuid4().hex + waiter_pid = await _name(contender, name) + request = frozen_request(receipt) + await ProjectLockedPolicyRepository(reader).lock_locked_policy_context(request) + task = asyncio.create_task( + contender.scalar( + select(PreSubmitCheckerPolicy) + .where( + PreSubmitCheckerPolicy.id == str(request.pre_submit_policy_id), + ) + .with_for_update() + ) + ) + await wait_for_named_database_lock( + clean_postgres_database, + name, + expected_waiter_pid=waiter_pid, + expected_blocker_pid=pid, + ) + await reader.rollback() + assert (await asyncio.wait_for(task, 15)).id == str(request.pre_submit_policy_id) + finally: + if task is not None: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + await reader.rollback() + await contender.rollback() + + +@pytest.mark.parametrize("reader_first", (True, False)) +async def test_context_serializes_guide_replacement(clean_postgres_database, reader_first): + from tests.authorization.guide_activation.pg_support import service + from tests.projects.guide_activation.test_successor import successor_command + from app.modules.projects.models import ProjectGuide + + async with activated_context(clean_postgres_database) as ( + factory, + first, + actor, + grant, + _, + policy, + ): + successor = await successor_command(factory, first.command, actor, grant, policy) + successor = successor.model_copy( + update={ + "expected_previous_active_guide_id": first.command.target.proposal.guide_id, + "expected_previous_active_guide_generation": first.activation_generation, + } + ) + task = None + async with ( + factory() as reader, + service(factory, actor) as (writer, activation, request_id, _), + ): + try: + cached = await reader.get(ProjectGuide, str(first.command.target.proposal.guide_id)) + assert cached.status == "active" + name = "arch03a-replace-" + uuid4().hex + reader_pid = await _name(reader, name if not reader_first else name + "-reader") + writer_pid = await _name(writer, name if reader_first else name + "-writer") + owner = ProjectLockedPolicyRepository(reader) + if reader_first: + before = await owner.lock_active_policy_context(first.contribution.project_id) + assert before.activation_receipt == first + task = asyncio.create_task( + activation.activate(successor, actor=actor, request_id=request_id) + ) + await wait_for_named_database_lock( + clean_postgres_database, + name, + expected_waiter_pid=writer_pid, + expected_blocker_pid=reader_pid, + ) + await reader.commit() + next_receipt = await asyncio.wait_for(task, 20) + await writer.commit() + else: + next_receipt = await activation.activate( + successor, actor=actor, request_id=request_id + ) + task = asyncio.create_task( + owner.lock_active_policy_context(first.contribution.project_id) + ) + await wait_for_named_database_lock( + clean_postgres_database, + name, + expected_waiter_pid=reader_pid, + expected_blocker_pid=writer_pid, + ) + await writer.commit() + after = await asyncio.wait_for(task, 20) + assert after.activation_receipt == next_receipt + await reader.rollback() + async with reader.begin(): + active = await owner.lock_active_policy_context(first.contribution.project_id) + historical = await owner.lock_locked_policy_context(frozen_request(first)) + assert active.activation_receipt == next_receipt + assert historical.activation_receipt == first + assert historical.guide_status == cached.status == "superseded" + finally: + if task is not None: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + await reader.rollback() diff --git a/backend/tests/projects/test_locked_policy_context.py b/backend/tests/projects/test_locked_policy_context.py index 72ee1b29c..cf30ad6db 100644 --- a/backend/tests/projects/test_locked_policy_context.py +++ b/backend/tests/projects/test_locked_policy_context.py @@ -1,276 +1,318 @@ -"""PROJECT public locked-policy context capability tests.""" +"""Fail closed at exact complete-context boundaries using real activated sources.""" -from __future__ import annotations - -import asyncio +import json from dataclasses import replace -from typing import Any -from uuid import UUID, uuid4 +from uuid import uuid4 -from httpx import AsyncClient import pytest -from sqlalchemy import select, text, update -from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.orm.attributes import set_committed_value -from app.db import session as db_session -from app.modules.projects.api import ( - ProjectLockedPolicyContextRequest, - ProjectLockedPolicyContextUnavailable, -) +from app.modules.projects.api import CanonicalJsonObject, ProjectLockedPolicyContextUnavailable +from app.modules.projects.locked_policy_repository import ProjectLockedPolicyRepository from app.modules.projects.models import ( EffectiveProjectSubmissionArtifactPolicy, + GuideMutationIdempotencyRecord, + GuideSourceSnapshot, + PostSubmitCheckerPolicy, PreSubmitCheckerPolicy, Project, + ReviewPolicy, + RevisionPolicy, ) -from app.modules.projects.locked_policy_repository import ProjectLockedPolicyRepository -from projects.guide_fixtures import ( - complete_guide_payload, - create_guide, - create_project, +from app.modules.projects.guide_compilation.models import ( + ProjectGuideCompilationAttempt, + ProjectGuideProposalApproval, + ProjectGuideSetupFinalization, ) -from projects.policy_bundle_fixtures import create_approved_policy_bundle -from project_create_fixtures import seed_active_guide_for_downstream_test -from projects.client_fixtures import ( - project_client as project_client, - project_database_env as project_database_env, +from app.modules.projects.post_policy.models import PostPolicyOperation +from tests.projects.locked_policy_fixtures import activated_context, frozen_request + + +@pytest.mark.parametrize( + "failure", + [ + "activation", + "activation_digest", + "pre_approval", + "post_approval", + "finalization", + "catalogue", + "foreign_selector", + "foreign_project", + "snapshot_hash", + "effective_hash", + "pre_hash", + "post_hash", + "review_hash", + "revision_hash", + "review_incomplete", + "revision_incomplete", + "project_archived", + "pre_pending", + "snapshot_array", + "effective_array", + "pre_array", + "nonfinite_body", + ], ) +async def test_context_rejects_missing_or_substituted_custody( + clean_postgres_database, monkeypatch, failure +): + async with activated_context(clean_postgres_database) as (factory, receipt, *_): + request = frozen_request(receipt) + async with factory() as session, session.begin(): + owner = ProjectLockedPolicyRepository(session) + assert (await owner.lock_locked_policy_context(request)).activation_receipt == receipt + missing = { + "activation": GuideMutationIdempotencyRecord, + "pre_approval": ProjectGuideProposalApproval, + "finalization": ProjectGuideSetupFinalization, + }.get(failure) + altered = { + "activation_digest": ( + GuideMutationIdempotencyRecord, + "request_digest", + "sha256:" + "a" * 64, + ), + "catalogue": ( + ProjectGuideCompilationAttempt, + "pre_catalogue_manifest_hash", + "sha256:" + "a" * 64, + ), + "snapshot_hash": (GuideSourceSnapshot, "manifest_json", {"wrong": True}), + "effective_hash": ( + EffectiveProjectSubmissionArtifactPolicy, + "effective_policy", + {"wrong": True}, + ), + "pre_hash": (PreSubmitCheckerPolicy, "compiled_bundle", {"wrong": True}), + "post_hash": (PostSubmitCheckerPolicy, "policy_body", {"wrong": True}), + "review_hash": (ReviewPolicy, "human_review_required", False), + "revision_hash": (RevisionPolicy, "max_revision_rounds", 999), + "review_incomplete": (ReviewPolicy, "semantics_status", "legacy_incomplete"), + "revision_incomplete": (RevisionPolicy, "semantics_status", "legacy_incomplete"), + "project_archived": (Project, "status", "archived"), + "pre_pending": (PreSubmitCheckerPolicy, "lifecycle_status", "pending_compilation"), + "snapshot_array": (GuideSourceSnapshot, "manifest_json", []), + "effective_array": ( + EffectiveProjectSubmissionArtifactPolicy, + "effective_policy", + [], + ), + "pre_array": (PreSubmitCheckerPolicy, "compiled_bundle", []), + "nonfinite_body": ( + EffectiveProjectSubmissionArtifactPolicy, + "effective_policy", + {"invalid": float("nan")}, + ), + }.get(failure) + seen = [] + def corrupt(row): + if missing is not None and isinstance(row, missing): + seen.append(failure) + return None + if altered is not None and isinstance(row, altered[0]): + seen.append(failure) + set_committed_value(row, altered[1], altered[2]) + return row -async def create_locked_policy_context_fixture( - client: AsyncClient, *, superseded=False, -) -> ProjectLockedPolicyContextRequest: - """Create and activate one complete PROJECT policy lineage.""" - project = await create_project(client, name=f"Locked Context {uuid4()}") - guide = await create_guide(client, project["id"], complete_guide_payload()) - bundle = await create_approved_policy_bundle(client, project["id"], guide["id"]) - if superseded: - from projects.unified_policy_fixtures import supersede_unified_submission_policy - await supersede_unified_submission_policy(project["id"], guide["id"], bundle["submission_artifact_policy"]["id"]) - await seed_active_guide_for_downstream_test( - db_session.get_session_factory(), - project_id=project["id"], - guide_id=guide["id"], - ) - snapshot = bundle["source_snapshot"] - effective = bundle["effective_policy"] - pre_submit = bundle["pre_submit_checker_policy"] - assert pre_submit is not None - return ProjectLockedPolicyContextRequest( - project_id=UUID(project["id"]), - guide_version=guide["version"], - source_snapshot_id=UUID(snapshot["id"]), - source_snapshot_hash=snapshot["bundle_hash"], - effective_policy_id=UUID(effective["id"]), - effective_policy_hash=effective["effective_policy_hash"], - pre_submit_policy_id=UUID(pre_submit["id"]), - pre_submit_policy_bundle_hash=pre_submit["compiled_bundle_hash"], - ) + original_scalar, original_get, original_scalars = ( + session.scalar, + session.get, + session.scalars, + ) + original_refresh = session.refresh + async def scalar(*args, **kwargs): + return corrupt(await original_scalar(*args, **kwargs)) -async def _wait_for_project_database_lock( - database_url: str, - application_name: str, -) -> None: - """Wait until one named PROJECT race participant blocks on PostgreSQL.""" - engine = create_async_engine(database_url) - try: - async with engine.connect() as connection: - deadline = asyncio.get_running_loop().time() + 30.0 - while asyncio.get_running_loop().time() < deadline: - waiting = await connection.scalar( - text( - "select exists(select 1 from pg_stat_activity where " - "application_name = :application_name " - "and wait_event_type = 'Lock')" - ), - {"application_name": application_name}, - ) - if waiting: - return - await asyncio.sleep(0.01) - finally: - await engine.dispose() - raise AssertionError(f"{application_name} never reached the PostgreSQL lock") + async def get(*args, **kwargs): + return corrupt(await original_get(*args, **kwargs)) + async def scalars(statement, *args, **kwargs): + if ( + failure == "post_approval" + and statement.column_descriptions[0]["entity"] is PostPolicyOperation + ): + seen.append(failure) + statement = statement.where(PostPolicyOperation.kind != "approve") + return await original_scalars(statement, *args, **kwargs) -@pytest.mark.asyncio -async def test_locked_policy_repository_postgresql_resolves_current( - project_client: AsyncClient, -) -> None: - request = await create_locked_policy_context_fixture(project_client) - async with db_session.get_session_factory()() as session: - current = await ProjectLockedPolicyRepository(session).lock_locked_policy_context(request) - assert current.guide_status == "active" - assert current.effective_policy_status == "approved" - assert current.pre_submit_policy_status == "compiled" + async def refresh(row, *args, **kwargs): + await original_refresh(row, *args, **kwargs) + corrupt(row) + monkeypatch.setattr(session, "refresh", refresh) + monkeypatch.setattr(session, "scalar", scalar) + monkeypatch.setattr(session, "get", get) + monkeypatch.setattr(session, "scalars", scalars) + if failure == "foreign_selector": + request = replace(request, effective_policy_id=uuid4()) + elif failure == "foreign_project": + request = replace(request, project_id=uuid4()) + with pytest.raises(ProjectLockedPolicyContextUnavailable) as denied: + await owner.lock_locked_policy_context(request) + assert denied.value.code == "project_locked_policy_context_changed" + if not failure.startswith("foreign_"): + assert seen, "the intended corrupt read was not reached" + assert not session.new and not session.dirty and not session.deleted -@pytest.mark.asyncio -async def test_locked_policy_repository_postgresql_resolves_superseded( - project_client: AsyncClient, -) -> None: - request = await create_locked_policy_context_fixture(project_client, superseded=True) - async with db_session.get_session_factory()() as session: - historical = await ProjectLockedPolicyRepository(session).lock_locked_policy_context( - request - ) - assert historical.guide_status == "active" - assert historical.effective_policy_status == "superseded" - assert historical.pre_submit_policy_status == "superseded" +async def test_context_requires_caller_root_transaction(clean_postgres_database): + async with activated_context(clean_postgres_database) as (factory, receipt, *_): + async with factory() as session: + owner = ProjectLockedPolicyRepository(session) + with pytest.raises(ProjectLockedPolicyContextUnavailable): + await owner.lock_active_policy_context(receipt.contribution.project_id) + assert not session.in_transaction() + async with session.begin(), session.begin_nested(): + with pytest.raises(ProjectLockedPolicyContextUnavailable): + await owner.lock_locked_policy_context(frozen_request(receipt)) -@pytest.mark.asyncio -async def test_locked_policy_repository_postgresql_rejects_unknown_effective_policy( - project_client: AsyncClient, -) -> None: - request = await create_locked_policy_context_fixture(project_client, superseded=True) - wrong_successor = replace(request, effective_policy_id=uuid4()) - async with db_session.get_session_factory()() as session: - with pytest.raises( - ProjectLockedPolicyContextUnavailable, - match="project_locked_policy_context_changed", - ): - await ProjectLockedPolicyRepository(session).lock_locked_policy_context(wrong_successor) +@pytest.mark.parametrize( + "field,value", + [ + ("guide_status", "draft"), + ("effective_policy_status", "draft"), + ("pre_submit_policy_status", "pending_compilation"), + ("pre_submit_compiler_version", ""), + ("guide_version", ""), + ("effective_policy_id", None), + ], +) +async def test_context_rejects_invalid_public_facts(clean_postgres_database, field, value): + async with activated_context(clean_postgres_database) as (factory, receipt, *_): + async with factory() as session, session.begin(): + facts = await ProjectLockedPolicyRepository(session).lock_locked_policy_context( + frozen_request(receipt) + ) + with pytest.raises(ValueError): + replace(facts, **{field: value}) -@pytest.mark.asyncio -async def test_locked_policy_repository_postgresql_rejects_pending_pre_submit( - project_client: AsyncClient, -) -> None: - """Approved lineage cannot regress to pending before a locked reader observes it.""" - from sqlalchemy.exc import IntegrityError - request = await create_locked_policy_context_fixture(project_client) - async with db_session.get_session_factory()() as session: - await session.execute( - update(PreSubmitCheckerPolicy) - .where(PreSubmitCheckerPolicy.id == str(request.pre_submit_policy_id)) - .values(lifecycle_status="pending_compilation", superseded_at=None) - ) - with pytest.raises(IntegrityError, match="proposal approval lifecycle mismatch"): - await session.commit() - await session.rollback() - current = await ProjectLockedPolicyRepository(session).lock_locked_policy_context(request) - assert current.pre_submit_policy_status == "compiled" +async def test_context_refreshes_preloaded_custody(clean_postgres_database): + from sqlalchemy import select + from app.modules.projects.models import ProjectGuide, SubmissionPolicyMutationIdempotencyRecord + from app.modules.projects.guide_compilation.models import ( + ProjectGuideCompilationRequestOperation, + ) + async with activated_context(clean_postgres_database) as (factory, receipt, *_): + async with factory() as session, session.begin(): + owner = ProjectLockedPolicyRepository(session) + expected = await owner.lock_locked_policy_context(frozen_request(receipt)) + held = [] + for model, field, bad in ( + (Project, "status", "archived"), + (ProjectGuide, "status", "draft"), + (GuideMutationIdempotencyRecord, "request_digest", "sha256:" + "f" * 64), + ( + ProjectGuideCompilationAttempt, + "pre_catalogue_manifest_hash", + "sha256:" + "f" * 64, + ), + (ProjectGuideCompilationRequestOperation, "source_snapshot_id", str(uuid4())), + (ProjectGuideSetupFinalization, "facts_digest", "sha256:" + "f" * 64), + (ProjectGuideProposalApproval, "output_digest", "sha256:" + "f" * 64), + (SubmissionPolicyMutationIdempotencyRecord, "status", "reserved"), + (EffectiveProjectSubmissionArtifactPolicy, "effective_policy", {}), + (PreSubmitCheckerPolicy, "compiled_bundle", {}), + (PostSubmitCheckerPolicy, "policy_body", {}), + (PostPolicyOperation, "output_digest", "sha256:" + "f" * 64), + (ReviewPolicy, "human_review_required", False), + (RevisionPolicy, "max_revision_rounds", 999), + ): + statement = select(model) + if model is GuideMutationIdempotencyRecord: + statement = statement.where(model.operation_id == receipt.operation_id) + elif model is SubmissionPolicyMutationIdempotencyRecord: + statement = statement.where( + model.operation_id + == receipt.command.target.upstream.operation_id + ) + rows = list(await session.scalars(statement)) + assert rows + for row in rows: + original = getattr(row, field) + held.append((row, field, original)) + set_committed_value(row, field, bad) + assert await owner.lock_locked_policy_context(frozen_request(receipt)) == expected + for row, field, original in held: + assert getattr(row, field) == original, (type(row).__name__, field) + assert not session.dirty -@pytest.mark.asyncio -async def test_locked_policy_repository_postgresql_rejects_inactive_project( - project_client: AsyncClient, -) -> None: - """Writer commits first: refresh stale identity-map state before returning facts.""" - request = await create_locked_policy_context_fixture(project_client) - factory = db_session.get_session_factory() - async with factory() as observer: - project = await observer.get(Project, str(request.project_id)) - assert project is not None and project.status == "active" - current = await ProjectLockedPolicyRepository(observer).lock_locked_policy_context(request) - assert current.project_id == request.project_id - await observer.commit() - async with factory() as writer: - await writer.execute( - update(Project).where(Project.id == str(request.project_id)).values(status="draft") +async def test_context_rejects_substituted_post_policy_body(clean_postgres_database): + async with activated_context(clean_postgres_database) as (factory, receipt, *_): + async with factory() as session, session.begin(): + facts = await ProjectLockedPolicyRepository(session).lock_locked_policy_context( + frozen_request(receipt) ) - await writer.commit() + assert replace(facts) == facts + body = json.loads(facts.compiled_post_submit_policy.value) + body["guide_version"] = "different-guide" + with pytest.raises(ValueError, match="project locked policy facts differ from activation"): + replace(facts, compiled_post_submit_policy=CanonicalJsonObject.from_mapping(body)) - assert project.status == "active" # The observer still holds its cached object. - with pytest.raises(ProjectLockedPolicyContextUnavailable) as denied: - await ProjectLockedPolicyRepository(observer).lock_locked_policy_context(request) - assert denied.value.code == "project_locked_policy_context_changed" - assert project.status == "draft" - assert not observer.new and not observer.dirty and not observer.deleted - -@pytest.mark.asyncio -async def test_locked_policy_repository_postgresql_serializes_project_status_change( - project_client: AsyncClient, - project_database_env: str, -) -> None: - """Reader locks first: inactivation waits for the exact owning transaction.""" - request = await create_locked_policy_context_fixture(project_client) - contender_name = f"project-inactivation-{uuid4()}" - factory = db_session.get_session_factory() - holder, contender = factory(), factory() - contender_call: asyncio.Task[Any] | None = None - try: - await ProjectLockedPolicyRepository(holder).lock_locked_policy_context(request) - await contender.execute( - text("select set_config('application_name', :application_name, true)"), - {"application_name": contender_name}, - ) - contender_call = asyncio.create_task( - contender.execute( - update(Project).where(Project.id == str(request.project_id)).values(status="draft") +async def test_context_rejects_substituted_review_body(clean_postgres_database): + async with activated_context(clean_postgres_database) as (factory, receipt, *_): + async with factory() as session, session.begin(): + facts = await ProjectLockedPolicyRepository(session).lock_locked_policy_context( + frozen_request(receipt) ) - ) - await _wait_for_project_database_lock(project_database_env, contender_name) - assert not contender_call.done() - await holder.commit() - await contender_call - await contender.commit() - async with factory() as observer: - project = await observer.get(Project, str(request.project_id)) - assert project is not None and project.status == "draft" - finally: - if contender_call is not None: - contender_call.cancel() - await asyncio.gather(contender_call, return_exceptions=True) - await holder.close() - await contender.close() + assert replace(facts) == facts + body = json.loads(facts.review_policy.value) + assert body["human_review_required"] is True + body["human_review_required"] = False + with pytest.raises(ValueError, match="policy semantics digest mismatch"): + replace(facts, review_policy=CanonicalJsonObject.from_mapping(body)) -@pytest.mark.asyncio -async def test_locked_policy_repository_postgresql_does_not_substitute_successors( - project_client: AsyncClient, -) -> None: - """Keep resolving exact historical IDs after a real approved successor exists.""" - from sqlalchemy import select - request = await create_locked_policy_context_fixture(project_client, superseded=True) - async with db_session.get_session_factory()() as session: - successor = (await session.scalars(select(EffectiveProjectSubmissionArtifactPolicy).where( - EffectiveProjectSubmissionArtifactPolicy.project_id == str(request.project_id), - EffectiveProjectSubmissionArtifactPolicy.lifecycle_status == "approved", - ))).one() - assert successor.id != str(request.effective_policy_id) - assert successor.supersedes_effective_policy_id == str(request.effective_policy_id) - historical = await ProjectLockedPolicyRepository(session).lock_locked_policy_context(request) - assert historical.effective_policy_id == request.effective_policy_id - assert historical.pre_submit_policy_id == request.pre_submit_policy_id +async def test_context_rejects_substituted_revision_body(clean_postgres_database): + async with activated_context(clean_postgres_database) as (factory, receipt, *_): + async with factory() as session, session.begin(): + facts = await ProjectLockedPolicyRepository(session).lock_locked_policy_context( + frozen_request(receipt) + ) + assert replace(facts) == facts + body = json.loads(facts.revision_policy.value) + assert body["max_revision_rounds"] != 999 + body["max_revision_rounds"] = 999 + with pytest.raises(ValueError, match="policy semantics digest mismatch"): + replace(facts, revision_policy=CanonicalJsonObject.from_mapping(body)) -@pytest.mark.asyncio -async def test_locked_policy_repository_postgresql_serializes_race( - project_client: AsyncClient, - project_database_env: str, -) -> None: - """Prove exact PROJECT lineage observation holds its pre-submit row lock.""" - request = await create_locked_policy_context_fixture(project_client) - contender_name = f"project-locked-policy-{uuid4()}" - holder = db_session.get_session_factory()() - contender = db_session.get_session_factory()() - contender_call: asyncio.Task[Any] | None = None - try: - held = await ProjectLockedPolicyRepository(holder).lock_locked_policy_context(request) - await contender.execute( - text("select set_config('application_name', :application_name, true)"), - {"application_name": contender_name}, - ) - contender_call = asyncio.create_task( - contender.scalar( - select(PreSubmitCheckerPolicy) - .where(PreSubmitCheckerPolicy.id == str(request.pre_submit_policy_id)) - .with_for_update() +@pytest.mark.parametrize("semantics_format,human_review_required", [("v1", True), ("v2", True), ("v2", False)]) +async def test_context_binds_explicit_review_format( + clean_postgres_database, semantics_format, human_review_required, +): + """Public value reconstruction is not authorization to activate changed policies.""" + from app.modules.projects.api.guide_activation import GuideActivationReceipt + from app.modules.projects.api.policy_lineage import ReviewPolicySemantics, policy_digest + + async with activated_context(clean_postgres_database) as (factory, receipt, *_): + async with factory() as session, session.begin(): + facts = await ProjectLockedPolicyRepository(session).lock_locked_policy_context( + frozen_request(receipt) ) + assert facts.review_semantics_format == "v2" + body = json.loads(facts.review_policy.value) + body["human_review_required"] = human_review_required + values = receipt.model_dump(mode="json") + values["command"]["review"]["policy_hash"] = policy_digest( + "review", ReviewPolicySemantics.model_validate(body), + review_semantics_format=semantics_format, + ) + consistent = replace( + facts, activation_receipt=GuideActivationReceipt.model_validate(values), + review_policy=CanonicalJsonObject.from_mapping(body), + review_semantics_format=semantics_format, ) - await _wait_for_project_database_lock(project_database_env, contender_name) - assert not contender_call.done() - await holder.rollback() - assert await contender_call is not None - assert held.pre_submit_policy_id == request.pre_submit_policy_id - finally: - if contender_call is not None: - contender_call.cancel() - await asyncio.gather(contender_call, return_exceptions=True) - await holder.close() - await contender.close() + assert replace(consistent) == consistent + if human_review_required: + wrong_format = "v2" if semantics_format == "v1" else "v1" + with pytest.raises(ValueError, match="policy semantics digest mismatch"): + replace(consistent, review_semantics_format=wrong_format) diff --git a/backend/tests/projects/test_locked_policy_contract.py b/backend/tests/projects/test_locked_policy_contract.py index 7da260775..494f59f23 100644 --- a/backend/tests/projects/test_locked_policy_contract.py +++ b/backend/tests/projects/test_locked_policy_contract.py @@ -3,90 +3,30 @@ from __future__ import annotations from dataclasses import replace -from types import SimpleNamespace from typing import Any, cast -from uuid import UUID, uuid4 +from uuid import uuid4 import pytest from app.core.hashing import canonical_json_hash from app.modules.projects.api import ( CanonicalJsonObject, - ProjectLockedPolicyContextFacts, ProjectLockedPolicyContextRequest, ProjectLockedPolicyContextUnavailable, ) -from app.modules.projects.locked_policy_repository import ProjectLockedPolicyRepository -def _project_locked_policy_rows( - *, - guide_status: str = "active", - effective_status: str = "approved", - pre_submit_status: str = "compiled", -) -> tuple[ProjectLockedPolicyContextRequest, tuple[SimpleNamespace, ...]]: - """Build one internally consistent locked PROJECT lineage.""" - project_id, guide_id, snapshot_id, effective_id, pre_submit_id = (uuid4() for _ in range(5)) - manifest = {"items": [{"name": "guide.md"}], "schema_version": "v1"} - effective_body = {"allowed": ["zip"], "limits": {"max_bytes": 1024}} - compiled_bundle = {"rules": [{"primitive": "zip_safety"}]} - snapshot_hash = canonical_json_hash(manifest) - effective_hash = canonical_json_hash(effective_body) - bundle_hash = canonical_json_hash(compiled_bundle) - request = ProjectLockedPolicyContextRequest( - project_id=project_id, - guide_version="v1", - source_snapshot_id=snapshot_id, - source_snapshot_hash=snapshot_hash, - effective_policy_id=effective_id, - effective_policy_hash=effective_hash, - pre_submit_policy_id=pre_submit_id, - pre_submit_policy_bundle_hash=bundle_hash, +def _request(): + return ProjectLockedPolicyContextRequest( + project_id=uuid4(), + guide_version="initial", + source_snapshot_id=uuid4(), + source_snapshot_hash="sha256:" + "1" * 64, + effective_policy_id=uuid4(), + effective_policy_hash="sha256:" + "2" * 64, + pre_submit_policy_id=uuid4(), + pre_submit_policy_bundle_hash="sha256:" + "3" * 64, ) - rows = ( - SimpleNamespace(id=str(project_id), status="active"), - SimpleNamespace( - id=str(guide_id), - project_id=str(project_id), - version="v1", - status=guide_status, - ), - SimpleNamespace( - id=str(snapshot_id), - project_id=str(project_id), - guide_id=str(guide_id), - guide_version="v1", - manifest_json=manifest, - bundle_hash=snapshot_hash, - ), - SimpleNamespace( - id=str(effective_id), - project_id=str(project_id), - guide_id=str(guide_id), - guide_version="v1", - source_snapshot_id=str(snapshot_id), - source_snapshot_hash=snapshot_hash, - effective_policy=effective_body, - effective_policy_hash=effective_hash, - lifecycle_status=effective_status, - ), - SimpleNamespace( - id=str(pre_submit_id), - project_id=str(project_id), - guide_id=str(guide_id), - guide_version="v1", - source_snapshot_id=str(snapshot_id), - source_snapshot_hash=snapshot_hash, - effective_policy_id=str(effective_id), - effective_policy_hash=effective_hash, - lifecycle_status=pre_submit_status, - compiler_version="pre-submit-v1", - compiled_bundle=compiled_bundle, - compiled_bundle_hash=bundle_hash, - ), - ) - return request, rows - def test_project_locked_policy_copies_nested_input() -> None: @@ -130,169 +70,57 @@ def test_project_locked_policy_preserves_bounded_failure_code() -> None: def test_project_locked_policy_rejects_empty_guide_version() -> None: - request, _rows = _project_locked_policy_rows() + request = _request() with pytest.raises(ValueError, match="guide version is empty"): replace(request, guide_version=" ") def test_project_locked_policy_rejects_malformed_digest() -> None: - request, _rows = _project_locked_policy_rows() + request = _request() with pytest.raises(ValueError, match="hash is invalid"): replace(request, effective_policy_hash="sha256:invalid") -def _valid_public_facts() -> ProjectLockedPolicyContextFacts: - request, rows = _project_locked_policy_rows() - return ProjectLockedPolicyContextFacts( - project_id=request.project_id, - guide_id=UUID(rows[1].id), - guide_version="v1", - guide_status="active", - source_snapshot_id=request.source_snapshot_id, - source_snapshot_hash=request.source_snapshot_hash, - effective_policy_id=request.effective_policy_id, - effective_policy_hash=request.effective_policy_hash, - effective_policy_status="approved", - effective_policy=CanonicalJsonObject.from_mapping(rows[3].effective_policy), - pre_submit_policy_id=request.pre_submit_policy_id, - pre_submit_policy_bundle_hash=request.pre_submit_policy_bundle_hash, - pre_submit_policy_status="compiled", - pre_submit_compiler_version="pre-submit-v1", - compiled_pre_submit_bundle=CanonicalJsonObject.from_mapping(rows[4].compiled_bundle), - ) - - -def test_project_locked_policy_rejects_invalid_fact_status() -> None: - with pytest.raises(ValueError, match="facts are invalid"): - replace(_valid_public_facts(), guide_status=cast(Any, "draft")) +@pytest.mark.parametrize("semantics_format", ("v1", "v2")) +def test_context_preserves_persisted_review_semantics(semantics_format): + """The projection validates retained business hashes using their canonical owner.""" + from types import SimpleNamespace + from app.modules.projects.api.guide_activation import GuidePolicySelection + from app.modules.projects.locked_policy_projection import _policy_body + from app.modules.projects.api.policy_lineage import ReviewPolicySemantics, policy_digest - -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("guide_status", "effective_status", "pre_submit_status"), - (("active", "approved", "compiled"), ("superseded", "superseded", "superseded")), -) -async def test_locked_policy_repository_resolves_exact_current_and_superseded_policy( - guide_status: str, - effective_status: str, - pre_submit_status: str, -) -> None: - """Resolve exact valid historical lineage without selecting successors.""" - request, rows = _project_locked_policy_rows( - guide_status=guide_status, - effective_status=effective_status, - pre_submit_status=pre_submit_status, + semantics = ReviewPolicySemantics( + review_preference_window_seconds=3600, + review_lease_duration_seconds=1800, + allowed_decisions=("accept", "needs_revision", "reject"), ) - statements: list[Any] = [] - - class Session: - def __init__(self) -> None: - self.rows = iter(rows) - - async def scalar(self, statement: Any) -> Any: - statements.append(statement) - return next(self.rows) - - facts = await ProjectLockedPolicyRepository(cast(Any, Session())).lock_locked_policy_context( - request - ) - assert facts == ProjectLockedPolicyContextFacts( - project_id=request.project_id, - guide_id=UUID(rows[1].id), - guide_version="v1", - guide_status=cast(Any, guide_status), - source_snapshot_id=request.source_snapshot_id, - source_snapshot_hash=request.source_snapshot_hash, - effective_policy_id=request.effective_policy_id, - effective_policy_hash=request.effective_policy_hash, - effective_policy_status=cast(Any, effective_status), - effective_policy=CanonicalJsonObject.from_mapping(rows[3].effective_policy), - pre_submit_policy_id=request.pre_submit_policy_id, - pre_submit_policy_bundle_hash=request.pre_submit_policy_bundle_hash, - pre_submit_policy_status=cast(Any, pre_submit_status), - pre_submit_compiler_version="pre-submit-v1", - compiled_pre_submit_bundle=CanonicalJsonObject.from_mapping(rows[4].compiled_bundle), + identity = uuid4() + digest = policy_digest("review", semantics, review_semantics_format=semantics_format) + guide = SimpleNamespace(project_id=str(uuid4()), version="retained-guide") + policy = SimpleNamespace( + id=str(identity), + project_id=guide.project_id, + guide_version=guide.version, + policy_generation=1, + policy_hash=digest, + semantics_status="complete", + semantics_format=semantics_format, + **semantics.model_dump(), ) - assert len(statements) == 5 - assert all("FOR UPDATE" in str(statement) for statement in statements) - assert all( - statement.get_execution_options().get("populate_existing") is True - for statement in statements - ) - + selection = GuidePolicySelection(policy_id=identity, generation=1, policy_hash=digest) + expected = CanonicalJsonObject.from_mapping(semantics.model_dump(mode="json")) + assert _policy_body("review", policy, selection, guide) == expected + policy.human_review_required = False + with pytest.raises(ValueError): + _policy_body("review", policy, selection, guide) -@pytest.mark.asyncio -@pytest.mark.parametrize( - "failure", - ( - "draft", - "inactive_project", - "pending", - "hash", - "cross_project", - "effective_link", - "non_canonical", - "invalid_guide_id", - "snapshot_array", - "effective_array", - "bundle_array", - ), -) -async def test_locked_policy_repository_rejects_invalid_lineage( - failure: str, -) -> None: - """Fail closed for draft, pending, drifted, or cross-project lineage.""" - request, rows = _project_locked_policy_rows() - if failure == "draft": - rows[1].status = "draft" - elif failure == "inactive_project": - rows[0].status = "draft" - elif failure == "pending": - rows[4].lifecycle_status = "pending_compilation" - elif failure == "hash": - rows[3].effective_policy = {"allowed": ["tar"]} - elif failure == "cross_project": - rows[2].project_id = str(uuid4()) - elif failure == "effective_link": - rows[4].effective_policy_id = str(uuid4()) - elif failure == "non_canonical": - rows[3].effective_policy = {"invalid": float("nan")} - elif failure == "invalid_guide_id": - rows[1].id = "invalid-guide-id" - rows[2].guide_id = rows[1].id - rows[3].guide_id = rows[1].id - rows[4].guide_id = rows[1].id - elif failure == "snapshot_array": - drift_hash = canonical_json_hash(cast(Any, [])) - request = replace(request, source_snapshot_hash=drift_hash) - rows[2].manifest_json = [] - rows[2].bundle_hash = drift_hash - rows[3].source_snapshot_hash = drift_hash - rows[4].source_snapshot_hash = drift_hash - elif failure == "effective_array": - drift_hash = canonical_json_hash(cast(Any, [])) - request = replace(request, effective_policy_hash=drift_hash) - rows[3].effective_policy = [] - rows[3].effective_policy_hash = drift_hash - rows[4].effective_policy_hash = drift_hash - else: - drift_hash = canonical_json_hash(cast(Any, [])) - request = replace(request, pre_submit_policy_bundle_hash=drift_hash) - rows[4].compiled_bundle = [] - rows[4].compiled_bundle_hash = drift_hash - - class Session: - def __init__(self) -> None: - self.rows = iter(rows) +def test_project_context_contract_does_not_cycle_agent_port_import(): + import subprocess + import sys - async def scalar(self, _statement: Any) -> Any: - return next(self.rows) - - with pytest.raises( - ProjectLockedPolicyContextUnavailable, - match="project_locked_policy_context_changed", - ): - await ProjectLockedPolicyRepository(cast(Any, Session())).lock_locked_policy_context( - request - ) + result = subprocess.run( + [sys.executable, "-c", "import app.interfaces.project_agents"], + capture_output=True, text=True, check=False, + ) + assert result.returncode == 0, result.stderr diff --git a/backend/tests/projects/test_locked_policy_custody.py b/backend/tests/projects/test_locked_policy_custody.py new file mode 100644 index 000000000..f2e1b61d3 --- /dev/null +++ b/backend/tests/projects/test_locked_policy_custody.py @@ -0,0 +1,242 @@ +"""Complete PROJECTS context uses actual activated guide custody.""" + +import json + +from sqlalchemy import select + +from app.modules.projects.locked_policy_repository import ProjectLockedPolicyRepository +from app.modules.projects.models import SubmissionArtifactPolicy, PostSubmitCheckerPolicy +from tests.projects.locked_policy_fixtures import activated_context, frozen_request + + +async def test_active_and_frozen_context_are_complete(clean_postgres_database): + async with activated_context(clean_postgres_database) as (factory, receipt, *_): + async with factory() as session, session.begin(): + owner = ProjectLockedPolicyRepository(session) + active = await owner.lock_active_policy_context(receipt.contribution.project_id) + frozen = await owner.lock_locked_policy_context(frozen_request(receipt)) + assert active == frozen + assert active.activation_receipt == receipt + artifact = await session.scalar( + select(SubmissionArtifactPolicy).where( + SubmissionArtifactPolicy.id + == str(receipt.command.target.proposal.artifact_policy_id) + ) + ) + post = await session.scalar( + select(PostSubmitCheckerPolicy).where( + PostSubmitCheckerPolicy.id == str(receipt.command.target.policy_id) + ) + ) + assert json.loads(active.artifact_policy.value) == artifact.policy_body + assert json.loads(active.compiled_post_submit_policy.value) == post.policy_body + assert json.loads(active.review_policy.value)["human_review_required"] is True + assert json.loads(active.revision_policy.value)["max_revision_rounds"] > 0 + assert not session.new and not session.dirty and not session.deleted + + +async def test_frozen_context_survives_successor_and_retirement(clean_postgres_database): + from tests.projects.guide_activation.test_successor import successor_command + from tests.authorization.guide_activation.pg_support import activate + + async with activated_context(clean_postgres_database) as ( + factory, + first, + actor, + grant, + world, + policy, + ): + async with factory() as session, session.begin(): + original = await ProjectLockedPolicyRepository(session).lock_locked_policy_context( + frozen_request(first) + ) + successor = await successor_command(factory, first.command, actor, grant, policy) + successor = successor.model_copy( + update={ + "expected_previous_active_guide_id": first.command.target.proposal.guide_id, + "expected_previous_active_guide_generation": first.activation_generation, + } + ) + next_receipt = await activate(factory, actor, successor) + async with factory() as session, session.begin(): + await world.service(session).retire(world.request("retire", policy)) + async with factory() as session, session.begin(): + owner = ProjectLockedPolicyRepository(session) + active = await owner.lock_active_policy_context(first.contribution.project_id) + frozen = await owner.lock_locked_policy_context(frozen_request(first)) + assert active.activation_receipt == next_receipt + from dataclasses import replace + + assert frozen == replace(original, guide_status="superseded") + + +async def test_new_publication_does_not_reselect_context(clean_postgres_database): + from tests.projects.guide_activation.pg_support import publish_policy + + async with activated_context(clean_postgres_database) as (factory, receipt, *_): + _, newer = await publish_policy(factory, receipt.contribution.project_id) + assert ( + newer.contribution_policy_version_id + != receipt.contribution.contribution_policy_version_id + ) + async with factory() as session, session.begin(): + owner = ProjectLockedPolicyRepository(session) + assert ( + await owner.lock_active_policy_context(receipt.contribution.project_id) + ).activation_receipt == receipt + assert ( + await owner.lock_locked_policy_context(frozen_request(receipt)) + ).activation_receipt == receipt + + +async def test_context_result_is_deeply_immutable(clean_postgres_database): + from dataclasses import FrozenInstanceError + from pydantic import ValidationError + import pytest + + async with activated_context(clean_postgres_database) as (factory, receipt, *_): + async with factory() as session, session.begin(): + facts = await ProjectLockedPolicyRepository(session).lock_locked_policy_context( + frozen_request(receipt) + ) + with pytest.raises(FrozenInstanceError): + facts.review_policy.value = "{}" + with pytest.raises(ValidationError): + facts.activation_receipt.command.target.proposal.component_hashes.pre_submit_hash = ( + "sha256:" + "0" * 64 + ) + detached = facts.activation_receipt.model_dump(mode="json") + detached["contribution"]["adapter_binding_ids"].append("not-a-binding") + assert facts.activation_receipt == receipt + assert not facts.activation_receipt.contribution.adapter_binding_ids + + +async def test_context_read_does_not_allow_activated_proposal_mutations(clean_postgres_database): + import pytest + from uuid import uuid4 + from app.modules.projects.api import ProjectGuideSetupFinalizationError + from app.modules.projects.api.guide_proposals import ( + GuideProposalApproval, + GuideProposalCorrection, + GuideProposalError, + GuideProposalSelection, + ) + from app.modules.projects.guide_compilation.proposal_repository import GuideProposalRepository + from app.modules.projects.guide_compilation.proposal_service import GuideProposalService + from tests.projects.guide_compilation.proposals.pg_support import ProposalAuthority + from tests.projects.guide_compilation.proposals.test_postgresql import approve + from tests.projects.guide_activation.test_successor import successor_command + from tests.authorization.guide_activation.pg_support import activate + + async with activated_context(clean_postgres_database) as ( + factory, + receipt, + actor, + grant, + _, + policy, + ): + target = receipt.command.target.proposal + selected = GuideProposalSelection( + project_id=target.project_id, + guide_id=target.guide_id, + compilation_id=target.compilation_id, + ) + for status in ("active", "superseded"): + async with factory() as session, session.begin(): + assert ( + await ProjectLockedPolicyRepository(session).lock_locked_policy_context( + frozen_request(receipt) + ) + ).guide_status == status + async with factory() as session, session.begin(): + with pytest.raises(ProjectGuideSetupFinalizationError): + await GuideProposalRepository(session).lock(selected) + with pytest.raises(GuideProposalError): + await approve( + factory, + selected, + actor, + grant, + GuideProposalApproval( + target=target, + idempotency_key=uuid4(), + acknowledged_warning_hashes=receipt.command.target.upstream.acknowledged_warning_hashes, + ), + ) + async with factory() as session, session.begin(): + with pytest.raises(GuideProposalError): + await GuideProposalService( + session, ProposalAuthority(session, actor, target.project_id, grant) + ).request_correction( + GuideProposalCorrection( + target=target, + idempotency_key=uuid4(), + reason="Clarify the requirements.", + ), + actor=actor, + request_id=uuid4(), + ) + if status == "active": + next_command = await successor_command( + factory, receipt.command, actor, grant, policy + ) + next_target = next_command.target.proposal + async with factory() as session, session.begin(): + draft = await GuideProposalRepository(session).lock( + GuideProposalSelection( + project_id=next_target.project_id, + guide_id=next_target.guide_id, + compilation_id=next_target.compilation_id, + ) + ) + assert draft.view.guide.status == "draft" + await activate( + factory, + actor, + next_command.model_copy( + update={ + "expected_previous_active_guide_id": target.guide_id, + "expected_previous_active_guide_generation": receipt.activation_generation, + } + ), + ) + + +async def test_catalogue_rollout_preserves_context_but_blocks_new_activation( + clean_postgres_database, monkeypatch, +): + import pytest + from app.modules.projects.api.guide_proposals import GuideProposalError + from app.modules.projects import post_submit_policy + from app.modules.projects.post_policy import custody + from tests.checkers.post_submit.support import altered_catalogue + from tests.projects.guide_activation.pg_support import activation_case + from tests.authorization.guide_activation.pg_support import ( + activate, activation_state, service, + ) + + async with activation_case(clean_postgres_database) as ( + factory, command, actor, *_ + ): + newer = altered_catalogue(index=8, state="disabled") + assert newer.manifest_sha256 != command.target.proposal.post_catalogue_manifest_hash + before = await activation_state(factory) + with pytest.raises(GuideProposalError): + async with service(factory, actor) as (_, owner, request_id, _): + owner.post_catalogue = newer + await owner.activate(command, actor=actor, request_id=request_id) + assert await activation_state(factory) == before + receipt = await activate(factory, actor, command) + async with factory() as session, session.begin(): + original = await ProjectLockedPolicyRepository(session).lock_locked_policy_context( + frozen_request(receipt) + ) + monkeypatch.setattr(post_submit_policy, "current_post_submit_catalogue", lambda: newer) + monkeypatch.setattr(custody, "current_post_submit_catalogue", lambda: newer) + async with factory() as session, session.begin(): + owner = ProjectLockedPolicyRepository(session) + assert await owner.lock_locked_policy_context(frozen_request(receipt)) == original + assert await owner.lock_active_policy_context(receipt.contribution.project_id) == original + assert not session.new and not session.dirty and not session.deleted diff --git a/backend/tests/projects/unified_policy_fixtures.py b/backend/tests/projects/unified_policy_fixtures.py index 0215826bb..60aeec723 100644 --- a/backend/tests/projects/unified_policy_fixtures.py +++ b/backend/tests/projects/unified_policy_fixtures.py @@ -159,50 +159,31 @@ async def approve_unified_submission_policy(project_id, guide_id, policy_id, *, ).model_dump(mode="json") -async def create_standalone_unified_policy(sessions, namespace, *, guide_version="v1", artifact_proposal=None, include_post_policy=False): - """Arrange canonical setup before a lower-level artifact transaction starts.""" - from tests.projects.guide_compilation.helpers import context, seed_database - from tests.projects.guide_compilation.finalization.pg_prerequisites import compilation_and_projections - from app.modules.projects.api.guide_documents import GuideDocumentManifestRequest +async def create_standalone_unified_policy(sessions, namespace, *, guide_version="v1", artifact_proposal=None): + """Arrange complete activated context before a downstream artifact transaction.""" from app.modules.projects.models import PreSubmitCheckerPolicy + from app.modules.projects.api.post_policy import PostPolicyApproval + from tests.projects.guide_activation.source_fixtures import source_case + from tests.projects.guide_activation.pg_support import publish_policy, activation_command + from tests.authorization.guide_activation.pg_support import activate + from tests.projects.guide_compilation.helpers import service_actor + from tests.projects.guide_compilation.proposals.pg_support import seed_selected_review_revision_inputs + from tests.projects.post_policy.pg_support import prepare_post_policy, operate url = sessions.kw["bind"].url.render_as_string(hide_password=False) - values = await seed_database(url, namespace=namespace, guide_version=guide_version) + async with source_case(url, namespace=namespace, guide_version=guide_version, + artifact_proposal=artifact_proposal) as (values, _, command, actor, grant): + await seed_selected_review_revision_inputs(sessions, command, actor) + _, derived = await prepare_post_policy(sessions, command, actor, grant, service_actor(values)) + approved = await operate(sessions, actor, command.project_id, grant, "approve", + PostPolicyApproval(target=derived.target, idempotency_key=uuid4())) + _, policy = await publish_policy(sessions, command.project_id) + await activate(sessions, actor, await activation_command(sessions, approved, policy)) + upstream = approved.target.upstream async with sessions() as session: - manifest = await guide_document_manifest_port(session).load(GuideDocumentManifestRequest( - project_id=values["project"], guide_id=values["guide"], - guide_source_snapshot_id=values["snapshot"], - project_setup_run_id=values["setup_1"], setup_generation=1, - )) - compilation_context = context(values, guide_version=guide_version).model_copy(update={"material": manifest}) - from tests.projects.guide_compilation.helpers import result - outcome = result() - if artifact_proposal is not None: - outcome = outcome.model_copy(update={"submission_artifact_policy": artifact_proposal}) - command = await compilation_and_projections( - url, sessions, values, compilation_context=compilation_context, outcome=outcome, - ) - await finalize(sessions, values, command) - async with sessions() as session: - policy = (await session.scalars(select(SubmissionArtifactPolicy).where( - SubmissionArtifactPolicy.guide_id == str(values["guide"]), - ))).one() - policy_id = policy.id - effective = await approve_unified_submission_policy( - str(values["project"]), str(values["guide"]), policy_id, sessions=sessions, - ) - async with sessions() as session: - pre = (await session.scalars(select(PreSubmitCheckerPolicy).where( - PreSubmitCheckerPolicy.effective_policy_id == effective["id"], - ))).one() - if include_post_policy: - from tests.projects.post_submit_fixtures import seed_post_submit_policy_for_downstream_tests - await seed_post_submit_policy_for_downstream_tests( - project_id=str(values["project"]), guide_id=str(values["guide"]), - source_snapshot={"id": effective["source_snapshot_id"], "bundle_hash": effective["source_snapshot_hash"]}, - pre_submit_checker_policy={"id": pre.id}, sessions=sessions, - ) - return values, effective, pre + effective = await session.get(EffectiveProjectSubmissionArtifactPolicy, str(upstream.effective_policy_id)) + pre = await session.get(PreSubmitCheckerPolicy, str(upstream.pre_submit_policy_id)) + return values, EffectiveProjectSubmissionArtifactPolicyResponse.model_validate(effective).model_dump(mode="json"), pre async def _approval_context(sessions, project_id, guide_id, policy_id): diff --git a/backend/tests/test_artifact_admission.py b/backend/tests/test_artifact_admission.py index 7b95b3f71..284080b6e 100644 --- a/backend/tests/test_artifact_admission.py +++ b/backend/tests/test_artifact_admission.py @@ -95,13 +95,6 @@ PaymentPolicy, PostSubmitCheckerPolicy, ProjectGuide, - ReviewPolicy, - RevisionPolicy, -) -from app.modules.projects.policy_lineage import ( - ReviewPolicySemantics, - RevisionPolicySemantics, - policy_digest, ) from project_create_fixtures import seed_historical_project, suspend_historical_product_custody from app.modules.tasks.models import AuditEvent, Submission, WorkstreamTask @@ -344,14 +337,12 @@ async def _seed_checker_output_relationships(session, namespace, *, policy_bundl if policy_bundle is None: assert not session.in_transaction(), "Arrange canonical setup before staging artifact rows" policy_bundle = await create_standalone_unified_policy( - async_sessionmaker(session.bind, expire_on_commit=False), namespace, include_post_policy=True, + async_sessionmaker(session.bind, expire_on_commit=False), namespace, ) values, effective, pre = policy_bundle project_id, guide_id, snapshot_id = (str(values[key]) for key in ("project", "guide", "snapshot")) effective_policy_id = effective["id"] pre_submit_policy_id = pre.id - review_policy_id = str(uuid4()) - revision_policy_id = str(uuid4()) task_id = str(uuid4()) submission_id = str(uuid4()) contributor_id = str(uuid4()) @@ -362,23 +353,6 @@ async def _seed_checker_output_relationships(session, namespace, *, policy_bundl effective_policy_hash = effective["effective_policy_hash"] pre_submit_bundle_hash = pre.compiled_bundle_hash now = datetime.now(UTC) - review_hash = policy_digest( - "review", - ReviewPolicySemantics( - review_preference_window_seconds=3600, - review_lease_duration_seconds=1800, - allowed_decisions=("accept", "needs_revision", "reject"), - ), - ) - revision_hash = policy_digest( - "revision", - RevisionPolicySemantics( - max_revision_rounds=1, - revision_deadline_hours=24, - allowed_resubmission_states=("needs_revision",), - ), - ) - existing_post = await session.scalar(select(PostSubmitCheckerPolicy).where( PostSubmitCheckerPolicy.effective_policy_id == effective_policy_id, )) @@ -386,85 +360,15 @@ async def _seed_checker_output_relationships(session, namespace, *, policy_bundl post_submit_policy_id = existing_post.id post_submit_policy_body = existing_post.policy_body post_submit_policy_hash = existing_post.policy_hash - existing_review = await session.scalar(select(ReviewPolicy).where( - ReviewPolicy.project_id == project_id, ReviewPolicy.guide_version == guide_version, + guide = await session.get(ProjectGuide, guide_id) + assert guide is not None and guide.status == "active" and guide.activation_operation_id is not None + review_policy_id, review_hash = guide.selected_review_policy_id, guide.selected_review_policy_hash + revision_policy_id, revision_hash = guide.selected_revision_policy_id, guide.selected_revision_policy_hash + payment = await session.scalar(select(PaymentPolicy).where( + PaymentPolicy.project_id == project_id, PaymentPolicy.guide_version == guide_version, )) - if existing_review is None: - async with ( - suspend_historical_product_custody( - session, - table="review_policies", - triggers=("review_policy_mutation_custody",), - ), - suspend_historical_product_custody( - session, - table="revision_policies", - triggers=("revision_policy_mutation_custody",), - ), - ): - session.add_all( - [ - ReviewPolicy( - id=review_policy_id, - project_id=project_id, - guide_version=guide_version, - policy_generation=1, - policy_hash=review_hash, - semantics_status="legacy_incomplete", - review_preference_window_seconds=3600, - review_lease_duration_seconds=1800, - max_active_review_leases_per_reviewer=1, - self_review_allowed=False, - reject_policy="close_task", - finding_evidence_requirement="optional", - requires_second_review=False, - allowed_decisions=["accept", "needs_revision", "reject"], - minimum_finding_fields=[], - ), - RevisionPolicy( - id=revision_policy_id, - project_id=project_id, - guide_version=guide_version, - policy_generation=1, - policy_hash=revision_hash, - semantics_status="legacy_incomplete", - max_revision_rounds=1, - revision_deadline_hours=24, - allowed_resubmission_states=["needs_revision"], - ), - PaymentPolicy( - id=str(uuid4()), - project_id=project_id, - guide_version=guide_version, - ), - ] - ) - await session.flush() - async with suspend_historical_product_custody( - session, - table="project_guides", - triggers=("guide_mutation_product_custody", "guide_lineage_lifecycle_guard"), - ): - guide = await session.get(ProjectGuide, guide_id) - assert guide is not None - guide.selected_review_policy_id = review_policy_id - guide.selected_review_policy_generation = 1 - guide.selected_review_policy_hash = review_hash - guide.selected_revision_policy_id = revision_policy_id - guide.selected_revision_policy_generation = 1 - guide.selected_revision_policy_hash = revision_hash - guide.status = "active" - guide.approved_by = guide.created_by = "setup-actor" - guide.effective_at = now - await session.flush() - else: - post_submit_policy_id = existing_post.id - guide = await session.get(ProjectGuide, guide_id) - assert guide is not None and guide.status == "active" - review_policy_id = guide.selected_review_policy_id - revision_policy_id = guide.selected_revision_policy_id - assert guide.selected_review_policy_hash == review_hash - assert guide.selected_revision_policy_hash == revision_hash + if payment is None: + session.add(PaymentPolicy(id=str(uuid4()), project_id=project_id, guide_version=guide_version)) session.add( WorkstreamTask( id=task_id, @@ -692,7 +596,7 @@ async def test_committed_put_and_independent_verification_are_fenced( factory = async_sessionmaker(engine, expire_on_commit=False) bootstrap, store = _local_store(settings, namespace) authority = _AllowArtifactAuthority() - policy_bundle = await create_standalone_unified_policy(factory, namespace, include_post_policy=True) + policy_bundle = await create_standalone_unified_policy(factory, namespace) try: async with factory() as session: async with minted_source( @@ -738,8 +642,10 @@ async def test_committed_put_and_independent_verification_are_fenced( replica.availability_state, replica.integrity_state, ] == ["verified", "available", "valid"] - # One retained guide upload receipt plus this checker-output receipt. - assert await _count(session, ArtifactOperationReceipt) == 2 + # Exact output custody is independent of the number of guide documents. + assert await session.scalar(select(func.count()).select_from(ArtifactOperationReceipt).where( + ArtifactOperationReceipt.put_attempt_id == str(admission.attempt_id), + )) == 1 assert await _count(session, ArtifactVerificationReceipt) == 1 await session.rollback() assert await orchestrator.verify_object(job_id) == "stale" @@ -782,7 +688,7 @@ async def observe_put_result(self, _commitment): def open(self, _provider_object_ref): raise AssertionError("read must not run after namespace drift") - policy_bundle = await create_standalone_unified_policy(factory, namespace, include_post_policy=True) + policy_bundle = await create_standalone_unified_policy(factory, namespace) try: async with factory() as session: async with minted_source(tmp_path / "namespace-fence", b"fenced") as source: @@ -1379,7 +1285,7 @@ async def test_verification_claim_takeover_and_scanner_due_order_are_fenced( engine = create_async_engine(admission_database_env) factory = async_sessionmaker(engine, expire_on_commit=False) bootstrap, store = _local_store(settings, namespace) - policy_bundle = await create_standalone_unified_policy(factory, namespace, include_post_policy=True) + policy_bundle = await create_standalone_unified_policy(factory, namespace) try: job_ids, prior_audit_ids = await _seed_verification_scan_jobs( factory, settings, namespace, store, tmp_path, policy_bundle, @@ -1589,7 +1495,7 @@ async def test_verification_resource_drift_after_read_is_stale_without_terminal_ bootstrap, store = _local_store(settings, namespace) engine = create_async_engine(admission_database_env) factory = async_sessionmaker(engine, expire_on_commit=False) - policy_bundle = await create_standalone_unified_policy(factory, namespace, include_post_policy=True) + policy_bundle = await create_standalone_unified_policy(factory, namespace) try: async with factory() as session: async with minted_source(tmp_path / "verification-drift", b"expected") as source: @@ -1664,7 +1570,7 @@ async def test_verification_rechecks_relationship_after_prepare_before_io( engine = create_async_engine(admission_database_env) factory = async_sessionmaker(engine, expire_on_commit=False) bootstrap, store = _local_store(settings, namespace) - policy_bundle = await create_standalone_unified_policy(factory, namespace, include_post_policy=True) + policy_bundle = await create_standalone_unified_policy(factory, namespace) try: async with factory() as session: async with minted_source(tmp_path / "preclaim-drift", b"expected") as source: @@ -1754,7 +1660,7 @@ async def prepare(self, **values: object) -> None: await super().prepare(**values) self.phases.append(str(values["phase"])) - policy_bundle = await create_standalone_unified_policy(factory, namespace, include_post_policy=True) + policy_bundle = await create_standalone_unified_policy(factory, namespace) try: async with factory() as session: attempts: list[ArtifactPutAttempt] = [] @@ -1813,7 +1719,7 @@ async def test_verification_rechecks_authorized_object_ref_before_io( engine = create_async_engine(admission_database_env) factory = async_sessionmaker(engine, expire_on_commit=False) bootstrap, store = _local_store(settings, namespace) - policy_bundle = await create_standalone_unified_policy(factory, namespace, include_post_policy=True) + policy_bundle = await create_standalone_unified_policy(factory, namespace) try: async with factory() as session: async with minted_source(tmp_path / "preclaim-object-ref-drift", b"expected") as source: @@ -1870,7 +1776,7 @@ async def test_verification_rechecks_authorized_object_ref_after_io( bootstrap, store = _local_store(settings, namespace) engine = create_async_engine(admission_database_env) factory = async_sessionmaker(engine, expire_on_commit=False) - policy_bundle = await create_standalone_unified_policy(factory, namespace, include_post_policy=True) + policy_bundle = await create_standalone_unified_policy(factory, namespace) try: async with factory() as session: async with minted_source(tmp_path / "postread-object-ref-drift", b"expected") as source: @@ -1928,7 +1834,7 @@ async def test_verification_terminal_result_matrix( engine = create_async_engine(admission_database_env) factory = async_sessionmaker(engine, expire_on_commit=False) bootstrap, store = _local_store(settings, namespace) - policy_bundle = await create_standalone_unified_policy(factory, namespace, include_post_policy=True) + policy_bundle = await create_standalone_unified_policy(factory, namespace) try: async with factory() as session: async with minted_source(tmp_path / expected, b"verification matrix") as source: @@ -1974,7 +1880,7 @@ async def test_verification_terminal_authority_denial_writes_zero_result_facts( engine = create_async_engine(admission_database_env) factory = async_sessionmaker(engine, expire_on_commit=False) bootstrap, store = _local_store(settings, namespace) - policy_bundle = await create_standalone_unified_policy(factory, namespace, include_post_policy=True) + policy_bundle = await create_standalone_unified_policy(factory, namespace) try: async with factory() as session: async with minted_source( @@ -2029,7 +1935,7 @@ async def test_verification_unavailable_retries_then_exhausts( engine = create_async_engine(admission_database_env) factory = async_sessionmaker(engine, expire_on_commit=False) bootstrap, store = _local_store(settings, namespace) - policy_bundle = await create_standalone_unified_policy(factory, namespace, include_post_policy=True) + policy_bundle = await create_standalone_unified_policy(factory, namespace) try: async with factory() as session: async with minted_source(tmp_path / "unavailable", b"retry") as source: @@ -2620,6 +2526,10 @@ async def test_checker_output_requires_exact_active_fixed_service_identity( await session.commit() await session.rollback() + baseline = {model: await _count(session, model) for model in ( + ArtifactPutAttempt, ArtifactContent, ArtifactReplica, ArtifactOperationReceipt, + )} + await session.rollback() async with minted_source(tmp_path / "scratch-source", b"checker") as source: service = ArtifactAdmissionService(session, settings, namespace) forged = context.model_copy(update={"identity_link_id": uuid4()}) @@ -2638,7 +2548,7 @@ async def test_checker_output_requires_exact_active_fixed_service_identity( assert await _count(session, ArtifactStorageNamespace) == 1 assert await _count(session, ArtifactAdmissionScope) == 0 assert await _count(session, ArtifactAdmissionCharge) == 0 - assert await _count(session, ArtifactPutAttempt) == 1 + assert await _count(session, ArtifactPutAttempt) == baseline[ArtifactPutAttempt] await session.rollback() request = CheckerOutputArtifactAdmissionRequest( @@ -2651,29 +2561,12 @@ async def test_checker_output_requires_exact_active_fixed_service_identity( replay = await service.admit(request) attempt = await session.get(ArtifactPutAttempt, str(result.attempt_id)) - scopes = ( - ( - await session.execute( - select(ArtifactAdmissionScope).order_by( - ArtifactAdmissionScope.scope_type, - ArtifactAdmissionScope.scope_id, - ) - ) - ) - .scalars() - .all() - ) - links = ( - ( - await session.execute( - select(ArtifactPutAttemptCharge).where( - ArtifactPutAttemptCharge.attempt_id == str(result.attempt_id) - ) - ) - ) - .scalars() - .all() - ) + scopes = (await session.scalars(select(ArtifactAdmissionScope).order_by( + ArtifactAdmissionScope.scope_type, ArtifactAdmissionScope.scope_id, + ))).all() + links = (await session.scalars(select(ArtifactPutAttemptCharge).where( + ArtifactPutAttemptCharge.attempt_id == str(result.attempt_id), + ))).all() assert attempt is not None assert replay.attempt_id == result.attempt_id assert replay.charge_ids == result.charge_ids @@ -2697,11 +2590,11 @@ async def test_checker_output_requires_exact_active_fixed_service_identity( } assert len(result.charge_ids) == 4 assert len(links) == 4 - assert await _count(session, ArtifactPutAttempt) == 2 + assert await _count(session, ArtifactPutAttempt) == baseline[ArtifactPutAttempt] + 1 assert await _count(session, ArtifactAdmissionCharge) == 4 - assert await _count(session, ArtifactContent) == 1 - assert await _count(session, ArtifactReplica) == 1 - assert await _count(session, ArtifactOperationReceipt) == 1 + assert await _count(session, ArtifactContent) == baseline[ArtifactContent] + assert await _count(session, ArtifactReplica) == baseline[ArtifactReplica] + assert await _count(session, ArtifactOperationReceipt) == baseline[ArtifactOperationReceipt] finally: await engine.dispose() @@ -2726,7 +2619,7 @@ async def test_checker_output_shared_put_and_verification_lifecycle( bootstrap, store = _local_store(settings, namespace) engine = create_async_engine(admission_database_env) factory = async_sessionmaker(engine, expire_on_commit=False) - policy_bundle = await create_standalone_unified_policy(factory, namespace, include_post_policy=True) + policy_bundle = await create_standalone_unified_policy(factory, namespace) try: async with factory() as session: async with minted_source( @@ -2806,7 +2699,7 @@ async def test_checker_output_put_observation_terminal_outcomes( namespace = _namespace(settings) engine = create_async_engine(admission_database_env) factory = async_sessionmaker(engine, expire_on_commit=False) - policy_bundle = await create_standalone_unified_policy(factory, namespace, include_post_policy=True) + policy_bundle = await create_standalone_unified_policy(factory, namespace) try: async with factory() as session: async with minted_source( diff --git a/backend/tests/test_artifact_recovery.py b/backend/tests/test_artifact_recovery.py index dcb5f5b4d..734a443bb 100644 --- a/backend/tests/test_artifact_recovery.py +++ b/backend/tests/test_artifact_recovery.py @@ -169,7 +169,7 @@ async def _exhausted_job(session, settings, tmp_path, context): ) ) from projects.unified_policy_fixtures import create_standalone_unified_policy - policy_bundle = await create_standalone_unified_policy(async_sessionmaker(session.bind, expire_on_commit=False), namespace, include_post_policy=True) + policy_bundle = await create_standalone_unified_policy(async_sessionmaker(session.bind, expire_on_commit=False), namespace) async with minted_source(tmp_path / "checker-output", b"recover checker output") as source: project_id, task_id, checker_run_id, admission = await _admit_checker_output( session, settings, namespace, source, policy_bundle=policy_bundle) diff --git a/backend/tests/test_behavior_ownership.py b/backend/tests/test_behavior_ownership.py index 17a6b6775..7b4be9123 100644 --- a/backend/tests/test_behavior_ownership.py +++ b/backend/tests/test_behavior_ownership.py @@ -1914,3 +1914,17 @@ def test_partition_accepts_only_exact_auth12h_activation_targets() -> None: _partition(sorted({retained, *expected, "backend/app/modules/authorization/domain/activation_extra.py"})), _partition([retained]), ) + + +def test_arch03a_partition_transition_accepts_only_exact_policy_lineage_relocation(): + old = "backend/app/modules/projects/policy_lineage.py" + new = "backend/app/modules/projects/api/policy_lineage.py" + for before, after in (([old], [old]), ([old], [new]), ([new], [new])): + ownership._validate_additive_partition_transition(_partition(after), _partition(before)) + for before, after in ( + ([old], sorted([old, new])), ([old], []), ([new], [old]), + ([], [new]), ([new], []), (sorted([old, new]), [new]), + ([old], sorted([new, "backend/scripts/unapproved_owner.py"])), + ): + with pytest.raises(ownership.BehaviorOwnershipError, match="untrusted_partition_change"): + ownership._validate_additive_partition_transition(_partition(after), _partition(before)) diff --git a/backend/tests/test_checkers.py b/backend/tests/test_checkers.py index 86cdcdfbc..ecd18d267 100644 --- a/backend/tests/test_checkers.py +++ b/backend/tests/test_checkers.py @@ -64,6 +64,7 @@ pre_review_gate_system_actor, ) from app.modules.projects.models import PostSubmitCheckerPolicy +from app.modules.checkers.api.post_submit_catalogue import current_post_submit_catalogue from app.modules.projects.post_submit_policy import ( DEFAULT_DURABLE_CHECKERS, POST_SUBMIT_CHECKER_POLICY_SPEC_SCHEMA_VERSION, @@ -282,14 +283,17 @@ def _canonical_test_post_policy(): ) -def _parse_test_post_body(body): - return parse_locked_post_submit_checker_policy_body( +def _validate_test_post_body(body): + parsed = parse_locked_post_submit_checker_policy_body( body, project_id=body["project_id"], guide_version=body["guide_version"], policy_hash=canonical_json_hash(body), ) + parsed.validate_catalogue(current_post_submit_catalogue()) + return parsed + def test_locked_post_submit_policy_parser_uses_persisted_body_hash() -> None: compiled = _canonical_test_post_policy() @@ -507,13 +511,13 @@ def test_post_submit_compiler_rejects_blocking_severity_downgrade( [*DEFAULT_DURABLE_CHECKERS, "extra_default_checker"], ], ) -def test_locked_post_submit_policy_parser_rejects_default_checker_drift(default_checkers: list[str]) -> None: +def test_locked_post_submit_policy_validation_rejects_default_checker_drift(default_checkers: list[str]) -> None: body = _canonical_test_post_policy().policy_body by_name = {entry["checker_id"]: entry for entry in body["entries"]} template = body["entries"][0] body["entries"] = [by_name.get(name, {**template, "checker_id": name}) for name in default_checkers] with pytest.raises(ValueError): - _parse_test_post_body(body) + _validate_test_post_body(body) @pytest.mark.parametrize( @@ -525,20 +529,20 @@ def test_locked_post_submit_policy_parser_rejects_default_checker_drift(default_ [*DEFAULT_DURABLE_CHECKERS, "extra_default_checker"], ], ) -def test_locked_post_submit_policy_parser_rejects_self_consistent_default_drift(drifted_defaults: list[str]) -> None: +def test_locked_post_submit_policy_validation_rejects_self_consistent_default_drift(drifted_defaults: list[str]) -> None: body = _canonical_test_post_policy().policy_body template = body["entries"][0] body["entries"] = [{**template, "checker_id": name} for name in drifted_defaults] # Recomputed digest proves the canonical catalogue rejects changed defaults. with pytest.raises(ValueError): - _parse_test_post_body(body) + _validate_test_post_body(body) -def test_locked_post_submit_policy_parser_rejects_unsupported_compiler_version() -> None: +def test_locked_post_submit_policy_validation_rejects_unsupported_compiler_version() -> None: body = _canonical_test_post_policy().policy_body body["compiler_version"] = "unsupported" with pytest.raises(ValueError, match="compiler_version"): - _parse_test_post_body(body) + _validate_test_post_body(body) @pytest.mark.parametrize( @@ -552,7 +556,7 @@ def test_locked_post_submit_policy_parser_rejects_unsupported_compiler_version() ([], ["check_submission_packet"], list(DEFAULT_DURABLE_CHECKERS)), ], ) -def test_locked_post_submit_policy_parser_rejects_conflicting_classifications( +def test_locked_post_submit_policy_validation_rejects_conflicting_classifications( required_checkers: list[str], warning_checkers: list[str], execution_checkers: list[str], ) -> None: body = _canonical_test_post_policy().policy_body @@ -563,18 +567,18 @@ def test_locked_post_submit_policy_parser_rejects_conflicting_classifications( else: body["entries"][0]["classification"] = "project_warning" with pytest.raises(ValueError, match="duplicate entries|classification mismatch"): - _parse_test_post_body(body) + _validate_test_post_body(body) @pytest.mark.parametrize( "blocking_severities", [[], ["critical"], ["high"]], ) -def test_locked_post_submit_policy_parser_rejects_blocking_severity_downgrade(blocking_severities: list[str]) -> None: +def test_locked_post_submit_policy_validation_rejects_blocking_severity_downgrade(blocking_severities: list[str]) -> None: body = _canonical_test_post_policy().policy_body body["blocking_severities"] = blocking_severities with pytest.raises(ValueError, match="blocking_severities"): - _parse_test_post_body(body) + _validate_test_post_body(body) def test_checker_models_are_registered_for_alembic_metadata() -> None: @@ -3912,11 +3916,11 @@ def test_old_checker_name_blocks_post_submit_compilation_without_alias( @pytest.mark.asyncio @pytest.mark.parametrize( "field,damage", - [(name, "crossed") for name in ("required_checkers", "warning_checkers", "blocking_severities", "policy_body")] + [(name, "crossed") for name in ("required_checkers", "warning_checkers", "blocking_severities", "policy_body", "catalogue")] + [("submission." + name, damage) for name in _current_locked_test_facts() for damage in ("missing", "crossed")], ) -async def test_canonical_policy_sidecars_deny_before_manual_execution(field: str, damage: str) -> None: +async def test_canonical_policy_sidecars_deny_before_manual_execution(field: str, damage: str, monkeypatch) -> None: from tests.checkers.post_submit.support import request source = request() @@ -3956,6 +3960,13 @@ async def test_canonical_policy_sidecars_deny_before_manual_execution(field: str "sha256:" + "b" * 64 if "sha256:" in previous else "other" if name.endswith("version") else str(uuid4())) setattr(submission, name, replacement) + elif field == "catalogue": + from tests.checkers.post_submit.support import altered_catalogue + + monkeypatch.setattr( + "app.modules.checkers.service.current_post_submit_catalogue", + lambda: altered_catalogue(index=8, state="disabled"), + ) elif field == "policy_body": row.policy_body = {**row.policy_body, "guide_version": "crossed"} else: @@ -3972,7 +3983,8 @@ async def test_canonical_policy_sidecars_deny_before_manual_execution(field: str service._enter_evaluation_pending = AsyncMock() service._write_checker_audit = AsyncMock() service._registry.run = AsyncMock() - with pytest.raises(CheckerPolicyInvalid, match="context|summaries"): + expected_error = "policy hash is invalid" if field == "catalogue" else "context|summaries" + with pytest.raises(CheckerPolicyInvalid, match=expected_error): await service.run_submission_checkers( pre_review_gate_system_actor(), submission.id, "check" ) diff --git a/backend/tests/test_ci_lane_catalogue.py b/backend/tests/test_ci_lane_catalogue.py index ae83d2735..79c467c05 100644 --- a/backend/tests/test_ci_lane_catalogue.py +++ b/backend/tests/test_ci_lane_catalogue.py @@ -138,6 +138,8 @@ def test_measured_hotspots_have_explicit_semantic_owners() -> None: "tests/projects/guide_compilation/test_repository_attempts.py", "tests/projects/guide_compilation/test_repository_persistence.py", "tests/projects/test_locked_policy_context.py", + "tests/projects/test_locked_policy_custody.py", + "tests/projects/test_locked_policy_concurrency.py", "tests/projects/test_locked_policy_contract.py", "tests/projects/test_activation_readiness.py", "tests/projects/test_policy_read_composition.py", diff --git a/backend/tests/test_default_pre_submit_execution.py b/backend/tests/test_default_pre_submit_execution.py index 24899f04a..9db07bd76 100644 --- a/backend/tests/test_default_pre_submit_execution.py +++ b/backend/tests/test_default_pre_submit_execution.py @@ -2,7 +2,7 @@ from __future__ import annotations -from project_create_fixtures import guide_snapshot_columns, activate_retained_project_for_test +from project_create_fixtures import guide_snapshot_columns import asyncio from io import BytesIO @@ -460,10 +460,6 @@ async def test_effective_evidence_workflow_persists_once_and_replays_exactly( actor_id = uuid4() identity_link_id = uuid4() lineage = request.effective_plan.lineage - custody_triggers = ( - ("project_guides", "guide_mutation_product_custody"), - ("project_guides", "guide_lineage_lifecycle_guard"), - ) blocked_prepared = replay_prepared = drift_prepared = denied_prepared = None original_prepared_closed = False tables = ( @@ -511,12 +507,6 @@ async def test_effective_evidence_workflow_persists_once_and_replays_exactly( ), params, ) - for table, trigger in custody_triggers: - await connection.execute(text(f"alter table {table} disable trigger {trigger}")) - await activate_retained_project_for_test(connection, lineage.project_id) - await connection.execute(text( - "update project_guides set status='active',approved_by=:actor,effective_at=now() where id=:guide" - ), params) await connection.execute( text( "insert into workstream_tasks " @@ -965,12 +955,7 @@ async def publish_ready() -> str: try: manager.close() finally: - try: - async with engine.begin() as connection: - for table, trigger in reversed(custody_triggers): - await connection.execute(text(f"alter table {table} enable trigger {trigger}")) - finally: - await engine.dispose() + await engine.dispose() assert first.evidence.replayed is False assert replay.evidence.replayed is True diff --git a/backend/tests/test_policy_identity_lineage.py b/backend/tests/test_policy_identity_lineage.py index ee7204fb6..1b092ba72 100644 --- a/backend/tests/test_policy_identity_lineage.py +++ b/backend/tests/test_policy_identity_lineage.py @@ -6,7 +6,7 @@ from pydantic import ValidationError from app.modules.authorization.catalogue import ACTION_BY_ID, ActionAvailability, ActionId -from app.modules.projects.policy_lineage import ( +from app.modules.projects.api.policy_lineage import ( ReviewPolicySemantics, RevisionPolicySemantics, policy_digest, diff --git a/backend/tests/test_pre_submit_attempt_migration.py b/backend/tests/test_pre_submit_attempt_migration.py index 6dac4b361..95722ea06 100644 --- a/backend/tests/test_pre_submit_attempt_migration.py +++ b/backend/tests/test_pre_submit_attempt_migration.py @@ -6,26 +6,21 @@ from pathlib import Path from uuid import uuid4 -from alembic import command -from alembic.config import Config import pytest from sqlalchemy import text from sqlalchemy.exc import DBAPIError from sqlalchemy.ext.asyncio import create_async_engine -from tests.migration_fixtures import run_guarded_revision_downgrade +from tests.migration_fixtures import ( + current_schema_revision, run_guarded_revision_downgrade, run_scoped_revision_upgrade, +) from tests.test_pre_submit_attempt_recovery import _harness -PRIOR = "0020_post_submit_policy_custody" OWN = "0021_pre_submit_attempts" pytestmark = pytest.mark.postgres_schema_contract -def _config() -> Config: - return Config(Path(__file__).resolve().parents[1] / "alembic.ini") - - def _seed_retained_evidence(tmp_path: Path, database_url: str) -> str: """Prepare current owners, then insert the actual pre-0021 evidence shape.""" async def prepare(): @@ -34,7 +29,9 @@ async def prepare(): return harness harness = asyncio.run(prepare()) - command.downgrade(_config(), PRIOR) + activation = asyncio.run(_activation_snapshot(database_url)) + asyncio.run(run_guarded_revision_downgrade(database_url, OWN)) + assert asyncio.run(_activation_snapshot(database_url)) == activation return asyncio.run(_insert_retained_evidence(harness)) @@ -124,11 +121,33 @@ async def _snapshot( if key not in {"attempt_id", "attempt_request_digest", "packet_sha256"}}, {key: value for key, value in result.items() if key not in {"checker_order", "metadata_json"}}, + await _activation_snapshot(database_url), ) finally: await engine.dispose() +async def _activation_snapshot(database_url: str): + """Keep complete later-owner evidence and the head marker across scoped DDL.""" + engine = create_async_engine(database_url) + try: + async with engine.connect() as connection: + marker = await connection.scalar(text("SELECT version_num FROM alembic_version")) + assert marker == current_schema_revision() + operations = list(await connection.scalars(text( + "SELECT to_jsonb(r) FROM guide_mutation_idempotency_records r " + "WHERE action_id='project.guide.activate' ORDER BY operation_id" + ))) + events = list(await connection.scalars(text( + "SELECT to_jsonb(e) FROM audit_events e " + "WHERE resource_type='project_guide_activation' ORDER BY id" + ))) + assert operations and events + return marker, operations, events + finally: + await engine.dispose() + + async def _audit_privacy_constraint(database_url: str) -> str: engine = create_async_engine(database_url) try: @@ -151,14 +170,16 @@ def test_retained_evidence_round_trip_does_not_invent_attempt_or_result_details( original = asyncio.run(_snapshot(isolated_database_env, evidence_id, upgraded=False)) old_audit = asyncio.run(_audit_privacy_constraint(isolated_database_env)) assert "pre_submit_checker_input" not in old_audit - command.upgrade(_config(), OWN) + assert "compensation_adapter_binding" in old_audit + assert "project_guide_activation" in old_audit + asyncio.run(run_scoped_revision_upgrade(isolated_database_env, OWN)) assert asyncio.run(_snapshot(isolated_database_env, evidence_id, upgraded=True)) == original new_audit = asyncio.run(_audit_privacy_constraint(isolated_database_env)) assert new_audit.count("pre_submit_checker_input") == 1 - command.downgrade(_config(), PRIOR) + asyncio.run(run_guarded_revision_downgrade(isolated_database_env, OWN)) assert asyncio.run(_snapshot(isolated_database_env, evidence_id, upgraded=False)) == original assert asyncio.run(_audit_privacy_constraint(isolated_database_env)) == old_audit - command.upgrade(_config(), OWN) + asyncio.run(run_scoped_revision_upgrade(isolated_database_env, OWN)) assert asyncio.run(_snapshot(isolated_database_env, evidence_id, upgraded=True)) == original assert asyncio.run(_audit_privacy_constraint(isolated_database_env)) == new_audit @@ -169,7 +190,7 @@ def test_retained_reservation_refuses_downgrade_without_mutation( with migration_lock(): migration_schema_at("head") evidence_id = _seed_retained_evidence(tmp_path, isolated_database_env) - command.upgrade(_config(), OWN) + asyncio.run(run_scoped_revision_upgrade(isolated_database_env, OWN)) async def reserve() -> str: engine = create_async_engine(isolated_database_env) @@ -230,7 +251,7 @@ def test_new_result_rows_require_valid_order_and_bounded_metadata( with migration_lock(): migration_schema_at("head") retained_id = _seed_retained_evidence(tmp_path, isolated_database_env) - command.upgrade(_config(), OWN) + asyncio.run(run_scoped_revision_upgrade(isolated_database_env, OWN)) async def probe() -> None: engine = create_async_engine(isolated_database_env) diff --git a/backend/tests/test_pre_submit_attempt_recovery.py b/backend/tests/test_pre_submit_attempt_recovery.py index e57b1b729..38d435228 100644 --- a/backend/tests/test_pre_submit_attempt_recovery.py +++ b/backend/tests/test_pre_submit_attempt_recovery.py @@ -36,7 +36,6 @@ submission_preparation_request, ) from tests.submission_preparation_auth_helpers import install_submitter_grant -from tests.project_create_fixtures import activate_retained_project_for_test from tests.test_default_pre_submit_execution import _AllowAuthority, _archive, _bytes, _request from app.modules.artifacts.service import ArtifactStorageNamespaceSpec from app.modules.authorization.runtime import ( @@ -133,13 +132,6 @@ async def fresh_request(self, data=None): async def close(self): await self.request.prepared_artifact.close() self.manager.close() - async with self.engine.begin() as connection: - await connection.execute(text( - "alter table project_guides enable trigger guide_mutation_product_custody" - )) - await connection.execute(text( - "alter table project_guides enable trigger guide_lineage_lifecycle_guard" - )) await self.engine.dispose() def contributor_authority(self, session): @@ -200,17 +192,6 @@ async def _harness(tmp_path: Path, database_url: str) -> _Harness: "(id,actor_profile_id,issuer,subject,subject_kind,status,linked_by,last_verified_at) " "values (:link,:actor,'flow-test',:actor,'human','active','test',now())" ), params) - await connection.execute(text( - "alter table project_guides disable trigger guide_mutation_product_custody" - )) - await connection.execute(text( - "alter table project_guides disable trigger guide_lineage_lifecycle_guard" - )) - await activate_retained_project_for_test(connection, lineage.project_id) - await connection.execute(text( - "update project_guides set status='active',approved_by=:actor,effective_at=now() " - "where id=:guide" - ), params) await connection.execute(text( "insert into workstream_tasks " "(id,project_id,locked_guide_version,locked_guide_source_snapshot_id," diff --git a/backend/tests/test_review_lease_persistence.py b/backend/tests/test_review_lease_persistence.py index e5de0c12b..10f32023d 100644 --- a/backend/tests/test_review_lease_persistence.py +++ b/backend/tests/test_review_lease_persistence.py @@ -9,7 +9,7 @@ from httpx import ASGITransport, AsyncClient import pytest -from sqlalchemy import text, update +from sqlalchemy import select, text, update from sqlalchemy.exc import DBAPIError, IntegrityError from app.adapters.contributions import contribution_policy_service @@ -25,6 +25,7 @@ ContributionRule, ) from app.modules.contributions.api import ContributionPolicyPublishRequest +from app.modules.projects.models import ProjectGuide from app.modules.reviews.models import ReviewLease, ReviewQueueEntry from app.modules.reviews.repository import ReviewQueueRepository from app.modules.reviews.schemas import ReviewLeaseInput @@ -242,7 +243,10 @@ async def _seed_queue_and_policy( _queue_input(project, task, submission) ) reviewer_id = await _human_actor(session, label="reviewer") - version_id = await _published_reviewer_policy(session, project["id"], reviewer_id) + version_id = await session.scalar(select(ProjectGuide.contribution_policy_version_id).where( + ProjectGuide.project_id == project["id"], ProjectGuide.status == "active", + )) + assert version_id is not None await session.commit() return project, queue, reviewer_id, version_id diff --git a/backend/tests/test_tasks.py b/backend/tests/test_tasks.py index 4039cd0b7..c0d1069aa 100644 --- a/backend/tests/test_tasks.py +++ b/backend/tests/test_tasks.py @@ -4953,3 +4953,42 @@ async def test_json_and_numeric_fields_round_trip_under_postgres(task_client: As assert task.skill_tags == ["stem", "proofs"] assert task.source_payload_hash == "hash-123" assert task.base_amount == Decimal("25.00") + + +@pytest.mark.parametrize("transition", ("screen", "release")) +async def test_catalogue_rollout_blocks_task_transition_without_writes( + task_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, transition: str, +) -> None: + from tests.checkers.post_submit.support import altered_catalogue + + project = await create_active_project(task_client) + task = await create_draft_task(task_client, project["id"]) + if transition == "release": + screened = await task_client.post( + f"/api/v1/tasks/{task['id']}/screen", headers=auth_headers(), + json={"reason": "initial screening"}, + ) + assert screened.status_code == 200, screened.text + task_query = select(WorkstreamTask.__table__).where(WorkstreamTask.id == task["id"]) + audit_query = select(AuditEvent.id).where(AuditEvent.entity_id == task["id"]).order_by(AuditEvent.id) + async with db_session.get_session_factory()() as session: + before = dict((await session.execute(task_query)).mappings().one()) + audits = list(await session.scalars(audit_query)) + if transition == "screen": + assert before["status"] == "draft" + assert all(value is None for key, value in before.items() if key.startswith("locked_")) + else: + assert before["status"] == "screening" + newer = altered_catalogue(index=8, state="disabled") + monkeypatch.setattr("app.modules.tasks.service.current_post_submit_catalogue", lambda: newer) + response = await task_client.post( + f"/api/v1/tasks/{task['id']}/{transition}", headers=auth_headers(), + json={"reason": "must remain unchanged"}, + ) + assert response.status_code == 422, response.text + expected = ("active post-submit checker policy hash is invalid" if transition == "screen" + else "task locked post-submit checker policy body is invalid") + assert expected in response.text + async with db_session.get_session_factory()() as session: + assert dict((await session.execute(task_query)).mappings().one()) == before + assert list(await session.scalars(audit_query)) == audits diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index 7aefb4d28..4e9487708 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -277,6 +277,24 @@ explicitly selected retained predecessor without backfilling it. AUTH-12H suppli explicit internal activation authority for a live exact-project Project Manager; composition without an authority adapter and HTTP activation remain unavailable. +The internal `ProjectLockedPolicyContextPort` returns a complete immutable graph +through `lock_active_policy_context(project_id)` or +`lock_locked_policy_context(exact_selectors)`. Both require a caller root +transaction. The existing frozen selectors resolve the receipt-bound source, +compilation, separate approvals, artifact/effective/pre/post policies, +review/revision selections and ContributionPolicy version. Canonical bodies are +copied into immutable JSON values. Catalogue facts are the recorded ID, version, +schema version and manifest hash; no historical catalogue body is reconstructed. + +The reader locks Project, discovers the exact candidate without locking Guide, +then reuses Attempt -> Request -> Guide and the canonical custody order. It +refreshes persisted rows after waiting and retains the caller's locks without +committing. A successor changes active selection but does not redirect frozen +lookup; contribution-policy retirement does not rewrite its saved activation +facts. Inactive Projects and incomplete or inconsistent custody remain +unavailable. This port adds no Task/Assignment/Submission columns or writers; +CP08 owns that next change, including its minimal production copy paths. + Draft guides may have no selected review/revision policy while the authorized policy writer is unavailable. Active and superseded guides require both exact identity triples, and PostgreSQL freezes those selections after activation. diff --git a/docs/operations_project_operating_manual.md b/docs/operations_project_operating_manual.md index 11778e3f4..668cb50a4 100644 --- a/docs/operations_project_operating_manual.md +++ b/docs/operations_project_operating_manual.md @@ -185,6 +185,13 @@ services cannot activate. Replay requires current authority and returns the orig receipt. Public activation API wiring remains pending. An active-guide read requires the committed binding; historical rows without that binding are unavailable. +Internal task/intake integrations now resolve that complete binding through one +PROJECTS context port. New work selects the active guide; frozen work keeps its +exact guide and approved policy identities after a successor is activated. +Retiring a contribution policy does not silently replace that binding. This is +an internal contract, not a new manager API. Task-attempt lineage persistence +and its existing writers are the next CP08 boundary. + The intended unified flow uses one compilation result for sufficiency and artifact/pre-submit/post-submit proposals. Once finalized, its `ProjectSetupRun`, receipt, timestamps and output references are immutable. Approval cannot resume diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index 1e34be01c..b1a8b04c7 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -424,8 +424,8 @@ Setup receipts remain immutable. The next guide boundary is: 1. **Complete the guide and task lineage contracts.** CP05 authorization, CP06 selected-policy validation, CP07 hidden activation/binding and AUTH-12H - live manager authority are complete. Next are CP08 task-attempt lineage and - ARCH-03A complete internal guide facts. HTTP activation exposure remains + live manager authority and ARCH-03A complete internal guide context are complete. + Next is CP08 task-attempt lineage and its minimal existing writers. HTTP activation exposure remains pending and is not implied by an internal public port. The operation requires exact current compilation, sufficiency, separate pre/post approvals, review/revision inputs and an explicitly selected published ContributionPolicyVersion. @@ -577,13 +577,15 @@ above. The main remaining trace sequence is: - Unified guide: POL-04B1/04B/04B2, POL-05A/AUTH-12F4/POL-05B, - POL-06A/AUTH-12G/POL-06B, POL-07A/07B and AUTH-12H are delivered. Next is - `CP08` task-attempt lineage, then `ARCH-03A` complete internal guide facts. ARCH-04A supplies registered-capability + POL-06A/AUTH-12G/POL-06B, POL-07A/07B, AUTH-12H and ARCH-03A are delivered. + The complete internal guide context is available; next is `CP08` task-attempt + lineage and minimal existing writers. ARCH-04A supplies registered-capability contracts; activation does not require a Task, Submission or completed run. - Contribution lineage: CP05 authorization, CP06 validation and hidden `CP07` activation - and `AUTH-12H` live manager authority are complete. `CP08` supplies - lineage fields after `CP07`; `ARCH-03A` follows both `AUTH-12H` and `CP08`, - then `ARCH-03B -> ARCH-03C`. `CP09` physical cleanup waits for all remaining + and `AUTH-12H` live manager authority are complete. `ARCH-03A` has completed + the existing internal guide-context port. `CP08` next adds lineage fields + and minimal Task/Assignment/Submission writers together, followed by remaining + queues/projections in `ARCH-03B` and authorization/public cutover in `ARCH-03C`. `CP09` physical cleanup waits for all remaining legacy consumers to be replaced; it is outside the `allow_review` critical path. Live assignment invalidation also requires shared dispatch and its exact service authority; current authority is still checked on every request.