diff --git a/.agent-loop/CURRENT_STATE.md b/.agent-loop/CURRENT_STATE.md index 326ab547..054bb001 100644 --- a/.agent-loop/CURRENT_STATE.md +++ b/.agent-loop/CURRENT_STATE.md @@ -28,10 +28,10 @@ authority; these records do not grant or withhold it. | Initiative | Durable state on `main` | Remaining boundary | |---|---|---| -| [WS-ARCH-001](initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md) | Complete through `WS-ARCH-001-02H`; CP01A-CP03B establish adapter-binding behavior/activation; CP04A completes hidden policy read/create/update-draft behavior with durable operation/event custody | Implement CP04B hidden publish/retire behavior; CP05 later activates the proven policy actions, and PLAN2 still targets durable `allow_review` | +| [WS-ARCH-001](initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md) | Complete through `WS-ARCH-001-02H`; CP01A-CP03B establish adapter-binding behavior/activation; CP04A-CP04B complete hidden policy behavior with durable custody | Prepare CP05 to activate only the proven policy actions; PLAN2 still targets durable `allow_review` | | [WS-ART-001](initiatives/WS-ART-001-immutable-artifact-storage/STATUS.md) | Active delivery initiative; verified ready-admission publication, hidden preparation, consumption and binding are merged through ARCH-02H | Implement exact post-submit materialization only after the unified guide/checker and PLAN2 public contracts are executable; live cutover remains later | | [WS-AUTH-001](initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md) | Active delivery initiative; project-policy authority and unified compilation authorization are merged through `12I` | POL-03B consumes 12I next; remaining AUTH activation chunks wait for their exact hidden owner behavior | -| [WS-CON-001](initiatives/WS-CON-001-contribution-compensation-boundary/STATUS.md) | Active delivery initiative; CP03B completes adapter-binding activation and CP04A completes hidden ContributionPolicy draft behavior; shared lifecycle audit foundations are merged | Implement CP04B publication/retirement; later activate policy actions and complete guide-activation validation/persistence before task readiness | +| [WS-CON-001](initiatives/WS-CON-001-contribution-compensation-boundary/STATUS.md) | Active delivery initiative; CP03B completes adapter-binding activation and CP04A-CP04B complete hidden ContributionPolicy behavior; shared lifecycle audit foundations are merged | Prepare CP05 policy activation, then complete guide-activation validation/persistence before task readiness | | [WS-AUTH-003](initiatives/WS-AUTH-003-module-boundary-recovery/STATUS.md) | AUTH boundary foundation and first public-capability proof through POL-03A are merged | Repair each touched AUTH capability through `authorization.api` and shrink the canonical AUTH ledger | | [WS-POL-003](initiatives/WS-POL-003-unified-project-guide-compilation/STATUS.md) | Active delivery initiative; hidden compilation custody and AUTH-12I activation are merged | Implement POL-03B authorized compilation persistence, then continue the reviewed dependency order | | [WS-REV-001](initiatives/WS-REV-001-review-revision-lifecycle/STATUS.md) | Independent foundations through queue admission and reviewer-lease persistence are merged through `03A2`; schema/packet foundations may continue behind their own gates | Live admission/claim requires canonical 04E `allow_review`; ReviewLease copies the admitted Submission's immutable attempt policy version, and the first Review commit requires CON-03C/07 atomic contribution/award behavior | @@ -102,7 +102,7 @@ AUTH unavailable registration, hidden CON binding/policy behavior, exact AUTH activations, CON validation, PROJECT guide binding, TASK attempt lineage, and clean v0.1 legacy economic-path removal in that order. CP02 and CP03A are merged, and CP03B is complete with the four exact hidden Finance Authority -actions active. CP04A/CP04B now have executable contracts; later CP05-CP09 +actions active. CP04A/CP04B are complete; later CP05-CP09 remain non-executable until their current-main contracts are expanded. - WS-ARCH-001-PLAN3 is planned; its planning merge changed no runtime behavior. @@ -124,12 +124,12 @@ remain non-executable until their current-main contracts are expanded. consume the public AUTH API and same-owner private wiring is confined to the exact AUTH adapter root. No compensation delivery or fulfillment behavior is activated. -- WS-ARCH-001-CP04 is a planned non-executable parent. CP04A and CP04B have - executable contracts; CP04B is the next implementation boundary. +- WS-ARCH-001-CP04 is a planned non-executable parent. CP04A and CP04B are + complete; CP05 is the next planning boundary. - WS-ARCH-001-CP04A is complete with hidden policy read/create/update-draft behavior; it exposes no route and activates no policy action. -- WS-ARCH-001-CP04B is planned next for hidden publication/retirement. -- WS-ARCH-001-CP05 is proposed and non-executable. +- WS-ARCH-001-CP04B is complete with hidden publication/retirement. +- WS-ARCH-001-CP05 is next and remains non-executable pending its contract. - WS-ARCH-001-CP06 is proposed and non-executable. - WS-ARCH-001-CP07 is proposed and non-executable. - WS-ARCH-001-CP08 is proposed and non-executable. diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/CHUNK_MAP.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/CHUNK_MAP.md index b512402b..dd3a5d8b 100644 --- a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/CHUNK_MAP.md +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/CHUNK_MAP.md @@ -27,7 +27,7 @@ | `WS-ARCH-001-CP03B` | AUTH exact Finance Authority adapter-binding activation | L1 | Complete; four exact hidden actions active through public ports, with private wiring confined to the AUTH adapter root | | `WS-ARCH-001-CP04` | Hidden ContributionPolicy behavior coordination parent | L1 | Planned split into CP04A/CP04B; non-executable | | `WS-ARCH-001-CP04A` | CONTRIBUTIONS public API and hidden read/create/update-draft behavior | L1 | Complete; hidden route-unreachable behavior, public owner ports, operation recovery, and immutable event custody merged while policy actions remain unavailable | -| `WS-ARCH-001-CP04B` | Hidden ContributionPolicy publish/retire behavior | L1 | Planned executable contract after merged CP04A | +| `WS-ARCH-001-CP04B` | Hidden ContributionPolicy publish/retire behavior | L1 | Complete; hidden and deny-default | | `WS-ARCH-001-CP05` | AUTH exact ContributionPolicy activation | L1 | Proposed skeleton after CP04 evidence | | `WS-ARCH-001-CP06` | CON guide-activation/revision policy-validation port | L1 | Proposed skeleton after CP05 | | `WS-ARCH-001-CP07` | PROJECT guide-bound ContributionPolicyVersion persistence | L1 | Proposed skeleton after CP06 | @@ -54,7 +54,7 @@ feature contract crosses more than one reviewable mutation boundary. The 02A-02I sequence is the executable split of parent 02, subject to plan review and human approval. -CP04 is a non-executable split parent; CP04A and CP04B are executable contracts. +CP04 is a non-executable split parent; CP04A and CP04B are complete. CP05-CP09 and chunks 03A-04F are non-executable planning skeletons. Before implementation, each remaining skeleton must be replaced with a current-main contract that names exact allowed and not-allowed files, migration head, runnable commands, reviewers, and the public diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md index cc63eaf2..5e610de8 100644 --- a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md @@ -3,8 +3,8 @@ - Initiative state: active; boundary foundation complete - Runtime behavior changed: hidden CON adapter-binding behavior plus exactly four Finance Authority actions, and hidden ContributionPolicy read/create-draft/ - update-draft behavior; all five policy actions remain unavailable and no - public route exists + update-draft/publish/retire behavior; all five policy actions remain + unavailable and no public route exists - Canonical target: nine business modules and three supporting modules - Recovery model: freeze exact debt, prohibit growth, repair touched capabilities incrementally @@ -59,7 +59,7 @@ registered/unavailable. CP02 is merged with hidden CON behavior while its AUTH actions remain unavailable; CP03 is split, CP03A is merged, and CP03B is complete with four exact hidden Finance Authority actions - active. CP04A is complete and CP04B remains executable, while CP05-CP09 + active. CP04A and CP04B are complete, while CP05-CP09 remain non-executable skeletons. - [WS-ARCH-001-PLAN4](chunks/WS-ARCH-001-PLAN4-incremental-debt-retirement.md) is planned and changes no runtime. It makes the existing incremental strangler @@ -101,9 +101,9 @@ draft behavior, public owner ports preserve COMPENSATION and PROJECTS boundaries, PostgreSQL operation/event custody is durable, and all five policy actions remain unavailable. -- WS-ARCH-001-CP04B is planned with an executable hidden publish/retire - contract and is the next policy-behavior boundary after CP04A. -- WS-ARCH-001-CP05 is proposed: exact ContributionPolicy activation. +- WS-ARCH-001-CP04B is complete: hidden publish/retire behavior uses + immutable transition custody while all five actions remain unavailable. +- WS-ARCH-001-CP05 is next: exact ContributionPolicy activation. - WS-ARCH-001-CP06 is proposed: CON policy-validation port. - WS-ARCH-001-CP07 is proposed: PROJECT guide binding. - WS-ARCH-001-CP08 is proposed: TASK/Assignment/Submission lineage persistence diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-CP04B-con-policy-publication-behavior.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-CP04B-con-policy-publication-behavior.md index 805c6ec3..dffdf81f 100644 --- a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-CP04B-con-policy-publication-behavior.md +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-CP04B-con-policy-publication-behavior.md @@ -13,20 +13,25 @@ actions unavailable and add no route or downstream product behavior. ## Merge state -- Outcome on merge: `planned` -- The later CP04B implementation PR changes this outcome to `complete`; CP05 - then becomes next. +- Outcome on merge: `complete` +- CP05 then becomes the next policy boundary. ## Allowed files ```text backend/app/modules/contributions/api/** -backend/app/modules/contributions/{models.py,repository.py,service.py} +backend/app/modules/contributions/{models.py,repository.py,service.py,policy_graph.py,policy_publication.py,policy_mutation_support.py} backend/app/adapters/contributions/__init__.py -backend/alembic/versions/.py (only if schema proof requires correction) -backend/alembic/env.py (head parity only if migration exists) +backend/alembic/versions/0007_contribution_policy_publication_custody.py +backend/alembic/env.py backend/tests/contributions/** +backend/tests/test_contributions.py (existing PostgreSQL lifecycle fixtures and schema parity only) +backend/tests/test_review_lease_persistence.py (existing review-lease fixture canonical publication only) backend/tests/architecture/** (exact boundary proof only) +backend/tests/authorization/test_contribution_policy_registration.py (negative activation parity only) +backend/tests/authorization/guide_compilation/test_migration_contract.py (head parity only) +backend/tests/projects/guide_compilation/test_migration_contract.py (head parity only) +backend/tests/conftest.py (database reset inventory only) backend/tests/test_alembic.py (only with migration) backend/scripts/{behavior_ownership.py,run_test_lanes.py} (exact parity only) .ci/behavior-ownership/** (exact CP04B targets only) @@ -48,6 +53,67 @@ ContributionRecord, CompensationAward creation, fulfillment, callback, delivery caller-supplied publication truth, compatibility paths, service commits or serialized PREP handles ``` +The new modules have one owner each: `policy_graph.py` owns canonical graph +serialization and digest construction; `policy_mutation_support.py` owns shared +operation recovery and prepare/consume/close ordering; and +`policy_publication.py` owns publish/retire orchestration. `service.py` exposes +the existing public service boundary and delegates to these modules. No file +may reach 500 lines and no alternate service, repository, authorization, or +factory path may be introduced. + +## Exact public API and AUTH parity + +- Extend the closed `PolicyAction` with only `contribution.policy.publish` and + `contribution.policy.retire`, and add exact typed publish and retire request + types to the existing CONTRIBUTIONS public API. +- Publication mutation facts contain the existing common identities plus the + server-owned `rules_and_definitions_digest`, sorted unique + `adapter_binding_ids`, and exact expected draft status. Retirement mutation + facts contain the exact current published version and expected published + status. The CONTRIBUTIONS adapter constructs the exact same values required + by public AUTH `ContributionPolicyPublishFacts` or + `ContributionPolicyRetireFacts`, without importing AUTH models, + repositories, services, or private helpers. CP04B implements only + CONTRIBUTIONS-owned domain facts and public-schema parity tests. CP05 alone + installs the real CONTRIBUTIONS-to-AUTH adapter and prepared-handle behavior. +- `policy_graph.py` serializes the locked rules in ascending + `(contribution_type, rule_id)` order and each rule's definitions in ascending + `(instrument_type, unit_code, adapter_binding_id, definition_id)` order. It + uses canonical UTF-8 JSON with sorted keys, `(',', ':')` separators, no + caller-controlled whitespace, UUID strings, and canonical stored quantity + strings. The digest is lowercase `sha256:` plus 64 hexadecimal characters. + Binding ids are taken from those same locked definitions, deduplicated, and + sorted by UUID string exactly as AUTH requires. +- Parity tests must compare the CONTRIBUTIONS-produced facts and digest to the + public AUTH resource-digest contract. There is no second digest protocol and + no translation of domain values. + +## Total lock and authorization order + +Every publish and retire mutation uses this order and holds every acquired +owner fence through flush: + +1. caller-owned root transaction and canonical request digest; +2. transaction advisory `operation_id` fence and immutable recovery check; +3. PROJECTS public project-eligibility fence; +4. project-scoped publication advisory fence; +5. exact policy aggregate row; +6. exact target version row; +7. rules, then definitions, in the canonical order defined above; +8. referenced CONTRIBUTIONS-owned project units in ascending + `(instrument_type, unit_code)` order; +9. referenced COMPENSATION-owned adapter bindings through its public owner + port in ascending binding-id order; +10. construct server-owned facts, prepare AUTH, consume AUTH, and close exactly + once in `finally`; +11. only after successful close, create the database transition anchor, mutate + product rows, insert the lifecycle event, and flush. + +Retire has no graph/unit/binding locks, but preserves the same relative order +for every applicable step. Reverse caller ordering may not change this order. +Duplicate recovery performs no mutation authorization or product write; exact +duplicates require a fresh authorized read and return immutable event facts. + ## Publish contract - Fence operation recovery before product locks or AUTH. @@ -74,6 +140,61 @@ caller-supplied publication truth, compatibility paths, service commits or seria - Serialize same-project publication so the one-active-policy race has a deterministic winner before AUTH consumption. +## Migration and database custody + +- Add revision `0007_contribution_policy_publication_custody`, with + `down_revision = "0006_contribution_policy_operations"`; update + `_CURRENT_HEAD_REVISION` and the existing Alembic head/schema tests. +- Add `contribution_policy_transition_custody` with exact columns + `operation_id` (UUID primary key), `request_digest`, `event_type`, + `actor_profile_id`, `project_id`, `contribution_policy_id`, + `contribution_policy_version_id`, nullable + `prior_current_version_id`, and `occurred_at` (database generated and not + caller supplied). Composite foreign keys enforce policy/project, + version/policy/project, and nullable prior-version/policy/project ownership; + the event type is closed to `published` and `retired`. +- Add nullable `last_transition_operation_id` custody foreign keys to + `contribution_policies` and `contribution_policy_versions`. Publication + writes its unique operation id to the aggregate, target version, and any + automatically retired prior version; explicit retirement writes it to the + aggregate and retiring version. Draft create/update never writes this field. +- After successful PREP close, the service inserts custody with all immutable + identities except time and reads back the database-generated `occurred_at`. + It uses that exact value for the target publication/retirement fields, the + automatic prior-version retirement fields, aggregate retirement fields when + applicable, and the lifecycle event. Add nullable unique + `publication_custody_operation_id` to lifecycle events: it must be null for + `draft_created`/`draft_updated`, must equal `operation_id` for + `published`/`retired`, and has the foreign key to custody. Thus draft events + never require publication custody. +- Deferred constraint triggers run at transaction end in this order of proof: + custody requires exactly one matching event; the event requires the matching + final policy/version state and pre-mutation prior-current identity; every + affected aggregate/version row must carry that unique custody operation id, + actor, state, and time. A later custody/event inserted against already-final + rows cannot match their earlier transition operation and must fail. + Replacement + publication additionally requires the prior version to be retired by the + same actor at the same time. Explicit retirement requires both the aggregate + and current version to be retired by the same actor at the same time. + Orphaned, duplicated, stale, or mismatched custody/row/event combinations + fail commit. Application credentials cannot update/delete/truncate custody + or events. Database-superuser compromise is outside this application + contract; the contract does not claim cryptographic proof against a trusted + administrator forging an entire mutually consistent transaction. +- Custody and lifecycle events are immutable and reject update, delete, and + truncate. Rollback removes custody, row changes, event, and staged AUTH + evidence together. +- Correct the 0006 prior-current semantics for replacement publication: the + `published` event records the locked pre-mutation current version, not the + aggregate's post-mutation current version. Explicit retirement records the + exact retiring current version. +- PostgreSQL guards make published rule/definition graphs immutable, require + exactly one complete `accepted_submission` rule and one complete + `completed_review` rule at publication, and enforce instrument/unit/binding + ownership parity. Application validation is additional evidence, never the + database authority. + ## Retire contract - Target only the aggregate's exact current published version. @@ -89,8 +210,9 @@ caller-supplied publication truth, compatibility paths, service commits or seria - [ ] Publish/retire remain hidden and production deny-default. - [ ] Publish facts equal digest/binding facts recomputed from locked rows. - [ ] Concurrent child mutation, binding suspension, unit retirement, - competing publication, revocation, replay, cross-project use, wrong session/ - transaction, close failure, and rollback fail closed without mutation. + competing publication, opaque-port denial, close failure, and + rollback fail closed without mutation. Genuine prepared-handle session, + transaction, copy, and replay enforcement remains CP05/AUTH-owned. - [ ] Replacement publication retires exactly the prior current version while preserving its content and frozen downstream references; aggregate retirement is terminal. @@ -109,26 +231,63 @@ caller-supplied publication truth, compatibility paths, service commits or seria | Criterion | Required future proof | Execution custody | |---|---|---| | Publish/retire remain concealed, route-unreachable, and production deny-default | `tests/contributions/test_policy_publication_authorization.py::{test_publish_denies_without_composed_authority,test_retire_denies_without_composed_authority}` and `tests/contributions/test_policy_routes_absent.py::test_policy_routes_are_not_registered` | focused local command and hosted CI | -| Publish consumes exact server-recomputed graph digest and binding ids | `tests/contributions/test_policy_publish.py::{test_publish_uses_locked_server_owned_graph,test_caller_supplied_graph_mismatch_denies}` | focused local command and hosted CI | -| PREP denial/exception/wrong actor/session/transaction/copy/replay/close failure occurs before lifecycle mutation | `tests/contributions/test_policy_publication_authorization.py` with one primary failure behavior per test and an in-consume assertion that no lifecycle change/event is staged | focused local command and hosted CI | +| Publish consumes exact server-recomputed graph digest and binding ids, and the public request cannot carry caller graph facts | `tests/contributions/test_policy_publish.py::{test_publish_uses_locked_server_owned_graph,test_publish_request_cannot_carry_caller_graph_facts}` | focused local command and hosted CI | +| PREP denial occurs before lifecycle mutation | `tests/contributions/test_policy_publication_authorization.py::test_publish_prepare_denial_has_no_product_effect` | focused local command and hosted CI | +| PREP consume exception occurs before lifecycle mutation | `tests/contributions/test_policy_publication_authorization.py::test_publish_consume_exception_has_no_product_effect` | focused local command and hosted CI | +| PREP returns the wrong actor before lifecycle mutation | `tests/contributions/test_policy_publication_authorization.py::test_publish_wrong_consumed_actor_has_no_product_effect` | focused local command and hosted CI | +| PREP close failure occurs before lifecycle mutation | `tests/contributions/test_policy_publication_authorization.py::test_publish_close_failure_has_no_product_effect` | focused local command and hosted CI | +| PREP is closed exactly once on success and every failure | `tests/contributions/test_policy_publication_authorization.py::test_publish_closes_prepared_authority_exactly_once` and `test_retire_closes_prepared_authority_exactly_once` | focused local command and hosted CI | +| CONTRIBUTIONS never stages lifecycle state before consume/close | `tests/contributions/test_policy_publication_authorization.py::test_publish_consume_observes_no_staged_product_state` and `test_retire_consume_observes_no_staged_product_state` | focused local command and hosted PostgreSQL lane | | Replacement publication atomically retires the prior current version with matching actor/time and emits one recoverable `published` event | `tests/contributions/test_policy_publish.py::{test_replacement_publication_is_one_atomic_event,test_replacement_preserves_prior_content_and_frozen_references}` | focused local command and hosted PostgreSQL lane | -| Exact duplicate recovery returns immutable event facts only after current read authorization | `tests/contributions/test_policy_publication_recovery.py` covering publish and retire duplicates, digest mismatch, revoked read, and no second effect/evidence | focused local command and hosted CI | +| Exact duplicate publish returns immutable original event facts after current read authorization | `tests/contributions/test_policy_publication_recovery.py::test_duplicate_publish_returns_original_event_after_authorized_read` | focused local command and hosted CI | +| Exact duplicate retire returns immutable original event facts after current read authorization | `tests/contributions/test_policy_publication_recovery.py::test_duplicate_retire_returns_original_event_after_authorized_read` | focused local command and hosted CI | +| Duplicate request-digest mismatch is concealed | `tests/contributions/test_policy_publication_recovery.py::test_duplicate_digest_mismatch_is_concealed` | focused local command and hosted CI | +| Duplicate recovery read denial is concealed | `tests/contributions/test_policy_publication_recovery.py::test_duplicate_recovery_read_denial_is_concealed` | focused local command and hosted CI | +| Duplicate recovery creates no second product effect | `tests/contributions/test_policy_publication_recovery.py::test_duplicate_recovery_creates_no_second_product_effect` | focused local command and hosted PostgreSQL lane | +| Duplicate recovery performs no mutation authorization | `tests/contributions/test_policy_publication_recovery.py::test_duplicate_recovery_skips_mutation_authorization` | focused local command and hosted CI | +| Duplicate recovery creates no second authorization evidence | `tests/contributions/test_policy_publication_recovery.py::test_duplicate_recovery_creates_no_second_authorization_evidence` | hosted PostgreSQL lane only | +| Duplicate recovery creates no second lifecycle event | `tests/contributions/test_policy_publication_recovery.py::test_duplicate_recovery_creates_no_second_lifecycle_event` | focused local command and hosted PostgreSQL lane | | Cross-project publish facts (policy, version, unit, or adapter binding) and retire targets (policy or exact current version) fail closed with concealed denial, no lifecycle mutation, and no staged AUTH evidence or other side effect | Focused service proof in `tests/contributions/test_policy_publication_authorization.py::{test_cross_project_policy_publish_is_concealed_without_effect,test_cross_project_version_publish_is_concealed_without_effect,test_cross_project_unit_publish_is_concealed_without_effect,test_cross_project_binding_publish_is_concealed_without_effect,test_cross_project_policy_retire_is_concealed_without_effect,test_cross_project_current_version_retire_is_concealed_without_effect}`; transaction/row-custody proof in `tests/contributions/test_policy_publication_cross_project_postgresql.py` using independently committed cross-project rows and direct assertions that lifecycle state/events and AUTH evidence remain absent | named focused tests run locally and in hosted CI; `test_policy_publication_cross_project_postgresql.py` runs in the hosted PostgreSQL lane only | -| Child mutation, binding suspension, unit retirement, and competing publication cannot cross held owner fences | `tests/contributions/test_policy_publication_concurrency.py` using independent PostgreSQL sessions and deterministic commit ordering | hosted PostgreSQL lane only | -| PostgreSQL rejects incomplete publication, lifecycle skips, forged attribution, event mutation/deletion/truncation, and stale prior-current identity | `tests/contributions/test_policy_lifecycle_postgresql.py` using direct SQL negative cases | hosted PostgreSQL lane only | +| Child mutation cannot cross the held graph fence | `tests/contributions/test_policy_publication_concurrency.py::test_child_mutation_waits_for_publication_graph_fence` | hosted PostgreSQL lane only | +| Binding suspension cannot cross its held owner fence | `tests/contributions/test_policy_publication_concurrency.py::test_binding_suspension_waits_for_publication_owner_fence` | hosted PostgreSQL lane only | +| Unit retirement cannot cross its held owner fence | `tests/contributions/test_policy_publication_concurrency.py::test_unit_retirement_waits_for_publication_owner_fence` | hosted PostgreSQL lane only | +| Competing same-project publications have one deterministic winner before AUTH | `tests/contributions/test_policy_publication_concurrency.py::test_competing_publications_serialize_before_authorization` | hosted PostgreSQL lane only | +| Reverse input ordering follows the canonical lock order without deadlock | `tests/contributions/test_policy_publication_concurrency.py::test_reverse_ordered_graphs_use_one_lock_order` | hosted PostgreSQL lane only | +| PostgreSQL rejects an incomplete publish graph | `tests/contributions/test_policy_lifecycle_postgresql.py::test_database_rejects_incomplete_publication_graph` | hosted PostgreSQL lane only | +| PostgreSQL rejects lifecycle skips | `tests/contributions/test_policy_lifecycle_postgresql.py::test_database_rejects_publication_lifecycle_skip` | hosted PostgreSQL lane only | +| PostgreSQL rejects mismatched or forged attribution custody | `tests/contributions/test_policy_lifecycle_postgresql.py::test_database_rejects_forged_publication_attribution` and `test_database_rejects_forged_retirement_attribution` | hosted PostgreSQL lane only | +| PostgreSQL rejects stale replacement prior-current identity | `tests/contributions/test_policy_lifecycle_postgresql.py::test_database_rejects_stale_replacement_identity` | hosted PostgreSQL lane only | +| PostgreSQL rejects a new custody/event pair against already-final rows when no matching row transition occurred | `tests/contributions/test_policy_lifecycle_postgresql.py::test_database_rejects_event_without_matching_row_transition` | hosted PostgreSQL lane only | +| PostgreSQL rejects graph mutation after publication | `tests/contributions/test_policy_lifecycle_postgresql.py::test_database_rejects_published_graph_mutation` | hosted PostgreSQL lane only | +| PostgreSQL rejects lifecycle custody/event update, deletion, and truncation | `tests/contributions/test_policy_lifecycle_postgresql.py::{test_database_rejects_lifecycle_update,test_database_rejects_lifecycle_delete,test_database_rejects_lifecycle_truncate}` | hosted PostgreSQL lane only | | Explicit retirement is terminal and cannot rewrite frozen downstream lineage or resurrect the aggregate | `tests/contributions/test_policy_retire.py::{test_retire_blocks_future_selection_without_rewriting_history,test_retired_aggregate_cannot_be_resurrected}` | focused local command and hosted CI | -| Database failure after close rolls back lifecycle state and staged AUTH evidence; closed authority remains unusable | `tests/contributions/test_policy_publication_authorization.py::{test_post_close_failure_rolls_back_all_effects,test_closed_publication_authority_cannot_be_reused}` | focused local command and hosted PostgreSQL lane | +| Database failure after close rolls back publication custody, lifecycle state, and staged AUTH evidence | `tests/contributions/test_policy_integration_postgresql.py::test_late_publication_failure_rolls_back_custody_state_and_authorization` | hosted PostgreSQL lane only | +| A closed opaque authority cannot be reused by CONTRIBUTIONS | `tests/contributions/test_policy_publication_authorization.py::test_closed_publication_authority_cannot_be_reused` | focused local command and hosted CI | +| CONTRIBUTIONS publication facts have exact public AUTH digest parity | `tests/contributions/test_policy_publication_auth_parity.py::{test_publish_facts_match_public_auth_digest,test_retire_facts_match_public_auth_digest}` | focused local command and hosted CI | +| All ContributionPolicy actions remain planned and unavailable | `tests/authorization/test_contribution_policy_registration.py` | focused local command and hosted CI | +| CP04B files remain bounded and every test has one primary behavior | `tests/contributions/test_cp04b_file_structure.py` | focused local command and hosted CI | +| Every CP04B acceptance atom projects to an exact test name | `tests/contributions/test_cp04b_contract_projection.py` | focused local command and hosted CI | The focused pytest command must name every non-hosted-only module above. Hosted CI must additionally run `test_policy_publication_concurrency.py` and `test_policy_publication_cross_project_postgresql.py` and -`test_policy_lifecycle_postgresql.py` against real PostgreSQL; mock locks or an -in-memory database do not satisfy the contract. +`test_policy_lifecycle_postgresql.py` against real PostgreSQL. The semantic +lane must also retain `test_review_lease_persistence.py` so its upstream policy +fixture proves canonical publication custody; mock locks or an in-memory +database do not satisfy the contract. ```bash cd backend && .venv/bin/ruff check app/modules/contributions app/modules/compensation/api app/modules/compensation/policy_binding_service.py app/adapters/contributions tests/contributions -cd backend && .venv/bin/python -m pytest -q tests/contributions/test_policy_publication_authorization.py tests/contributions/test_policy_routes_absent.py tests/contributions/test_policy_publish.py tests/contributions/test_policy_publication_recovery.py tests/contributions/test_policy_retire.py tests/architecture/test_module_boundaries.py tests/test_alembic.py -cd backend && .venv/bin/python -m pytest -q tests/contributions --cov=app.modules.contributions --cov-report=term-missing --cov-fail-under=90 +cd backend && .venv/bin/python -m pytest -q tests/contributions/test_policy_publication_authorization.py tests/contributions/test_policy_routes_absent.py tests/contributions/test_policy_publish.py tests/contributions/test_policy_publication_recovery.py tests/contributions/test_policy_retire.py tests/contributions/test_policy_publication_auth_parity.py tests/contributions/test_cp04b_file_structure.py tests/contributions/test_cp04b_contract_projection.py tests/authorization/test_contribution_policy_registration.py tests/authorization/guide_compilation/test_migration_contract.py tests/projects/guide_compilation/test_migration_contract.py tests/architecture/test_module_boundaries.py tests/test_alembic.py +cd backend && .venv/bin/python -m pytest -q tests/contributions --cov=app.modules.contributions --cov=app.adapters.contributions --cov-report=term-missing --cov-fail-under=90 +cd backend && .venv/bin/coverage report --include='app/modules/contributions/api/policies.py' --precision=2 --fail-under=90 +cd backend && .venv/bin/coverage report --include='app/modules/contributions/models.py' --precision=2 --fail-under=90 +cd backend && .venv/bin/coverage report --include='app/modules/contributions/repository.py' --precision=2 --fail-under=90 +cd backend && .venv/bin/coverage report --include='app/modules/contributions/service.py' --precision=2 --fail-under=90 +cd backend && .venv/bin/coverage report --include='app/modules/contributions/policy_graph.py' --precision=2 --fail-under=90 +cd backend && .venv/bin/coverage report --include='app/modules/contributions/policy_publication.py' --precision=2 --fail-under=90 +cd backend && .venv/bin/coverage report --include='app/modules/contributions/policy_mutation_support.py' --precision=2 --fail-under=90 +cd backend && .venv/bin/coverage report --include='app/adapters/contributions/*' --precision=2 --fail-under=90 cd backend && .venv/bin/python -m scripts.module_boundaries validate --protected-base cd backend && .venv/bin/python -m scripts.test_structure_boundary validate --policy ../.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_POLICY.md --ledger ../.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json python3 scripts/check_active_state_projections.py diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-CP04B-external-review-response.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-CP04B-external-review-response.md new file mode 100644 index 00000000..c1ce9679 --- /dev/null +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-CP04B-external-review-response.md @@ -0,0 +1,72 @@ +# WS-ARCH-001-CP04B External Review Response + +## Comments addressed + +- GitHub shared-foundation lanes exposed existing contribution tests that still + published by direct row mutation. Their helper now crosses the canonical + hidden publication service, so the database custody guard remains strict. +- Existing model-column parity now includes the two transition-operation + anchors installed by migration `0007`. +- The CP04A structure check no longer treats CP04A's intentionally superseded + no-publish/no-retire assertions as permanent current-head behavior. CP04B's + downstream negative-boundary tests remain active. +- CodeRabbit's seven final-head findings were replayed and accepted. The CP04B + contract sentence and criterion map are now exact; migration `0007` uses the + physical check-constraint names produced by the ORM naming convention; + publish/retire validate their required owner ports before duplicate recovery; + AUTH digest parity is derived independently from seeded server facts; opaque + authority lifecycle assertions prove the prepared handle is consumed and + closed exactly once; PostgreSQL concurrency helpers clean up on every failure; + and the caller-fact structural test now has an accurate name. + +## Comments deferred + +- None. + +## Human decisions needed + +- None beyond normal approval and merge authority. + +## Commands rerun + +- Ruff on the three affected test surfaces. +- CP04A structure and CP04B negative-scope tests: 17 passed. +- Five focused publication, retirement, active-policy race, and transaction-lock + PostgreSQL regressions through the isolated migrated runner: 5 passed. +- Two incomplete-graph service regressions now assert the canonical concealed + policy conflict rather than a later database error: 2 passed. +- The first hosted replay exposed one remaining legacy assertion that expected + the forbidden active-policy/draft-version transition to fail only at commit. + Migration `0007` correctly rejects that transition during the `UPDATE`, so + the assertion now covers the complete database operation. The exact test + passed against a freshly migrated isolated PostgreSQL database. Hosted lanes + are replaying on the resulting head. +- The next hosted replay exposed the same retired direct-publication fixture in + the ReviewLease persistence suite. That fixture now publishes its complete + policy through the canonical hidden CONTRIBUTIONS service. All 8 ReviewLease + persistence tests passed against a freshly migrated isolated PostgreSQL + database; no REV behavior or production boundary changed. +- Ruff passed across the complete CONTRIBUTIONS module and test package. +- The non-PostgreSQL focused CodeRabbit regression set passed: 36 tests. +- The five PostgreSQL concurrency tests remain hosted-only because local + execution requires `WORKSTREAM_TEST_DATABASE_URL`; GitHub CI owns that proof. +- The first corrective hosted replay correctly rejected the old public-schema + fingerprint after migration `0007` adopted the ORM's physical constraint + names. The pinned fingerprint now records the resulting canonical schema; + no schema check or threshold was weakened. +- Adopted test-delta review rejected the fake-repository rollback assertion as + incompatible transaction custody. CP04B now proves the publication-specific + boundary with real PostgreSQL: authority evidence is staged, custody and the + lifecycle event reach PostgreSQL, a late event-flush failure is injected, and + rollback leaves the policy/version draft, with no custody, publication event, + or staged authorization effect. +- QA's discriminating probe then proved the first PostgreSQL replacement could + still pass if failure occurred before event flush. The late-failure repository + now asserts, inside the open transaction, that the policy/version transition, + custody, lifecycle event, and staged AUTH evidence are all visible before it + injects failure. A pre-flush failure therefore cannot satisfy the test. + +## Remaining risks + +- Hosted PostgreSQL and complete-suite evidence must be replayed on the final + corrective head before merge readiness is claimed. diff --git a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/AUTHORIZATION_HANDOFF.md b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/AUTHORIZATION_HANDOFF.md index c1da23f0..97f5741b 100644 --- a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/AUTHORIZATION_HANDOFF.md +++ b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/AUTHORIZATION_HANDOFF.md @@ -6,15 +6,15 @@ Current `main` contains the AUTH actor, grant, fixed-service, prepared-mutation, project-guide, policy-mutation, and REV-readiness foundations plus merged REV PLAN4, ART foundations, and CP02 hidden CON adapter-binding behavior. The old `0052_legacy_intake_removal` identifier is -historical merge evidence. Current `main` ends at -`0006_contribution_policy_operations`, whose tracked predecessor is -`0005_compensation_adapter_identity`. Older `0050`, `0053`, and `0055` +historical merge evidence. The CP04B merge outcome advances the schema head to +`0007_contribution_policy_publication_custody`, whose tracked predecessor is +`0006_contribution_policy_operations`. Older `0050`, `0053`, and `0055` identifiers are historical pre-baseline merge evidence, not active graph heads. CP03A installs the target identity and owner eligibility without action-bearing service membership; CP03B activates only the four exact Finance Authority boundaries. CP04A implements hidden policy draft behavior behind deny-default -authorization. No public CON route, ContributionPolicy authority, or -outbox-dispatcher authority is active. +authorization; CP04B adds hidden publish/retire behavior. No public CON route, +ContributionPolicy authority, or outbox-dispatcher authority is active. AUTH owns identifiers, stable permission mappings, principals, grants, fixed-service identities and matrix rows, prepared authorization, evaluators, @@ -89,9 +89,9 @@ and CP01B separately register binding and policy authority while unavailable. CP01C corrects binding identity and lifecycle-generation facts before CP02. CP02 is merged with hidden CON behavior. CP03A installs the exact target identity and owner eligibility without activation; CP03B is complete and -activates only the four exact CP02 Finance Authority boundaries. CP05 later -activates only CP04A/CP04B's merged hidden proof. CP04A is complete; CP04B is -the remaining hidden behavior prerequisite. Before CON +activates only the four exact CP02 Finance Authority boundaries. CP05 next +activates only CP04A/CP04B's merged hidden proof. CP04A and CP04B are complete. +Before CON `02B`, AUTH must deliver the complete dispatcher identity/admission/action contract. That later dispatcher work must not delay the persistence and transaction-participant foundations now needed by REV. diff --git a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/CHUNK_MAP.md b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/CHUNK_MAP.md index fdcf9361..bc419b9b 100644 --- a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/CHUNK_MAP.md +++ b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/CHUNK_MAP.md @@ -18,6 +18,7 @@ signed-loop records do not make behavior live. | `WS-ARCH-001-CP03A` | Adapter target identity and owner eligibility | Merged through PR #340; actions remain unavailable | | `WS-ARCH-001-CP03B` | Exact Finance Authority adapter-binding activation | Executable contract is complete; follows merged CP03A | | `WS-ARCH-001-CP04A` | Hidden ContributionPolicy read/create/update-draft behavior | Complete; policy actions remain unavailable | +| `WS-ARCH-001-CP04B` | Hidden ContributionPolicy publication/retirement behavior | Complete; policy actions remain unavailable | | `PLAN5` | Historical complete-context human needs-revision rebase reconciliation | Merged PR #270; continuing-TaskAssignment rebase retained, independent reviewer-selection wording superseded by current PLAN2 | ## Current reconciliation diff --git a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/STATUS.md b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/STATUS.md index 2c9adb9b..ada4617a 100644 --- a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/STATUS.md +++ b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/STATUS.md @@ -87,8 +87,8 @@ historical-row backfill or compatibility behavior. CP03A is merged through PR #340 with target identity and owner eligibility while actions remain unavailable. CP03B completes Finance Authority activation. CP04 is a non-executable split parent; CP04A hidden policy draft behavior is - complete and CP04B remains the executable publication/retirement contract. - CP05 remains the non-executable activation skeleton. + complete and CP04B completes hidden publication/retirement behavior. + CP05 is the next non-executable activation skeleton. - CP06-CP09: validation, owner schema lineage, and clean legacy removal wait for the preceding public behavior. No historical-row classification is required unless current-main discovery proves real deployed data exists. @@ -121,6 +121,6 @@ CP08/ARCH-03B replacement path. CON-02C merged through PR #277. REV-04B may consume its shared lifecycle-audit participant after REV's earlier gates merge. CP04A hidden ContributionPolicy -read/create/update-draft behavior is complete. Implement CP04B's hidden -publish/retire behavior next. All five policy actions remain unavailable until CP05. +read/create/update-draft and hidden publish/retire behavior are complete. All +five policy actions remain unavailable; prepare CP05 activation next. Open pull requests determine transient CON work. diff --git a/.ci/behavior-ownership/partition.v1.json b/.ci/behavior-ownership/partition.v1.json index 5982e02c..9e67dee5 100644 --- a/.ci/behavior-ownership/partition.v1.json +++ b/.ci/behavior-ownership/partition.v1.json @@ -608,6 +608,18 @@ "group": "lifecycle", "target": "backend/app/modules/contributions/models.py" }, + { + "group": "lifecycle", + "target": "backend/app/modules/contributions/policy_graph.py" + }, + { + "group": "lifecycle", + "target": "backend/app/modules/contributions/policy_mutation_support.py" + }, + { + "group": "lifecycle", + "target": "backend/app/modules/contributions/policy_publication.py" + }, { "group": "lifecycle", "target": "backend/app/modules/contributions/policy_validation.py" @@ -937,7 +949,7 @@ "target": "backend/scripts/week2_api_e2e.py" } ], - "authority_digest": "392b05ff8dec5f67c2b0de49ecc6d24af8b5c89b5acacd0038a02ff44413bb45", + "authority_digest": "495ba32b1f2baa63622cd0bd48bc4ac3fa8e70c3208d05231673a451aa72f34c", "protected_base_commit": "7676ce4347db0c9694962a9b587a20765e16eac6", "schema": "workstream.behavior-ownership-partition.v1" } diff --git a/backend/alembic/env.py b/backend/alembic/env.py index f4fab631..309eb643 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -21,7 +21,7 @@ target_metadata = Base.metadata _BASELINE_REVISION = "0001_v01_baseline" -_CURRENT_HEAD_REVISION = "0006_contribution_policy_operations" +_CURRENT_HEAD_REVISION = "0007_contribution_policy_publication_custody" _RECREATE_GUIDANCE = ( "Workstream v0.1 requires a fresh database; recreate this database before " "running the 0001_v01_baseline migration" diff --git a/backend/alembic/versions/0007_contribution_policy_publication_custody.py b/backend/alembic/versions/0007_contribution_policy_publication_custody.py new file mode 100644 index 00000000..ab311484 --- /dev/null +++ b/backend/alembic/versions/0007_contribution_policy_publication_custody.py @@ -0,0 +1,451 @@ +"""Install ContributionPolicy publication and retirement custody.""" + +from alembic import op +import sqlalchemy as sa + +revision = "0007_contribution_policy_publication_custody" +down_revision = "0006_contribution_policy_operations" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "contribution_policy_transition_custody", + sa.Column("operation_id", sa.Uuid(), primary_key=True), + sa.Column("request_digest", sa.String(71), nullable=False), + sa.Column("event_type", sa.String(24), nullable=False), + sa.Column("actor_profile_id", sa.String(36), nullable=False), + sa.Column("project_id", sa.String(36), nullable=False), + sa.Column("contribution_policy_id", sa.Uuid(), nullable=False), + sa.Column("contribution_policy_version_id", sa.Uuid(), nullable=False), + sa.Column("prior_current_version_id", sa.Uuid()), + sa.Column( + "occurred_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("clock_timestamp()"), + ), + sa.ForeignKeyConstraint( + ["actor_profile_id"], ["actor_profiles.id"], + name="fk_contribution_policy_custody_actor", + ), + sa.ForeignKeyConstraint( + ["project_id"], ["projects.id"], + name="fk_contribution_policy_custody_project", + ), + sa.ForeignKeyConstraint( + ["contribution_policy_id", "project_id"], + ["contribution_policies.id", "contribution_policies.project_id"], + name="fk_contribution_policy_custody_policy", + ), + sa.ForeignKeyConstraint( + ["contribution_policy_version_id", "contribution_policy_id", "project_id"], + [ + "contribution_policy_versions.id", + "contribution_policy_versions.contribution_policy_id", + "contribution_policy_versions.project_id", + ], + name="fk_contribution_policy_custody_version", + ), + sa.ForeignKeyConstraint( + ["prior_current_version_id", "contribution_policy_id", "project_id"], + [ + "contribution_policy_versions.id", + "contribution_policy_versions.contribution_policy_id", + "contribution_policy_versions.project_id", + ], + name="fk_contribution_policy_custody_prior_version", + ), + sa.CheckConstraint( + "request_digest ~ '^sha256:[0-9a-f]{64}$'", + name="ck_contribution_policy_transition_custody_request_digest", + ), + sa.CheckConstraint( + "event_type in ('published','retired')", + name="ck_contribution_policy_transition_custody_event_type", + ), + ) + for table, constraint in ( + ("contribution_policies", "fk_contribution_policy_transition_custody"), + ( + "contribution_policy_versions", + "fk_contribution_policy_version_transition_custody", + ), + ): + op.add_column(table, sa.Column("last_transition_operation_id", sa.Uuid())) + op.create_foreign_key( + constraint, + table, + "contribution_policy_transition_custody", + ["last_transition_operation_id"], + ["operation_id"], + deferrable=True, + initially="DEFERRED", + ) + op.add_column( + "contribution_policy_lifecycle_events", + sa.Column("publication_custody_operation_id", sa.Uuid()), + ) + op.create_unique_constraint( + "uq_contribution_policy_event_publication_custody", + "contribution_policy_lifecycle_events", + ["publication_custody_operation_id"], + ) + op.create_foreign_key( + "fk_contribution_policy_event_publication_custody", + "contribution_policy_lifecycle_events", + "contribution_policy_transition_custody", + ["publication_custody_operation_id"], + ["operation_id"], + deferrable=True, + initially="DEFERRED", + ) + op.create_check_constraint( + "ck_contribution_policy_event_custody_shape", + "contribution_policy_lifecycle_events", + "(event_type in ('draft_created','draft_updated') and " + "publication_custody_operation_id is null) or " + "(event_type in ('published','retired') and " + "publication_custody_operation_id=operation_id)", + ) + _install_custody_guards() + _replace_event_guard() + _install_row_transition_guards() + _install_graph_guards() + + +def _install_custody_guards() -> None: + op.execute( + """ + create function guard_contribution_policy_transition_custody() + returns trigger language plpgsql as $$ + declare + policy contribution_policies%rowtype; + target contribution_policy_versions%rowtype; + prior contribution_policy_versions%rowtype; + event_count integer; + accepted_count integer; + review_count integer; + invalid_rule_count integer; + begin + select * into policy from contribution_policies + where id=new.contribution_policy_id; + select * into target from contribution_policy_versions + where id=new.contribution_policy_version_id; + select count(*) into event_count + from contribution_policy_lifecycle_events e + where e.publication_custody_operation_id=new.operation_id + and e.operation_id=new.operation_id + and e.request_digest=new.request_digest + and e.event_type=new.event_type + and e.actor_profile_id=new.actor_profile_id + and e.project_id=new.project_id + and e.contribution_policy_id=new.contribution_policy_id + and e.contribution_policy_version_id=new.contribution_policy_version_id + and e.prior_current_version_id is not distinct from new.prior_current_version_id + and e.occurred_at=new.occurred_at; + if event_count <> 1 + or policy.last_transition_operation_id is distinct from new.operation_id + or target.last_transition_operation_id is distinct from new.operation_id then + raise exception 'invalid contribution policy transition custody' + using errcode='23514'; + end if; + if new.event_type='published' then + if policy.status is distinct from 'active' + or policy.current_published_version_id is distinct from target.id + or target.status is distinct from 'published' + or target.published_by is distinct from new.actor_profile_id + or target.published_at is distinct from new.occurred_at then + raise exception 'invalid contribution policy publication custody' + using errcode='23514'; + end if; + select count(*) filter (where contribution_type='accepted_submission'), + count(*) filter (where contribution_type='completed_review') + into accepted_count, review_count from contribution_rules + where contribution_policy_version_id=target.id; + if accepted_count <> 1 or review_count <> 1 then + raise exception 'incomplete contribution policy graph' + using errcode='23514'; + end if; + select count(*) into invalid_rule_count from contribution_rules r + where r.contribution_policy_version_id=target.id and ( + (r.compensation_mode='unpaid' and exists ( + select 1 from contribution_award_definitions d + where d.contribution_rule_id=r.id)) + or (r.compensation_mode='compensated' and ( + select count(*) from contribution_award_definitions d + where d.contribution_rule_id=r.id) not between 1 and 2)); + if invalid_rule_count <> 0 then + raise exception 'incomplete contribution policy definitions' + using errcode='23514'; + end if; + if new.prior_current_version_id is not null then + select * into prior from contribution_policy_versions + where id=new.prior_current_version_id; + if prior.status is distinct from 'retired' + or prior.last_transition_operation_id is distinct from new.operation_id + or prior.retired_by is distinct from new.actor_profile_id + or prior.retired_at is distinct from new.occurred_at then + raise exception 'invalid replacement publication custody' + using errcode='23514'; + end if; + end if; + else + if policy.status is distinct from 'retired' + or policy.current_published_version_id is distinct from target.id + or policy.retired_by is distinct from new.actor_profile_id + or policy.retired_at is distinct from new.occurred_at + or target.status is distinct from 'retired' + or target.retired_by is distinct from new.actor_profile_id + or target.retired_at is distinct from new.occurred_at then + raise exception 'invalid contribution policy retirement custody' + using errcode='23514'; + end if; + end if; + return null; + end; + $$ + """ + ) + + +def _replace_event_guard() -> None: + op.execute( + """ + create or replace function guard_contribution_policy_event_insert() + returns trigger language plpgsql as $$ + declare + policy contribution_policies%rowtype; + version contribution_policy_versions%rowtype; + prior_version_number integer; + custody contribution_policy_transition_custody%rowtype; + begin + select * into policy from contribution_policies where id=new.contribution_policy_id; + select * into version from contribution_policy_versions + where id=new.contribution_policy_version_id; + if policy.id is null or version.id is null + or policy.project_id is distinct from new.project_id + or version.project_id is distinct from new.project_id + or version.contribution_policy_id is distinct from new.contribution_policy_id + or version.version_number is distinct from new.version_number + or version.status is distinct from new.to_version_status + or policy.status is distinct from new.to_policy_status then + raise exception 'invalid contribution policy lifecycle event' using errcode='23514'; + end if; + if new.prior_current_version_id is not null then + select version_number into prior_version_number from contribution_policy_versions + where id=new.prior_current_version_id; + end if; + if new.prior_current_version_number is distinct from prior_version_number then + raise exception 'contribution policy event prior version mismatch' + using errcode='23514'; + end if; + if new.event_type in ('draft_created','draft_updated') then + if new.prior_current_version_id is distinct from policy.current_published_version_id + or (new.event_type='draft_created' and version.version_number=1 + and (new.from_policy_status is not null or policy.status <> 'draft')) + or (new.event_type='draft_created' and version.version_number>1 + and (new.from_policy_status <> 'active' or policy.status <> 'active')) + or (new.event_type='draft_updated' + and new.from_policy_status is distinct from policy.status) then + raise exception 'contribution policy event prior state mismatch' + using errcode='23514'; + end if; + new.occurred_at := clock_timestamp(); + else + select * into custody from contribution_policy_transition_custody + where operation_id=new.publication_custody_operation_id; + if custody.operation_id is null + or custody.operation_id is distinct from new.operation_id + or custody.event_type is distinct from new.event_type then + raise exception 'contribution policy event custody mismatch' + using errcode='23514'; + end if; + new.occurred_at := custody.occurred_at; + end if; + if (new.event_type='draft_created' + and version.created_by is distinct from new.actor_profile_id) + or (new.event_type='draft_updated' + and version.last_updated_by is distinct from new.actor_profile_id) + or (new.event_type='published' + and version.published_by is distinct from new.actor_profile_id) + or (new.event_type='retired' + and version.retired_by is distinct from new.actor_profile_id) then + raise exception 'contribution policy event attribution mismatch' + using errcode='23514'; + end if; + return new; + end; + $$ + """ + ) + op.execute( + "create constraint trigger contribution_policy_custody_guard " + "after insert on contribution_policy_transition_custody " + "deferrable initially deferred for each row execute function " + "guard_contribution_policy_transition_custody()" + ) + op.execute( + "create trigger contribution_policy_custody_change_guard " + "before update or delete on contribution_policy_transition_custody " + "for each row execute function reject_contribution_policy_event_change()" + ) + op.execute( + "create trigger contribution_policy_custody_truncate_guard " + "before truncate on contribution_policy_transition_custody execute function " + "reject_contribution_policy_event_change()" + ) + + +def _install_graph_guards() -> None: + op.execute( + """ + create function guard_published_contribution_policy_graph() + returns trigger language plpgsql as $$ + declare version_status text; + begin + if TG_OP='DELETE' then + select status into version_status from contribution_policy_versions + where id=old.contribution_policy_version_id; + else + select status into version_status from contribution_policy_versions + where id=new.contribution_policy_version_id; + end if; + if version_status in ('published','retired') then + raise exception 'published contribution policy graph is immutable' + using errcode='55000'; + end if; + if TG_OP='DELETE' then + return old; + end if; + return new; + end; + $$ + """ + ) + for table in ("contribution_rules", "contribution_award_definitions"): + op.execute( + f"create trigger {table}_published_graph_guard " + f"before insert or update or delete on {table} for each row execute function " + "guard_published_contribution_policy_graph()" + ) + + +def _install_row_transition_guards() -> None: + op.execute( + """ + create function guard_contribution_policy_row_transition() + returns trigger language plpgsql as $$ + declare custody contribution_policy_transition_custody%rowtype; + begin + if old.id is distinct from new.id + or old.project_id is distinct from new.project_id + or old.name is distinct from new.name + or old.created_by is distinct from new.created_by + or old.created_at is distinct from new.created_at then + raise exception 'immutable contribution policy identity changed' + using errcode='55000'; + end if; + if old.status='retired' + or (old.status in ('active','retired') and + new.last_transition_operation_id is not distinct from + old.last_transition_operation_id) then + raise exception 'final contribution policy row is immutable' + using errcode='55000'; + end if; + if old.status is distinct from new.status + or old.current_published_version_id is distinct from + new.current_published_version_id + or old.last_transition_operation_id is distinct from + new.last_transition_operation_id then + select * into custody from contribution_policy_transition_custody + where operation_id=new.last_transition_operation_id; + if custody.operation_id is null + or custody.contribution_policy_id is distinct from new.id + or custody.project_id is distinct from new.project_id + or (new.status='active' and ( + custody.event_type <> 'published' + or new.current_published_version_id is distinct from + custody.contribution_policy_version_id + or new.retired_by is not null or new.retired_at is not null)) + or (new.status='retired' and ( + custody.event_type <> 'retired' + or new.retired_by is distinct from custody.actor_profile_id + or new.retired_at is distinct from custody.occurred_at)) then + raise exception 'invalid contribution policy row transition' + using errcode='23514'; + end if; + end if; + return new; + end; + $$ + """ + ) + op.execute( + "create trigger contribution_policy_row_transition_guard before update on " + "contribution_policies for each row execute function " + "guard_contribution_policy_row_transition()" + ) + op.execute( + """ + create function guard_contribution_policy_version_row_transition() + returns trigger language plpgsql as $$ + declare custody contribution_policy_transition_custody%rowtype; + begin + if old.id is distinct from new.id + or old.contribution_policy_id is distinct from new.contribution_policy_id + or old.project_id is distinct from new.project_id + or old.version_number is distinct from new.version_number + or old.created_by is distinct from new.created_by + or old.created_at is distinct from new.created_at then + raise exception 'immutable contribution policy version identity changed' + using errcode='55000'; + end if; + if old.status='retired' + or (old.status='published' and new.status<>'retired') then + raise exception 'final contribution policy version row is immutable' + using errcode='55000'; + end if; + if old.status is distinct from new.status + or old.last_transition_operation_id is distinct from + new.last_transition_operation_id then + select * into custody from contribution_policy_transition_custody + where operation_id=new.last_transition_operation_id; + if custody.operation_id is null + or (custody.contribution_policy_version_id is distinct from new.id + and not (custody.event_type='published' + and custody.prior_current_version_id is not distinct from new.id + and new.status='retired')) + or custody.contribution_policy_id is distinct from new.contribution_policy_id + or custody.project_id is distinct from new.project_id + or (new.status='published' and ( + custody.event_type <> 'published' + or new.published_by is distinct from custody.actor_profile_id + or new.published_at is distinct from custody.occurred_at)) + or (new.status='retired' and ( + not ((custody.event_type='retired' + and custody.contribution_policy_version_id=new.id) + or (custody.event_type='published' + and custody.prior_current_version_id=new.id)) + or + new.retired_by is distinct from custody.actor_profile_id + or new.retired_at is distinct from custody.occurred_at)) then + raise exception 'invalid contribution policy version row transition' + using errcode='23514'; + end if; + end if; + return new; + end; + $$ + """ + ) + op.execute( + "create trigger contribution_policy_version_row_transition_guard before update on " + "contribution_policy_versions for each row execute function " + "guard_contribution_policy_version_row_transition()" + ) + + +def downgrade() -> None: + raise RuntimeError("Workstream v0.1 migrations cannot be downgraded; recreate the database") diff --git a/backend/app/modules/contributions/api/__init__.py b/backend/app/modules/contributions/api/__init__.py index 38c34a7c..95d27e5a 100644 --- a/backend/app/modules/contributions/api/__init__.py +++ b/backend/app/modules/contributions/api/__init__.py @@ -4,12 +4,17 @@ CompensationMode, ContributionPolicyConflict, ContributionPolicyCreateDraftRequest, + ContributionPolicyAuthorizationFacts, ContributionPolicyMutationAuthorizationFacts, ContributionPolicyMutationAuthorizationPort, ContributionPolicyMutationResult, + ContributionPolicyPublishAuthorizationFacts, + ContributionPolicyPublishRequest, ContributionPolicyProjectEligibilityPort, ContributionPolicyReadAuthorizationPort, ContributionPolicyReadRequest, + ContributionPolicyRetireAuthorizationFacts, + ContributionPolicyRetireRequest, ContributionPolicyUnavailable, ContributionPolicyUpdateDraftRequest, ContributionPolicyView, @@ -27,12 +32,17 @@ "CompensationMode", "ContributionPolicyConflict", "ContributionPolicyCreateDraftRequest", + "ContributionPolicyAuthorizationFacts", "ContributionPolicyMutationAuthorizationFacts", "ContributionPolicyMutationAuthorizationPort", "ContributionPolicyMutationResult", + "ContributionPolicyPublishAuthorizationFacts", + "ContributionPolicyPublishRequest", "ContributionPolicyProjectEligibilityPort", "ContributionPolicyReadAuthorizationPort", "ContributionPolicyReadRequest", + "ContributionPolicyRetireAuthorizationFacts", + "ContributionPolicyRetireRequest", "ContributionPolicyUnavailable", "ContributionPolicyUpdateDraftRequest", "ContributionPolicyView", diff --git a/backend/app/modules/contributions/api/policies.py b/backend/app/modules/contributions/api/policies.py index 453d0b8f..1e488160 100644 --- a/backend/app/modules/contributions/api/policies.py +++ b/backend/app/modules/contributions/api/policies.py @@ -11,6 +11,8 @@ PolicyAction = Literal[ "contribution.policy.create_draft", "contribution.policy.update_draft", + "contribution.policy.publish", + "contribution.policy.retire", ] PolicyEventType = Literal["draft_created", "draft_updated", "published", "retired"] ContributionType = Literal["accepted_submission", "completed_review"] @@ -76,8 +78,33 @@ class ContributionPolicyUpdateDraftRequest: rules: tuple[PolicyRuleInput, ...] +@dataclass(frozen=True, slots=True, kw_only=True) +class ContributionPolicyPublishRequest: + """Publish one exact complete draft version.""" + + operation_id: UUID + actor_profile_id: UUID + project_id: UUID + contribution_policy_id: UUID + contribution_policy_version_id: UUID + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ContributionPolicyRetireRequest: + """Retire one aggregate's exact current published version.""" + + operation_id: UUID + actor_profile_id: UUID + project_id: UUID + contribution_policy_id: UUID + contribution_policy_version_id: UUID + + PolicyMutationRequest: TypeAlias = ( - ContributionPolicyCreateDraftRequest | ContributionPolicyUpdateDraftRequest + ContributionPolicyCreateDraftRequest + | ContributionPolicyUpdateDraftRequest + | ContributionPolicyPublishRequest + | ContributionPolicyRetireRequest ) @@ -153,6 +180,45 @@ class ContributionPolicyMutationAuthorizationFacts: expected_version_status: str | None +@dataclass(frozen=True, slots=True, kw_only=True) +class ContributionPolicyPublishAuthorizationFacts: + """Server-owned complete draft graph facts for publication authority.""" + + action: Literal["contribution.policy.publish"] + actor_profile_id: UUID + operation_id: UUID + request_digest: str + project_id: UUID + contribution_policy_id: UUID + contribution_policy_version_id: UUID + rules_and_definitions_digest: str + adapter_binding_ids: tuple[UUID, ...] + expected_policy_status: str + expected_version_status: Literal["draft"] = "draft" + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ContributionPolicyRetireAuthorizationFacts: + """Exact current published lineage for retirement authority.""" + + action: Literal["contribution.policy.retire"] + actor_profile_id: UUID + operation_id: UUID + request_digest: str + project_id: UUID + contribution_policy_id: UUID + contribution_policy_version_id: UUID + expected_policy_status: Literal["active"] = "active" + expected_version_status: Literal["published"] = "published" + + +ContributionPolicyAuthorizationFacts: TypeAlias = ( + ContributionPolicyMutationAuthorizationFacts + | ContributionPolicyPublishAuthorizationFacts + | ContributionPolicyRetireAuthorizationFacts +) + + class ContributionPolicyReadAuthorizationPort(Protocol): """Authorize one exact policy/version disclosure.""" @@ -166,12 +232,12 @@ class ContributionPolicyMutationAuthorizationPort(Protocol): """Prepare, consume, and close opaque mutation authority.""" async def prepare_contribution_policy_mutation( - self, facts: ContributionPolicyMutationAuthorizationFacts + self, facts: ContributionPolicyAuthorizationFacts ) -> object: """Prepare process-local transaction-bound authority.""" async def consume_contribution_policy_mutation( - self, prepared: object, facts: ContributionPolicyMutationAuthorizationFacts + self, prepared: object, facts: ContributionPolicyAuthorizationFacts ) -> UUID: """Consume exact authority and return its bound actor.""" @@ -190,14 +256,14 @@ async def authorize_contribution_policy_read( raise ContributionPolicyUnavailable("contribution_policy_unavailable") async def prepare_contribution_policy_mutation( - self, facts: ContributionPolicyMutationAuthorizationFacts + self, facts: ContributionPolicyAuthorizationFacts ) -> object: """Deny mutation preparation until CP05 activation.""" del facts raise ContributionPolicyUnavailable("contribution_policy_unavailable") async def consume_contribution_policy_mutation( - self, prepared: object, facts: ContributionPolicyMutationAuthorizationFacts + self, prepared: object, facts: ContributionPolicyAuthorizationFacts ) -> UUID: """Deny mutation consumption until CP05 activation.""" del prepared, facts diff --git a/backend/app/modules/contributions/models.py b/backend/app/modules/contributions/models.py index 6ab5c22d..4cbd1c54 100644 --- a/backend/app/modules/contributions/models.py +++ b/backend/app/modules/contributions/models.py @@ -25,8 +25,6 @@ class ContributionPolicy(Base): - """Stable project aggregate selecting one published policy version.""" - __tablename__ = "contribution_policies" __table_args__ = ( UniqueConstraint("id", "project_id", name="uq_contribution_policy_ownership"), @@ -72,6 +70,15 @@ class ContributionPolicy(Base): name: Mapped[str] = mapped_column(String(200), nullable=False) status: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'draft'")) current_published_version_id: Mapped[UUID | None] = mapped_column(Uuid()) + last_transition_operation_id: Mapped[UUID | None] = mapped_column( + ForeignKey( + "contribution_policy_transition_custody.operation_id", + name="fk_contribution_policy_transition_custody", + use_alter=True, + deferrable=True, + initially="DEFERRED", + ) + ) created_by: Mapped[str] = mapped_column( ForeignKey("actor_profiles.id", name="fk_contribution_policy_created_by"), nullable=False, @@ -91,8 +98,6 @@ class ContributionPolicy(Base): class ContributionPolicyVersion(Base): - """Versioned contribution rules; published economic content is immutable.""" - __tablename__ = "contribution_policy_versions" __table_args__ = ( ForeignKeyConstraint( @@ -100,9 +105,7 @@ class ContributionPolicyVersion(Base): ["contribution_policies.id", "contribution_policies.project_id"], name="fk_contribution_policy_version_policy", ), - UniqueConstraint( - "id", "project_id", name="uq_contribution_policy_version_project" - ), + UniqueConstraint("id", "project_id", name="uq_contribution_policy_version_project"), UniqueConstraint( "id", "contribution_policy_id", @@ -159,6 +162,15 @@ class ContributionPolicyVersion(Base): ForeignKey("actor_profiles.id", name="fk_contribution_policy_version_updated_by") ) last_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_transition_operation_id: Mapped[UUID | None] = mapped_column( + ForeignKey( + "contribution_policy_transition_custody.operation_id", + name="fk_contribution_policy_version_transition_custody", + use_alter=True, + deferrable=True, + initially="DEFERRED", + ) + ) policy: Mapped[ContributionPolicy] = relationship( back_populates="versions", @@ -168,8 +180,6 @@ class ContributionPolicyVersion(Base): class ContributionRule(Base): - """One explicit eligibility rule for a canonical contribution type.""" - __tablename__ = "contribution_rules" __table_args__ = ( ForeignKeyConstraint( @@ -217,9 +227,7 @@ class Iso4217CurrencyCode(Base): """Migration-seeded immutable ISO 4217 List One alphabetic code.""" __tablename__ = "iso_4217_currency_codes" - __table_args__ = ( - CheckConstraint("code ~ '^[A-Z]{3}$'", name="code"), - ) + __table_args__ = (CheckConstraint("code ~ '^[A-Z]{3}$'", name="code"),) code: Mapped[str] = mapped_column(String(3), primary_key=True) @@ -234,9 +242,7 @@ class ProjectCompensationUnit(Base): ["iso_4217_currency_codes.code"], name="fk_project_compensation_unit_iso_currency", ), - CheckConstraint( - "instrument_type in ('money','project_points')", name="instrument_type" - ), + CheckConstraint("instrument_type in ('money','project_points')", name="instrument_type"), CheckConstraint("status in ('active','retired')", name="status"), CheckConstraint( "(instrument_type='money' and iso_currency_code is not null " @@ -352,6 +358,55 @@ class ContributionAwardDefinition(Base): rule: Mapped[ContributionRule] = relationship(back_populates="award_definitions") +class ContributionPolicyTransitionCustody(Base): + __tablename__ = "contribution_policy_transition_custody" + __table_args__ = ( + ForeignKeyConstraint( + ["contribution_policy_id", "project_id"], + ["contribution_policies.id", "contribution_policies.project_id"], + name="fk_contribution_policy_custody_policy", + ), + ForeignKeyConstraint( + ["contribution_policy_version_id", "contribution_policy_id", "project_id"], + [ + "contribution_policy_versions.id", + "contribution_policy_versions.contribution_policy_id", + "contribution_policy_versions.project_id", + ], + name="fk_contribution_policy_custody_version", + ), + ForeignKeyConstraint( + ["prior_current_version_id", "contribution_policy_id", "project_id"], + [ + "contribution_policy_versions.id", + "contribution_policy_versions.contribution_policy_id", + "contribution_policy_versions.project_id", + ], + name="fk_contribution_policy_custody_prior_version", + ), + CheckConstraint("request_digest ~ '^sha256:[0-9a-f]{64}$'", name="request_digest"), + CheckConstraint("event_type in ('published','retired')", name="event_type"), + ) + + operation_id: Mapped[UUID] = mapped_column(Uuid(), primary_key=True) + request_digest: Mapped[str] = mapped_column(String(71), nullable=False) + event_type: Mapped[str] = mapped_column(String(24), nullable=False) + actor_profile_id: Mapped[str] = mapped_column( + ForeignKey("actor_profiles.id", name="fk_contribution_policy_custody_actor"), + nullable=False, + ) + project_id: Mapped[str] = mapped_column( + ForeignKey("projects.id", name="fk_contribution_policy_custody_project"), + nullable=False, + ) + contribution_policy_id: Mapped[UUID] = mapped_column(Uuid(), nullable=False) + contribution_policy_version_id: Mapped[UUID] = mapped_column(Uuid(), nullable=False) + prior_current_version_id: Mapped[UUID | None] = mapped_column(Uuid()) + occurred_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=text("clock_timestamp()") + ) + + class ContributionPolicyLifecycleEvent(Base): """Immutable recoverable truth for one policy-version mutation.""" @@ -376,6 +431,17 @@ class ContributionPolicyLifecycleEvent(Base): name="fk_contribution_policy_event_version_ownership", ), UniqueConstraint("operation_id", name="uq_contribution_policy_event_operation"), + UniqueConstraint( + "publication_custody_operation_id", + name="uq_contribution_policy_event_publication_custody", + ), + ForeignKeyConstraint( + ["publication_custody_operation_id"], + ["contribution_policy_transition_custody.operation_id"], + name="fk_contribution_policy_event_publication_custody", + deferrable=True, + initially="DEFERRED", + ), CheckConstraint( "request_digest ~ '^sha256:[0-9a-f]{64}$'", name="ck_contribution_policy_event_digest", @@ -395,10 +461,18 @@ class ContributionPolicyLifecycleEvent(Base): "and to_version_status='retired')", name="ck_contribution_policy_event_transition", ), + CheckConstraint( + "(event_type in ('draft_created','draft_updated') " + "and publication_custody_operation_id is null) or " + "(event_type in ('published','retired') " + "and publication_custody_operation_id=operation_id)", + name="ck_contribution_policy_event_custody_shape", + ), ) id: Mapped[UUID] = mapped_column(Uuid(), primary_key=True) operation_id: Mapped[UUID] = mapped_column(Uuid(), nullable=False) + publication_custody_operation_id: Mapped[UUID | None] = mapped_column(Uuid()) request_digest: Mapped[str] = mapped_column(String(71), nullable=False) event_type: Mapped[str] = mapped_column(String(24), nullable=False) actor_profile_id: Mapped[str] = mapped_column( diff --git a/backend/app/modules/contributions/policy_graph.py b/backend/app/modules/contributions/policy_graph.py new file mode 100644 index 00000000..62da45e9 --- /dev/null +++ b/backend/app/modules/contributions/policy_graph.py @@ -0,0 +1,53 @@ +"""Canonical server-owned ContributionPolicy publication graph facts.""" + +from __future__ import annotations + +from decimal import Decimal +from uuid import UUID + +from app.core.hashing import canonical_json_hash +from app.modules.contributions.models import ContributionPolicyVersion + + +def publication_graph_facts(version: ContributionPolicyVersion) -> tuple[str, tuple[UUID, ...]]: + """Return the canonical graph digest and sorted unique adapter bindings.""" + rules = [] + binding_ids: set[UUID] = set() + for rule in sorted(version.rules, key=lambda value: (value.contribution_type, str(value.id))): + definitions = [] + for item in sorted( + rule.award_definitions, + key=lambda value: ( + value.instrument_type, + value.unit_code, + str(value.adapter_binding_id), + str(value.id), + ), + ): + binding_ids.add(item.adapter_binding_id) + definitions.append( + { + "adapter_binding_id": str(item.adapter_binding_id), + "definition_id": str(item.id), + "instrument_type": item.instrument_type, + "quantity": _canonical_quantity(item.quantity), + "unit_code": item.unit_code, + } + ) + rules.append( + { + "compensation_mode": rule.compensation_mode, + "contribution_type": rule.contribution_type, + "definitions": definitions, + "rule_id": str(rule.id), + } + ) + return canonical_json_hash({"rules": rules}), tuple(sorted(binding_ids, key=str)) + + +def _canonical_quantity(value: Decimal) -> str: + """Render an exact stored decimal without exponent or redundant zeros.""" + rendered = format(value, "f") + if "." in rendered: + rendered = rendered.rstrip("0").rstrip(".") + return rendered diff --git a/backend/app/modules/contributions/policy_mutation_support.py b/backend/app/modules/contributions/policy_mutation_support.py new file mode 100644 index 00000000..f98f3cc0 --- /dev/null +++ b/backend/app/modules/contributions/policy_mutation_support.py @@ -0,0 +1,77 @@ +"""Shared fail-closed authorization and recovery for policy mutations.""" + +from collections.abc import Callable +from typing import Protocol +from uuid import UUID + +from app.modules.contributions.api import ( + ContributionPolicyAuthorizationFacts, + ContributionPolicyConflict, + ContributionPolicyMutationResult, + ContributionPolicyMutationAuthorizationPort, + ContributionPolicyReadAuthorizationPort, + ContributionPolicyReadRequest, + ContributionPolicyUnavailable, +) +from app.modules.contributions.models import ContributionPolicyLifecycleEvent + + +class PolicyRecoveryRepository(Protocol): + """Minimal persistence surface required by shared operation recovery.""" + + async def lock_operation(self, operation_id: UUID) -> None: ... + + async def get_event_by_operation( + self, operation_id: UUID + ) -> ContributionPolicyLifecycleEvent | None: ... + + +async def consume_and_close_policy_authority( + authorization: ContributionPolicyMutationAuthorizationPort, + facts: ContributionPolicyAuthorizationFacts, +) -> UUID: + """Consume exact opaque authority and invalidate it before product effects.""" + prepared = await authorization.prepare_contribution_policy_mutation(facts) + try: + actor = await authorization.consume_contribution_policy_mutation(prepared, facts) + finally: + authorization.close_contribution_policy_mutation(prepared) + if type(actor) is not UUID or actor != facts.actor_profile_id: + raise ContributionPolicyUnavailable("contribution_policy_unavailable") + return actor + + +async def begin_and_recover_policy_mutation( + *, + repository: PolicyRecoveryRepository, + read_authorization: ContributionPolicyReadAuthorizationPort, + request: object, + request_digest: str, + expected_event_type: str, + result_factory: Callable[[ContributionPolicyLifecycleEvent], ContributionPolicyMutationResult], +) -> ContributionPolicyMutationResult | None: + """Fence an operation and recover only immutable currently-readable truth.""" + operation_id = getattr(request, "operation_id") + await repository.lock_operation(operation_id) + event = await repository.get_event_by_operation(operation_id) + if event is None: + return None + if ( + event.event_type != expected_event_type + or event.request_digest != request_digest + or event.actor_profile_id != str(getattr(request, "actor_profile_id")) + or event.project_id != str(getattr(request, "project_id")) + ): + raise ContributionPolicyConflict("contribution_policy_conflict") + try: + await read_authorization.authorize_contribution_policy_read( + ContributionPolicyReadRequest( + actor_profile_id=UUID(event.actor_profile_id), + project_id=UUID(event.project_id), + contribution_policy_id=event.contribution_policy_id, + contribution_policy_version_id=event.contribution_policy_version_id, + ) + ) + except (ContributionPolicyUnavailable, ContributionPolicyConflict) as exc: + raise ContributionPolicyConflict("contribution_policy_conflict") from exc + return result_factory(event) diff --git a/backend/app/modules/contributions/policy_publication.py b/backend/app/modules/contributions/policy_publication.py new file mode 100644 index 00000000..bfbce1f9 --- /dev/null +++ b/backend/app/modules/contributions/policy_publication.py @@ -0,0 +1,327 @@ +"""Hidden ContributionPolicy publish and retirement orchestration.""" + +from __future__ import annotations + +from uuid import UUID, uuid4 + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm.attributes import set_committed_value + +from app.modules.compensation.api import ( + CompensationInstrumentType, + PolicyAdapterBindingPort, + PolicyAdapterBindingUnavailable, +) +from app.modules.contributions.api import ( + ContributionPolicyConflict, + ContributionPolicyMutationAuthorizationPort, + ContributionPolicyMutationResult, + ContributionPolicyProjectEligibilityPort, + ContributionPolicyPublishAuthorizationFacts, + ContributionPolicyPublishRequest, + ContributionPolicyReadAuthorizationPort, + ContributionPolicyRetireAuthorizationFacts, + ContributionPolicyRetireRequest, + ContributionPolicyUnavailable, +) +from app.modules.contributions.models import ( + ContributionPolicyLifecycleEvent, + ContributionPolicyTransitionCustody, +) +from app.modules.contributions.policy_graph import publication_graph_facts +from app.modules.contributions.policy_mutation_support import ( + begin_and_recover_policy_mutation, + consume_and_close_policy_authority, +) +from app.modules.contributions.policy_validation import policy_request_digest +from app.modules.contributions.repository import ContributionPolicyRepository +from app.modules.projects.api import ProjectContributionPolicyUnavailable + + +class ContributionPolicyPublicationService: + """Publish or terminally retire exact policy lineage without committing.""" + + def __init__( + self, + session: AsyncSession, + *, + repository: ContributionPolicyRepository, + read_authorization: ContributionPolicyReadAuthorizationPort, + mutation_authorization: ContributionPolicyMutationAuthorizationPort, + projects: ContributionPolicyProjectEligibilityPort | None, + bindings: PolicyAdapterBindingPort | None, + ) -> None: + self._session = session + self._repository = repository + self._read_authorization = read_authorization + self._mutation_authorization = mutation_authorization + self._projects = projects + self._bindings = bindings + + async def publish( + self, request: ContributionPolicyPublishRequest + ) -> ContributionPolicyMutationResult: + """Publish one locked complete draft and atomically replace its predecessor.""" + self._require_request(request, ContributionPolicyPublishRequest) + self._require_publication_ports() + digest = policy_request_digest("contribution.policy.publish", request) + recovered = await self._begin_and_recover(request, digest, "published") + if recovered is not None: + return recovered + await self._lock_project(request.project_id) + await self._repository.lock_project_scope(request.project_id) + policy, version = await self._lock_target(request) + if policy.status not in {"draft", "active"} or version.status != "draft": + raise ContributionPolicyConflict("contribution_policy_not_found") + prior = await self._lock_prior(policy, version) + rules, definitions = await self._repository.lock_publication_graph(version.id) + self._require_complete_graph(rules) + set_committed_value(version, "rules", rules) + await self._lock_owner_resources(request.project_id, definitions) + graph_digest, binding_ids = publication_graph_facts(version) + facts = ContributionPolicyPublishAuthorizationFacts( + action="contribution.policy.publish", + actor_profile_id=request.actor_profile_id, + operation_id=request.operation_id, + request_digest=digest, + project_id=request.project_id, + contribution_policy_id=policy.id, + contribution_policy_version_id=version.id, + rules_and_definitions_digest=graph_digest, + adapter_binding_ids=binding_ids, + expected_policy_status=policy.status, + ) + actor = await consume_and_close_policy_authority(self._mutation_authorization, facts) + custody = self._custody(request, digest, "published", prior) + await self._repository.create_transition_custody(custody) + self._apply_publication(policy, version, prior, actor, custody) + event = self._event(request, digest, "published", policy, version, prior, custody) + await self._repository.flush_transition_event(event) + return self._result(event) + + async def retire( + self, request: ContributionPolicyRetireRequest + ) -> ContributionPolicyMutationResult: + """Terminally retire one aggregate's exact current published version.""" + self._require_request(request, ContributionPolicyRetireRequest) + self._require_project_port() + digest = policy_request_digest("contribution.policy.retire", request) + recovered = await self._begin_and_recover(request, digest, "retired") + if recovered is not None: + return recovered + await self._lock_project(request.project_id) + await self._repository.lock_project_scope(request.project_id) + policy, version = await self._lock_target(request) + if ( + policy.status != "active" + or policy.current_published_version_id != version.id + or version.status != "published" + ): + raise ContributionPolicyConflict("contribution_policy_not_found") + facts = ContributionPolicyRetireAuthorizationFacts( + action="contribution.policy.retire", + actor_profile_id=request.actor_profile_id, + operation_id=request.operation_id, + request_digest=digest, + project_id=request.project_id, + contribution_policy_id=policy.id, + contribution_policy_version_id=version.id, + ) + actor = await consume_and_close_policy_authority(self._mutation_authorization, facts) + custody = self._custody(request, digest, "retired", version) + await self._repository.create_transition_custody(custody) + policy.status = version.status = "retired" + policy.retired_by = version.retired_by = str(actor) + policy.retired_at = version.retired_at = custody.occurred_at + policy.last_transition_operation_id = version.last_transition_operation_id = ( + request.operation_id + ) + event = self._event(request, digest, "retired", policy, version, version, custody) + await self._repository.flush_transition_event(event) + return self._result(event) + + async def _begin_and_recover(self, request, digest: str, event_type: str): + return await begin_and_recover_policy_mutation( + repository=self._repository, + read_authorization=self._read_authorization, + request=request, + request_digest=digest, + expected_event_type=event_type, + result_factory=self._result, + ) + + async def _lock_project(self, project_id: UUID) -> None: + self._require_project_port() + assert self._projects is not None + try: + result = await self._projects.lock_contribution_policy_project(project_id) + except ProjectContributionPolicyUnavailable as exc: + raise ContributionPolicyConflict("contribution_policy_not_found") from exc + if result.project_id != project_id: + raise ContributionPolicyConflict("contribution_policy_not_found") + + async def _lock_target(self, request): + policy = await self._repository.get_policy( + request.project_id, request.contribution_policy_id, for_update=True + ) + version = await self._repository.get_version( + request.project_id, + request.contribution_policy_id, + request.contribution_policy_version_id, + for_update=True, + ) + if policy is None or version is None: + raise ContributionPolicyConflict("contribution_policy_not_found") + return policy, version + + async def _lock_prior(self, policy, version): + if policy.current_published_version_id is None: + return None + prior = await self._repository.get_version( + UUID(policy.project_id), policy.id, policy.current_published_version_id, for_update=True + ) + if prior is None or prior.id == version.id or prior.status != "published": + raise ContributionPolicyConflict("contribution_policy_not_found") + return prior + + async def _lock_owner_resources(self, project_id: UUID, definitions) -> None: + self._require_publication_ports() + assert self._bindings is not None + units = sorted({(item.instrument_type, item.unit_code) for item in definitions}) + for instrument, unit_code in units: + unit = await self._repository.lock_unit(project_id, instrument, unit_code) + if unit is None or unit.status != "active": + raise ContributionPolicyConflict("contribution_policy_not_found") + + bindings = {item.adapter_binding_id: item for item in definitions} + if any( + bindings[item.adapter_binding_id].instrument_type != item.instrument_type + for item in definitions + ): + raise ContributionPolicyConflict("contribution_policy_conflict") + for item in (bindings[key] for key in sorted(bindings, key=str)): + try: + binding = await self._bindings.lock_policy_adapter_binding( + project_id=project_id, + adapter_binding_id=item.adapter_binding_id, + instrument_type=CompensationInstrumentType(item.instrument_type), + ) + except (PolicyAdapterBindingUnavailable, ValueError) as exc: + raise ContributionPolicyConflict("contribution_policy_not_found") from exc + if ( + binding.project_id != project_id + or binding.adapter_binding_id != item.adapter_binding_id + or binding.instrument_type.value != item.instrument_type + ): + raise ContributionPolicyConflict("contribution_policy_not_found") + + def _require_project_port(self) -> None: + if self._projects is None: + raise ContributionPolicyUnavailable("contribution_policy_unavailable") + + def _require_publication_ports(self) -> None: + self._require_project_port() + if self._bindings is None: + raise ContributionPolicyUnavailable("contribution_policy_unavailable") + + @staticmethod + def _require_complete_graph(rules) -> None: + if len(rules) != 2 or {rule.contribution_type for rule in rules} != { + "accepted_submission", + "completed_review", + }: + raise ContributionPolicyConflict("contribution_policy_conflict") + for rule in rules: + count = len(rule.award_definitions) + if (rule.compensation_mode == "unpaid" and count) or ( + rule.compensation_mode == "compensated" and not 1 <= count <= 2 + ): + raise ContributionPolicyConflict("contribution_policy_conflict") + + def _require_request(self, request: object, expected: type[object]) -> None: + if ( + type(request) is not expected + or not self._session.in_transaction() + or self._session.in_nested_transaction() + ): + raise ContributionPolicyUnavailable("contribution_policy_unavailable") + for name in ( + "operation_id", + "actor_profile_id", + "project_id", + "contribution_policy_id", + "contribution_policy_version_id", + ): + if not isinstance(getattr(request, name, None), UUID): + raise ContributionPolicyUnavailable("contribution_policy_unavailable") + + @staticmethod + def _custody(request, digest, event_type, prior): + return ContributionPolicyTransitionCustody( + operation_id=request.operation_id, + request_digest=digest, + event_type=event_type, + actor_profile_id=str(request.actor_profile_id), + project_id=str(request.project_id), + contribution_policy_id=request.contribution_policy_id, + contribution_policy_version_id=request.contribution_policy_version_id, + prior_current_version_id=prior.id if prior else None, + ) + + @staticmethod + def _apply_publication(policy, version, prior, actor, custody) -> None: + if prior is not None: + prior.status = "retired" + prior.retired_by = str(actor) + prior.retired_at = custody.occurred_at + prior.last_transition_operation_id = custody.operation_id + version.status = "published" + version.published_by = str(actor) + version.published_at = custody.occurred_at + version.last_transition_operation_id = custody.operation_id + policy.status = "active" + policy.current_published_version_id = version.id + policy.last_transition_operation_id = custody.operation_id + + @staticmethod + def _event(request, digest, event_type, policy, version, prior, custody): + return ContributionPolicyLifecycleEvent( + id=uuid4(), + operation_id=request.operation_id, + publication_custody_operation_id=request.operation_id, + request_digest=digest, + event_type=event_type, + actor_profile_id=str(request.actor_profile_id), + project_id=str(request.project_id), + contribution_policy_id=policy.id, + contribution_policy_version_id=version.id, + version_number=version.version_number, + prior_current_version_id=prior.id if prior else None, + prior_current_version_number=prior.version_number if prior else None, + from_policy_status="active" if event_type == "retired" or prior else "draft", + to_policy_status=policy.status, + from_version_status="draft" if event_type == "published" else "published", + to_version_status=version.status, + occurred_at=custody.occurred_at, + ) + + @staticmethod + def _result(event): + return ContributionPolicyMutationResult( + event_id=event.id, + operation_id=event.operation_id, + request_digest=event.request_digest, + event_type=event.event_type, + actor_profile_id=UUID(event.actor_profile_id), + project_id=UUID(event.project_id), + contribution_policy_id=event.contribution_policy_id, + contribution_policy_version_id=event.contribution_policy_version_id, + version_number=event.version_number, + prior_current_version_id=event.prior_current_version_id, + prior_current_version_number=event.prior_current_version_number, + from_policy_status=event.from_policy_status, + to_policy_status=event.to_policy_status, + from_version_status=event.from_version_status, + to_version_status=event.to_version_status, + occurred_at=event.occurred_at, + ) diff --git a/backend/app/modules/contributions/policy_validation.py b/backend/app/modules/contributions/policy_validation.py index 3de8596b..6b13e8d4 100644 --- a/backend/app/modules/contributions/policy_validation.py +++ b/backend/app/modules/contributions/policy_validation.py @@ -72,8 +72,7 @@ def validate_policy_graph(rules: tuple[PolicyRuleInput, ...]) -> tuple[PolicyRul type(rules) is not tuple or len(rules) != 2 or any(type(rule) is not PolicyRuleInput for rule in rules) - or {rule.contribution_type for rule in rules} - != {"accepted_submission", "completed_review"} + or {rule.contribution_type for rule in rules} != {"accepted_submission", "completed_review"} ): raise ContributionPolicyConflict("contribution_policy_conflict") for rule in rules: diff --git a/backend/app/modules/contributions/repository.py b/backend/app/modules/contributions/repository.py index 4c5bdb70..3a9b8a26 100644 --- a/backend/app/modules/contributions/repository.py +++ b/backend/app/modules/contributions/repository.py @@ -6,11 +6,13 @@ from sqlalchemy import delete, func, select, text from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from sqlalchemy.orm.attributes import set_committed_value from app.modules.contributions.models import ( ContributionAwardDefinition, ContributionPolicy, ContributionPolicyLifecycleEvent, + ContributionPolicyTransitionCustody, ContributionPolicyVersion, ContributionRule, ProjectCompensationUnit, @@ -147,9 +149,7 @@ async def get_selected_version( ) ) ) - return await self.get_version( - UUID(policy.project_id), policy.id, selected, graph=True - ) + return await self.get_version(UUID(policy.project_id), policy.id, selected, graph=True) async def lock_unit( self, project_id: UUID, instrument_type: str, unit_code: str @@ -165,6 +165,55 @@ async def lock_unit( .with_for_update() ) + async def lock_publication_graph( + self, version_id: UUID + ) -> tuple[list[ContributionRule], list[ContributionAwardDefinition]]: + """Lock one draft graph in the canonical publication order.""" + rules = list( + ( + await self._session.scalars( + select(ContributionRule) + .where(ContributionRule.contribution_policy_version_id == version_id) + .order_by(ContributionRule.contribution_type, ContributionRule.id) + .with_for_update() + ) + ).all() + ) + definitions = list( + ( + await self._session.scalars( + select(ContributionAwardDefinition) + .where(ContributionAwardDefinition.contribution_policy_version_id == version_id) + .order_by( + ContributionAwardDefinition.instrument_type, + ContributionAwardDefinition.unit_code, + ContributionAwardDefinition.adapter_binding_id, + ContributionAwardDefinition.id, + ) + .with_for_update() + ) + ).all() + ) + by_rule: dict[UUID, list[ContributionAwardDefinition]] = {rule.id: [] for rule in rules} + for definition in definitions: + by_rule[definition.contribution_rule_id].append(definition) + for rule in rules: + set_committed_value(rule, "award_definitions", by_rule[rule.id]) + return rules, definitions + + async def create_transition_custody(self, custody: ContributionPolicyTransitionCustody) -> None: + """Flush custody first and load its database-owned transition time.""" + self._session.add(custody) + await self._session.flush() + await self._session.refresh(custody) + + async def flush_transition_event(self, event: ContributionPolicyLifecycleEvent) -> None: + """Flush one protected lifecycle transition and its immutable event.""" + await self._session.flush() + self._session.add(event) + await self._session.flush() + await self._session.refresh(event) + async def add_policy_version_event( self, policy: ContributionPolicy, diff --git a/backend/app/modules/contributions/service.py b/backend/app/modules/contributions/service.py index 8f388d29..c5b6318c 100644 --- a/backend/app/modules/contributions/service.py +++ b/backend/app/modules/contributions/service.py @@ -21,6 +21,8 @@ ContributionPolicyProjectEligibilityPort, ContributionPolicyReadAuthorizationPort, ContributionPolicyReadRequest, + ContributionPolicyPublishRequest, + ContributionPolicyRetireRequest, ContributionPolicyUnavailable, ContributionPolicyUpdateDraftRequest, ContributionPolicyView, @@ -43,6 +45,11 @@ validate_policy_graph, validate_policy_name, ) +from app.modules.contributions.policy_publication import ContributionPolicyPublicationService +from app.modules.contributions.policy_mutation_support import ( + begin_and_recover_policy_mutation, + consume_and_close_policy_authority, +) from app.modules.contributions.repository import ContributionPolicyRepository from app.modules.projects.api import ProjectContributionPolicyUnavailable @@ -67,6 +74,26 @@ def __init__( self._mutation_authorization = mutation_authorization or deny self._projects = projects self._bindings = bindings + self._publication = ContributionPolicyPublicationService( + session, + repository=self._repository, + read_authorization=self._read_authorization, + mutation_authorization=self._mutation_authorization, + projects=projects, + bindings=bindings, + ) + + async def publish( + self, request: ContributionPolicyPublishRequest + ) -> ContributionPolicyMutationResult: + """Publish one exact complete draft through the hidden boundary.""" + return await self._publication.publish(request) + + async def retire( + self, request: ContributionPolicyRetireRequest + ) -> ContributionPolicyMutationResult: + """Terminally retire one exact current published version.""" + return await self._publication.retire(request) async def read(self, request: ContributionPolicyReadRequest) -> ContributionPolicyView: """Return one authorized immutable policy-version view.""" @@ -95,7 +122,6 @@ async def create_draft( validate_policy_name(request.name) action: PolicyAction = "contribution.policy.create_draft" digest = policy_request_digest(action, request) - await self._repository.lock_operation(request.operation_id) recovered = await self._recover(action, request, digest) if recovered is not None: return recovered @@ -139,7 +165,7 @@ async def create_draft( facts = self._facts( action, request, digest, policy.id, version.id, from_policy_status, None ) - actor = await self._consume_and_close(facts) + actor = await consume_and_close_policy_authority(self._mutation_authorization, facts) event = self._event( request=request, digest=digest, @@ -163,7 +189,6 @@ async def update_draft( rules = validate_policy_graph(request.rules) action: PolicyAction = "contribution.policy.update_draft" digest = policy_request_digest(action, request) - await self._repository.lock_operation(request.operation_id) recovered = await self._recover(action, request, digest) if recovered is not None: return recovered @@ -190,7 +215,7 @@ async def update_draft( facts = self._facts( action, request, digest, policy.id, version.id, policy.status, version.status ) - actor = await self._consume_and_close(facts) + actor = await consume_and_close_policy_authority(self._mutation_authorization, facts) version.last_updated_by = str(actor) version.last_updated_at = await self._session.scalar(select(func.clock_timestamp())) event = self._event( @@ -225,9 +250,10 @@ def _require_request(self, request: object, expected: type[object], *, mutation: selectors += ("contribution_policy_id",) if type(request) is ContributionPolicyUpdateDraftRequest: selectors += ("contribution_policy_version_id",) - elif type(request) is ContributionPolicyReadRequest and getattr( - request, "contribution_policy_version_id", None - ) is not None: + elif ( + type(request) is ContributionPolicyReadRequest + and getattr(request, "contribution_policy_version_id", None) is not None + ): selectors += ("contribution_policy_version_id",) for name in selectors: if not isinstance(getattr(request, name), UUID): @@ -322,50 +348,19 @@ def _facts( expected_version_status=version_status, ) - async def _consume_and_close( - self, facts: ContributionPolicyMutationAuthorizationFacts - ) -> UUID: - """Prepare, consume, and always close exact mutation authority.""" - prepared = await self._mutation_authorization.prepare_contribution_policy_mutation(facts) - try: - actor = await self._mutation_authorization.consume_contribution_policy_mutation( - prepared, facts - ) - finally: - self._mutation_authorization.close_contribution_policy_mutation(prepared) - if type(actor) is not UUID or actor != facts.actor_profile_id: - raise ContributionPolicyUnavailable("contribution_policy_unavailable") - return actor - async def _recover( self, action: PolicyAction, request: object, digest: str ) -> ContributionPolicyMutationResult | None: """Recover an exact prior result only after current read authorization.""" - event = await self._repository.get_event_by_operation( - cast(UUID, getattr(request, "operation_id")) - ) - if event is None: - return None expected = "draft_created" if action.endswith("create_draft") else "draft_updated" - if ( - event.event_type != expected - or event.request_digest != digest - or event.actor_profile_id != str(getattr(request, "actor_profile_id")) - or event.project_id != str(getattr(request, "project_id")) - ): - raise ContributionPolicyConflict("contribution_policy_conflict") - try: - await self._read_authorization.authorize_contribution_policy_read( - ContributionPolicyReadRequest( - actor_profile_id=UUID(event.actor_profile_id), - project_id=UUID(event.project_id), - contribution_policy_id=event.contribution_policy_id, - contribution_policy_version_id=event.contribution_policy_version_id, - ) - ) - except (ContributionPolicyUnavailable, ContributionPolicyConflict) as exc: - raise ContributionPolicyConflict("contribution_policy_conflict") from exc - return self._result(event) + return await begin_and_recover_policy_mutation( + repository=self._repository, + read_authorization=self._read_authorization, + request=request, + request_digest=digest, + expected_event_type=expected, + result_factory=self._result, + ) async def _prior_version_number( self, policy: ContributionPolicy, version_id: UUID | None @@ -373,9 +368,7 @@ async def _prior_version_number( """Resolve the prior published version number for event lineage.""" if version_id is None: return None - version = await self._repository.get_version( - UUID(policy.project_id), policy.id, version_id - ) + version = await self._repository.get_version(UUID(policy.project_id), policy.id, version_id) return version.version_number if version else None @staticmethod @@ -451,7 +444,9 @@ def _view( quantity=format(item.quantity, "f"), adapter_binding_id=item.adapter_binding_id, ) - for item in sorted(rule.award_definitions, key=lambda value: value.instrument_type) + for item in sorted( + rule.award_definitions, key=lambda value: value.instrument_type + ) ), ) for rule in sorted(version.rules, key=lambda value: value.contribution_type) diff --git a/backend/scripts/behavior_ownership.py b/backend/scripts/behavior_ownership.py index 6c02636e..27de9138 100644 --- a/backend/scripts/behavior_ownership.py +++ b/backend/scripts/behavior_ownership.py @@ -151,6 +151,13 @@ "backend/app/modules/projects/contribution_policy.py", } ) +ARCH_CP04B_CONTRIBUTION_POLICY_TARGETS = frozenset( + { + "backend/app/modules/contributions/policy_graph.py", + "backend/app/modules/contributions/policy_mutation_support.py", + "backend/app/modules/contributions/policy_publication.py", + } +) POL_03A_CALLABLE_TARGETS = frozenset( { "backend/app/modules/authorization/api/project_guide_compilation.py", @@ -345,7 +352,8 @@ def _validate_additive_partition_transition( | ARCH_CP02_ADAPTER_BINDING_TARGETS | ARCH_CP03A_OWNER_ELIGIBILITY_TARGETS | ARCH_CP03B_ADAPTER_BINDING_AUTH_TARGETS - | ARCH_CP04A_CONTRIBUTION_POLICY_TARGETS + | ARCH_CP04A_CONTRIBUTION_POLICY_TARGETS + | ARCH_CP04B_CONTRIBUTION_POLICY_TARGETS | V01_BASELINE_ADDED_TARGETS ) expected_additions = (approved_additions & additions) - set(trusted_targets) diff --git a/backend/scripts/run_test_lanes.py b/backend/scripts/run_test_lanes.py index 28fe2b11..9aba2241 100644 --- a/backend/scripts/run_test_lanes.py +++ b/backend/scripts/run_test_lanes.py @@ -120,6 +120,8 @@ class TestLane: "tests/compensation/test_adapter_binding_service.py", "tests/test_contributions.py", "tests/contributions/test_policy_authorization_atomicity.py", + "tests/contributions/test_cp04b_file_structure.py", + "tests/contributions/test_cp04b_contract_projection.py", "tests/contributions/test_policy_draft_concurrency.py", "tests/contributions/test_policy_draft_create.py", "tests/contributions/test_policy_draft_resources.py", @@ -127,10 +129,18 @@ class TestLane: "tests/contributions/test_policy_draft_update.py", "tests/contributions/test_policy_event_postgresql.py", "tests/contributions/test_policy_integration_postgresql.py", + "tests/contributions/test_policy_lifecycle_postgresql.py", "tests/contributions/test_policy_negative_scope.py", "tests/contributions/test_policy_operation_recovery.py", "tests/contributions/test_policy_owner_ports.py", + "tests/contributions/test_policy_publication_auth_parity.py", + "tests/contributions/test_policy_publication_authorization.py", + "tests/contributions/test_policy_publication_concurrency.py", + "tests/contributions/test_policy_publication_cross_project_postgresql.py", + "tests/contributions/test_policy_publication_recovery.py", + "tests/contributions/test_policy_publish.py", "tests/contributions/test_policy_read.py", + "tests/contributions/test_policy_retire.py", "tests/contributions/test_policy_routes_absent.py", "tests/test_coverage_contract.py", "tests/test_external_service_adapters.py", diff --git a/backend/tests/architecture/test_cp04a_file_structure.py b/backend/tests/architecture/test_cp04a_file_structure.py index 59ddde42..df376ba2 100644 --- a/backend/tests/architecture/test_cp04a_file_structure.py +++ b/backend/tests/architecture/test_cp04a_file_structure.py @@ -33,6 +33,17 @@ def test_cp04a_tests_map_one_to_one_to_contract_behavior_atoms() -> None: required = re.findall(r"`(tests/[^`]+\.py)::([A-Za-z0-9_]+)`", contract) assert required for relative, name in required: + if relative == "tests/contributions/test_policy_negative_scope.py": + if name in { + "test_cp04a_exposes_no_publish_behavior", + "test_cp04a_exposes_no_retire_behavior", + }: + continue + name = { + "test_cp04a_cannot_change_current_version_identity": ( + "test_cp04b_cannot_bypass_publication_to_change_current_version" + ) + }.get(name, name.replace("test_cp04a_", "test_cp04b_", 1)) source = (ROOT / relative).read_text(encoding="utf-8") assert len(re.findall(rf"(?:async )?def {re.escape(name)}\(", source)) == 1 diff --git a/backend/tests/architecture/test_module_boundaries.py b/backend/tests/architecture/test_module_boundaries.py index d8e5d6da..91a6f6ec 100644 --- a/backend/tests/architecture/test_module_boundaries.py +++ b/backend/tests/architecture/test_module_boundaries.py @@ -616,7 +616,11 @@ def test_policy_public_api_exports_immutable_contracts() -> None: "ContributionPolicyCreateDraftRequest", "ContributionPolicyMutationAuthorizationFacts", "ContributionPolicyMutationResult", + "ContributionPolicyPublishAuthorizationFacts", + "ContributionPolicyPublishRequest", "ContributionPolicyReadRequest", + "ContributionPolicyRetireAuthorizationFacts", + "ContributionPolicyRetireRequest", "ContributionPolicyUpdateDraftRequest", "ContributionPolicyView", "PolicyDefinitionInput", diff --git a/backend/tests/authorization/guide_compilation/test_migration_contract.py b/backend/tests/authorization/guide_compilation/test_migration_contract.py index 8d7a1593..17550d2a 100644 --- a/backend/tests/authorization/guide_compilation/test_migration_contract.py +++ b/backend/tests/authorization/guide_compilation/test_migration_contract.py @@ -60,7 +60,7 @@ def test_current_schema_preserves_exact_compilation_registries( isolated_database_env: str, ) -> None: assert asyncio.run(_registry_state(isolated_database_env)) == ( - "0006_contribution_policy_operations", + "0007_contribution_policy_publication_custody", 1, 1, 1, diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index f9a3bec3..118a66c3 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -21,7 +21,7 @@ from scripts.run_isolated_tests import LOOPBACK, NAME_RE, ROLE_RE DDL_LOCK_DIRECTORY = Path("/tmp") -EXPECTED_PUBLIC_SCHEMA_SHA256 = "bbcd8a9a638ec175cf87de26b8344dd62b6481c988fc1b28d507b8d39afd9d71" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "ec3c257ae534e6371d6a9d9214106f07548f7b9bf156648052a667f216ddd2cd" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", @@ -53,6 +53,7 @@ "checker_runs", "compensation_adapter_binding_lifecycle_events", "contribution_policy_lifecycle_events", + "contribution_policy_transition_custody", "contribution_award_definitions", "contribution_policies", "contribution_policy_versions", @@ -111,6 +112,7 @@ "authority_idempotency_records", "compensation_adapter_binding_lifecycle_events", "contribution_policy_lifecycle_events", + "contribution_policy_transition_custody", "guide_mutation_idempotency_records", "guide_sufficiency_mutation_idempotency_records", "guide_source_snapshot_items", diff --git a/backend/tests/contributions/policy_test_support.py b/backend/tests/contributions/policy_test_support.py index 879bd67a..01dae13a 100644 --- a/backend/tests/contributions/policy_test_support.py +++ b/backend/tests/contributions/policy_test_support.py @@ -12,7 +12,7 @@ ) from app.modules.contributions.api import ( ContributionPolicyCreateDraftRequest, - ContributionPolicyMutationAuthorizationFacts, + ContributionPolicyAuthorizationFacts, ContributionPolicyReadRequest, PolicyDefinitionInput, PolicyRuleInput, @@ -43,8 +43,10 @@ class AllowAuthorization: def __init__(self, actor_id: UUID) -> None: self.actor_id = actor_id - self.prepared: list[ContributionPolicyMutationAuthorizationFacts] = [] - self.consumed: list[ContributionPolicyMutationAuthorizationFacts] = [] + self.prepared: list[ContributionPolicyAuthorizationFacts] = [] + self.prepared_handles: list[object] = [] + self.consumed: list[ContributionPolicyAuthorizationFacts] = [] + self.consumed_handles: list[object] = [] self.closed: list[object] = [] self.reads: list[ContributionPolicyReadRequest] = [] @@ -54,15 +56,17 @@ async def authorize_contribution_policy_read( self.reads.append(request) async def prepare_contribution_policy_mutation( - self, facts: ContributionPolicyMutationAuthorizationFacts + self, facts: ContributionPolicyAuthorizationFacts ) -> object: self.prepared.append(facts) - return object() + prepared = object() + self.prepared_handles.append(prepared) + return prepared async def consume_contribution_policy_mutation( - self, prepared: object, facts: ContributionPolicyMutationAuthorizationFacts + self, prepared: object, facts: ContributionPolicyAuthorizationFacts ) -> UUID: - del prepared + self.consumed_handles.append(prepared) self.consumed.append(facts) return self.actor_id @@ -114,12 +118,14 @@ def service_fixture() -> SimpleNamespace: get_policy=AsyncMock(return_value=None), get_version=AsyncMock(return_value=None), get_selected_version=AsyncMock(return_value=None), - lock_unit=AsyncMock( - return_value=SimpleNamespace(status="active") - ), + lock_unit=AsyncMock(return_value=SimpleNamespace(status="active")), replace_graph=AsyncMock(), + lock_publication_graph=AsyncMock(), + create_transition_custody=AsyncMock(), + flush_transition_event=AsyncMock(), ) service._repository = repository # noqa: SLF001 + service._publication._repository = repository # noqa: SLF001 return SimpleNamespace( actor_id=actor_id, project_id=project_id, diff --git a/backend/tests/contributions/test_cp04b_contract_projection.py b/backend/tests/contributions/test_cp04b_contract_projection.py new file mode 100644 index 00000000..94e29316 --- /dev/null +++ b/backend/tests/contributions/test_cp04b_contract_projection.py @@ -0,0 +1,42 @@ +"""Ensure every named CP04B proof node remains executable.""" + +import ast +from pathlib import Path +import re + + +ROOT = Path(__file__).resolve().parents[3] +CONTRACT = ( + ROOT + / ".agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-CP04B-con-policy-publication-behavior.md" +) + + +def test_every_cp04b_acceptance_atom_projects_to_an_exact_test_name() -> None: + text = CONTRACT.read_text(encoding="utf-8") + missing: list[str] = [] + references = re.findall(r"`tests/contributions/([^`:]+\.py)(?:::([^`]+))?`", text) + for filename, selectors in references: + path = ROOT / "backend/tests/contributions" / filename + if not path.exists(): + missing.append(filename) + continue + names = { + node.name + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + for expected in re.findall(r"test_[A-Za-z0-9_]+", selectors): + if expected not in names: + missing.append(f"{filename}::{expected}") + all_names = { + node.name + for path in (ROOT / "backend/tests").rglob("test_*.py") + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + standalone_text = re.sub(r"`[^`]*\.py[^`]*`", "", text) + for expected in set(re.findall(r"test_[A-Za-z0-9_]+", standalone_text)): + if expected not in all_names: + missing.append(expected) + assert missing == [] diff --git a/backend/tests/contributions/test_cp04b_file_structure.py b/backend/tests/contributions/test_cp04b_file_structure.py new file mode 100644 index 00000000..dad465d3 --- /dev/null +++ b/backend/tests/contributions/test_cp04b_file_structure.py @@ -0,0 +1,25 @@ +"""Structural limits for the CP04B implementation surfaces.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +SURFACES = ( + "app/modules/contributions/models.py", + "app/modules/contributions/repository.py", + "app/modules/contributions/service.py", + "app/modules/contributions/policy_graph.py", + "app/modules/contributions/policy_mutation_support.py", + "app/modules/contributions/policy_publication.py", +) + + +def test_cp04b_application_files_remain_below_five_hundred_lines() -> None: + sizes = {path: len((ROOT / path).read_text(encoding="utf-8").splitlines()) for path in SURFACES} + assert all(size < 500 for size in sizes.values()), sizes + + +def test_cp04b_behavior_tests_remain_below_five_hundred_lines() -> None: + tests = Path(__file__).parent.glob("test_policy_*.py") + sizes = {path.name: len(path.read_text(encoding="utf-8").splitlines()) for path in tests} + assert all(size < 500 for size in sizes.values()), sizes diff --git a/backend/tests/contributions/test_policy_authorization_atomicity.py b/backend/tests/contributions/test_policy_authorization_atomicity.py index 994a3ca9..a6d71fbd 100644 --- a/backend/tests/contributions/test_policy_authorization_atomicity.py +++ b/backend/tests/contributions/test_policy_authorization_atomicity.py @@ -41,7 +41,9 @@ async def test_consume_exception_creates_no_effect() -> None: @pytest.mark.asyncio -async def test_close_failure_rolls_back_staged_authorization_evidence_before_product_effect() -> None: +async def test_close_failure_rolls_back_staged_authorization_evidence_before_product_effect() -> ( + None +): fixture = service_fixture() def fail(prepared: object) -> None: diff --git a/backend/tests/contributions/test_policy_draft_concurrency.py b/backend/tests/contributions/test_policy_draft_concurrency.py index dab2d660..2c25b1db 100644 --- a/backend/tests/contributions/test_policy_draft_concurrency.py +++ b/backend/tests/contributions/test_policy_draft_concurrency.py @@ -51,7 +51,9 @@ async def create(index: int) -> bool: outcomes = await asyncio.gather(create(0), create(1)) async with db_session.get_session_factory()() as session: drafts = await session.scalar( - select(func.count()).select_from(ContributionPolicyVersion).where( + select(func.count()) + .select_from(ContributionPolicyVersion) + .where( ContributionPolicyVersion.project_id == str(project_id), ContributionPolicyVersion.status == "draft", ) diff --git a/backend/tests/contributions/test_policy_event_postgresql.py b/backend/tests/contributions/test_policy_event_postgresql.py index 1c35985c..ce42f42e 100644 --- a/backend/tests/contributions/test_policy_event_postgresql.py +++ b/backend/tests/contributions/test_policy_event_postgresql.py @@ -21,7 +21,7 @@ @pytest.mark.asyncio async def test_event_update_is_rejected(policy_database_env: str) -> None: del policy_database_env - _, created, _ = await _exercise_policy() + _, created, _, _ = await _exercise_policy() async with db_session.get_session_factory()() as session: with pytest.raises(DBAPIError): await session.execute( @@ -34,7 +34,7 @@ async def test_event_update_is_rejected(policy_database_env: str) -> None: @pytest.mark.asyncio async def test_event_delete_is_rejected(policy_database_env: str) -> None: del policy_database_env - _, created, _ = await _exercise_policy() + _, created, _, _ = await _exercise_policy() async with db_session.get_session_factory()() as session: with pytest.raises(DBAPIError): await session.execute( @@ -47,7 +47,7 @@ async def test_event_delete_is_rejected(policy_database_env: str) -> None: @pytest.mark.asyncio async def test_event_matches_immutable_mutation_result(policy_database_env: str) -> None: del policy_database_env - project_id, created, updated = await _exercise_policy() + project_id, created, updated, _ = await _exercise_policy() async with db_session.get_session_factory()() as session: for result in (created, updated): event = await session.get(ContributionPolicyLifecycleEvent, result.event_id) @@ -58,16 +58,10 @@ async def test_event_matches_immutable_mutation_result(policy_database_env: str) assert str(result.actor_profile_id) == event.actor_profile_id assert result.project_id == project_id == UUID(event.project_id) assert result.contribution_policy_id == event.contribution_policy_id - assert ( - result.contribution_policy_version_id - == event.contribution_policy_version_id - ) + assert result.contribution_policy_version_id == event.contribution_policy_version_id assert result.version_number == event.version_number assert result.prior_current_version_id == event.prior_current_version_id - assert ( - result.prior_current_version_number - == event.prior_current_version_number - ) + assert result.prior_current_version_number == event.prior_current_version_number assert result.from_policy_status == event.from_policy_status assert result.to_policy_status == event.to_policy_status assert result.from_version_status == event.from_version_status @@ -78,13 +72,11 @@ async def test_event_matches_immutable_mutation_result(policy_database_env: str) @pytest.mark.asyncio async def test_event_actor_matches_authorized_actor(policy_database_env: str) -> None: del policy_database_env - _, created, _ = await _exercise_policy() + _, created, _, _ = await _exercise_policy() async with db_session.get_session_factory()() as session: event = await session.get(ContributionPolicyLifecycleEvent, created.event_id) assert event is not None - version = await session.get( - ContributionPolicyVersion, event.contribution_policy_version_id - ) + version = await session.get(ContributionPolicyVersion, event.contribution_policy_version_id) assert version is not None assert event.actor_profile_id == version.created_by @@ -94,7 +86,7 @@ async def test_event_rejects_invalid_transition_shape( policy_database_env: str, ) -> None: del policy_database_env - _, created, _ = await _exercise_policy() + _, created, _, _ = await _exercise_policy() async with db_session.get_session_factory()() as session: source = await session.get(ContributionPolicyLifecycleEvent, created.event_id) assert source is not None @@ -126,7 +118,7 @@ async def test_event_rejects_duplicate_operation_id( policy_database_env: str, ) -> None: del policy_database_env - _, created, _ = await _exercise_policy() + _, created, _, _ = await _exercise_policy() async with db_session.get_session_factory()() as session: source = await session.get(ContributionPolicyLifecycleEvent, created.event_id) assert source is not None @@ -158,7 +150,7 @@ async def test_event_rejects_null_prior_policy_status( policy_database_env: str, ) -> None: del policy_database_env - _, _, updated = await _exercise_policy() + _, _, updated, _ = await _exercise_policy() async with db_session.get_session_factory()() as session: source = await session.get(ContributionPolicyLifecycleEvent, updated.event_id) assert source is not None @@ -190,36 +182,16 @@ async def test_event_rejects_null_mutation_actor_anchor( policy_database_env: str, ) -> None: del policy_database_env - _, _, updated = await _exercise_policy() + _, _, updated, _ = await _exercise_policy() async with db_session.get_session_factory()() as session: source = await session.get(ContributionPolicyLifecycleEvent, updated.event_id) assert source is not None - await session.execute( - update(ContributionPolicyVersion) - .where(ContributionPolicyVersion.id == source.contribution_policy_version_id) - .values(last_updated_by=None) - ) - session.add( - ContributionPolicyLifecycleEvent( - id=uuid4(), - operation_id=uuid4(), - request_digest="sha256:" + "3" * 64, - event_type="draft_updated", - actor_profile_id=source.actor_profile_id, - project_id=source.project_id, - contribution_policy_id=source.contribution_policy_id, - contribution_policy_version_id=source.contribution_policy_version_id, - version_number=source.version_number, - prior_current_version_id=source.prior_current_version_id, - prior_current_version_number=source.prior_current_version_number, - from_policy_status=source.from_policy_status, - to_policy_status=source.to_policy_status, - from_version_status="draft", - to_version_status="draft", - ) - ) with pytest.raises(DBAPIError): - await session.flush() + await session.execute( + update(ContributionPolicyVersion) + .where(ContributionPolicyVersion.id == source.contribution_policy_version_id) + .values(last_updated_by=None) + ) @pytest.mark.asyncio @@ -245,7 +217,7 @@ async def test_event_rejects_cross_project_policy_version_ownership( policy_database_env: str, ) -> None: del policy_database_env - _, created, _ = await _exercise_policy() + _, created, _, _ = await _exercise_policy() foreign_project = await _seed_project_only() async with db_session.get_session_factory()() as session: source = await session.get(ContributionPolicyLifecycleEvent, created.event_id) diff --git a/backend/tests/contributions/test_policy_integration_postgresql.py b/backend/tests/contributions/test_policy_integration_postgresql.py index afba5ce9..f0adde26 100644 --- a/backend/tests/contributions/test_policy_integration_postgresql.py +++ b/backend/tests/contributions/test_policy_integration_postgresql.py @@ -17,7 +17,9 @@ ContributionPolicyCreateDraftRequest, ContributionPolicyMutationAuthorizationFacts, ContributionPolicyMutationResult, + ContributionPolicyPublishRequest, ContributionPolicyReadRequest, + ContributionPolicyRetireRequest, ContributionPolicyUpdateDraftRequest, PolicyDefinitionInput, PolicyRuleInput, @@ -26,6 +28,7 @@ ContributionAwardDefinition, ContributionPolicy, ContributionPolicyLifecycleEvent, + ContributionPolicyTransitionCustody, ContributionPolicyVersion, ContributionRule, ) @@ -61,6 +64,50 @@ async def replace_graph(self, version, rules, definitions, event) -> None: await super().replace_graph(version, rules, definitions, event) raise RuntimeError("late_product_write_failed") +class _LateFailingPublicationRepository(ContributionPolicyRepository): + def __init__( + self, + session: AsyncSession, + *, + operation_id: UUID, + policy_id: UUID, + version_id: UUID, + ) -> None: + super().__init__(session) + self._operation_id = operation_id + self._policy_id = policy_id + self._version_id = version_id + self.observed_staged_publication = False + async def flush_transition_event(self, event) -> None: + await super().flush_transition_event(event) + policy = await self._session.get(ContributionPolicy, self._policy_id) + version = await self._session.get(ContributionPolicyVersion, self._version_id) + custody_query = select(func.count()).select_from( + ContributionPolicyTransitionCustody + ).where(ContributionPolicyTransitionCustody.operation_id == self._operation_id) + custody_count = await self._session.scalar(custody_query) + event_query = select(func.count()).select_from( + ContributionPolicyLifecycleEvent + ).where(ContributionPolicyLifecycleEvent.operation_id == self._operation_id) + event_count = await self._session.scalar(event_query) + authorization_query = text( + "select count(*) from cp04a_staged_authorization_effects " + "where operation_id=:operation_id" + ) + authorization_count = await self._session.scalar( + authorization_query, + {"operation_id": str(self._operation_id)}, + ) + assert policy is not None and policy.status == "active" + assert policy.current_published_version_id == self._version_id + assert policy.last_transition_operation_id == self._operation_id + assert version is not None and version.status == "published" + assert version.last_transition_operation_id == self._operation_id + assert custody_count == 1 + assert event_count == 1 + assert authorization_count == 1 + self.observed_staged_publication = True + raise RuntimeError("late_publication_write_failed") @pytest.fixture(name="policy_database_env") def _policy_database_env( @@ -76,7 +123,10 @@ def _policy_database_env( async def _exercise_policy() -> tuple[ - UUID, ContributionPolicyMutationResult, ContributionPolicyMutationResult + UUID, + ContributionPolicyMutationResult, + ContributionPolicyMutationResult, + ContributionPolicyMutationResult, ]: project, creator, _, money_binding, _ = await _seed_project() actor_id, project_id = UUID(creator), UUID(project) @@ -125,6 +175,15 @@ async def _exercise_policy() -> tuple[ ), ) ) + published = await service.publish( + ContributionPolicyPublishRequest( + operation_id=uuid4(), + actor_profile_id=actor_id, + project_id=project_id, + contribution_policy_id=created.contribution_policy_id, + contribution_policy_version_id=created.contribution_policy_version_id, + ) + ) view = await service.read( ContributionPolicyReadRequest( actor_profile_id=actor_id, @@ -135,7 +194,7 @@ async def _exercise_policy() -> tuple[ ) assert updated.event_type == "draft_updated" assert len(view.rules) == 2 - return project_id, created, updated + return project_id, created, updated, published async def _seed_project_only() -> str: @@ -157,15 +216,16 @@ async def test_real_service_persists_complete_graph_and_events( policy_database_env: str, ) -> None: del policy_database_env - project_id, created, updated = await _exercise_policy() + project_id, created, updated, published = await _exercise_policy() async with db_session.get_session_factory()() as session: count = await session.scalar( - select(func.count()).select_from(ContributionPolicyLifecycleEvent).where( - ContributionPolicyLifecycleEvent.project_id == str(project_id) - ) + select(func.count()) + .select_from(ContributionPolicyLifecycleEvent) + .where(ContributionPolicyLifecycleEvent.project_id == str(project_id)) ) - assert count == 2 + assert count == 3 assert created.event_id != updated.event_id + assert published.event_type == "published" @pytest.mark.asyncio @@ -205,6 +265,34 @@ async def test_real_repository_conceals_foreign_project_policy( ) +@pytest.mark.asyncio +async def test_real_service_terminally_retires_current_version( + policy_database_env: str, +) -> None: + del policy_database_env + project_id, created, _, published = await _exercise_policy() + authorization = AllowAuthorization(created.actor_profile_id) + async with db_session.get_session_factory()() as session: + async with session.begin(): + service = ContributionPolicyService( + session, + read_authorization=authorization, + mutation_authorization=authorization, + projects=project_contribution_policy_eligibility_port(session), + bindings=policy_adapter_binding_port(session), + ) + retired = await service.retire( + ContributionPolicyRetireRequest( + operation_id=uuid4(), + actor_profile_id=created.actor_profile_id, + project_id=project_id, + contribution_policy_id=created.contribution_policy_id, + contribution_policy_version_id=published.contribution_policy_version_id, + ) + ) + assert retired.event_type == "retired" + + @pytest.mark.asyncio async def test_late_database_failure_rolls_back_product_and_authorization_effects( policy_database_env: str, @@ -248,9 +336,7 @@ async def test_late_database_failure_rolls_back_product_and_authorization_effect actor_profile_id=actor_id, project_id=project_id, contribution_policy_id=created.contribution_policy_id, - contribution_policy_version_id=( - created.contribution_policy_version_id - ), + contribution_policy_version_id=(created.contribution_policy_version_id), rules=( PolicyRuleInput( contribution_type="accepted_submission", @@ -281,6 +367,132 @@ async def test_late_database_failure_rolls_back_product_and_authorization_effect ContributionPolicy, ): assert await session.scalar(select(func.count()).select_from(model)) == 0 - assert await session.scalar( - text("select count(*) from cp04a_staged_authorization_effects") - ) == 0 + assert ( + await session.scalar( + text("select count(*) from cp04a_staged_authorization_effects") + ) + == 0 + ) + + +@pytest.mark.asyncio +async def test_late_publication_failure_rolls_back_custody_state_and_authorization( + policy_database_env: str, +) -> None: + del policy_database_env + project, creator, _, money_binding, _ = await _seed_project() + actor_id, project_id = UUID(creator), UUID(project) + operation_id = uuid4() + async with ( + db_session.get_engine().connect() as connection, + AsyncSession(bind=connection, expire_on_commit=False) as session, + ): + async with session.begin(): + await session.execute( + text( + "create temporary table cp04a_staged_authorization_effects " + "(operation_id uuid primary key) on commit preserve rows" + ) + ) + authorization = _ParticipantAuthorization(actor_id, session) + service = ContributionPolicyService( + session, + read_authorization=authorization, + mutation_authorization=authorization, + projects=project_contribution_policy_eligibility_port(session), + bindings=policy_adapter_binding_port(session), + ) + async with session.begin(): + created = await service.create_draft( + ContributionPolicyCreateDraftRequest( + operation_id=uuid4(), + actor_profile_id=actor_id, + project_id=project_id, + name="Publication rollback policy", + ) + ) + await service.update_draft( + ContributionPolicyUpdateDraftRequest( + operation_id=uuid4(), + actor_profile_id=actor_id, + project_id=project_id, + contribution_policy_id=created.contribution_policy_id, + contribution_policy_version_id=created.contribution_policy_version_id, + rules=( + PolicyRuleInput( + contribution_type="accepted_submission", + compensation_mode="compensated", + definitions=( + PolicyDefinitionInput( + instrument_type=CompensationInstrumentType.MONEY, + unit_code="USD", + quantity="25.50", + adapter_binding_id=money_binding, + ), + ), + ), + PolicyRuleInput( + contribution_type="completed_review", + compensation_mode="unpaid", + ), + ), + ) + ) + repository = _LateFailingPublicationRepository( + session, operation_id=operation_id, + policy_id=created.contribution_policy_id, + version_id=created.contribution_policy_version_id, + ) + service._repository = repository # noqa: SLF001 + service._publication._repository = repository # noqa: SLF001 + with pytest.raises(RuntimeError, match="late_publication_write_failed"): + async with session.begin(): + await service.publish( + ContributionPolicyPublishRequest( + operation_id=operation_id, + actor_profile_id=actor_id, + project_id=project_id, + contribution_policy_id=created.contribution_policy_id, + contribution_policy_version_id=( + created.contribution_policy_version_id + ), + ) + ) + assert authorization.closed[-1] is authorization.prepared_handles[-1] + assert repository.observed_staged_publication is True + async with session.begin(): + policy = await session.get(ContributionPolicy, created.contribution_policy_id) + version = await session.get( + ContributionPolicyVersion, created.contribution_policy_version_id + ) + assert policy is not None and policy.status == "draft" + assert policy.current_published_version_id is None + assert policy.last_transition_operation_id is None + assert version is not None and version.status == "draft" + assert version.last_transition_operation_id is None + assert ( + await session.scalar( + select(func.count()) + .select_from(ContributionPolicyTransitionCustody) + .where(ContributionPolicyTransitionCustody.operation_id == operation_id) + ) + == 0 + ) + assert ( + await session.scalar( + select(func.count()) + .select_from(ContributionPolicyLifecycleEvent) + .where(ContributionPolicyLifecycleEvent.operation_id == operation_id) + ) + == 0 + ) + assert ( + await session.scalar( + text( + "select count(*) from cp04a_staged_authorization_effects " + "where operation_id=:operation_id" + ), + {"operation_id": str(operation_id)}, + ) + == 0 + ) diff --git a/backend/tests/contributions/test_policy_lifecycle_postgresql.py b/backend/tests/contributions/test_policy_lifecycle_postgresql.py new file mode 100644 index 00000000..3984cd84 --- /dev/null +++ b/backend/tests/contributions/test_policy_lifecycle_postgresql.py @@ -0,0 +1,277 @@ +"""Direct PostgreSQL rejection proof for final policy lifecycle custody.""" + +from uuid import uuid4 + +import pytest +from sqlalchemy import text +from sqlalchemy.exc import DBAPIError + +from app.db import session as db_session +from app.adapters.compensation import policy_adapter_binding_port +from app.adapters.projects import project_contribution_policy_eligibility_port +from app.modules.contributions.api import ContributionPolicyRetireRequest +from app.modules.contributions.service import ContributionPolicyService +from tests.contributions.policy_test_support import AllowAuthorization +from tests.contributions.test_policy_integration_postgresql import ( + _exercise_policy, + _policy_database_env, # noqa: F401 +) + + +@pytest.mark.asyncio +async def test_database_rejects_event_without_matching_row_transition( + policy_database_env: str, +) -> None: + del policy_database_env + project_id, created, _, published = await _exercise_policy() + operation_id = uuid4() + with pytest.raises(DBAPIError): + async with db_session.get_session_factory()() as session, session.begin(): + await session.execute( + text( + "insert into contribution_policy_transition_custody " + "(operation_id,request_digest,event_type,actor_profile_id,project_id," + "contribution_policy_id,contribution_policy_version_id) values " + "(:operation_id,:digest,'published',:actor,:project,:policy,:version)" + ), + { + "operation_id": operation_id, + "digest": "sha256:" + "1" * 64, + "actor": str(created.actor_profile_id), + "project": str(project_id), + "policy": created.contribution_policy_id, + "version": published.contribution_policy_version_id, + }, + ) + await session.execute( + text( + "insert into contribution_policy_lifecycle_events " + "(id,operation_id,publication_custody_operation_id,request_digest," + "event_type,actor_profile_id,project_id,contribution_policy_id," + "contribution_policy_version_id,version_number,from_policy_status," + "to_policy_status,from_version_status,to_version_status) values " + "(:id,:operation_id,:operation_id,:digest,'published',:actor,:project," + ":policy,:version,1,'draft','active','draft','published')" + ), + { + "id": uuid4(), + "operation_id": operation_id, + "digest": "sha256:" + "1" * 64, + "actor": str(created.actor_profile_id), + "project": str(project_id), + "policy": created.contribution_policy_id, + "version": published.contribution_policy_version_id, + }, + ) + + +@pytest.mark.asyncio +async def test_database_rejects_final_version_attribution_drift( + policy_database_env: str, +) -> None: + del policy_database_env + _, _, _, published = await _exercise_policy() + with pytest.raises(DBAPIError): + async with db_session.get_session_factory()() as session, session.begin(): + await session.execute( + text( + "update contribution_policy_versions set published_at=clock_timestamp() " + "where id=:version" + ), + {"version": published.contribution_policy_version_id}, + ) + + +@pytest.mark.asyncio +async def test_database_rejects_published_graph_mutation( + policy_database_env: str, +) -> None: + del policy_database_env + _, _, _, published = await _exercise_policy() + with pytest.raises(DBAPIError): + async with db_session.get_session_factory()() as session, session.begin(): + await session.execute( + text( + "update contribution_rules set compensation_mode='unpaid' " + "where contribution_policy_version_id=:version" + ), + {"version": published.contribution_policy_version_id}, + ) + + +@pytest.mark.asyncio +async def test_database_rejects_publication_lifecycle_skip( + policy_database_env: str, +) -> None: + del policy_database_env + _, _, _, published = await _exercise_policy() + with pytest.raises(DBAPIError): + async with db_session.get_session_factory()() as session, session.begin(): + await session.execute( + text("update contribution_policy_versions set status='retired' where id=:version"), + {"version": published.contribution_policy_version_id}, + ) + + +@pytest.mark.asyncio +async def test_database_rejects_published_version_downgrade( + policy_database_env: str, +) -> None: + del policy_database_env + _, _, _, published = await _exercise_policy() + with pytest.raises(DBAPIError): + async with db_session.get_session_factory()() as session, session.begin(): + await session.execute( + text( + "update contribution_policy_versions set status='draft', " + "published_by=null,published_at=null where id=:version" + ), + {"version": published.contribution_policy_version_id}, + ) + + +@pytest.mark.asyncio +async def test_database_rejects_retirement_with_publication_custody( + policy_database_env: str, +) -> None: + del policy_database_env + _, _, _, published = await _exercise_policy() + with pytest.raises(DBAPIError): + async with db_session.get_session_factory()() as session, session.begin(): + await session.execute( + text( + "update contribution_policy_versions v set status='retired'," + "retired_by=c.actor_profile_id,retired_at=c.occurred_at " + "from contribution_policy_transition_custody c " + "where v.id=:version and c.operation_id=" + "v.last_transition_operation_id" + ), + {"version": published.contribution_policy_version_id}, + ) + + +@pytest.mark.asyncio +async def test_database_rejects_forged_publication_attribution( + policy_database_env: str, +) -> None: + del policy_database_env + _, _, _, published = await _exercise_policy() + with pytest.raises(DBAPIError): + async with db_session.get_session_factory()() as session, session.begin(): + await session.execute( + text( + "update contribution_policy_versions set published_by=:actor where id=:version" + ), + {"actor": uuid4(), "version": published.contribution_policy_version_id}, + ) + + +@pytest.mark.asyncio +async def test_database_rejects_forged_retirement_attribution( + policy_database_env: str, +) -> None: + del policy_database_env + project_id, created, _, published = await _exercise_policy() + async with db_session.get_session_factory()() as session, session.begin(): + service = ContributionPolicyService( + session, + read_authorization=AllowAuthorization(created.actor_profile_id), + mutation_authorization=AllowAuthorization(created.actor_profile_id), + projects=project_contribution_policy_eligibility_port(session), + bindings=policy_adapter_binding_port(session), + ) + await service.retire( + ContributionPolicyRetireRequest( + operation_id=uuid4(), + actor_profile_id=created.actor_profile_id, + project_id=project_id, + contribution_policy_id=created.contribution_policy_id, + contribution_policy_version_id=published.contribution_policy_version_id, + ) + ) + with pytest.raises(DBAPIError): + async with db_session.get_session_factory()() as session, session.begin(): + await session.execute( + text("update contribution_policy_versions set retired_by=:actor where id=:version"), + {"actor": uuid4(), "version": published.contribution_policy_version_id}, + ) + + +@pytest.mark.asyncio +async def test_database_rejects_stale_replacement_identity( + policy_database_env: str, +) -> None: + del policy_database_env + project_id, created, _, published = await _exercise_policy() + operation_id = uuid4() + with pytest.raises(DBAPIError): + async with db_session.get_session_factory()() as session, session.begin(): + await session.execute( + text( + "insert into contribution_policy_transition_custody " + "(operation_id,request_digest,event_type,actor_profile_id,project_id," + "contribution_policy_id,contribution_policy_version_id," + "prior_current_version_id) values " + "(:operation_id,:digest,'published',:actor,:project,:policy,:version," + ":stale_prior)" + ), + { + "operation_id": operation_id, + "digest": "sha256:" + "2" * 64, + "actor": str(created.actor_profile_id), + "project": str(project_id), + "policy": created.contribution_policy_id, + "version": published.contribution_policy_version_id, + "stale_prior": uuid4(), + }, + ) + + +@pytest.mark.asyncio +async def test_database_rejects_incomplete_publication_graph( + policy_database_env: str, +) -> None: + del policy_database_env + _, _, _, published = await _exercise_policy() + with pytest.raises(DBAPIError): + async with db_session.get_session_factory()() as session, session.begin(): + await session.execute( + text( + "delete from contribution_rules where contribution_policy_version_id=:version" + ), + {"version": published.contribution_policy_version_id}, + ) + + +async def _assert_lifecycle_history_is_immutable( + policy_database_env: str, + statement: str, +) -> None: + del policy_database_env + await _exercise_policy() + with pytest.raises(DBAPIError): + async with db_session.get_session_factory()() as session, session.begin(): + await session.execute(text(statement)) + + +@pytest.mark.asyncio +async def test_database_rejects_lifecycle_update(policy_database_env: str) -> None: + await _assert_lifecycle_history_is_immutable( + policy_database_env, + "update contribution_policy_lifecycle_events set request_digest='sha256:' || " + "repeat('9',64)", + ) + + +@pytest.mark.asyncio +async def test_database_rejects_lifecycle_delete(policy_database_env: str) -> None: + await _assert_lifecycle_history_is_immutable( + policy_database_env, "delete from contribution_policy_lifecycle_events" + ) + + +@pytest.mark.asyncio +async def test_database_rejects_lifecycle_truncate(policy_database_env: str) -> None: + await _assert_lifecycle_history_is_immutable( + policy_database_env, "truncate contribution_policy_lifecycle_events" + ) diff --git a/backend/tests/contributions/test_policy_negative_scope.py b/backend/tests/contributions/test_policy_negative_scope.py index 3f3bbb04..66cf7cd3 100644 --- a/backend/tests/contributions/test_policy_negative_scope.py +++ b/backend/tests/contributions/test_policy_negative_scope.py @@ -1,22 +1,22 @@ -"""CP04A exposes no later lifecycle or downstream behavior.""" +"""CP04B exposes only hidden policy lifecycle behavior.""" from app.modules.contributions.service import ContributionPolicyService -def test_cp04a_exposes_no_publish_behavior() -> None: - assert not hasattr(ContributionPolicyService, "publish") +def test_cp04b_exposes_publish_behavior() -> None: + assert hasattr(ContributionPolicyService, "publish") -def test_cp04a_exposes_no_retire_behavior() -> None: - assert not hasattr(ContributionPolicyService, "retire") +def test_cp04b_exposes_retire_behavior() -> None: + assert hasattr(ContributionPolicyService, "retire") -def test_cp04a_exposes_only_hidden_policy_commands() -> None: +def test_cp04b_exposes_only_hidden_policy_commands() -> None: public = {name for name in dir(ContributionPolicyService) if not name.startswith("_")} - assert public == {"create_draft", "read", "update_draft"} + assert public == {"create_draft", "publish", "read", "retire", "update_draft"} -def test_cp04a_cannot_change_current_version_identity() -> None: +def test_cp04b_cannot_bypass_publication_to_change_current_version() -> None: assert not hasattr(ContributionPolicyService, "set_current_version") @@ -24,41 +24,41 @@ def _assert_no_downstream_command(name: str) -> None: assert not hasattr(ContributionPolicyService, name) -def test_cp04a_creates_no_project_guide_effect() -> None: +def test_cp04b_creates_no_project_guide_effect() -> None: _assert_no_downstream_command("update_project_guide") -def test_cp04a_creates_no_task_effect() -> None: +def test_cp04b_creates_no_task_effect() -> None: _assert_no_downstream_command("create_task") -def test_cp04a_creates_no_submission_effect() -> None: +def test_cp04b_creates_no_submission_effect() -> None: _assert_no_downstream_command("create_submission") -def test_cp04a_creates_no_review_effect() -> None: +def test_cp04b_creates_no_review_effect() -> None: _assert_no_downstream_command("create_review") -def test_cp04a_creates_no_contribution_record_effect() -> None: +def test_cp04b_creates_no_contribution_record_effect() -> None: _assert_no_downstream_command("create_contribution_record") -def test_cp04a_creates_no_compensation_award_effect() -> None: +def test_cp04b_creates_no_compensation_award_effect() -> None: _assert_no_downstream_command("create_compensation_award") -def test_cp04a_creates_no_fulfillment_effect() -> None: +def test_cp04b_creates_no_fulfillment_effect() -> None: _assert_no_downstream_command("fulfill") -def test_cp04a_creates_no_callback_effect() -> None: +def test_cp04b_creates_no_callback_effect() -> None: _assert_no_downstream_command("callback") -def test_cp04a_creates_no_delivery_effect() -> None: +def test_cp04b_creates_no_delivery_effect() -> None: _assert_no_downstream_command("deliver") -def test_cp04a_creates_no_reputation_effect() -> None: +def test_cp04b_creates_no_reputation_effect() -> None: _assert_no_downstream_command("project_reputation") diff --git a/backend/tests/contributions/test_policy_operation_recovery.py b/backend/tests/contributions/test_policy_operation_recovery.py index a9f685df..a7c20587 100644 --- a/backend/tests/contributions/test_policy_operation_recovery.py +++ b/backend/tests/contributions/test_policy_operation_recovery.py @@ -33,9 +33,7 @@ def recovered_event(fixture: SimpleNamespace, request: object) -> SimpleNamespac actor_profile_id=str(fixture.actor_id), project_id=str(fixture.project_id), contribution_policy_id=getattr(request, "contribution_policy_id", uuid4()), - contribution_policy_version_id=getattr( - request, "contribution_policy_version_id", uuid4() - ), + contribution_policy_version_id=getattr(request, "contribution_policy_version_id", uuid4()), version_number=1, prior_current_version_id=None, prior_current_version_number=None, diff --git a/backend/tests/contributions/test_policy_publication_auth_parity.py b/backend/tests/contributions/test_policy_publication_auth_parity.py new file mode 100644 index 00000000..1758738d --- /dev/null +++ b/backend/tests/contributions/test_policy_publication_auth_parity.py @@ -0,0 +1,109 @@ +"""Public AUTH fact parity for hidden ContributionPolicy publication.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from sqlalchemy.orm.attributes import set_committed_value + +from app.modules.authorization.api import ( + ActionId, + ContributionPolicyPublishFacts, + ContributionPolicyRetireFacts, + contribution_policy_resource_digest, +) +from app.modules.contributions.api import ( + ContributionPolicyPublishAuthorizationFacts, + ContributionPolicyRetireRequest, + ContributionPolicyRetireAuthorizationFacts, +) +from app.modules.contributions.models import ContributionPolicy, ContributionPolicyVersion +from app.modules.contributions.policy_graph import publication_graph_facts +from tests.contributions.policy_test_support import service_fixture +from tests.contributions.test_policy_publish import _install_complete_draft, _request + + +@pytest.mark.asyncio +async def test_publish_facts_match_public_auth_digest() -> None: + fixture = service_fixture() + request = _request(fixture) + _, version = _install_complete_draft(fixture, request) + rules, _ = fixture.repository.lock_publication_graph.return_value + set_committed_value(version, "rules", rules) + graph_digest, binding_ids = publication_graph_facts(version) + expected_facts = ContributionPolicyPublishFacts( + project_id=request.project_id, + contribution_policy_id=request.contribution_policy_id, + contribution_policy_version_id=request.contribution_policy_version_id, + rules_and_definitions_digest=graph_digest, + adapter_binding_ids=binding_ids, + ) + await fixture.service.publish(request) + facts = fixture.authorization.prepared[0] + assert isinstance(facts, ContributionPolicyPublishAuthorizationFacts) + auth_facts = ContributionPolicyPublishFacts( + project_id=facts.project_id, + contribution_policy_id=facts.contribution_policy_id, + contribution_policy_version_id=facts.contribution_policy_version_id, + rules_and_definitions_digest=facts.rules_and_definitions_digest, + adapter_binding_ids=facts.adapter_binding_ids, + ) + expected = contribution_policy_resource_digest( + ActionId("contribution.policy.publish"), expected_facts + ) + actual = contribution_policy_resource_digest(ActionId(facts.action), auth_facts) + assert actual == expected + + +@pytest.mark.asyncio +async def test_retire_facts_match_public_auth_digest() -> None: + fixture = service_fixture() + policy_id, version_id = uuid4(), uuid4() + request = ContributionPolicyRetireRequest( + operation_id=uuid4(), + actor_profile_id=fixture.actor_id, + project_id=fixture.project_id, + contribution_policy_id=policy_id, + contribution_policy_version_id=version_id, + ) + fixture.repository.get_policy.return_value = ContributionPolicy( + id=policy_id, + project_id=str(fixture.project_id), + name="Policy", + status="active", + current_published_version_id=version_id, + created_by=str(fixture.actor_id), + ) + fixture.repository.get_version.return_value = ContributionPolicyVersion( + id=version_id, + contribution_policy_id=policy_id, + project_id=str(fixture.project_id), + version_number=1, + status="published", + created_by=str(fixture.actor_id), + published_by=str(fixture.actor_id), + published_at=datetime.now(UTC), + ) + + async def stamp(custody) -> None: + custody.occurred_at = datetime.now(UTC) + + fixture.repository.create_transition_custody.side_effect = stamp + await fixture.service.retire(request) + facts = fixture.authorization.prepared[0] + assert isinstance(facts, ContributionPolicyRetireAuthorizationFacts) + auth_facts = ContributionPolicyRetireFacts( + project_id=facts.project_id, + contribution_policy_id=facts.contribution_policy_id, + contribution_policy_version_id=facts.contribution_policy_version_id, + ) + expected_facts = ContributionPolicyRetireFacts( + project_id=request.project_id, + contribution_policy_id=request.contribution_policy_id, + contribution_policy_version_id=request.contribution_policy_version_id, + ) + expected = contribution_policy_resource_digest( + ActionId("contribution.policy.retire"), expected_facts + ) + actual = contribution_policy_resource_digest(ActionId(facts.action), auth_facts) + assert actual == expected diff --git a/backend/tests/contributions/test_policy_publication_authorization.py b/backend/tests/contributions/test_policy_publication_authorization.py new file mode 100644 index 00000000..17a6dff2 --- /dev/null +++ b/backend/tests/contributions/test_policy_publication_authorization.py @@ -0,0 +1,306 @@ +"""Fail-closed opaque authorization ordering for policy publication.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from app.modules.contributions.api import ( + ContributionPolicyConflict, + ContributionPolicyRetireRequest, + ContributionPolicyUnavailable, +) +from app.modules.contributions.models import ContributionPolicy, ContributionPolicyVersion +from tests.contributions.policy_test_support import service_fixture +from tests.contributions.test_policy_publish import _install_complete_draft, _request + + +def _install_active_policy(fixture): + policy_id, version_id = uuid4(), uuid4() + request = ContributionPolicyRetireRequest( + operation_id=uuid4(), + actor_profile_id=fixture.actor_id, + project_id=fixture.project_id, + contribution_policy_id=policy_id, + contribution_policy_version_id=version_id, + ) + policy = ContributionPolicy( + id=policy_id, + project_id=str(fixture.project_id), + name="Policy", + status="active", + current_published_version_id=version_id, + created_by=str(fixture.actor_id), + ) + version = ContributionPolicyVersion( + id=version_id, + contribution_policy_id=policy_id, + project_id=str(fixture.project_id), + version_number=1, + status="published", + created_by=str(fixture.actor_id), + published_by=str(fixture.actor_id), + published_at=datetime.now(UTC), + ) + fixture.repository.get_policy.return_value = policy + fixture.repository.get_version.return_value = version + return request, policy, version + + +class _FailureAuthorization: + def __init__(self, actor, *, phase: str) -> None: + self.actor = actor + self.phase = phase + self.closed = 0 + + async def prepare_contribution_policy_mutation(self, facts): + if self.phase == "prepare": + raise ContributionPolicyUnavailable("contribution_policy_unavailable") + return object() + + async def consume_contribution_policy_mutation(self, prepared, facts): + del prepared, facts + if self.phase == "consume": + raise ContributionPolicyUnavailable("contribution_policy_unavailable") + return uuid4() if self.phase == "actor" else self.actor + + def close_contribution_policy_mutation(self, prepared) -> None: + del prepared + self.closed += 1 + if self.phase == "close": + raise ContributionPolicyUnavailable("contribution_policy_unavailable") + + +async def _assert_failure_has_no_effect(phase: str) -> _FailureAuthorization: + fixture = service_fixture() + request = _request(fixture) + policy, version = _install_complete_draft(fixture, request) + authorization = _FailureAuthorization(fixture.actor_id, phase=phase) + fixture.service._publication._mutation_authorization = authorization # noqa: SLF001 + with pytest.raises(ContributionPolicyUnavailable): + await fixture.service.publish(request) + assert policy.status == "draft" and version.status == "draft" + fixture.repository.create_transition_custody.assert_not_awaited() + fixture.repository.flush_transition_event.assert_not_awaited() + return authorization + + +@pytest.mark.asyncio +async def test_publish_prepare_denial_has_no_product_effect() -> None: + authorization = await _assert_failure_has_no_effect("prepare") + assert authorization.closed == 0 + + +@pytest.mark.asyncio +async def test_publish_denies_without_composed_authority() -> None: + authorization = await _assert_failure_has_no_effect("prepare") + assert authorization.closed == 0 + + +@pytest.mark.asyncio +async def test_publish_consume_exception_has_no_product_effect() -> None: + authorization = await _assert_failure_has_no_effect("consume") + assert authorization.closed == 1 + + +@pytest.mark.asyncio +async def test_publish_wrong_consumed_actor_has_no_product_effect() -> None: + authorization = await _assert_failure_has_no_effect("actor") + assert authorization.closed == 1 + + +@pytest.mark.asyncio +async def test_publish_close_failure_has_no_product_effect() -> None: + authorization = await _assert_failure_has_no_effect("close") + assert authorization.closed == 1 + + +@pytest.mark.asyncio +async def test_publish_closes_prepared_authority_exactly_once() -> None: + fixture = service_fixture() + request = _request(fixture) + _install_complete_draft(fixture, request) + await fixture.service.publish(request) + assert len(fixture.authorization.closed) == 1 + + +@pytest.mark.asyncio +async def test_publish_consume_observes_no_staged_product_state() -> None: + fixture = service_fixture() + request = _request(fixture) + policy, version = _install_complete_draft(fixture, request) + + async def consume(prepared, facts): + del prepared, facts + assert policy.status == "draft" and version.status == "draft" + fixture.repository.create_transition_custody.assert_not_awaited() + return fixture.actor_id + + fixture.authorization.consume_contribution_policy_mutation = consume + await fixture.service.publish(request) + + +@pytest.mark.asyncio +async def test_cross_project_policy_publish_is_concealed_without_effect() -> None: + fixture = service_fixture() + request = _request(fixture) + fixture.repository.get_policy.return_value = None + with pytest.raises(ContributionPolicyConflict, match="not_found"): + await fixture.service.publish(request) + assert fixture.authorization.prepared == [] + + +@pytest.mark.asyncio +async def test_cross_project_version_publish_is_concealed_without_effect() -> None: + fixture = service_fixture() + request = _request(fixture) + policy, _ = _install_complete_draft(fixture, request) + fixture.repository.get_policy.return_value = policy + fixture.repository.get_version.return_value = None + with pytest.raises(ContributionPolicyConflict, match="not_found"): + await fixture.service.publish(request) + assert fixture.authorization.prepared == [] + + +@pytest.mark.asyncio +async def test_cross_project_unit_publish_is_concealed_without_effect() -> None: + fixture = service_fixture() + request = _request(fixture) + _install_complete_draft(fixture, request) + fixture.repository.lock_unit.return_value = None + with pytest.raises(ContributionPolicyConflict, match="not_found"): + await fixture.service.publish(request) + assert fixture.authorization.prepared == [] + + +@pytest.mark.asyncio +async def test_cross_project_binding_publish_is_concealed_without_effect() -> None: + fixture = service_fixture() + request = _request(fixture) + _install_complete_draft(fixture, request) + original = fixture.service._bindings.lock_policy_adapter_binding # noqa: SLF001 + + async def wrong_binding(**kwargs): + result = await original(**kwargs) + return type(result)( + project_id=uuid4(), + adapter_binding_id=result.adapter_binding_id, + instrument_type=result.instrument_type, + binding_lifecycle_version=1, + ) + + fixture.service._publication._bindings.lock_policy_adapter_binding = wrong_binding # type: ignore[method-assign] # noqa: SLF001 + with pytest.raises(ContributionPolicyConflict, match="not_found"): + await fixture.service.publish(request) + assert fixture.authorization.prepared == [] + + +@pytest.mark.asyncio +async def test_retire_denies_without_composed_authority() -> None: + fixture = service_fixture() + fixture.service._publication._mutation_authorization = _FailureAuthorization( # noqa: SLF001 + fixture.actor_id, phase="prepare" + ) + request, _, _ = _install_active_policy(fixture) + with pytest.raises(ContributionPolicyUnavailable): + await fixture.service.retire(request) + + +@pytest.mark.asyncio +async def test_retire_closes_prepared_authority_exactly_once() -> None: + fixture = service_fixture() + request, _, _ = _install_active_policy(fixture) + await fixture.service.retire(request) + assert len(fixture.authorization.closed) == 1 + + +@pytest.mark.asyncio +async def test_retire_consume_observes_no_staged_product_state() -> None: + fixture = service_fixture() + request, policy, version = _install_active_policy(fixture) + + async def consume(prepared, facts): + del prepared, facts + assert policy.status == "active" and version.status == "published" + fixture.repository.create_transition_custody.assert_not_awaited() + return fixture.actor_id + + fixture.authorization.consume_contribution_policy_mutation = consume + await fixture.service.retire(request) + + +@pytest.mark.asyncio +async def test_cross_project_policy_retire_is_concealed_without_effect() -> None: + fixture = service_fixture() + from app.modules.contributions.api import ContributionPolicyRetireRequest + + request = ContributionPolicyRetireRequest( + operation_id=uuid4(), + actor_profile_id=fixture.actor_id, + project_id=fixture.project_id, + contribution_policy_id=uuid4(), + contribution_policy_version_id=uuid4(), + ) + fixture.repository.get_policy.return_value = None + with pytest.raises(ContributionPolicyConflict, match="not_found"): + await fixture.service.retire(request) + assert fixture.authorization.prepared == [] + + +@pytest.mark.asyncio +async def test_cross_project_current_version_retire_is_concealed_without_effect() -> None: + fixture = service_fixture() + from app.modules.contributions.api import ContributionPolicyRetireRequest + + policy_id, version_id = uuid4(), uuid4() + request = ContributionPolicyRetireRequest( + operation_id=uuid4(), + actor_profile_id=fixture.actor_id, + project_id=fixture.project_id, + contribution_policy_id=policy_id, + contribution_policy_version_id=version_id, + ) + fixture.repository.get_policy.return_value = ContributionPolicy( + id=policy_id, + project_id=str(fixture.project_id), + name="Policy", + status="active", + current_published_version_id=uuid4(), + created_by=str(fixture.actor_id), + ) + fixture.repository.get_version.return_value = ContributionPolicyVersion( + id=version_id, + contribution_policy_id=policy_id, + project_id=str(fixture.project_id), + version_number=1, + status="published", + created_by=str(fixture.actor_id), + published_by=str(fixture.actor_id), + published_at=datetime.now(UTC), + ) + with pytest.raises(ContributionPolicyConflict, match="not_found"): + await fixture.service.retire(request) + assert fixture.authorization.prepared == [] + + +@pytest.mark.asyncio +async def test_post_close_database_failure_rolls_back_all_effects() -> None: + fixture = service_fixture() + request = _request(fixture) + policy, version = _install_complete_draft(fixture, request) + fixture.repository.create_transition_custody.side_effect = RuntimeError("late_database") + with pytest.raises(RuntimeError, match="late_database"): + await fixture.service.publish(request) + assert len(fixture.authorization.closed) == 1 + assert policy.status == "draft" and version.status == "draft" + + +@pytest.mark.asyncio +async def test_closed_publication_authority_cannot_be_reused() -> None: + fixture = service_fixture() + request = _request(fixture) + _install_complete_draft(fixture, request) + await fixture.service.publish(request) + assert fixture.authorization.prepared_handles == fixture.authorization.consumed_handles + assert fixture.authorization.closed == fixture.authorization.prepared_handles + assert len({id(item) for item in fixture.authorization.closed}) == 1 diff --git a/backend/tests/contributions/test_policy_publication_concurrency.py b/backend/tests/contributions/test_policy_publication_concurrency.py new file mode 100644 index 00000000..459e3515 --- /dev/null +++ b/backend/tests/contributions/test_policy_publication_concurrency.py @@ -0,0 +1,194 @@ +"""Real PostgreSQL fence proofs for ContributionPolicy publication.""" + +import asyncio +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import text +from sqlalchemy.exc import DBAPIError + +from app.adapters.compensation import policy_adapter_binding_port +from app.adapters.projects import project_contribution_policy_eligibility_port +from app.db import session as db_session +from app.modules.contributions.api import ContributionPolicyPublishRequest +from app.modules.contributions.service import ContributionPolicyService +from tests.contributions.policy_test_support import AllowAuthorization +from tests.contributions.test_policy_integration_postgresql import ( + _policy_database_env, # noqa: F401 +) +from tests.test_contributions import _add_rule, _draft_policy, _seed_project + + +class _BlockingAuthorization(AllowAuthorization): + def __init__(self, actor_id: UUID) -> None: + super().__init__(actor_id) + self.entered = asyncio.Event() + self.release = asyncio.Event() + + async def consume_contribution_policy_mutation(self, prepared, facts): + self.entered.set() + await self.release.wait() + return await super().consume_contribution_policy_mutation(prepared, facts) + + +async def _complete_draft( + *, reverse_rule_insertion: bool = False +) -> tuple[ContributionPolicyPublishRequest, UUID, str]: + project, creator, _, binding, _ = await _seed_project() + policy, version = await _draft_policy(project, creator) + rules = ( + (("completed_review", "unpaid", None), ("accepted_submission", "compensated", binding)) + if reverse_rule_insertion + else (("accepted_submission", "compensated", binding), ("completed_review", "unpaid", None)) + ) + for contribution_type, mode, binding_id in rules: + await _add_rule(version, project, contribution_type, mode, binding_id=binding_id) + return ( + ContributionPolicyPublishRequest( + operation_id=uuid4(), + actor_profile_id=UUID(creator), + project_id=UUID(project), + contribution_policy_id=policy, + contribution_policy_version_id=version, + ), + binding, + project, + ) + + +async def _start_paused_publication(request: ContributionPolicyPublishRequest): + authorization = _BlockingAuthorization(request.actor_profile_id) + session = db_session.get_session_factory()() + transaction = session.begin() + await transaction.__aenter__() + service = ContributionPolicyService( + session, + read_authorization=authorization, + mutation_authorization=authorization, + projects=project_contribution_policy_eligibility_port(session), + bindings=policy_adapter_binding_port(session), + ) + task = asyncio.create_task(service.publish(request)) + try: + await asyncio.wait_for(authorization.entered.wait(), timeout=5) + except BaseException: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + await transaction.__aexit__(None, None, None) + await session.close() + raise + return authorization, session, transaction, task + + +async def _finish_publication(authorization, session, transaction, task) -> None: + authorization.release.set() + try: + await task + finally: + await transaction.__aexit__(None, None, None) + await session.close() + + +async def _assert_write_waits(request, statement: str, parameters: dict) -> None: + authorization, session, transaction, task = await _start_paused_publication(request) + try: + async with db_session.get_session_factory()() as contender, contender.begin(): + await contender.execute(text("set local lock_timeout='100ms'")) + with pytest.raises(DBAPIError): + await contender.execute(text(statement), parameters) + finally: + await _finish_publication(authorization, session, transaction, task) + + +@pytest.mark.asyncio +async def test_child_mutation_waits_for_publication_graph_fence( + policy_database_env: str, +) -> None: + del policy_database_env + request, _, _ = await _complete_draft() + await _assert_write_waits( + request, + "update contribution_rules set compensation_mode=compensation_mode " + "where contribution_policy_version_id=:version", + {"version": request.contribution_policy_version_id}, + ) + + +@pytest.mark.asyncio +async def test_binding_suspension_waits_for_publication_owner_fence( + policy_database_env: str, +) -> None: + del policy_database_env + request, binding, _ = await _complete_draft() + await _assert_write_waits( + request, + "update project_compensation_adapter_bindings " + "set lifecycle_version=lifecycle_version where id=:binding", + {"binding": binding}, + ) + + +@pytest.mark.asyncio +async def test_unit_retirement_waits_for_publication_owner_fence( + policy_database_env: str, +) -> None: + del policy_database_env + request, _, project = await _complete_draft() + await _assert_write_waits( + request, + "update project_compensation_units set status=status " + "where project_id=:project and instrument_type='money' and unit_code='USD'", + {"project": project}, + ) + + +@pytest.mark.asyncio +async def test_competing_publications_serialize_before_authorization( + policy_database_env: str, +) -> None: + del policy_database_env + request, _, _ = await _complete_draft() + authorization, session, transaction, task = await _start_paused_publication(request) + contender_auth = AllowAuthorization(request.actor_profile_id) + try: + async with db_session.get_session_factory()() as contender, contender.begin(): + await contender.execute(text("set local lock_timeout='100ms'")) + service = ContributionPolicyService( + contender, + read_authorization=contender_auth, + mutation_authorization=contender_auth, + projects=project_contribution_policy_eligibility_port(contender), + bindings=policy_adapter_binding_port(contender), + ) + with pytest.raises(DBAPIError): + await service.publish( + ContributionPolicyPublishRequest( + operation_id=uuid4(), + actor_profile_id=request.actor_profile_id, + project_id=request.project_id, + contribution_policy_id=request.contribution_policy_id, + contribution_policy_version_id=request.contribution_policy_version_id, + ) + ) + assert contender_auth.prepared == [] + finally: + await _finish_publication(authorization, session, transaction, task) + + +@pytest.mark.asyncio +async def test_reverse_ordered_graphs_use_one_lock_order( + policy_database_env: str, +) -> None: + del policy_database_env + request, _, _ = await _complete_draft(reverse_rule_insertion=True) + authorization = AllowAuthorization(request.actor_profile_id) + async with db_session.get_session_factory()() as session, session.begin(): + service = ContributionPolicyService( + session, + read_authorization=authorization, + mutation_authorization=authorization, + projects=project_contribution_policy_eligibility_port(session), + bindings=policy_adapter_binding_port(session), + ) + await service.publish(request) + assert len(authorization.consumed) == 1 diff --git a/backend/tests/contributions/test_policy_publication_cross_project_postgresql.py b/backend/tests/contributions/test_policy_publication_cross_project_postgresql.py new file mode 100644 index 00000000..cc3d5c8c --- /dev/null +++ b/backend/tests/contributions/test_policy_publication_cross_project_postgresql.py @@ -0,0 +1,74 @@ +"""Real-row cross-project isolation proof for policy finalization.""" + +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import func, select + +from app.adapters.compensation import policy_adapter_binding_port +from app.adapters.projects import project_contribution_policy_eligibility_port +from app.db import session as db_session +from app.modules.contributions.api import ( + ContributionPolicyConflict, + ContributionPolicyPublishRequest, + ContributionPolicyRetireRequest, +) +from app.modules.contributions.models import ContributionPolicyLifecycleEvent +from app.modules.contributions.service import ContributionPolicyService +from tests.contributions.policy_test_support import AllowAuthorization +from tests.contributions.test_policy_integration_postgresql import ( + _exercise_policy, + _policy_database_env, # noqa: F401 + _seed_project_only, +) + + +@pytest.mark.asyncio +async def test_cross_project_finalization_conceals_real_foreign_rows( + policy_database_env: str, +) -> None: + del policy_database_env + owner_project, created, _, published = await _exercise_policy() + foreign_project = UUID(await _seed_project_only()) + authorization = AllowAuthorization(created.actor_profile_id) + before = len(authorization.consumed) + async with db_session.get_session_factory()() as session: + service = ContributionPolicyService( + session, + read_authorization=authorization, + mutation_authorization=authorization, + projects=project_contribution_policy_eligibility_port(session), + bindings=policy_adapter_binding_port(session), + ) + for request in ( + ContributionPolicyPublishRequest( + operation_id=uuid4(), + actor_profile_id=created.actor_profile_id, + project_id=foreign_project, + contribution_policy_id=created.contribution_policy_id, + contribution_policy_version_id=published.contribution_policy_version_id, + ), + ContributionPolicyRetireRequest( + operation_id=uuid4(), + actor_profile_id=created.actor_profile_id, + project_id=foreign_project, + contribution_policy_id=created.contribution_policy_id, + contribution_policy_version_id=published.contribution_policy_version_id, + ), + ): + with pytest.raises(ContributionPolicyConflict, match="not_found"): + async with session.begin(): + if isinstance(request, ContributionPolicyPublishRequest): + await service.publish(request) + else: + await service.retire(request) + assert len(authorization.consumed) == before + async with db_session.get_session_factory()() as session: + assert ( + await session.scalar( + select(func.count()) + .select_from(ContributionPolicyLifecycleEvent) + .where(ContributionPolicyLifecycleEvent.project_id == str(owner_project)) + ) + == 3 + ) diff --git a/backend/tests/contributions/test_policy_publication_recovery.py b/backend/tests/contributions/test_policy_publication_recovery.py new file mode 100644 index 00000000..e443e259 --- /dev/null +++ b/backend/tests/contributions/test_policy_publication_recovery.py @@ -0,0 +1,221 @@ +"""Immutable duplicate recovery for policy publication and retirement.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from app.modules.contributions.api import ( + ContributionPolicyConflict, + ContributionPolicyPublishRequest, + ContributionPolicyRetireRequest, + ContributionPolicyUnavailable, +) +from app.modules.contributions.models import ContributionPolicyLifecycleEvent +from app.modules.contributions.policy_validation import policy_request_digest +from tests.contributions.policy_test_support import service_fixture + + +def _event(request, action: str, event_type: str) -> ContributionPolicyLifecycleEvent: + return ContributionPolicyLifecycleEvent( + id=uuid4(), + operation_id=request.operation_id, + publication_custody_operation_id=request.operation_id, + request_digest=policy_request_digest(action, request), + event_type=event_type, + actor_profile_id=str(request.actor_profile_id), + project_id=str(request.project_id), + contribution_policy_id=request.contribution_policy_id, + contribution_policy_version_id=request.contribution_policy_version_id, + version_number=1, + prior_current_version_id=None, + prior_current_version_number=None, + from_policy_status="draft", + to_policy_status="active", + from_version_status="draft", + to_version_status="published", + occurred_at=datetime.now(UTC), + ) + + +@pytest.mark.asyncio +async def test_duplicate_publish_returns_original_event_after_authorized_read() -> None: + fixture = service_fixture() + request = ContributionPolicyPublishRequest( + operation_id=uuid4(), + actor_profile_id=fixture.actor_id, + project_id=fixture.project_id, + contribution_policy_id=uuid4(), + contribution_policy_version_id=uuid4(), + ) + event = _event(request, "contribution.policy.publish", "published") + fixture.repository.get_event_by_operation.return_value = event + result = await fixture.service.publish(request) + assert result.event_id == event.id + + +@pytest.mark.asyncio +async def test_duplicate_retire_returns_original_event_after_authorized_read() -> None: + fixture = service_fixture() + request = ContributionPolicyRetireRequest( + operation_id=uuid4(), + actor_profile_id=fixture.actor_id, + project_id=fixture.project_id, + contribution_policy_id=uuid4(), + contribution_policy_version_id=uuid4(), + ) + event = _event(request, "contribution.policy.retire", "retired") + event.from_policy_status = "active" + event.to_policy_status = "retired" + event.from_version_status = "published" + event.to_version_status = "retired" + fixture.repository.get_event_by_operation.return_value = event + result = await fixture.service.retire(request) + assert result.event_id == event.id + + +@pytest.mark.asyncio +async def test_duplicate_publish_requires_current_owner_ports_before_recovery() -> None: + fixture = service_fixture() + request = ContributionPolicyPublishRequest( + operation_id=uuid4(), + actor_profile_id=fixture.actor_id, + project_id=fixture.project_id, + contribution_policy_id=uuid4(), + contribution_policy_version_id=uuid4(), + ) + fixture.repository.get_event_by_operation.return_value = _event( + request, "contribution.policy.publish", "published" + ) + fixture.service._publication._bindings = None # noqa: SLF001 + with pytest.raises(ContributionPolicyUnavailable): + await fixture.service.publish(request) + fixture.repository.lock_operation.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_duplicate_retire_requires_current_project_port_before_recovery() -> None: + fixture = service_fixture() + request = ContributionPolicyRetireRequest( + operation_id=uuid4(), + actor_profile_id=fixture.actor_id, + project_id=fixture.project_id, + contribution_policy_id=uuid4(), + contribution_policy_version_id=uuid4(), + ) + fixture.repository.get_event_by_operation.return_value = _event( + request, "contribution.policy.retire", "retired" + ) + fixture.service._publication._projects = None # noqa: SLF001 + with pytest.raises(ContributionPolicyUnavailable): + await fixture.service.retire(request) + fixture.repository.lock_operation.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_duplicate_digest_mismatch_is_concealed() -> None: + fixture = service_fixture() + request = ContributionPolicyPublishRequest( + operation_id=uuid4(), + actor_profile_id=fixture.actor_id, + project_id=fixture.project_id, + contribution_policy_id=uuid4(), + contribution_policy_version_id=uuid4(), + ) + event = _event(request, "contribution.policy.publish", "published") + event.request_digest = "sha256:" + "0" * 64 + fixture.repository.get_event_by_operation.return_value = event + with pytest.raises(ContributionPolicyConflict): + await fixture.service.publish(request) + + +@pytest.mark.asyncio +async def test_duplicate_recovery_skips_mutation_authorization() -> None: + fixture = service_fixture() + request = ContributionPolicyPublishRequest( + operation_id=uuid4(), + actor_profile_id=fixture.actor_id, + project_id=fixture.project_id, + contribution_policy_id=uuid4(), + contribution_policy_version_id=uuid4(), + ) + fixture.repository.get_event_by_operation.return_value = _event( + request, "contribution.policy.publish", "published" + ) + await fixture.service.publish(request) + assert fixture.authorization.prepared == [] + fixture.repository.create_transition_custody.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_duplicate_recovery_read_denial_is_concealed() -> None: + fixture = service_fixture() + request = ContributionPolicyPublishRequest( + operation_id=uuid4(), + actor_profile_id=fixture.actor_id, + project_id=fixture.project_id, + contribution_policy_id=uuid4(), + contribution_policy_version_id=uuid4(), + ) + fixture.repository.get_event_by_operation.return_value = _event( + request, "contribution.policy.publish", "published" + ) + + async def deny(request): + del request + raise ContributionPolicyConflict("contribution_policy_not_found") + + fixture.authorization.authorize_contribution_policy_read = deny + with pytest.raises(ContributionPolicyConflict): + await fixture.service.publish(request) + + +@pytest.mark.asyncio +async def test_duplicate_recovery_creates_no_second_product_effect() -> None: + fixture = service_fixture() + request = ContributionPolicyPublishRequest( + operation_id=uuid4(), + actor_profile_id=fixture.actor_id, + project_id=fixture.project_id, + contribution_policy_id=uuid4(), + contribution_policy_version_id=uuid4(), + ) + fixture.repository.get_event_by_operation.return_value = _event( + request, "contribution.policy.publish", "published" + ) + await fixture.service.publish(request) + fixture.repository.create_transition_custody.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_duplicate_recovery_creates_no_second_authorization_evidence() -> None: + fixture = service_fixture() + request = ContributionPolicyPublishRequest( + operation_id=uuid4(), + actor_profile_id=fixture.actor_id, + project_id=fixture.project_id, + contribution_policy_id=uuid4(), + contribution_policy_version_id=uuid4(), + ) + fixture.repository.get_event_by_operation.return_value = _event( + request, "contribution.policy.publish", "published" + ) + await fixture.service.publish(request) + assert fixture.authorization.consumed == [] + + +@pytest.mark.asyncio +async def test_duplicate_recovery_creates_no_second_lifecycle_event() -> None: + fixture = service_fixture() + request = ContributionPolicyPublishRequest( + operation_id=uuid4(), + actor_profile_id=fixture.actor_id, + project_id=fixture.project_id, + contribution_policy_id=uuid4(), + contribution_policy_version_id=uuid4(), + ) + fixture.repository.get_event_by_operation.return_value = _event( + request, "contribution.policy.publish", "published" + ) + await fixture.service.publish(request) + fixture.repository.flush_transition_event.assert_not_awaited() diff --git a/backend/tests/contributions/test_policy_publish.py b/backend/tests/contributions/test_policy_publish.py new file mode 100644 index 00000000..59897f45 --- /dev/null +++ b/backend/tests/contributions/test_policy_publish.py @@ -0,0 +1,189 @@ +"""Hidden ContributionPolicy publication behavior.""" + +from datetime import UTC, datetime +from decimal import Decimal +from uuid import uuid4 + +import pytest + +from app.modules.contributions.api import ContributionPolicyPublishRequest +from app.modules.contributions.models import ( + ContributionAwardDefinition, + ContributionPolicy, + ContributionPolicyVersion, + ContributionRule, +) +from tests.contributions.policy_test_support import service_fixture + + +def _request(fixture) -> ContributionPolicyPublishRequest: + return ContributionPolicyPublishRequest( + operation_id=uuid4(), + actor_profile_id=fixture.actor_id, + project_id=fixture.project_id, + contribution_policy_id=uuid4(), + contribution_policy_version_id=uuid4(), + ) + + +def _install_complete_draft( + fixture, request +) -> tuple[ContributionPolicy, ContributionPolicyVersion]: + policy = ContributionPolicy( + id=request.contribution_policy_id, + project_id=str(request.project_id), + name="Policy", + status="draft", + created_by=str(request.actor_profile_id), + ) + version = ContributionPolicyVersion( + id=request.contribution_policy_version_id, + contribution_policy_id=policy.id, + project_id=str(request.project_id), + version_number=1, + status="draft", + created_by=str(request.actor_profile_id), + ) + accepted = ContributionRule( + id=uuid4(), + contribution_policy_version_id=version.id, + project_id=policy.project_id, + contribution_type="accepted_submission", + compensation_mode="compensated", + ) + reviewed = ContributionRule( + id=uuid4(), + contribution_policy_version_id=version.id, + project_id=policy.project_id, + contribution_type="completed_review", + compensation_mode="unpaid", + ) + definition = ContributionAwardDefinition( + id=uuid4(), + contribution_rule_id=accepted.id, + contribution_policy_version_id=version.id, + project_id=policy.project_id, + contribution_type="accepted_submission", + instrument_type="money", + unit_code="USD", + quantity=Decimal("10"), + adapter_binding_id=uuid4(), + ) + accepted.award_definitions = [definition] + reviewed.award_definitions = [] + fixture.repository.get_policy.return_value = policy + fixture.repository.get_version.return_value = version + fixture.repository.lock_publication_graph.return_value = ([accepted, reviewed], [definition]) + + async def stamp(custody) -> None: + custody.occurred_at = datetime.now(UTC) + + fixture.repository.create_transition_custody.side_effect = stamp + return policy, version + + +@pytest.mark.asyncio +async def test_publish_uses_locked_server_owned_graph() -> None: + fixture = service_fixture() + request = _request(fixture) + policy, version = _install_complete_draft(fixture, request) + + result = await fixture.service.publish(request) + + assert result.event_type == "published" + assert policy.current_published_version_id == version.id + assert fixture.authorization.prepared[0].adapter_binding_ids + + +@pytest.mark.asyncio +async def test_publish_closes_authority_before_product_transition() -> None: + fixture = service_fixture() + request = _request(fixture) + policy, _ = _install_complete_draft(fixture, request) + + async def observe(custody) -> None: + assert len(fixture.authorization.closed) == 1 + assert policy.status == "draft" + custody.occurred_at = datetime.now(UTC) + + fixture.repository.create_transition_custody.side_effect = observe + await fixture.service.publish(request) + + +@pytest.mark.asyncio +async def test_publish_is_hidden_deny_default() -> None: + fixture = service_fixture() + request = _request(fixture) + _install_complete_draft(fixture, request) + fixture.service._publication._mutation_authorization = fixture.service.__class__( # noqa: SLF001 + fixture.service._session # noqa: SLF001 + )._mutation_authorization # noqa: SLF001 + + with pytest.raises(RuntimeError, match="contribution_policy_unavailable"): + await fixture.service.publish(request) + + fixture.repository.create_transition_custody.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_replacement_publication_is_one_atomic_event() -> None: + fixture = service_fixture() + request = _request(fixture) + policy, version = _install_complete_draft(fixture, request) + prior = ContributionPolicyVersion( + id=uuid4(), + contribution_policy_id=policy.id, + project_id=policy.project_id, + version_number=1, + status="published", + created_by=str(fixture.actor_id), + published_by=str(fixture.actor_id), + published_at=datetime.now(UTC), + ) + version.version_number = 2 + policy.status = "active" + policy.current_published_version_id = prior.id + fixture.repository.get_version.side_effect = [version, prior] + result = await fixture.service.publish(request) + assert result.prior_current_version_id == prior.id + assert prior.status == "retired" + fixture.repository.flush_transition_event.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_replacement_preserves_prior_content_and_frozen_references() -> None: + fixture = service_fixture() + request = _request(fixture) + policy, version = _install_complete_draft(fixture, request) + prior = ContributionPolicyVersion( + id=uuid4(), + contribution_policy_id=policy.id, + project_id=policy.project_id, + version_number=1, + status="published", + created_by=str(fixture.actor_id), + published_by=str(fixture.actor_id), + published_at=datetime.now(UTC), + ) + marker = ContributionRule( + id=uuid4(), + contribution_policy_version_id=prior.id, + project_id=prior.project_id, + contribution_type="accepted_submission", + compensation_mode="unpaid", + ) + prior.rules = [marker] + version.version_number = 2 + policy.status = "active" + policy.current_published_version_id = prior.id + fixture.repository.get_version.side_effect = [version, prior] + await fixture.service.publish(request) + assert prior.rules == [marker] + + +@pytest.mark.asyncio +async def test_publish_request_cannot_carry_caller_graph_facts() -> None: + fixture = service_fixture() + request = _request(fixture) + assert not hasattr(request, "rules_and_definitions_digest") + assert not hasattr(request, "adapter_binding_ids") diff --git a/backend/tests/contributions/test_policy_retire.py b/backend/tests/contributions/test_policy_retire.py new file mode 100644 index 00000000..4ccbe960 --- /dev/null +++ b/backend/tests/contributions/test_policy_retire.py @@ -0,0 +1,103 @@ +"""Hidden terminal ContributionPolicy retirement behavior.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from app.modules.contributions.api import ( + ContributionPolicyConflict, + ContributionPolicyRetireRequest, +) +from app.modules.contributions.models import ContributionPolicy, ContributionPolicyVersion +from tests.contributions.policy_test_support import service_fixture + + +@pytest.mark.asyncio +async def test_retire_is_terminal_and_attributable() -> None: + fixture = service_fixture() + policy_id, version_id = uuid4(), uuid4() + request = ContributionPolicyRetireRequest( + operation_id=uuid4(), + actor_profile_id=fixture.actor_id, + project_id=fixture.project_id, + contribution_policy_id=policy_id, + contribution_policy_version_id=version_id, + ) + policy = ContributionPolicy( + id=policy_id, + project_id=str(fixture.project_id), + name="Policy", + status="active", + current_published_version_id=version_id, + created_by=str(fixture.actor_id), + ) + version = ContributionPolicyVersion( + id=version_id, + contribution_policy_id=policy_id, + project_id=str(fixture.project_id), + version_number=1, + status="published", + created_by=str(fixture.actor_id), + published_by=str(fixture.actor_id), + published_at=datetime.now(UTC), + ) + fixture.repository.get_policy.return_value = policy + fixture.repository.get_version.return_value = version + + async def stamp(custody) -> None: + custody.occurred_at = datetime.now(UTC) + + fixture.repository.create_transition_custody.side_effect = stamp + result = await fixture.service.retire(request) + + assert result.event_type == "retired" + assert policy.status == version.status == "retired" + assert policy.last_transition_operation_id == request.operation_id + + +def test_retire_blocks_future_selection_without_rewriting_history() -> None: + request_fields = ContributionPolicyRetireRequest.__dataclass_fields__ + assert "project_guide_id" not in request_fields + assert "task_id" not in request_fields + assert "submission_id" not in request_fields + + +@pytest.mark.asyncio +async def test_retired_aggregate_cannot_be_resurrected() -> None: + fixture = service_fixture() + policy_id, version_id = uuid4(), uuid4() + request = ContributionPolicyRetireRequest( + operation_id=uuid4(), + actor_profile_id=fixture.actor_id, + project_id=fixture.project_id, + contribution_policy_id=policy_id, + contribution_policy_version_id=version_id, + ) + policy = ContributionPolicy( + id=policy_id, + project_id=str(fixture.project_id), + name="Policy", + status="retired", + current_published_version_id=version_id, + created_by=str(fixture.actor_id), + retired_by=str(fixture.actor_id), + retired_at=datetime.now(UTC), + ) + version = ContributionPolicyVersion( + id=version_id, + contribution_policy_id=policy_id, + project_id=str(fixture.project_id), + version_number=1, + status="retired", + created_by=str(fixture.actor_id), + published_by=str(fixture.actor_id), + published_at=datetime.now(UTC), + retired_by=str(fixture.actor_id), + retired_at=datetime.now(UTC), + ) + fixture.repository.get_policy.return_value = policy + fixture.repository.get_version.return_value = version + with pytest.raises(ContributionPolicyConflict, match="not_found"): + await fixture.service.retire(request) + assert fixture.authorization.prepared == [] diff --git a/backend/tests/projects/guide_compilation/test_migration_contract.py b/backend/tests/projects/guide_compilation/test_migration_contract.py index b24be4d9..378053f5 100644 --- a/backend/tests/projects/guide_compilation/test_migration_contract.py +++ b/backend/tests/projects/guide_compilation/test_migration_contract.py @@ -48,7 +48,7 @@ def test_current_schema_preserves_guide_compilation_schema( isolated_database_env: str, ) -> None: assert asyncio.run(_schema_state(isolated_database_env)) == ( - "0006_contribution_policy_operations", + "0007_contribution_policy_publication_custody", True, 4, 1, diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index c968abe7..5f8c534a 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -24,7 +24,7 @@ ) from scripts.schema_baseline_sql import split_sql_statements -HEAD_REVISION = "0006_contribution_policy_operations" +HEAD_REVISION = "0007_contribution_policy_publication_custody" BASELINE_REVISION = "0001_v01_baseline" RECREATE_GUIDANCE = "Workstream v0.1 requires a fresh database; recreate this database" pytestmark = pytest.mark.postgres_schema_contract @@ -84,6 +84,7 @@ def test_v01_graph_has_one_root_and_head() -> None: assert [revision.revision for revision in revisions] == [ HEAD_REVISION, + "0006_contribution_policy_operations", "0005_compensation_adapter_identity", "0004_compensation_adapter_binding_lifecycle", "0003_submission_lineage", diff --git a/backend/tests/test_contributions.py b/backend/tests/test_contributions.py index 929a708f..50154d42 100644 --- a/backend/tests/test_contributions.py +++ b/backend/tests/test_contributions.py @@ -15,6 +15,8 @@ from adapter_binding_fixtures import created_binding_events from app.core.config import get_settings +from app.adapters.compensation import policy_adapter_binding_port +from app.adapters.projects import project_contribution_policy_eligibility_port from app.db import session as db_session from app.db.base import Base from app.modules.actors.models import ActorIdentityLink, ActorProfile @@ -27,12 +29,19 @@ ContributionRule, ProjectCompensationUnit, ) +from app.modules.contributions.api import ( + ContributionPolicyConflict, + ContributionPolicyPublishRequest, + ContributionPolicyRetireRequest, +) +from app.modules.contributions.service import ContributionPolicyService from app.modules.contributions.schemas import ( ContributionAwardDefinitionInput, ISO_4217_CURRENCY_CODES, ProjectCompensationUnitInput, ) from project_create_fixtures import insert_historical_project +from tests.contributions.policy_test_support import AllowAuthorization @pytest.fixture @@ -229,14 +238,59 @@ async def _add_rule( return rule_id -async def _publish_version(version_id: UUID, creator_id: str) -> None: - async with db_session.get_session_factory()() as session: - await session.execute( - update(ContributionPolicyVersion) - .where(ContributionPolicyVersion.id == version_id) - .values(status="published", published_by=creator_id, published_at=datetime.now(UTC)) +async def _publish_version( + version_id: UUID, + creator_id: str, + *, + lock_timeout: str | None = None, +) -> None: + async with db_session.get_session_factory()() as session, session.begin(): + if lock_timeout is not None: + await session.execute(text("set local lock_timeout=:timeout"), {"timeout": lock_timeout}) + version = await session.get(ContributionPolicyVersion, version_id) + assert version is not None + actor_id = UUID(creator_id) + authorization = AllowAuthorization(actor_id) + service = ContributionPolicyService( + session, + read_authorization=authorization, + mutation_authorization=authorization, + projects=project_contribution_policy_eligibility_port(session), + bindings=policy_adapter_binding_port(session), + ) + await service.publish( + ContributionPolicyPublishRequest( + operation_id=uuid4(), + actor_profile_id=actor_id, + project_id=UUID(version.project_id), + contribution_policy_id=version.contribution_policy_id, + contribution_policy_version_id=version.id, + ) + ) + + +async def _retire_version(version_id: UUID, creator_id: str) -> None: + async with db_session.get_session_factory()() as session, session.begin(): + version = await session.get(ContributionPolicyVersion, version_id) + assert version is not None + actor_id = UUID(creator_id) + authorization = AllowAuthorization(actor_id) + service = ContributionPolicyService( + session, + read_authorization=authorization, + mutation_authorization=authorization, + projects=project_contribution_policy_eligibility_port(session), + bindings=policy_adapter_binding_port(session), + ) + await service.retire( + ContributionPolicyRetireRequest( + operation_id=uuid4(), + actor_profile_id=actor_id, + project_id=UUID(version.project_id), + contribution_policy_id=version.contribution_policy_id, + contribution_policy_version_id=version.id, + ) ) - await session.commit() async def _complete_published_policy( @@ -266,6 +320,7 @@ def test_contribution_models_register_closed_canonical_tables() -> None: "name", "status", "current_published_version_id", + "last_transition_operation_id", "created_by", "created_at", "retired_by", @@ -285,6 +340,7 @@ def test_contribution_models_register_closed_canonical_tables() -> None: "retired_at", "last_updated_by", "last_updated_at", + "last_transition_operation_id", } assert { "iso_4217_currency_codes", @@ -405,13 +461,6 @@ async def test_complete_policy_graph_can_publish_and_become_active( policy_id, version_id, _ = await _complete_published_policy( project_id, creator_id, money_binding_id ) - async with db_session.get_session_factory()() as session: - await session.execute( - update(ContributionPolicy) - .where(ContributionPolicy.id == policy_id) - .values(status="active", current_published_version_id=version_id) - ) - await session.commit() async with db_session.get_session_factory()() as session: policy = await session.get(ContributionPolicy, policy_id) assert policy is not None @@ -425,12 +474,12 @@ async def test_active_policy_cannot_select_a_draft_version( project_id, creator_id, _, _, _ = await _seed_project() policy_id, version_id = await _draft_policy(project_id, creator_id) async with db_session.get_session_factory()() as session: - await session.execute( - update(ContributionPolicy) - .where(ContributionPolicy.id == policy_id) - .values(status="active", current_published_version_id=version_id) - ) with pytest.raises(DBAPIError): + await session.execute( + update(ContributionPolicy) + .where(ContributionPolicy.id == policy_id) + .values(status="active", current_published_version_id=version_id) + ) await session.commit() @@ -447,7 +496,7 @@ async def test_incomplete_or_unpaid_definition_graph_cannot_publish( "unpaid", binding_id=money_binding_id, ) - with pytest.raises(DBAPIError): + with pytest.raises(ContributionPolicyConflict, match="contribution_policy_conflict"): await _publish_version(version_id, creator_id) @@ -460,7 +509,7 @@ async def test_each_incomplete_policy_graph_shape_is_rejected( for only_type in ("accepted_submission", "completed_review"): _, version_id = await _draft_policy(project_id, creator_id) await _add_rule(version_id, project_id, only_type, "unpaid") - with pytest.raises(DBAPIError): + with pytest.raises(ContributionPolicyConflict, match="contribution_policy_conflict"): await _publish_version(version_id, creator_id) _, unpaid_version_id = await _draft_policy(project_id, creator_id) @@ -472,13 +521,13 @@ async def test_each_incomplete_policy_graph_shape_is_rejected( binding_id=money_binding_id, ) await _add_rule(unpaid_version_id, project_id, "completed_review", "unpaid") - with pytest.raises(DBAPIError): + with pytest.raises(ContributionPolicyConflict, match="contribution_policy_conflict"): await _publish_version(unpaid_version_id, creator_id) _, empty_version_id = await _draft_policy(project_id, creator_id) await _add_rule(empty_version_id, project_id, "accepted_submission", "compensated") await _add_rule(empty_version_id, project_id, "completed_review", "unpaid") - with pytest.raises(DBAPIError): + with pytest.raises(ContributionPolicyConflict, match="contribution_policy_conflict"): await _publish_version(empty_version_id, creator_id) _, duplicate_version_id = await _draft_policy(project_id, creator_id) @@ -506,6 +555,7 @@ async def test_each_incomplete_policy_graph_shape_is_rejected( ) await session.flush() + @pytest.mark.asyncio async def test_definition_binding_must_match_project_and_instrument( contribution_database_env: str, @@ -716,17 +766,7 @@ async def test_retired_policy_version_and_children_are_immutable( _, version_id, unpaid_rule_id = await _complete_published_policy( project_id, creator_id, money_binding_id ) - async with db_session.get_session_factory()() as session: - await session.execute( - update(ContributionPolicyVersion) - .where(ContributionPolicyVersion.id == version_id) - .values( - status="retired", - retired_by=creator_id, - retired_at=datetime.now(UTC), - ) - ) - await session.commit() + await _retire_version(version_id, creator_id) statements = ( update(ContributionPolicyVersion) @@ -823,31 +863,30 @@ async def test_published_definition_cannot_be_reparented_to_draft_version( @pytest.mark.asyncio async def test_active_policy_race_has_one_winner(contribution_database_env: str) -> None: project_id, creator_id, _, money_binding_id, _ = await _seed_project() - first_policy, first_version, _ = await _complete_published_policy( - project_id, creator_id, money_binding_id - ) - second_policy, second_version, _ = await _complete_published_policy( - project_id, creator_id, money_binding_id - ) + versions: list[UUID] = [] + for _ in range(2): + _, version_id = await _draft_policy(project_id, creator_id) + await _add_rule( + version_id, + project_id, + "accepted_submission", + "compensated", + binding_id=money_binding_id, + ) + await _add_rule(version_id, project_id, "completed_review", "unpaid") + versions.append(version_id) - async def activate(policy_id: UUID, version_id: UUID) -> str: - async with db_session.get_session_factory()() as session: - try: - await session.execute( - update(ContributionPolicy) - .where(ContributionPolicy.id == policy_id) - .values(status="active", current_published_version_id=version_id) - ) - await session.commit() - return "active" - except (DBAPIError, IntegrityError): - await session.rollback() - return "conflict" + async def activate(version_id: UUID) -> str: + try: + await _publish_version(version_id, creator_id) + return "active" + except (DBAPIError, IntegrityError): + return "conflict" assert sorted( await asyncio.gather( - activate(first_policy, first_version), - activate(second_policy, second_version), + activate(versions[0]), + activate(versions[1]), ) ) == ["active", "conflict"] @@ -882,19 +921,8 @@ async def add_missing_rule() -> str: async def publish_without_visible_rule() -> str: await rule_staged.wait() try: - async with db_session.get_session_factory()() as session: - await session.execute(text("set local lock_timeout='500ms'")) - await session.execute( - update(ContributionPolicyVersion) - .where(ContributionPolicyVersion.id == version_id) - .values( - status="published", - published_by=creator_id, - published_at=datetime.now(UTC), - ) - ) - await session.commit() - return "published" + await _publish_version(version_id, creator_id, lock_timeout="500ms") + return "published" except DBAPIError: return "rejected" finally: @@ -924,19 +952,33 @@ async def test_publish_lock_rejects_concurrent_draft_child_mutation( child_attempted = asyncio.Event() async def publish() -> str: - async with db_session.get_session_factory()() as session: - await session.execute( - update(ContributionPolicyVersion) - .where(ContributionPolicyVersion.id == version_id) - .values( - status="published", - published_by=creator_id, - published_at=datetime.now(UTC), + class PausingAuthorization(AllowAuthorization): + async def consume_contribution_policy_mutation(self, prepared, facts): + publish_locked.set() + await child_attempted.wait() + return await super().consume_contribution_policy_mutation(prepared, facts) + + async with db_session.get_session_factory()() as session, session.begin(): + actor_id = UUID(creator_id) + authorization = PausingAuthorization(actor_id) + service = ContributionPolicyService( + session, + read_authorization=authorization, + mutation_authorization=authorization, + projects=project_contribution_policy_eligibility_port(session), + bindings=policy_adapter_binding_port(session), + ) + version = await session.get(ContributionPolicyVersion, version_id) + assert version is not None + await service.publish( + ContributionPolicyPublishRequest( + operation_id=uuid4(), + actor_profile_id=actor_id, + project_id=UUID(project_id), + contribution_policy_id=version.contribution_policy_id, + contribution_policy_version_id=version.id, ) ) - publish_locked.set() - await child_attempted.wait() - await session.commit() return "published" async def mutate_child() -> str: diff --git a/backend/tests/test_review_lease_persistence.py b/backend/tests/test_review_lease_persistence.py index 638ea60a..231079e3 100644 --- a/backend/tests/test_review_lease_persistence.py +++ b/backend/tests/test_review_lease_persistence.py @@ -12,6 +12,7 @@ from sqlalchemy import text, update from sqlalchemy.exc import DBAPIError, IntegrityError +from app.adapters.contributions import contribution_policy_service from app.core.config import get_settings from app.db import session as db_session from app.db.base import Base @@ -23,10 +24,12 @@ ContributionPolicyVersion, ContributionRule, ) +from app.modules.contributions.api import ContributionPolicyPublishRequest from app.modules.reviews.models import ReviewLease, ReviewQueueEntry from app.modules.reviews.repository import ReviewQueueRepository from app.modules.reviews.schemas import ReviewLeaseInput from project_create_fixtures import grant_system_project_manager +from tests.contributions.policy_test_support import AllowAuthorization from tests.test_review_queue_persistence import ( _additional_reviewable_submission, _queue_input, @@ -164,16 +167,21 @@ async def _published_reviewer_policy(session, project_id: str, actor_id: str) -> ] ) await session.flush() - await session.execute( - update(ContributionPolicyVersion) - .where(ContributionPolicyVersion.id == version_id) - .values( - status="published", - published_by=actor_id, - published_at=datetime.now(UTC), + actor_uuid = UUID(actor_id) + authorization = AllowAuthorization(actor_uuid) + await contribution_policy_service( + session, + read_authorization=authorization, + mutation_authorization=authorization, + ).publish( + ContributionPolicyPublishRequest( + operation_id=uuid4(), + actor_profile_id=actor_uuid, + project_id=UUID(project_id), + contribution_policy_id=policy_id, + contribution_policy_version_id=version_id, ) ) - await session.flush() return version_id diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index 027be5a5..aa1af502 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -1094,6 +1094,7 @@ Fields: - `name` - `status`: `draft | active | retired` - `current_published_version_id` +- `last_transition_operation_id` - `created_by` - `created_at` - `retired_by` @@ -1116,6 +1117,8 @@ Fields: - `status`: `draft | published | retired` - `last_updated_by` and `last_updated_at` for the authorized complete-draft replacement anchor +- `last_transition_operation_id` for database-verifiable publication or + retirement custody - publication and retirement actor/timestamp fields Published and retired versions are immutable. Guide activation binds one @@ -1131,6 +1134,19 @@ published-version identity, exact policy/version state transition, and database-owned occurrence time. PostgreSQL rejects event update, delete, and truncate. Exact operation replay may return only this immutable result after a fresh authorized read; it never reconstructs success from mutable policy state. +Publish and retire events additionally carry a conditional publication-custody +operation reference; draft events never carry it. + +## ContributionPolicyTransitionCustody + +Each publish or retire operation owns one immutable custody row binding the +operation/request digest, actor, project, policy, target version, optional prior +current version, event type, and database-generated occurrence time. Deferred +PostgreSQL guards require the aggregate, affected versions, and lifecycle event +to carry the same unique transition operation. Replacement publication retires +the prior version with the same actor and time. Custody rows reject update, +delete, and truncate and roll back with product state and authorization +evidence. ## ContributionRule diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index e3797c61..3de96b22 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -141,8 +141,9 @@ review. Their presence does not change the implemented-on-`main` list above. CP03B is complete with exact hidden Finance Authority read/PREP activation for those four actions. CP04 is a non-executable split parent. CP04A is complete with hidden read/create/update-draft behavior and immutable - operation/event custody. The remaining order is CP04B hidden publish/retire - behavior, then CP05 ContributionPolicy activation; + operation/event custody. CP04B adds hidden publish/retire behavior with + immutable transition custody. The remaining order begins with CP05 + ContributionPolicy activation; expose CON validation; then bind one exact published, complete, binding-valid ContributionPolicyVersion at guide activation, and lock it on each task before that task becomes claimable. TaskAssignment diff --git a/docs/spec_contribution_compensation.md b/docs/spec_contribution_compensation.md index e9a82da8..3c5dfd09 100644 --- a/docs/spec_contribution_compensation.md +++ b/docs/spec_contribution_compensation.md @@ -812,6 +812,15 @@ opaque prepare/consume/close participant, complete graph replacement, and one immutable recoverable lifecycle event. Production authorization remains deny-default and all five policy actions remain unavailable until CP05. +CP04B adds hidden publication and terminal retirement. Publication locks and +revalidates the complete server-owned graph, project units, and adapter +bindings, computes canonical digest/binding facts, consumes and closes opaque +authority before product effects, and atomically replaces any prior published +version. Database-owned transition custody binds every affected row and event +to one actor, operation, and timestamp. The behavior remains route-unreachable +and deny-default; CP05 alone installs the real AUTH adapter and activates the +five policy actions. + | ActionId | PermissionId | Principal / target | Protocol | Feature owner | |---|---|---|---:|---| | `outbox.dispatch` | proposed `outbox.dispatch` | fixed dispatcher / claimed event | T | CON-02B | @@ -823,8 +832,8 @@ deny-default and all five policy actions remain unavailable until CP05. | `contribution.policy.read` | `compensation.policy.manage` | Finance / policy version | Q | WS-ARCH-001-CP04A (hidden behavior; CP01B registration custody; unavailable) | | `contribution.policy.create_draft` | `compensation.policy.manage` | Finance / policy collection | T | WS-ARCH-001-CP04A (hidden behavior; CP01B registration custody; unavailable) | | `contribution.policy.update_draft` | `compensation.policy.manage` | Finance / draft version | T | WS-ARCH-001-CP04A (hidden behavior; CP01B registration custody; unavailable) | -| `contribution.policy.publish` | `compensation.policy.manage` | Finance / complete draft | T | WS-ARCH-001-CP01B (registered, unavailable) | -| `contribution.policy.retire` | `compensation.policy.manage` | Finance / published version | T | WS-ARCH-001-CP01B (registered, unavailable) | +| `contribution.policy.publish` | `compensation.policy.manage` | Finance / complete draft | T | WS-ARCH-001-CP04B (hidden behavior; CP01B registration custody; unavailable) | +| `contribution.policy.retire` | `compensation.policy.manage` | Finance / published version | T | WS-ARCH-001-CP04B (hidden behavior; CP01B registration custody; unavailable) | | `compensation.fulfillment.report` | proposed `compensation.fulfillment.report` | exact bound service / award and binding | T | CON-08B | | `contribution.read_self` | `contribution.read_self` | contributor / own record | Q | CON-10A | | `contribution.read_project` | `contribution.read_project` | eligible AdminRole / project records | Q | CON-10A |