diff --git a/.agent-loop/CURRENT_STATE.md b/.agent-loop/CURRENT_STATE.md index 539296fc1..6ecc24f0b 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-CP01C registered and corrected unavailable CON authority facts; PLAN4 planned incremental debt retirement | Merge the CP02 executable-contract correction, then implement hidden route-unreachable adapter-binding behavior only after explicit human approval; PLAN4 keeps debt retirement delivery-coupled without blocking features on unrelated frozen debt; the wider PLAN2 path still targets durable `allow_review`, and `02I` remains later | +| [WS-ARCH-001](initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md) | Complete through `WS-ARCH-001-02H`; CP01A-CP02 establish unavailable authority facts and hidden CON adapter-binding behavior; PLAN4 planned incremental debt retirement | Expand and review CP03 before activating the exact adapter-binding actions; PLAN4 keeps debt retirement delivery-coupled without blocking features on unrelated frozen debt; the wider PLAN2 path still targets durable `allow_review`, and `02I` remains later | | [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; adapter-binding persistence, ContributionPolicy persistence, shared lifecycle audit, and unavailable AUTH registrations are merged | Establish hidden adapter-binding lifecycle behavior through CP02 before CP03 activation; later complete guide-activation validation/persistence before task readiness, preserving immutable attempt lineage through Submission and ReviewLease | +| [WS-CON-001](initiatives/WS-CON-001-contribution-compensation-boundary/STATUS.md) | Active delivery initiative; CP02 is complete on merge with hidden adapter-binding lifecycle behavior; ContributionPolicy persistence, shared lifecycle audit, and unavailable AUTH registrations are merged | Expand and review CP03 before exact adapter-binding activation; later complete guide-activation validation/persistence before task readiness, preserving immutable attempt lineage through Submission and ReviewLease | | [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 | @@ -98,8 +98,8 @@ selection wording. Do not start historical CON-05A directly. The CP01-CP09 sequence owns 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 has now been -expanded into a proposed executable contract; later children remain +clean v0.1 legacy economic-path removal in that order. CP02 is complete on +merge while all four actions remain unavailable; later children remain non-executable until their current-main contracts are expanded. - WS-ARCH-001-PLAN3 is planned; its planning merge changed no runtime behavior. @@ -110,8 +110,8 @@ non-executable until their current-main contracts are expanded. actions registered. - WS-ARCH-001-CP01C is complete on merge with corrected unavailable adapter-binding identity and lifecycle-version facts. -- WS-ARCH-001-CP02 has a proposed executable contract. Its planning correction - must merge and receive explicit human approval before implementation. +- WS-ARCH-001-CP02 is complete on merge with hidden, route-unreachable behavior, + exact lifecycle history, and deny-default production composition. - WS-ARCH-001-CP03 is proposed and non-executable. - WS-ARCH-001-CP04 is proposed and non-executable. - WS-ARCH-001-CP05 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 56744ea39..7e4fcdc74 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 @@ -21,8 +21,8 @@ | `WS-ARCH-001-CP01A` | AUTH adapter-binding unavailable registration | L1 | Complete on merge; four actions remain planned/unavailable | | `WS-ARCH-001-CP01B` | AUTH ContributionPolicy unavailable registration | L1 | Complete on merge; five actions remain planned/unavailable | | `WS-ARCH-001-CP01C` | AUTH adapter-binding fact correction | L1 | Complete on merge; corrects unavailable facts before CON behavior | -| `WS-ARCH-001-CP02` | CON hidden adapter-binding behavior | L1 | Proposed executable contract; implementation requires planning merge and explicit human approval | -| `WS-ARCH-001-CP03` | AUTH exact adapter-binding activation | L1 | Proposed skeleton after CP02 evidence | +| `WS-ARCH-001-CP02` | CON hidden adapter-binding behavior | L1 | Complete on merge; route-unreachable and deny-default while actions remain unavailable | +| `WS-ARCH-001-CP03` | AUTH exact adapter-binding activation | L1 | Next proposed skeleton; requires a current-main executable contract | | `WS-ARCH-001-CP04` | CON hidden ContributionPolicy behavior | L1 | Proposed skeleton after CP03 | | `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 | 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 994c64f7e..1c365ba4c 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 @@ -1,7 +1,7 @@ # Status: WS-ARCH-001 Modular Monolith Boundaries - Initiative state: active; boundary foundation complete -- Runtime behavior changed: no +- Runtime behavior changed: hidden CON adapter-binding behavior only; no route or action activation - Canonical target: nine business modules and three supporting modules - Recovery model: freeze exact debt, prohibit growth, repair touched capabilities incrementally @@ -53,8 +53,8 @@ behavior must precede owner-separated guide/task lineage. CP01 is the non-executable split parent; CP01A is complete on merge with its four actions registered/unavailable, CP01B is complete on merge with five policy actions - registered/unavailable. CP02 now has a proposed executable contract for the - hidden CON behavior; CP03-CP09 remain non-executable skeletons. + registered/unavailable. CP02 is complete on merge with hidden CON behavior + while its AUTH actions remain unavailable; CP03-CP09 remain non-executable skeletons. - [WS-ARCH-001-PLAN4](chunks/WS-ARCH-001-PLAN4-incremental-debt-retirement.md) is planned on merge and changes no runtime. It makes the existing incremental strangler operational: unrelated frozen debt cannot block delivery, new debt is @@ -75,11 +75,10 @@ ContributionPolicy registration. - WS-ARCH-001-CP01C is complete on merge: unavailable adapter-binding facts match exact binding identity and lifecycle generation without a unit alias. -- WS-ARCH-001-CP02 is the next planned implementation boundary after this planning - correction merges and receives explicit human approval: hidden, - route-unreachable adapter-binding behavior while its AUTH actions remain - unavailable. -- WS-ARCH-001-CP03 is proposed: exact adapter-binding activation. +- WS-ARCH-001-CP02 is complete on merge: hidden, route-unreachable + adapter-binding behavior while its AUTH actions remain unavailable. +- WS-ARCH-001-CP03 is the next proposed boundary: exact adapter-binding + activation after its current-main contract is expanded and reviewed. - WS-ARCH-001-CP04 is proposed: hidden ContributionPolicy behavior. - WS-ARCH-001-CP05 is proposed: exact ContributionPolicy activation. - WS-ARCH-001-CP06 is proposed: CON policy-validation port. diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-CP02-con-binding-behavior.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-CP02-con-binding-behavior.md index af0e68035..1185b9627 100644 --- a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-CP02-con-binding-behavior.md +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-CP02-con-binding-behavior.md @@ -1,7 +1,7 @@ # Chunk Contract: WS-ARCH-001-CP02 — Hidden Adapter-Binding Behavior -Status: proposed executable contract; implementation may begin only after this -planning correction merges and receives explicit human approval. Risk: L1. +Status: complete on implementation merge; CP03 is the next activation gate. +Risk: L1. ## Goal @@ -57,50 +57,77 @@ backend/app/modules/compensation/models.py backend/app/modules/compensation/repository.py backend/app/modules/compensation/service.py backend/app/modules/compensation/schemas.py +backend/app/db/models.py (new lifecycle-event metadata registration only) backend/app/modules/actors/api/__init__.py backend/app/modules/actors/api/compensation_adapter.py backend/app/modules/projects/api/__init__.py backend/app/modules/projects/api/compensation_binding.py backend/tests/compensation/test_adapter_binding_api.py +backend/tests/compensation/test_adapter_binding_authorization_failures.py +backend/tests/compensation/test_adapter_binding_database_guards.py +backend/tests/adapter_binding_fixtures.py (shared CP02 PostgreSQL facts only) +backend/tests/adapter_binding_test_support.py (strict CP02 test doubles only) backend/tests/compensation/test_adapter_binding_service.py +backend/tests/compensation/test_adapter_binding_recovery.py backend/tests/compensation/test_adapter_binding_persistence.py backend/tests/architecture/test_module_boundaries.py +backend/tests/compensation/test_adapter_binding_partition.py (exact CP02 additive partition proof only) +backend/tests/compensation/test_adapter_binding_owner_fences.py backend/tests/test_compensation.py (removal/replacement of superseded 03A proof only) backend/tests/conftest.py (schema fingerprint and reset inventory parity only) +backend/scripts/run_test_lanes.py (exact new-test lane assignment only) backend/tests/test_database_reset.py (new-table reset/guard proof only) backend/tests/test_alembic.py (HEAD_REVISION and 0003-to-0004 empty/non-empty upgrade proof only) -.ci/behavior-ownership/compensation/adapter-binding-behavior.json (new exact ownership entry only) +backend/tests/authorization/guide_compilation/test_migration_contract.py (current-head assertion only) +backend/tests/projects/guide_compilation/test_migration_contract.py (current-head assertion only) +backend/tests/test_contributions.py (canonical binding-event seed parity only) +.ci/behavior-ownership/lifecycle/adapter-binding-behavior.json (new exact ownership entry only) .ci/behavior-ownership/partition.v1.json (exact generated partition parity only) +backend/scripts/behavior_ownership.py (exact CP02 additive target allowlist only) .agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json (generated parity only) backend/alembic/baseline/v01_approved_manifest_delta.json (generated parity only) backend/alembic/baseline/v01_baseline_manifest.json (generated parity only) backend/alembic/baseline/v01_pre_reset_source_manifest.json (generated parity only) backend/alembic/baseline/v01_schema.sql (generated parity only; do not rewrite 0001) docs/spec_contribution_compensation.md -docs/spec_authorization_service.md -docs/operations_authorization_service.md +docs/architecture_data_model.md docs/roadmap_status.md .agent-loop/CURRENT_STATE.md .agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/CHUNK_MAP.md .agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md -.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/RISKS.md -.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/DECISIONS.md .agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-CP02-con-binding-behavior.md -.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-CP02-plan-review-evidence.md .agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-CP02-implementation-review-evidence.md .agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-CP02-external-review-response.md .agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-CP02-pr-trust-bundle.md .agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/CHUNK_MAP.md .agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/STATUS.md .agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/AUTHORIZATION_HANDOFF.md -.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/CONFORMANCE_MATRIX.md -.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/RUNTIME_VERIFICATION.md ``` The implementation plan review must remove any unused allowance before code is written. New files remain subject to the repository size and behavior-ownership gates. +## Reviewed scope correction + +The original merged CP02 contract did not contain every file now required by +the implementation. The expanded allowances are a correction discovered while +executing and reviewing the chunk, not evidence of prior approval. They require +explicit human acceptance with this PR. The additions are limited to: + +- ORM metadata registration for the lifecycle-event table; +- shared CP02 PostgreSQL fixtures and strict authorization doubles; +- split recovery, owner-fence, partition, authorization-failure, and database- + guard tests needed to keep each test file bounded by one primary behavior; +- exact semantic-lane and behavior-ownership registration for those tests; +- current-head assertions and legacy contribution fixtures affected by `0004`; +- generated schema/test-structure parity artifacts; and +- the canonical data-model document and CP02 review/trust records. + +Human approval of PR #337 accepts this corrected scope. It does not authorize +routes, activation, ContributionPolicy behavior, retirement, fulfillment, or +any change outside the explicit allowed-file list above. + ## Not allowed ```text @@ -328,7 +355,8 @@ then observe either the committed event or no event after rollback. 6. Recompose facts from the locked row, prepare, consume, and close from the unconditional `finally` around consume. 7. Only after successful close, transition `suspended/N -> active/N+1`, clear - current suspension fields, + current suspension fields, persist the database-verifiable `resumed_by` + attribution anchor (with database-owned `resumed_at`), and append one immutable resumed event containing the prior suspension and exact actor/version transition. 8. Flush only. @@ -362,7 +390,9 @@ deletes fail at the database boundary. A resumed event must reference the exact immediately preceding suspended event for the same binding, and `prior_suspended_event.to_lifecycle_version` must equal `resumed_event.from_lifecycle_version`. That immutable suspended event remains the source of -the cleared suspension actor/time. Events are product lifecycle truth, not +the cleared suspension actor/time. PostgreSQL also requires every resumed +event actor to equal the binding row's `resumed_by` anchor for that transition; +service-supplied event attribution alone is insufficient. Events are product lifecycle truth, not authorization decisions. CP03 separately proves AUTH allowed-decision evidence is staged atomically by the real PREP consumer in the same transaction. Planned actions cannot produce allowed AUTH evidence before CP03 activation. @@ -390,7 +420,8 @@ It must: - replace `compensation_binding_updates_deferred` with a fail-closed trigger; - permit only `active/N -> suspended/N+1` and `suspended/N -> active/N+1`; -- require database-owned transition timestamps and exact attribution fields; +- require database-owned transition timestamps and exact created, suspended, + and resumed attribution fields; - reject version skips, same-state updates, retired transitions, arbitrary updates, and changes to binding/project/`instrument_type`/adapter/route/creation identity; @@ -436,7 +467,9 @@ It must: produce no product effect; later product failure rolls back staged AUTH evidence and cannot make the already-closed object reusable. - Active replacement blocks resume of an older suspended binding. -- PostgreSQL independently rejects forbidden row/event mutations. +- PostgreSQL independently rejects same-state updates, version skips, retired + transitions, immutable identity changes, malformed or cross-binding resume + lineage, forged resume attribution, and every event update/delete/truncate. - Rollback removes binding changes, lifecycle events, and staged participant effects together. - No denial produces allowed AUTH evidence. @@ -495,6 +528,6 @@ changes beyond the exact adapter-binding aggregate and its immutable events. ## Merge state -- Outcome on merge: `planned` -- The planned CP02 boundary has an executable implementation contract. -- Runtime behavior changed by this planning correction: no. +- Outcome on merge: `complete` +- Hidden CON adapter-binding behavior and immutable lifecycle history are implemented. +- Runtime remains route-unreachable and deny-default; all four AUTH actions remain unavailable. diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-CP02-external-review-response.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-CP02-external-review-response.md index 41b840e36..6d3db20ca 100644 --- a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-CP02-external-review-response.md +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-CP02-external-review-response.md @@ -90,6 +90,91 @@ The earlier exact-diff reviewer passes were marked superseded before fresh internal re-review. The latest CodeRabbit check was rate-limited, so it is not represented as independent substantive review of the corrective head. +## Exact-diff correction after implementation review at `7f189f1a` + +The five reported blockers and the handoff correction were replayed against +the implementation rather than applied automatically. All were valid: + +1. Resume attribution had no database-verifiable anchor. The binding now + persists `resumed_by` and database-owned `resumed_at`, and PostgreSQL rejects + a resumed event whose actor differs from the transition anchor. +2. Dedicated PREP failure tests now cover consume denial, consume exception, + wrong returned actor, exactly-once closure, no mutation after failure, and a + product failure after successful close. The latter proves rollback of both + product and staged participant effects and rejection of the closed object. +3. PostgreSQL negative tests now cover same-state transitions, version skips, + retired transitions, every immutable binding identity, malformed and + cross-binding prior-suspension references, forged resume attribution, and + event update, delete, and truncate attempts. +4. The implementation exceeded the originally merged allowed-file list. The + executable contract now records the exact expansion as a reviewed scope + correction, explains why each category was required, and makes human + approval of PR #337 the acceptance boundary. It does not misrepresent the + files as part of the original approval. +5. The trust bundle and implementation evidence were revised after the new + proof was added; earlier reviewer conclusions do not stand in for fresh + exact-diff review. + +The active AUTH handoff now distinguishes current main at migration `0003` +from the `0004` head that CP02 installs on merge. No compatibility path was +added. + +## Test-integrity review at `b043c846` + +All three reported evidence gaps were replayed and found valid: + +1. A new concurrency test uses distinct operation identities and route keys + for the same project and instrument. It proves one create succeeds, one + receives the concealed conflict, only one mutation authorization is + prepared, and exactly one active binding/event commits. The existing + same-operation test remains the separate idempotent-recovery proof. +2. The authorization participant now queries the live transaction during + consume and requires zero binding rows and zero lifecycle events before it + stages its own effect. Rollback assertions remain as the separate atomicity + proof after denial, exception, wrong actor, or downstream product failure. +3. Direct PostgreSQL tests now additionally cover a valid-shape active no-op + update, suspended-to-active version skipping, and a resumed event that + references an older same-binding suspension instead of the immediately + preceding event. + +The unused allowed-file, generic constraint-name, and indirect route-validator +observations do not weaken a product or security invariant and are not expanded +in this corrective diff. CodeRabbit still has not supplied substantive review +of the corrected implementation head. + +The first hosted run of that correction exposed an independent strict-fake +defect: prepared authority stored `id(transaction)`, and Python could reuse that +address for a later transaction. The fake now retains the transaction object +itself and requires object identity during consume. This strengthens the +existing wrong-transaction proof without changing production behavior or any +CI threshold. + +## CodeRabbit implementation review at `31f6d730` + +All seven threads were replayed against the exact head and found valid. The +repair remains within CP02 and changes no production activation or lifecycle +semantics: + +1. `CURRENT_STATE.md` now describes CP02 as complete on merge, consistent with + the capability ledger while PR #337 remains open. +2. The ACTORS public fact describes an eligible adapter actor rather than + prematurely classifying it as the future CP03 service identity. +3. Transaction-participant rollback tests pin their temporary-table probe, + mutation, and assertion transactions to one physical PostgreSQL connection. +4. Owner-fence concurrency tests observe both the authorization event and the + mutation task with a timeout, propagate early failures, and cancel and await + both tasks during cleanup instead of hanging. +5. The committed-ineligibility test avoids a null-primary-key ORM lookup for + create operations. +6. The missing-event PostgreSQL assertion matches the lifecycle-event guard's + error rather than accepting an unrelated `DBAPIError`. +7. Alembic proof compares the exact four PostgreSQL function names rather than + accepting any four overload rows. + +CodeRabbit's advisory repository-external docstring percentage is not a +Workstream merge gate. The repository's own docstring and coverage gates remain +unchanged. + ## Verification after correction - architecture re-review: pass; diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-CP02-implementation-review-evidence.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-CP02-implementation-review-evidence.md new file mode 100644 index 000000000..97b871859 --- /dev/null +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-CP02-implementation-review-evidence.md @@ -0,0 +1,81 @@ +# WS-ARCH-001-CP02 Implementation Review Evidence + +## Scope reviewed + +The complete CP02 implementation diff: public CON/PROJECTS/ACTORS ports, hidden +CON service and repository, migration `0004`, lifecycle models, focused tests, +CI ownership/lane metadata, and canonical status/specification updates. + +## Deterministic evidence + +- touched-code Ruff: pass; +- repository docstring coverage: 80.2%, pass; +- focused non-PostgreSQL tests: pass; +- canonical semantic-lane collection: pass; +- module-boundary validation: pass; +- authorization-boundary validation: pass; +- test-structure validation: pass; +- behavior-ownership validation: pass; +- stale wording, Markdown links, chunk-state, and diff checks: pass. + +The first hosted implementation run exposed a migration naming-convention +error: Alembic expanded already-final baseline constraint names a second time. +Migration `0004` now marks both retired and replacement check-constraint names +with `op.f(...)`, and the Alembic contract test asserts the exact installed and +removed names. The same hosted run then proved that the new revision identifier +exceeds Alembic's default 32-character version column; `0004` now widens that +column to 64 characters before head stamping, and the schema test asserts it. +The reset harness fingerprint now records the exact installed `0004` schema, +and the nonempty-upgrade proof seeds a valid actor, identity link, project, and +binding while disabling only product custody triggers rather than PostgreSQL +system triggers. The current-head ACL effectiveness proof compares against the +manifest built from that installed head; the separate baseline test continues +to prove exact `0001` manifest equality. Downstream schema fixtures now name +head `0004`, satisfy the existing verified-human identity invariant, and pair +pre-existing ContributionPolicy binding seeds with canonical created events. +No schema rule or CI gate was weakened. + +Full PostgreSQL, migration/reset, semantic-lane, and coverage proof remains in +the mandatory hosted exact-head checks because the user explicitly requires +full-suite execution in GitHub rather than on the slow local machine. + +## Internal review disposition + +- Architecture: pass. Public-only cross-module dependencies, explicit test + eligibility marker, and no existing identity substitution. +- Security/authorization: pass with low CP03 follow-up risk. Production is + deny-default; PREP fake integrity is CP02 evidence, not real AUTH proof. +- Product/operations: pass with low risk. Hidden lifecycle semantics and CP03 + activation ordering are correct. +- QA: pass after runtime selector validation, per-mutation duplicate recovery, + deterministic owner locks, explicit fake owner eligibility, and denial + side-effect proof. +- Test delta: pass with low evidence-completeness risk; no test weakening. +- CI integrity: pass with low local-environment risk; lane collection succeeds + in the repository environment and hosted checks remain authoritative. +- Reuse/dedup: pass after shared strict doubles and fixtures were extracted. +- Senior engineering: pass; production and test files remain below 500 lines. +- Documentation: pass after replacing the planning trust bundle and finalizing + contract/status wording. + +## Valid findings fixed + +Findings fixed include database-verifiable create/suspend/resume attribution +guards, read concealment, exact PREP +closure ordering, owner eligibility races, operation fencing and recovery, +request runtime validation, all-mutation concurrency coverage, deterministic +lock tests, schema/lane parity, test-fixture isolation, service-actor proof, and +stale implementation evidence, documentation coverage for the new public and +repository surfaces, consume denial/exception/wrong-actor rollback proof, and +the complete promised PostgreSQL forbidden-mutation matrix. Follow-up evidence +also distinguishes same-operation recovery from distinct-operation create +competition and verifies inside AUTH consumption that product rows have not +been staged early. Hosted CI then exposed object-address reuse in the strict +fake's transaction token; retaining and comparing the transaction object by +identity removed that ambiguity without changing production behavior. + +## Residual risk owned by CP03 + +CP03 must prove the real AUTH prepared adapter and real PROJECTS/ACTORS owner +adapters, including exact service identity registration, session/transaction +binding, retained owner fences, atomic AUTH evidence, and action activation. diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-CP02-pr-trust-bundle.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-CP02-pr-trust-bundle.md index ff1b999e0..cad013562 100644 --- a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-CP02-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-CP02-pr-trust-bundle.md @@ -2,132 +2,147 @@ ## Chunk -`WS-ARCH-001-CP02` planning correction: Hidden Adapter-Binding Behavior. - -## Goal - -Turn the stale CP02 skeleton into a current-main executable contract before any -implementation begins. - -## Human-approved intent - -Plan the exact hidden CON-owned adapter-binding lifecycle first. Preserve AUTH -as the sole authorization owner, keep the behavior unreachable and unavailable, -and return to implementation only after this planning PR merges and receives -explicit human approval. - -## What changed - -- Defined hidden create/read/suspend/resume behavior and exact state transitions. -- Defined immutable requests, results, owner-capability ports, query - authorization, and an opaque mutation-authorization port. -- Defined migration `0004`, lifecycle-event history, concurrency/idempotency, - database guards, fail-closed proof, tests, reviewers, and stop conditions. -- Defined transaction-retained owner eligibility fences and authorized - unknown-commit recovery before any duplicate mutation authorization. -- Reconciled ARCH, CON, roadmap, and current-state records. -- Recorded the architectural decision and newly identified risks. - -## Why it changed - -The former skeleton could not safely guide implementation. It omitted the -public consume boundary, exact migration rules, service-actor eligibility, -tenant-safe loading, lifecycle history, and deterministic duplicate behavior. +`WS-ARCH-001-CP02`: Hidden Adapter-Binding Behavior. + +## Goal and approved intent + +Implement CON-owned, route-unreachable create/read/suspend/resume behavior for +project compensation adapter bindings. Keep production deny-default and all +four AUTH actions planned/unavailable until CP03 installs the real owner and +authorization adapters. + +## What changed and why + +- Added immutable public CON requests, results, views, and authorization ports. +- Added public PROJECTS and ACTORS eligibility ports without private imports. +- Added CON repository/service orchestration with PostgreSQL advisory operation + fencing, exact authorized recovery, row locking, and concealed conflicts. +- Added migration `0004` with immutable contiguous lifecycle-event history, + database-verifiable create/suspend/resume attribution guards, + one-active-binding constraints, and empty-table-only upgrade protection. +- Added focused unit, PostgreSQL, concurrency, boundary, ownership, reset, and + Alembic tests; split fixtures and test doubles into named files so no test + file reaches 500 lines or shadows repository pytest configuration. +- Reconciled ARCH, CON, roadmap, and current-state records because CP03 is the + next activation gate after this merge. ## Design chosen -CON owns binding commands, results, row locks, lifecycle rules, repository, -service orchestration, and immutable lifecycle history. AUTH owns action -availability, Finance Authority evaluation, opaque PREP machinery, decisions, -and decision evidence. Read uses request-scoped authorization. Mutations use a -CON-facing opaque prepare/consume/close port that CP03 will adapt to the existing -AUTH protocol. +Every mutation follows one order: root transaction, canonical digest, +transaction-scoped operation fence, recovery, owner/product locks, AUTH +prepare/consume/close, product mutation, lifecycle event, and flush. Exact +duplicates may return the immutable original result only after current read +authorization. Any mismatch or denied read returns one concealed conflict and +performs no second mutation authorization or effect. -## Alternatives rejected +PROJECTS then ACTORS eligibility fences are retained through create and resume. +Suspend remains possible after owner ineligibility so authorized operators can +disable a binding. CP02 test proof uses an explicit test-only eligibility +marker; existing service identities and unmarked human actors cannot +substitute. CP03 owns +the real compensation-adapter identity rule and AUTH composition. -- Generic service-actor eligibility: unrelated ART/REV identities could bind. -- AUTH-private imports: violate modular-monolith extraction boundaries. -- A second CON-local authorization protocol: duplicates security machinery. -- Replaying a mutation without current disclosure authority: rejected. Exact - duplicates return the immutable original result only after current - request-scoped read authorization; every mismatch or denied read receives - one concealed conflict without mutation PREP, new evidence, or a second - effect. -- Clearing suspension fields without immutable history: loses attribution. - -## Scope control +## Alternatives rejected -Planning and current-state documentation only. No runtime code, migration, -route, evaluator, grant, service identity, matrix row, action activation, -provider behavior, policy behavior, retirement, fulfillment, or compatibility -path is added. +- Raw role checks or a CON-local authorization evaluator. +- Imports from AUTH, PROJECTS, or ACTORS private implementation modules. +- Generic service eligibility or reuse of ART/REV identities. +- Mutation retry without an operation fence and current read authorization. +- Mutable or best-effort lifecycle history. +- Compatibility paths for retired fact names or lifecycle behavior. -## Product behavior +## Scope control and product behavior -Unchanged. All four adapter-binding actions remain planned/unavailable and no -route can reach the future behavior. +No route, evaluator, grant, service matrix row, production service identity, +action activation, ContributionPolicy behavior, retirement, fulfillment, +callback, delivery, provider call, or credential behavior is added. The hidden +service defaults to denial, so production behavior remains unavailable. ## Acceptance criteria proof -The contract now names exact scope, non-goals, public types and ports, owner -capabilities, read and mutation ordering, transition invariants, immutable -history, migration behavior, failure cases, verification, reviewers, human -focus, stop conditions, and merge outcome. - -## Tests/checks run - -Stale authorization wording, stale Workstream wording, changed Markdown links, -atomic chunk-state synchronization, and `git diff --check` all pass. - -## Test delta - -No tests changed because this PR changes planning only. The future contract -requires focused unit tests, PostgreSQL schema/lifecycle tests, concurrency -tests, reset tests, boundary tests, and hosted full-coverage proof. - -## CI integrity - -No workflow, test command, coverage floor, ledger exception, skip, or gate was -weakened. Hosted CI remains required before this PR is ready to merge. +Focused tests prove contiguous create/suspend/resume history; immutable event +and binding identity; exact duplicate recovery for all mutations; mismatch and +read-denial concealment; one-effect concurrency; owner ineligibility before +AUTH; retained project and actor locks; close-before-mutation failure; copied, +replayed, closed, or wrong-transaction fake PREP rejection; no existing ART or +human identity substitution; deny-default production composition; consume +denial/exception/wrong-actor closure and rollback; failure after successful +close; complete forbidden-transition and immutable-identity rejection; forged +resume-attribution rejection; and event update/delete/truncate rejection. +Distinct-operation create races separately prove the one-active-binding +invariant rather than relying on same-operation recovery. The AUTH participant +also inspects the live transaction during consume and proves that no binding or +lifecycle event has been staged before authorization. PostgreSQL proof includes +suspended-to-active version skips and stale same-binding suspension references. +The strict PREP fake retains the actual transaction object and compares by +identity, so Python object-address reuse cannot make a later transaction appear +to be the prepared transaction. + +## Tests and checks run + +- Ruff on all touched backend modules and tests: pass. +- Repository docstring coverage: 80.2%, pass. +- Focused non-database CP02 and AUTH registration tests: pass. +- Canonical semantic-lane collection: pass. +- Module, authorization, test-structure, and behavior-ownership gates: pass. +- Stale wording, changed Markdown links, chunk-state synchronization, and + `git diff --check`: pass. +- The hosted migration failure caused by double application of SQLAlchemy's + constraint naming convention was corrected with finalized Alembic names; + the schema test now proves the exact replacement constraints. +- Migration `0004` widens Alembic's revision column before stamping its + longer canonical revision identifier; the schema test proves the new bound. +- The database-reset fingerprint is reconciled to the exact hosted `0004` + schema, and migration preflight evidence retains internal foreign-key + enforcement instead of attempting to disable PostgreSQL system triggers. +- PostgreSQL behavior, migration, reset, semantic lanes, and repository-wide + coverage are proven by mandatory hosted GitHub checks, not a local full run. + +## Test delta and CI integrity + +The superseded 03A deny-all-update and raw-table race tests were replaced by +service-bound lifecycle, advisory-lock, recovery, and database-guard tests. No +skip, xfail, omission, threshold reduction, workflow bypass, or coverage +weakening was introduced. Repository coverage floors remain unchanged. + +## Reviewed scope correction + +Implementation revealed necessary parity and proof files outside the originally +merged allowed-file list: ORM aggregation, canonical architecture text, CI +ownership/lane metadata, shared test support, schema-reset/Alembic contracts, +and split authorization/database guard tests. The executable contract records +the exact paths and rationale. Approval of PR #337 explicitly accepts this +corrected scope; it is not represented as part of the earlier approval and does +not authorize adjacent CON behavior. ## Reviewer results -- Architecture status: pass. -- Security/authorization status: pass. -- Product/operations status: pass. Low-risk history-link concern was fixed. -- Documentation status: pass. Two findings were fixed. -- Senior engineering status: pass. Three findings were fixed. +Architecture, security, product/operations, QA, test-delta, CI-integrity, +reuse/dedup, senior-engineering, and documentation reviews are recorded in the +implementation review evidence. Valid findings were fixed; low risks are +carried to CP03 where the real adapters and identity are installed. ## External review -CodeRabbit produced seven valid bounded findings on the first published head. -All were fixed and recorded in the external-review response. GitHub's live -exact-head checks remain the authority for transient hosted CI and CodeRabbit -state; this durable bundle does not preserve a stale pending/passing claim. - -## Remaining risks - -Implementation remains L1. Its actual migration, concurrency behavior, opaque -handle semantics, public boundary composition, and denial side-effect ordering -must receive fresh focused review. - -## Follow-up work - -After human merge and explicit approval, implement CP02 only. CP03 later installs -the real AUTH/ACTORS adapters and activates exactly the proven actions. Broader -ContributionPolicy behavior remains in later chunks. +The planning-stage and implementation-head external findings and their +validated dispositions remain in +`WS-ARCH-001-CP02-external-review-response.md`. CodeRabbit's seven substantive +implementation findings were replayed and repaired without changing product +scope or weakening a gate. GitHub's live exact-head checks are the authority +for transient status. -## Human review focus +## Remaining risks and follow-up -- CON lifecycle ownership versus AUTH decision ownership. -- Exact `instrument_type` and `route_key` parity without translation. -- Query-only read versus opaque PREP mutations. -- Immutable suspension/resume attribution and one-effect duplicate handling. -- Shared transaction ordering, exact PostgreSQL operation fencing, prepared - object closure before product mutation, and resume-time owner eligibility - race proof. -- No activation or broader CON lifecycle work in this PR. +CP02 deliberately proves hidden behavior with deny-default production +composition and strict test adapters. CP03 must install the real AUTH PREP, +PROJECTS, and ACTORS adapters; register the approved compensation service +identity; repeat real-adapter transaction/fence proofs; and activate only the +four adapter-binding actions. CP04/CP05 own ContributionPolicy behavior and +activation. -## Human merge ownership +## Human review focus and merge ownership -Only an authorized human may approve and merge this PR. +Review the mutation order, owner-lock retention, database event guards, +authorized duplicate recovery, absence of existing-service substitution, and +continued lack of route/action activation. Only an authorized human may approve +and merge this PR. 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 331ad444e..054aa96e5 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 @@ -5,8 +5,9 @@ Current `main` contains the AUTH actor, grant, fixed-service, prepared-mutation, project-guide, policy-mutation, and REV-readiness foundations plus merged REV PLAN4 and ART foundations. The old -`0052_legacy_intake_removal` identifier is historical merge evidence; the -active consolidated graph ends at `0003_submission_lineage`. These foundations do +`0052_legacy_intake_removal` identifier is historical merge evidence. Current +main ends at `0003_submission_lineage`; CP02 installs +`0004_compensation_adapter_binding_lifecycle` as the active head on merge. These foundations do not create CON runtime, activate CON behavior, or give an outbox dispatcher feature authority. @@ -81,8 +82,8 @@ matrix rows, or evaluators. No AUTH change blocks planning or completed CON `03A`, `03B`, or `02C`. CP01A and CP01B separately register binding and policy authority while unavailable. CP01C corrects binding identity and lifecycle-generation facts before CP02. -CP02 must first prove hidden CON behavior through its executable current-main -contract while every binding action remains unavailable. CP03 and CP05 activate +CP02 is complete on merge with hidden CON behavior while every binding action +remains unavailable. CP03 and CP05 activate only their respective CP02/CP04 hidden proof. Before CON `02B`, AUTH must deliver the complete dispatcher identity/admission/action contract. That later dispatcher work must not delay the persistence and 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 42be92e91..0b86ec098 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 @@ -13,6 +13,7 @@ signed-loop records do not make behavior live. | `02A` | Shared transactional outbox persistence/append | Merged PR #155; migration 0029 | | `03A` | Adapter-binding persistence | Merged PR #267; migration 0053 | | `03B` | Contribution-policy persistence | Merged PR #274; migration 0055 | +| `WS-ARCH-001-CP02` | Hidden adapter-binding lifecycle behavior | Complete on merge; 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 467b51e02..9ed88f06c 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 @@ -81,11 +81,10 @@ historical-row backfill or compatibility behavior. - CP01A and CP01B are complete on merge with four exact adapter-binding and five exact ContributionPolicy actions registered but unavailable. CP01C is complete on merge with corrected binding identity and lifecycle-generation - facts. Callback/fulfillment authority stays separate; CP02 is the next boundary. -- CP02 has a proposed executable contract for hidden, route-unreachable - adapter-binding behavior; its planning correction must merge and receive - explicit human approval before implementation. CP03-CP05 remain - non-executable activation/policy skeletons. + facts. Callback/fulfillment authority stays separate. +- CP02 is complete on merge with hidden, route-unreachable adapter-binding + behavior and immutable lifecycle history while all four AUTH actions remain + unavailable. CP03-CP05 remain non-executable activation/policy skeletons. - 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. @@ -117,8 +116,7 @@ CP08/ARCH-03B replacement path. ## Immediate next action CON-02C merged through PR #277. REV-04B may consume its shared lifecycle-audit -participant after REV's earlier gates merge. Merge the CP02 executable-contract -correction, then obtain explicit human approval before implementing its hidden -adapter-binding behavior. Registration and planning alone activate no product -behavior. +participant after REV's earlier gates merge. Expand and review CP03 against +current main before activating the exact adapter-binding actions. CP02 itself +remains hidden and route-unreachable. Open pull requests determine transient CON work. diff --git a/.ci/behavior-ownership/lifecycle/adapter-binding-behavior.json b/.ci/behavior-ownership/lifecycle/adapter-binding-behavior.json new file mode 100644 index 000000000..01dc8c43b --- /dev/null +++ b/.ci/behavior-ownership/lifecycle/adapter-binding-behavior.json @@ -0,0 +1,37 @@ +{ + "behavior_id": "lifecycle.compensation.adapter_binding.behavior", + "boundaries": ["postgresql", "lock", "concurrency"], + "callables": [ + "app.modules.compensation.service.AdapterBindingService.__init__", + "app.modules.compensation.service.AdapterBindingService._consume_and_close", + "app.modules.compensation.service.AdapterBindingService._event", + "app.modules.compensation.service.AdapterBindingService._lock_transition_binding", + "app.modules.compensation.service.AdapterBindingService._recover", + "app.modules.compensation.service.AdapterBindingService._recovery_binding_matches", + "app.modules.compensation.service.AdapterBindingService._require_root_transaction", + "app.modules.compensation.service.AdapterBindingService._result", + "app.modules.compensation.service.AdapterBindingService._transition_facts", + "app.modules.compensation.service.AdapterBindingService._view", + "app.modules.compensation.service.AdapterBindingService.create", + "app.modules.compensation.service.AdapterBindingService.read", + "app.modules.compensation.service.AdapterBindingService.resume", + "app.modules.compensation.service.AdapterBindingService.suspend", + "app.modules.compensation.service._request_digest" + ], + "group": "lifecycle", + "outcomes": ["persisted_state", "idempotent_replay", "mapped_error"], + "reviewed_by": ["WS-ARCH-001-CP02 required reviewers"], + "schema": "workstream.behavior-ownership.v1", + "status": "reviewed", + "target": "backend/app/modules/compensation/service.py", + "tests": [ + "backend/tests/compensation/test_adapter_binding_authorization_failures.py::test_consume_failure_closes_once_and_creates_no_effect", + "backend/tests/compensation/test_adapter_binding_authorization_failures.py::test_product_failure_rolls_back_closed_authority_and_all_effects", + "backend/tests/compensation/test_adapter_binding_owner_fences.py::test_committed_owner_ineligibility_denies_before_authorization", + "backend/tests/compensation/test_adapter_binding_recovery.py::test_concurrent_duplicate_transition_recovers_one_effect", + "backend/tests/compensation/test_adapter_binding_recovery.py::test_duplicate_mismatch_and_read_denial_are_concealed_without_mutation_prep", + "backend/tests/compensation/test_adapter_binding_service.py::test_close_failure_prevents_product_state_and_event", + "backend/tests/compensation/test_adapter_binding_service.py::test_create_suspend_resume_persists_contiguous_immutable_history", + "backend/tests/compensation/test_adapter_binding_service.py::test_exact_duplicate_recovers_without_second_mutation_authorization" + ] +} diff --git a/.ci/behavior-ownership/partition.v1.json b/.ci/behavior-ownership/partition.v1.json index e72d65f5c..37f0625cf 100644 --- a/.ci/behavior-ownership/partition.v1.json +++ b/.ci/behavior-ownership/partition.v1.json @@ -136,6 +136,10 @@ "group": "shared", "target": "backend/app/main.py" }, + { + "group": "shared", + "target": "backend/app/modules/actors/api/compensation_adapter.py" + }, { "group": "shared", "target": "backend/app/modules/actors/legacy_classification.py" @@ -540,14 +544,26 @@ "group": "artifacts", "target": "backend/app/modules/checkers/service.py" }, + { + "group": "lifecycle", + "target": "backend/app/modules/compensation/api/adapter_bindings.py" + }, { "group": "shared", "target": "backend/app/modules/compensation/models.py" }, + { + "group": "lifecycle", + "target": "backend/app/modules/compensation/repository.py" + }, { "group": "shared", "target": "backend/app/modules/compensation/schemas.py" }, + { + "group": "lifecycle", + "target": "backend/app/modules/compensation/service.py" + }, { "group": "lifecycle", "target": "backend/app/modules/contributions/models.py" @@ -572,6 +588,10 @@ "group": "shared", "target": "backend/app/modules/outbox/service.py" }, + { + "group": "lifecycle", + "target": "backend/app/modules/projects/api/compensation_binding.py" + }, { "group": "lifecycle", "target": "backend/app/modules/projects/api/locked_policy.py" @@ -853,7 +873,7 @@ "target": "backend/scripts/week2_api_e2e.py" } ], - "authority_digest": "b3ab085b1c8964cbe157c68575f13dd8384a165fba4d1d5f686911b3c40974b2", + "authority_digest": "d6a735d82f7a8437aabee54badc111f2a5bb8e0e27fdd266a078d4325cf0779c", "protected_base_commit": "7676ce4347db0c9694962a9b587a20765e16eac6", "schema": "workstream.behavior-ownership-partition.v1" } diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 3a3d27677..cbf1898a5 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 = "0003_submission_lineage" +_CURRENT_HEAD_REVISION = "0004_compensation_adapter_binding_lifecycle" _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/0004_compensation_adapter_binding_lifecycle.py b/backend/alembic/versions/0004_compensation_adapter_binding_lifecycle.py new file mode 100644 index 000000000..27eb08843 --- /dev/null +++ b/backend/alembic/versions/0004_compensation_adapter_binding_lifecycle.py @@ -0,0 +1,298 @@ +"""Install the hidden compensation adapter-binding lifecycle.""" + +from alembic import op +import sqlalchemy as sa + +revision = "0004_compensation_adapter_binding_lifecycle" +down_revision = "0003_submission_lineage" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + connection = op.get_bind() + if connection.scalar( + sa.text("select exists(select 1 from project_compensation_adapter_bindings)") + ): + raise RuntimeError( + "Workstream v0.1 requires a fresh database before installing the " + "compensation adapter-binding lifecycle" + ) + + op.alter_column( + "alembic_version", + "version_num", + existing_type=sa.String(length=32), + type_=sa.String(length=64), + existing_nullable=False, + ) + + op.execute( + "drop trigger project_compensation_binding_update_guard " + "on project_compensation_adapter_bindings" + ) + op.execute("drop function enforce_compensation_binding_lifecycle()") + for name in ( + "ck_project_compensation_adapter_bindings_ck_project_com_95ba", + "ck_project_compensation_adapter_bindings_ck_project_com_da73", + "ck_project_compensation_adapter_bindings_ck_project_com_ade1", + ): + op.drop_constraint(op.f(name), "project_compensation_adapter_bindings", type_="check") + op.add_column( + "project_compensation_adapter_bindings", + sa.Column("resumed_by", sa.String(36), nullable=True), + ) + op.add_column( + "project_compensation_adapter_bindings", + sa.Column("resumed_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_foreign_key( + "fk_compensation_binding_resumed_by", + "project_compensation_adapter_bindings", + "actor_profiles", + ["resumed_by"], + ["id"], + ) + op.create_check_constraint( + op.f("ck_project_compensation_adapter_bindings_status"), + "project_compensation_adapter_bindings", + "status in ('active','suspended')", + ) + op.create_check_constraint( + op.f("ck_project_compensation_adapter_bindings_lifecycle_shape"), + "project_compensation_adapter_bindings", + "(status='active' and suspended_by is null and suspended_at is null " + "and ((binding_lifecycle_version=1 and resumed_by is null and resumed_at is null) " + "or (binding_lifecycle_version>1 and resumed_by is not null " + "and resumed_at is not null)) and retired_by is null and retired_at is null) or " + "(status='suspended' and binding_lifecycle_version > 1 " + "and suspended_by is not null and suspended_at is not null " + "and resumed_by is null and resumed_at is null " + "and retired_by is null and retired_at is null)", + ) + op.create_check_constraint( + op.f("ck_project_compensation_adapter_bindings_lifecycle_timestamps"), + "project_compensation_adapter_bindings", + "(suspended_at is null or suspended_at >= created_at) and " + "(resumed_at is null or resumed_at >= created_at) and " + "(retired_at is null or retired_at >= created_at) and " + "(retired_at is null or suspended_at is null or retired_at >= suspended_at)", + ) + + op.create_table( + "compensation_adapter_binding_lifecycle_events", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("operation_id", sa.Uuid(), nullable=False), + sa.Column("request_digest", sa.String(71), nullable=False), + sa.Column("project_id", sa.String(36), nullable=False), + sa.Column("adapter_binding_id", sa.Uuid(), nullable=False), + sa.Column("event_type", sa.String(16), nullable=False), + sa.Column("actor_profile_id", sa.String(36), nullable=False), + sa.Column("from_status", sa.String(16)), + sa.Column("to_status", sa.String(16), nullable=False), + sa.Column("from_lifecycle_version", sa.Integer(), nullable=False), + sa.Column("to_lifecycle_version", sa.Integer(), nullable=False), + sa.Column("prior_suspension_event_id", sa.Uuid()), + sa.Column( + "occurred_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("clock_timestamp()"), + ), + sa.CheckConstraint( + "request_digest ~ '^sha256:[0-9a-f]{64}$'", + name="ck_compensation_adapter_binding_lifecycle_events_request_digest", + ), + sa.CheckConstraint( + "event_type in ('created','suspended','resumed')", + name="ck_compensation_adapter_binding_lifecycle_events_event_type", + ), + sa.CheckConstraint( + "(event_type='created' and from_status is null and to_status='active' " + "and from_lifecycle_version=0 and to_lifecycle_version=1 " + "and prior_suspension_event_id is null) or " + "(event_type='suspended' and from_status='active' and to_status='suspended' " + "and from_lifecycle_version > 0 " + "and to_lifecycle_version=from_lifecycle_version+1 " + "and prior_suspension_event_id is null) or " + "(event_type='resumed' and from_status='suspended' and to_status='active' " + "and from_lifecycle_version > 0 " + "and to_lifecycle_version=from_lifecycle_version+1 " + "and prior_suspension_event_id is not null)", + name="ck_compensation_adapter_binding_lifecycle_events_transition_shape", + ), + sa.ForeignKeyConstraint( + ["project_id"], ["projects.id"], name="fk_compensation_binding_event_project" + ), + sa.ForeignKeyConstraint( + ["adapter_binding_id"], + ["project_compensation_adapter_bindings.id"], + name="fk_compensation_binding_event_binding", + ), + sa.ForeignKeyConstraint( + ["actor_profile_id"], + ["actor_profiles.id"], + name="fk_compensation_binding_event_actor", + ), + sa.ForeignKeyConstraint( + ["prior_suspension_event_id"], + ["compensation_adapter_binding_lifecycle_events.id"], + name="fk_compensation_binding_event_prior_suspension", + ), + sa.PrimaryKeyConstraint("id", name="pk_compensation_adapter_binding_lifecycle_events"), + sa.UniqueConstraint("operation_id", name="operation_id"), + sa.UniqueConstraint( + "adapter_binding_id", + "to_lifecycle_version", + name="binding_version", + ), + ) + op.create_index( + "ix_compensation_binding_event_binding", + "compensation_adapter_binding_lifecycle_events", + ["adapter_binding_id", "to_lifecycle_version"], + ) + + op.execute( + """ + create function enforce_compensation_binding_lifecycle() returns trigger + language plpgsql as $$ + begin + if (new.id,new.project_id,new.instrument_type,new.adapter_actor_id,new.route_key, + new.created_by,new.created_at,new.retired_by,new.retired_at) + is distinct from + (old.id,old.project_id,old.instrument_type,old.adapter_actor_id,old.route_key, + old.created_by,old.created_at,old.retired_by,old.retired_at) then + raise exception 'compensation binding identity is immutable' using errcode='55000'; + end if; + if old.status='active' and new.status='suspended' + and new.binding_lifecycle_version=old.binding_lifecycle_version+1 + and new.suspended_by is not null + and new.resumed_by is null and new.resumed_at is null then + new.suspended_at := clock_timestamp(); + return new; + end if; + if old.status='suspended' and new.status='active' + and new.binding_lifecycle_version=old.binding_lifecycle_version+1 + and new.suspended_by is null and new.suspended_at is null + and new.resumed_by is not null then + new.resumed_at := clock_timestamp(); + return new; + end if; + raise exception 'invalid compensation binding lifecycle transition' using errcode='23514'; + end; + $$ + """ + ) + op.execute( + "create trigger project_compensation_binding_update_guard before update on " + "project_compensation_adapter_bindings for each row execute function " + "enforce_compensation_binding_lifecycle()" + ) + op.execute( + """ + create function guard_compensation_binding_lifecycle_event() returns trigger + language plpgsql as $$ + declare + binding project_compensation_adapter_bindings%rowtype; + prior compensation_adapter_binding_lifecycle_events%rowtype; + preceding compensation_adapter_binding_lifecycle_events%rowtype; + begin + select * into binding from project_compensation_adapter_bindings + where id=new.adapter_binding_id; + if not found or binding.project_id<>new.project_id + or binding.status<>new.to_status + or binding.binding_lifecycle_version<>new.to_lifecycle_version then + raise exception 'invalid compensation binding lifecycle event' using errcode='23514'; + end if; + if (new.event_type='created' and binding.created_by<>new.actor_profile_id) + or (new.event_type='suspended' and binding.suspended_by<>new.actor_profile_id) + or (new.event_type='resumed' and binding.resumed_by<>new.actor_profile_id) then + raise exception 'compensation binding lifecycle attribution mismatch' + using errcode='23514'; + end if; + new.occurred_at := clock_timestamp(); + select * into preceding from compensation_adapter_binding_lifecycle_events + where adapter_binding_id=new.adapter_binding_id + order by to_lifecycle_version desc limit 1; + if new.event_type='created' then + if found then + raise exception 'created event must be first' using errcode='23514'; + end if; + elsif not found or preceding.to_lifecycle_version<>new.from_lifecycle_version + or preceding.to_status<>new.from_status then + raise exception 'lifecycle event is not contiguous' using errcode='23514'; + end if; + if new.event_type='resumed' then + select * into prior from compensation_adapter_binding_lifecycle_events + where id=new.prior_suspension_event_id; + if not found or prior.adapter_binding_id<>new.adapter_binding_id + or prior.event_type<>'suspended' + or prior.id<>preceding.id + or prior.to_lifecycle_version<>new.from_lifecycle_version then + raise exception 'invalid prior suspension event' using errcode='23514'; + end if; + end if; + return new; + end; + $$ + """ + ) + op.execute( + "create trigger compensation_binding_event_insert_guard before insert on " + "compensation_adapter_binding_lifecycle_events for each row execute function " + "guard_compensation_binding_lifecycle_event()" + ) + op.execute( + """ + create function reject_compensation_binding_lifecycle_event_change() returns trigger + language plpgsql as $$ + begin + raise exception 'compensation binding lifecycle events are immutable' + using errcode='55000'; + end; + $$ + """ + ) + op.execute( + "create trigger compensation_binding_event_change_guard before update or delete on " + "compensation_adapter_binding_lifecycle_events for each row execute function " + "reject_compensation_binding_lifecycle_event_change()" + ) + op.execute( + "create trigger compensation_binding_event_truncate_guard before truncate on " + "compensation_adapter_binding_lifecycle_events for each statement execute function " + "reject_compensation_binding_lifecycle_event_change()" + ) + op.execute( + """ + create function require_compensation_binding_lifecycle_event() returns trigger + language plpgsql as $$ + begin + if not exists ( + select 1 from compensation_adapter_binding_lifecycle_events e + where e.adapter_binding_id=new.id + and e.project_id=new.project_id + and e.to_status=new.status + and e.to_lifecycle_version=new.binding_lifecycle_version + ) then + raise exception 'compensation binding transition requires lifecycle event' + using errcode='23514'; + end if; + return null; + end; + $$ + """ + ) + op.execute( + "create constraint trigger compensation_binding_lifecycle_event_required " + "after insert or update on project_compensation_adapter_bindings " + "deferrable initially deferred for each row execute function " + "require_compensation_binding_lifecycle_event()" + ) + + +def downgrade() -> None: + raise RuntimeError( + "Workstream v0.1 migrations cannot be downgraded; recreate the database" + ) diff --git a/backend/app/db/models.py b/backend/app/db/models.py index cc21bf571..6f6c1f8fe 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -32,7 +32,10 @@ ProjectRoleQualificationSnapshot, ) from app.modules.checkers.models import CheckerResult, CheckerRun # noqa: F401 -from app.modules.compensation.models import ProjectCompensationAdapterBinding # noqa: F401 +from app.modules.compensation.models import ( # noqa: F401 + CompensationAdapterBindingLifecycleEvent, + ProjectCompensationAdapterBinding, +) from app.modules.contributions.models import ( # noqa: F401 ContributionAwardDefinition, ContributionPolicy, diff --git a/backend/app/modules/actors/api/__init__.py b/backend/app/modules/actors/api/__init__.py new file mode 100644 index 000000000..7d2eccef2 --- /dev/null +++ b/backend/app/modules/actors/api/__init__.py @@ -0,0 +1,13 @@ +"""Dependency-safe public APIs for the ACTORS business module.""" + +from app.modules.actors.api.compensation_adapter import ( + CompensationAdapterActorEligibilityFacts, + CompensationAdapterActorEligibilityPort, + CompensationAdapterActorUnavailable, +) + +__all__ = ( + "CompensationAdapterActorEligibilityFacts", + "CompensationAdapterActorEligibilityPort", + "CompensationAdapterActorUnavailable", +) diff --git a/backend/app/modules/actors/api/compensation_adapter.py b/backend/app/modules/actors/api/compensation_adapter.py new file mode 100644 index 000000000..9b681e926 --- /dev/null +++ b/backend/app/modules/actors/api/compensation_adapter.py @@ -0,0 +1,27 @@ +"""Public ACTORS capability for compensation-adapter eligibility.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol +from uuid import UUID + + +class CompensationAdapterActorUnavailable(RuntimeError): + """Conceal missing, inactive, or ineligible adapter actors.""" + + +@dataclass(frozen=True, slots=True, kw_only=True) +class CompensationAdapterActorEligibilityFacts: + """Exact active adapter actor retained under an ACTORS-owned fence.""" + + adapter_actor_id: UUID + + +class CompensationAdapterActorEligibilityPort(Protocol): + """Lock and validate one exact compensation-adapter actor.""" + + async def lock_compensation_adapter_actor( + self, adapter_actor_id: UUID + ) -> CompensationAdapterActorEligibilityFacts: + """Retain the ACTORS eligibility fence through the caller transaction.""" diff --git a/backend/app/modules/compensation/api/__init__.py b/backend/app/modules/compensation/api/__init__.py new file mode 100644 index 000000000..18c9aae27 --- /dev/null +++ b/backend/app/modules/compensation/api/__init__.py @@ -0,0 +1,43 @@ +"""Dependency-safe public API for the COMPENSATION business module.""" + +from app.modules.compensation.api.adapter_bindings import ( + AdapterBindingActorEligibilityPort, + AdapterBindingAction, + AdapterBindingConflict, + AdapterBindingCreateRequest, + AdapterBindingEventType, + AdapterBindingMutationAuthorizationFacts, + AdapterBindingMutationAuthorizationPort, + AdapterBindingMutationResult, + AdapterBindingProjectEligibilityPort, + AdapterBindingReadAuthorizationPort, + AdapterBindingReadRequest, + AdapterBindingResumeRequest, + AdapterBindingStatus, + AdapterBindingSuspendRequest, + AdapterBindingUnavailable, + AdapterBindingView, + DenyAdapterBindingAuthorization, + validate_adapter_route_key, +) + +__all__ = ( + "AdapterBindingActorEligibilityPort", + "AdapterBindingAction", + "AdapterBindingConflict", + "AdapterBindingCreateRequest", + "AdapterBindingEventType", + "AdapterBindingMutationAuthorizationFacts", + "AdapterBindingMutationAuthorizationPort", + "AdapterBindingMutationResult", + "AdapterBindingProjectEligibilityPort", + "AdapterBindingReadAuthorizationPort", + "AdapterBindingReadRequest", + "AdapterBindingResumeRequest", + "AdapterBindingStatus", + "AdapterBindingSuspendRequest", + "AdapterBindingUnavailable", + "AdapterBindingView", + "DenyAdapterBindingAuthorization", + "validate_adapter_route_key", +) diff --git a/backend/app/modules/compensation/api/adapter_bindings.py b/backend/app/modules/compensation/api/adapter_bindings.py new file mode 100644 index 000000000..e2cafda3e --- /dev/null +++ b/backend/app/modules/compensation/api/adapter_bindings.py @@ -0,0 +1,259 @@ +"""Public CON contracts for hidden compensation adapter-binding behavior.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Literal, Protocol, TypeAlias +from uuid import UUID + +from app.modules.actors.api import CompensationAdapterActorEligibilityPort +from app.modules.projects.api import ProjectCompensationBindingEligibilityPort + +AdapterBindingAction = Literal[ + "compensation.adapter_binding.create", + "compensation.adapter_binding.suspend", + "compensation.adapter_binding.resume", +] +AdapterBindingEventType = Literal["created", "suspended", "resumed"] +AdapterBindingStatus = Literal["active", "suspended"] + + +class AdapterBindingUnavailable(RuntimeError): + """Fail closed when hidden binding behavior has no authority.""" + + +class AdapterBindingConflict(RuntimeError): + """Conceal absence, mismatch, stale state, or unauthorized recovery.""" + + +def _require_positive_version(value: int) -> None: + """Require an exact positive lifecycle version.""" + if type(value) is not int or value < 1: + raise ValueError("expected_lifecycle_version must be a positive integer") + + +def _require_uuids(**values: object) -> None: + """Require already parsed UUID selectors at the public boundary.""" + invalid = next((name for name, value in values.items() if type(value) is not UUID), None) + if invalid is not None: + raise ValueError(f"{invalid} must be a UUID") + + +def validate_adapter_route_key(value: str) -> str: + """Return one canonical, bounded, traversal-free adapter route key.""" + if ( + not isinstance(value, str) + or not value + or len(value) > 120 + or not value[0].isascii() + or not value[0].isalpha() + or any( + not (character.isascii() and (character.isalnum() or character in "._:-")) + for character in value + ) + or ".." in value + ): + raise ValueError("route_key must be canonical and traversal-free") + return value + + +@dataclass(frozen=True, slots=True, kw_only=True) +class AdapterBindingCreateRequest: + """Immutable request to create one project adapter binding.""" + operation_id: UUID + actor_profile_id: UUID + project_id: UUID + instrument_type: Literal["money", "project_points"] + adapter_actor_id: UUID + route_key: str + + def __post_init__(self) -> None: + """Validate canonical create selectors and routing facts.""" + _require_uuids( + operation_id=self.operation_id, + actor_profile_id=self.actor_profile_id, + project_id=self.project_id, + adapter_actor_id=self.adapter_actor_id, + ) + if self.instrument_type not in {"money", "project_points"}: + raise ValueError("instrument_type must be money or project_points") + validate_adapter_route_key(self.route_key) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class AdapterBindingReadRequest: + """Immutable request to read one exact project binding.""" + actor_profile_id: UUID + project_id: UUID + adapter_binding_id: UUID + + def __post_init__(self) -> None: + """Validate exact read selectors.""" + _require_uuids( + actor_profile_id=self.actor_profile_id, + project_id=self.project_id, + adapter_binding_id=self.adapter_binding_id, + ) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class AdapterBindingSuspendRequest: + """Immutable request to suspend one active binding version.""" + operation_id: UUID + actor_profile_id: UUID + project_id: UUID + adapter_binding_id: UUID + expected_lifecycle_version: int + + def __post_init__(self) -> None: + """Validate exact suspend selectors and version.""" + _require_uuids( + operation_id=self.operation_id, + actor_profile_id=self.actor_profile_id, + project_id=self.project_id, + adapter_binding_id=self.adapter_binding_id, + ) + _require_positive_version(self.expected_lifecycle_version) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class AdapterBindingResumeRequest: + """Immutable request to resume one suspended binding version.""" + operation_id: UUID + actor_profile_id: UUID + project_id: UUID + adapter_binding_id: UUID + expected_lifecycle_version: int + + def __post_init__(self) -> None: + """Validate exact resume selectors and version.""" + _require_uuids( + operation_id=self.operation_id, + actor_profile_id=self.actor_profile_id, + project_id=self.project_id, + adapter_binding_id=self.adapter_binding_id, + ) + _require_positive_version(self.expected_lifecycle_version) + + +AdapterBindingMutationRequest: TypeAlias = ( + AdapterBindingCreateRequest | AdapterBindingSuspendRequest | AdapterBindingResumeRequest +) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class AdapterBindingView: + """Authorized current view of one adapter binding.""" + adapter_binding_id: UUID + project_id: UUID + instrument_type: str + adapter_actor_id: UUID + route_key: str + status: AdapterBindingStatus + lifecycle_version: int + created_by: UUID + created_at: datetime + suspended_by: UUID | None + suspended_at: datetime | None + resumed_by: UUID | None + resumed_at: datetime | None + + +@dataclass(frozen=True, slots=True, kw_only=True) +class AdapterBindingMutationResult: + """Immutable result reconstructed from one lifecycle event.""" + event_id: UUID + operation_id: UUID + request_digest: str + project_id: UUID + adapter_binding_id: UUID + event_type: AdapterBindingEventType + actor_profile_id: UUID + from_status: AdapterBindingStatus | None + to_status: AdapterBindingStatus + from_lifecycle_version: int + to_lifecycle_version: int + prior_suspension_event_id: UUID | None + occurred_at: datetime + + +@dataclass(frozen=True, slots=True, kw_only=True) +class AdapterBindingMutationAuthorizationFacts: + """Exact immutable facts bound to mutation authorization.""" + action: AdapterBindingAction + actor_profile_id: UUID + operation_id: UUID + request_digest: str + project_id: UUID + adapter_binding_id: UUID + instrument_type: str + adapter_actor_id: UUID + route_key: str + expected_status: AdapterBindingStatus | None + expected_lifecycle_version: int | None + + def __post_init__(self) -> None: + """Validate authorization selectors and instrument type.""" + _require_uuids( + actor_profile_id=self.actor_profile_id, + operation_id=self.operation_id, + project_id=self.project_id, + adapter_binding_id=self.adapter_binding_id, + adapter_actor_id=self.adapter_actor_id, + ) + if self.instrument_type not in {"money", "project_points"}: + raise ValueError("instrument_type must be money or project_points") + + +class AdapterBindingReadAuthorizationPort(Protocol): + """Authorize disclosure of one exact adapter binding.""" + async def authorize_adapter_binding_read(self, request: AdapterBindingReadRequest) -> None: + """Authorize disclosure of one exact project/binding pair.""" + + +class AdapterBindingMutationAuthorizationPort(Protocol): + """Prepare, consume, and close opaque mutation authority.""" + async def prepare_adapter_binding_mutation( + self, facts: AdapterBindingMutationAuthorizationFacts + ) -> object: + """Prepare opaque transaction-bound mutation authority.""" + + async def consume_adapter_binding_mutation( + self, prepared: object, facts: AdapterBindingMutationAuthorizationFacts + ) -> UUID: + """Consume exact authority and return the bound actor.""" + + def close_adapter_binding_mutation(self, prepared: object) -> None: + """Invalidate one prepared object exactly once.""" + + +class DenyAdapterBindingAuthorization: + """Production-safe default until CP03 installs real AUTH adapters.""" + + async def authorize_adapter_binding_read(self, request: AdapterBindingReadRequest) -> None: + """Deny reads until CP03 installs production authorization.""" + del request + raise AdapterBindingUnavailable("compensation_adapter_binding_unavailable") + + async def prepare_adapter_binding_mutation( + self, facts: AdapterBindingMutationAuthorizationFacts + ) -> object: + """Deny preparation until CP03 installs production authorization.""" + del facts + raise AdapterBindingUnavailable("compensation_adapter_binding_unavailable") + + async def consume_adapter_binding_mutation( + self, prepared: object, facts: AdapterBindingMutationAuthorizationFacts + ) -> UUID: + """Deny consumption until CP03 installs production authorization.""" + del prepared, facts + raise AdapterBindingUnavailable("compensation_adapter_binding_unavailable") + + def close_adapter_binding_mutation(self, prepared: object) -> None: + """Discard the deny-default placeholder without granting authority.""" + del prepared + + +AdapterBindingProjectEligibilityPort = ProjectCompensationBindingEligibilityPort +AdapterBindingActorEligibilityPort = CompensationAdapterActorEligibilityPort diff --git a/backend/app/modules/compensation/models.py b/backend/app/modules/compensation/models.py index 2376c9dd9..4240e8dc2 100644 --- a/backend/app/modules/compensation/models.py +++ b/backend/app/modules/compensation/models.py @@ -42,7 +42,7 @@ class ProjectCompensationAdapterBinding(Base): ), CheckConstraint("route_key not like '%..%'", name="route_key_no_traversal"), CheckConstraint( - "status in ('active','suspended','retired')", + "status in ('active','suspended')", name="status", ), CheckConstraint( @@ -50,13 +50,19 @@ class ProjectCompensationAdapterBinding(Base): name="lifecycle_version_positive", ), CheckConstraint( - "status='active' and binding_lifecycle_version=1 " - "and suspended_by is null and suspended_at is null " - "and retired_by is null and retired_at is null", + "(status='active' and suspended_by is null and suspended_at is null " + "and ((binding_lifecycle_version=1 and resumed_by is null and resumed_at is null) " + "or (binding_lifecycle_version>1 and resumed_by is not null " + "and resumed_at is not null)) and retired_by is null and retired_at is null) or " + "(status='suspended' and binding_lifecycle_version > 1 " + "and suspended_by is not null and suspended_at is not null " + "and resumed_by is null and resumed_at is null " + "and retired_by is null and retired_at is null)", name="lifecycle_shape", ), CheckConstraint( "(suspended_at is null or suspended_at >= created_at) and " + "(resumed_at is null or resumed_at >= created_at) and " "(retired_at is null or retired_at >= created_at) and " "(retired_at is null or suspended_at is null or retired_at >= suspended_at)", name="lifecycle_timestamps", @@ -101,7 +107,87 @@ class ProjectCompensationAdapterBinding(Base): ForeignKey("actor_profiles.id", name="fk_compensation_binding_suspended_by") ) suspended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + resumed_by: Mapped[str | None] = mapped_column( + ForeignKey("actor_profiles.id", name="fk_compensation_binding_resumed_by") + ) + resumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) retired_by: Mapped[str | None] = mapped_column( ForeignKey("actor_profiles.id", name="fk_compensation_binding_retired_by") ) retired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + +class CompensationAdapterBindingLifecycleEvent(Base): + """Immutable lifecycle truth for one adapter-binding version transition.""" + + __tablename__ = "compensation_adapter_binding_lifecycle_events" + __table_args__ = ( + UniqueConstraint("operation_id", name="operation_id"), + UniqueConstraint( + "adapter_binding_id", + "to_lifecycle_version", + name="binding_version", + ), + CheckConstraint( + "request_digest ~ '^sha256:[0-9a-f]{64}$'", + name="request_digest", + ), + CheckConstraint( + "event_type in ('created','suspended','resumed')", + name="event_type", + ), + CheckConstraint( + "(event_type='created' and from_status is null and to_status='active' " + "and from_lifecycle_version=0 and to_lifecycle_version=1 " + "and prior_suspension_event_id is null) or " + "(event_type='suspended' and from_status='active' and to_status='suspended' " + "and from_lifecycle_version > 0 " + "and to_lifecycle_version=from_lifecycle_version+1 " + "and prior_suspension_event_id is null) or " + "(event_type='resumed' and from_status='suspended' and to_status='active' " + "and from_lifecycle_version > 0 " + "and to_lifecycle_version=from_lifecycle_version+1 " + "and prior_suspension_event_id is not null)", + name="transition_shape", + ), + Index( + "ix_compensation_binding_event_binding", + "adapter_binding_id", + "to_lifecycle_version", + ), + ) + + id: Mapped[UUID] = mapped_column(Uuid(), primary_key=True) + operation_id: Mapped[UUID] = mapped_column(Uuid(), nullable=False) + request_digest: Mapped[str] = mapped_column(String(71), nullable=False) + project_id: Mapped[str] = mapped_column( + ForeignKey("projects.id", name="fk_compensation_binding_event_project"), + nullable=False, + ) + adapter_binding_id: Mapped[UUID] = mapped_column( + Uuid(), + ForeignKey( + "project_compensation_adapter_bindings.id", + name="fk_compensation_binding_event_binding", + ), + nullable=False, + ) + event_type: Mapped[str] = mapped_column(String(16), nullable=False) + actor_profile_id: Mapped[str] = mapped_column( + ForeignKey("actor_profiles.id", name="fk_compensation_binding_event_actor"), + nullable=False, + ) + from_status: Mapped[str | None] = mapped_column(String(16)) + to_status: Mapped[str] = mapped_column(String(16), nullable=False) + from_lifecycle_version: Mapped[int] = mapped_column(Integer, nullable=False) + to_lifecycle_version: Mapped[int] = mapped_column(Integer, nullable=False) + prior_suspension_event_id: Mapped[UUID | None] = mapped_column( + Uuid(), + ForeignKey( + "compensation_adapter_binding_lifecycle_events.id", + name="fk_compensation_binding_event_prior_suspension", + ), + ) + occurred_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=text("clock_timestamp()") + ) diff --git a/backend/app/modules/compensation/repository.py b/backend/app/modules/compensation/repository.py new file mode 100644 index 000000000..e948d69d8 --- /dev/null +++ b/backend/app/modules/compensation/repository.py @@ -0,0 +1,121 @@ +"""Persistence operations for hidden compensation adapter-binding behavior.""" + +from __future__ import annotations + +import hashlib +from uuid import UUID + +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.compensation.models import ( + CompensationAdapterBindingLifecycleEvent, + ProjectCompensationAdapterBinding, +) + + +def operation_advisory_lock_key(operation_id: UUID) -> int: + """Derive the contract-fixed signed PostgreSQL advisory key.""" + raw = int.from_bytes(hashlib.sha256(operation_id.bytes).digest()[:8], "big") + return raw - (1 << 64) if raw >= (1 << 63) else raw + + +class AdapterBindingRepository: + """Flush-only repository for one caller-owned root transaction.""" + + def __init__(self, session: AsyncSession) -> None: + """Bind persistence to the caller-owned root transaction.""" + self._session = session + + async def lock_operation(self, operation_id: UUID) -> None: + """Acquire the transaction-scoped operation fence.""" + await self._session.execute( + text("select pg_advisory_xact_lock(:lock_key)"), + {"lock_key": operation_advisory_lock_key(operation_id)}, + ) + + async def get_event_by_operation( + self, operation_id: UUID + ) -> CompensationAdapterBindingLifecycleEvent | None: + """Load immutable recovery evidence for one operation.""" + return await self._session.scalar( + select(CompensationAdapterBindingLifecycleEvent).where( + CompensationAdapterBindingLifecycleEvent.operation_id == operation_id + ) + ) + + async def get_binding( + self, project_id: UUID, adapter_binding_id: UUID, *, for_update: bool = False + ) -> ProjectCompensationAdapterBinding | None: + """Load one tenant-scoped binding, optionally for update.""" + query = select(ProjectCompensationAdapterBinding).where( + ProjectCompensationAdapterBinding.project_id == str(project_id), + ProjectCompensationAdapterBinding.id == adapter_binding_id, + ) + if for_update: + query = query.with_for_update() + return await self._session.scalar(query.execution_options(populate_existing=True)) + + async def lock_creation_scope(self, project_id: UUID, instrument_type: str) -> None: + """Serialize active binding changes for a project instrument.""" + key = f"compensation-adapter-binding:{project_id}:{instrument_type}" + await self._session.execute( + text("select pg_advisory_xact_lock(hashtextextended(:scope, 0))"), + {"scope": key}, + ) + + async def get_active_binding( + self, project_id: UUID, instrument_type: str + ) -> ProjectCompensationAdapterBinding | None: + """Lock and return the current active project binding.""" + return await self._session.scalar( + select(ProjectCompensationAdapterBinding) + .where( + ProjectCompensationAdapterBinding.project_id == str(project_id), + ProjectCompensationAdapterBinding.instrument_type == instrument_type, + ProjectCompensationAdapterBinding.status == "active", + ) + .with_for_update() + ) + + async def get_prior_suspension_event( + self, adapter_binding_id: UUID, lifecycle_version: int + ) -> CompensationAdapterBindingLifecycleEvent | None: + """Load the immediately preceding suspension event.""" + return await self._session.scalar( + select(CompensationAdapterBindingLifecycleEvent).where( + CompensationAdapterBindingLifecycleEvent.adapter_binding_id + == adapter_binding_id, + CompensationAdapterBindingLifecycleEvent.to_lifecycle_version + == lifecycle_version, + CompensationAdapterBindingLifecycleEvent.event_type == "suspended", + ) + ) + + async def add_binding_and_event( + self, + binding: ProjectCompensationAdapterBinding, + event: CompensationAdapterBindingLifecycleEvent, + ) -> None: + """Flush a binding and its mandatory created event together.""" + self._session.add(binding) + # The lifecycle-event trigger validates the binding's persisted status + # and version. Make that ordering explicit instead of relying on ORM + # unit-of-work ordering between two independently constructed objects. + await self._session.flush() + self._session.add(event) + await self._session.flush() + await self._session.refresh(binding) + await self._session.refresh(event) + + async def flush_event( + self, event: CompensationAdapterBindingLifecycleEvent + ) -> None: + """Flush an event with the pending binding transition.""" + # Persist the pending status/version transition first so the event + # trigger observes its exact target state. Both writes remain inside + # the caller-owned transaction and its deferred custody constraint. + await self._session.flush() + self._session.add(event) + await self._session.flush() + await self._session.refresh(event) diff --git a/backend/app/modules/compensation/schemas.py b/backend/app/modules/compensation/schemas.py index 1b8040cc1..b9d89e2ad 100644 --- a/backend/app/modules/compensation/schemas.py +++ b/backend/app/modules/compensation/schemas.py @@ -7,6 +7,8 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator +from app.modules.compensation.api.adapter_bindings import validate_adapter_route_key + class CompensationInstrumentType(StrEnum): """Canonical compensation instrument families.""" @@ -30,7 +32,5 @@ class ProjectCompensationAdapterBindingInput(BaseModel): @field_validator("route_key") @classmethod def reject_path_traversal(cls, value: str) -> str: - """Reject traversal-like dot pairs even though dots are route-safe.""" - if ".." in value: - raise ValueError("route_key must not contain path traversal") - return value + """Apply the canonical CON-owned route-key grammar.""" + return validate_adapter_route_key(value) diff --git a/backend/app/modules/compensation/service.py b/backend/app/modules/compensation/service.py new file mode 100644 index 000000000..e26cfb59f --- /dev/null +++ b/backend/app/modules/compensation/service.py @@ -0,0 +1,450 @@ +"""Hidden compensation adapter-binding lifecycle orchestration.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import cast +from uuid import UUID, uuid4 + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.hashing import canonical_json_hash +from app.modules.actors.api import CompensationAdapterActorUnavailable +from app.modules.compensation.api import ( + AdapterBindingActorEligibilityPort, + AdapterBindingConflict, + AdapterBindingCreateRequest, + AdapterBindingAction, + AdapterBindingEventType, + AdapterBindingMutationAuthorizationFacts, + AdapterBindingMutationAuthorizationPort, + AdapterBindingMutationResult, + AdapterBindingProjectEligibilityPort, + AdapterBindingReadAuthorizationPort, + AdapterBindingReadRequest, + AdapterBindingResumeRequest, + AdapterBindingStatus, + AdapterBindingSuspendRequest, + AdapterBindingUnavailable, + AdapterBindingView, + DenyAdapterBindingAuthorization, +) +from app.modules.compensation.models import ( + CompensationAdapterBindingLifecycleEvent, + ProjectCompensationAdapterBinding, +) +from app.modules.compensation.repository import AdapterBindingRepository +from app.modules.projects.api import ProjectCompensationBindingUnavailable + +_EVENT_BY_ACTION = { + "compensation.adapter_binding.create": "created", + "compensation.adapter_binding.suspend": "suspended", + "compensation.adapter_binding.resume": "resumed", +} + + +def _request_digest(action: AdapterBindingAction, request: object) -> str: + fields = { + key: str(value) if isinstance(value, UUID) else value + for key, value in asdict(request).items() # type: ignore[arg-type] + } + return canonical_json_hash( + { + "domain": "workstream.compensation.adapter_binding.operation.v1", + "action": action, + "request": fields, + } + ) + + +class AdapterBindingService: + """Run hidden binding commands without transaction or route ownership.""" + + def __init__( + self, + session: AsyncSession, + *, + read_authorization: AdapterBindingReadAuthorizationPort | None = None, + mutation_authorization: AdapterBindingMutationAuthorizationPort | None = None, + projects: AdapterBindingProjectEligibilityPort | None = None, + actors: AdapterBindingActorEligibilityPort | None = None, + ) -> None: + deny = DenyAdapterBindingAuthorization() + self._session = session + self._repository = AdapterBindingRepository(session) + self._read_authorization = read_authorization or deny + self._mutation_authorization = mutation_authorization or deny + self._projects = projects + self._actors = actors + + async def read(self, request: AdapterBindingReadRequest) -> AdapterBindingView: + if type(request) is not AdapterBindingReadRequest: + raise AdapterBindingConflict("compensation_adapter_binding_not_found") + request.__post_init__() + try: + await self._read_authorization.authorize_adapter_binding_read(request) + except (AdapterBindingUnavailable, AdapterBindingConflict) as exc: + raise AdapterBindingConflict("compensation_adapter_binding_not_found") from exc + binding = await self._repository.get_binding( + request.project_id, request.adapter_binding_id + ) + if binding is None: + raise AdapterBindingConflict("compensation_adapter_binding_not_found") + return self._view(binding) + + async def create( + self, request: AdapterBindingCreateRequest + ) -> AdapterBindingMutationResult: + self._require_root_transaction(request, AdapterBindingCreateRequest) + action: AdapterBindingAction = "compensation.adapter_binding.create" + digest = _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 + if self._projects is None or self._actors is None: + raise AdapterBindingUnavailable("compensation_adapter_binding_unavailable") + binding_id = uuid4() + try: + project = await self._projects.lock_compensation_binding_project( + request.project_id + ) + actor = await self._actors.lock_compensation_adapter_actor( + request.adapter_actor_id + ) + except (ProjectCompensationBindingUnavailable, CompensationAdapterActorUnavailable) as exc: + raise AdapterBindingConflict("compensation_adapter_binding_not_found") from exc + if project.project_id != request.project_id or actor.adapter_actor_id != request.adapter_actor_id: + raise AdapterBindingConflict("compensation_adapter_binding_not_found") + await self._repository.lock_creation_scope( + request.project_id, request.instrument_type + ) + if ( + await self._repository.get_active_binding( + request.project_id, request.instrument_type + ) + is not None + ): + raise AdapterBindingConflict("compensation_adapter_binding_conflict") + facts = AdapterBindingMutationAuthorizationFacts( + action=action, + actor_profile_id=request.actor_profile_id, + operation_id=request.operation_id, + request_digest=digest, + project_id=request.project_id, + adapter_binding_id=binding_id, + instrument_type=request.instrument_type, + adapter_actor_id=request.adapter_actor_id, + route_key=request.route_key, + expected_status=None, + expected_lifecycle_version=None, + ) + authorized_actor = await self._consume_and_close(facts) + binding = ProjectCompensationAdapterBinding( + id=binding_id, + project_id=str(request.project_id), + instrument_type=request.instrument_type, + adapter_actor_id=str(request.adapter_actor_id), + route_key=request.route_key, + status="active", + binding_lifecycle_version=1, + created_by=str(authorized_actor), + ) + event = self._event( + operation_id=request.operation_id, + request_digest=digest, + project_id=request.project_id, + binding_id=binding_id, + event_type="created", + actor_profile_id=authorized_actor, + from_status=None, + to_status="active", + from_version=0, + to_version=1, + ) + await self._repository.add_binding_and_event(binding, event) + return self._result(event) + + async def suspend( + self, request: AdapterBindingSuspendRequest + ) -> AdapterBindingMutationResult: + self._require_root_transaction(request, AdapterBindingSuspendRequest) + action: AdapterBindingAction = "compensation.adapter_binding.suspend" + digest = _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 + binding = await self._lock_transition_binding(request) + if ( + binding.status != "active" + or binding.binding_lifecycle_version != request.expected_lifecycle_version + ): + raise AdapterBindingConflict("compensation_adapter_binding_conflict") + facts = self._transition_facts(action, request, digest, binding) + authorized_actor = await self._consume_and_close(facts) + from_version = binding.binding_lifecycle_version + suspended_at = await self._session.scalar(select(func.clock_timestamp())) + binding.status = "suspended" + binding.binding_lifecycle_version = from_version + 1 + binding.suspended_by = str(authorized_actor) + binding.suspended_at = suspended_at + binding.resumed_by = None + binding.resumed_at = None + event = self._event( + operation_id=request.operation_id, + request_digest=digest, + project_id=request.project_id, + binding_id=request.adapter_binding_id, + event_type="suspended", + actor_profile_id=authorized_actor, + from_status="active", + to_status="suspended", + from_version=from_version, + to_version=from_version + 1, + ) + await self._repository.flush_event(event) + return self._result(event) + + async def resume( + self, request: AdapterBindingResumeRequest + ) -> AdapterBindingMutationResult: + self._require_root_transaction(request, AdapterBindingResumeRequest) + action: AdapterBindingAction = "compensation.adapter_binding.resume" + digest = _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 + if self._projects is None or self._actors is None: + raise AdapterBindingUnavailable("compensation_adapter_binding_unavailable") + try: + project = await self._projects.lock_compensation_binding_project( + request.project_id + ) + identity = await self._repository.get_binding( + request.project_id, request.adapter_binding_id + ) + if identity is None: + raise AdapterBindingConflict("compensation_adapter_binding_not_found") + actor_id = UUID(identity.adapter_actor_id) + actor = await self._actors.lock_compensation_adapter_actor(actor_id) + except (ProjectCompensationBindingUnavailable, CompensationAdapterActorUnavailable) as exc: + raise AdapterBindingConflict("compensation_adapter_binding_not_found") from exc + if project.project_id != request.project_id or actor.adapter_actor_id != actor_id: + raise AdapterBindingConflict("compensation_adapter_binding_not_found") + binding = await self._lock_transition_binding(request) + if binding.adapter_actor_id != str(actor_id): + raise AdapterBindingConflict("compensation_adapter_binding_conflict") + if ( + binding.status != "suspended" + or binding.binding_lifecycle_version != request.expected_lifecycle_version + ): + raise AdapterBindingConflict("compensation_adapter_binding_conflict") + prior = await self._repository.get_prior_suspension_event( + request.adapter_binding_id, request.expected_lifecycle_version + ) + if prior is None: + raise AdapterBindingConflict("compensation_adapter_binding_conflict") + await self._repository.lock_creation_scope( + request.project_id, binding.instrument_type + ) + active = await self._repository.get_active_binding( + request.project_id, binding.instrument_type + ) + if active is not None: + raise AdapterBindingConflict("compensation_adapter_binding_conflict") + facts = self._transition_facts(action, request, digest, binding) + authorized_actor = await self._consume_and_close(facts) + from_version = binding.binding_lifecycle_version + binding.status = "active" + binding.binding_lifecycle_version = from_version + 1 + binding.suspended_by = None + binding.suspended_at = None + binding.resumed_by = str(authorized_actor) + event = self._event( + operation_id=request.operation_id, + request_digest=digest, + project_id=request.project_id, + binding_id=request.adapter_binding_id, + event_type="resumed", + actor_profile_id=authorized_actor, + from_status="suspended", + to_status="active", + from_version=from_version, + to_version=from_version + 1, + prior_suspension_event_id=prior.id, + ) + await self._repository.flush_event(event) + return self._result(event) + + def _require_root_transaction(self, request: object, expected_type: type[object]) -> None: + if ( + type(request) is not expected_type + or not self._session.in_transaction() + or self._session.in_nested_transaction() + ): + raise AdapterBindingUnavailable("compensation_adapter_binding_unavailable") + request.__post_init__() # type: ignore[attr-defined] + + async def _recover( + self, action: AdapterBindingAction, request: object, digest: str + ) -> AdapterBindingMutationResult | None: + operation_id = cast(UUID, getattr(request, "operation_id")) + event = await self._repository.get_event_by_operation(operation_id) + if event is None: + return None + expected_event = _EVENT_BY_ACTION[action] + actor_id = cast(UUID, getattr(request, "actor_profile_id")) + project_id = cast(UUID, getattr(request, "project_id")) + if ( + event.event_type != expected_event + or event.actor_profile_id != str(actor_id) + or event.project_id != str(project_id) + or event.request_digest != digest + ): + raise AdapterBindingConflict("compensation_adapter_binding_conflict") + binding = await self._repository.get_binding(project_id, event.adapter_binding_id) + if binding is None or not self._recovery_binding_matches(request, binding): + raise AdapterBindingConflict("compensation_adapter_binding_conflict") + try: + await self._read_authorization.authorize_adapter_binding_read( + AdapterBindingReadRequest( + actor_profile_id=actor_id, + project_id=project_id, + adapter_binding_id=UUID(str(event.adapter_binding_id)), + ) + ) + except (AdapterBindingUnavailable, AdapterBindingConflict) as exc: + raise AdapterBindingConflict("compensation_adapter_binding_conflict") from exc + return self._result(event) + + @staticmethod + def _recovery_binding_matches( + request: object, binding: ProjectCompensationAdapterBinding + ) -> bool: + if type(request) is AdapterBindingCreateRequest: + return bool( + binding.instrument_type == request.instrument_type + and binding.adapter_actor_id == str(request.adapter_actor_id) + and binding.route_key == request.route_key + ) + return binding.id == getattr(request, "adapter_binding_id", None) + + async def _lock_transition_binding( + self, request: AdapterBindingSuspendRequest | AdapterBindingResumeRequest + ) -> ProjectCompensationAdapterBinding: + binding = await self._repository.get_binding( + request.project_id, request.adapter_binding_id, for_update=True + ) + if binding is None: + raise AdapterBindingConflict("compensation_adapter_binding_not_found") + return binding + + def _transition_facts( + self, + action: AdapterBindingAction, + request: AdapterBindingSuspendRequest | AdapterBindingResumeRequest, + digest: str, + binding: ProjectCompensationAdapterBinding, + ) -> AdapterBindingMutationAuthorizationFacts: + return AdapterBindingMutationAuthorizationFacts( + action=action, + actor_profile_id=request.actor_profile_id, + operation_id=request.operation_id, + request_digest=digest, + project_id=request.project_id, + adapter_binding_id=request.adapter_binding_id, + instrument_type=binding.instrument_type, + adapter_actor_id=UUID(binding.adapter_actor_id), + route_key=binding.route_key, + expected_status=cast(AdapterBindingStatus, binding.status), + expected_lifecycle_version=request.expected_lifecycle_version, + ) + + async def _consume_and_close( + self, facts: AdapterBindingMutationAuthorizationFacts + ) -> UUID: + prepared = await self._mutation_authorization.prepare_adapter_binding_mutation(facts) + try: + actor = await self._mutation_authorization.consume_adapter_binding_mutation( + prepared, facts + ) + finally: + self._mutation_authorization.close_adapter_binding_mutation(prepared) + if type(actor) is not UUID or actor != facts.actor_profile_id: + raise AdapterBindingUnavailable("compensation_adapter_binding_unavailable") + return actor + + @staticmethod + def _event( + *, + operation_id: UUID, + request_digest: str, + project_id: UUID, + binding_id: UUID, + event_type: AdapterBindingEventType, + actor_profile_id: UUID, + from_status: str | None, + to_status: AdapterBindingStatus, + from_version: int, + to_version: int, + prior_suspension_event_id: UUID | None = None, + ) -> CompensationAdapterBindingLifecycleEvent: + return CompensationAdapterBindingLifecycleEvent( + id=uuid4(), + operation_id=operation_id, + request_digest=request_digest, + project_id=str(project_id), + adapter_binding_id=binding_id, + event_type=event_type, + actor_profile_id=str(actor_profile_id), + from_status=from_status, + to_status=to_status, + from_lifecycle_version=from_version, + to_lifecycle_version=to_version, + prior_suspension_event_id=prior_suspension_event_id, + ) + + @staticmethod + def _result( + event: CompensationAdapterBindingLifecycleEvent, + ) -> AdapterBindingMutationResult: + return AdapterBindingMutationResult( + event_id=UUID(str(event.id)), + operation_id=UUID(str(event.operation_id)), + request_digest=event.request_digest, + project_id=UUID(event.project_id), + adapter_binding_id=UUID(str(event.adapter_binding_id)), + event_type=cast(AdapterBindingEventType, event.event_type), + actor_profile_id=UUID(event.actor_profile_id), + from_status=cast(AdapterBindingStatus | None, event.from_status), + to_status=cast(AdapterBindingStatus, event.to_status), + from_lifecycle_version=event.from_lifecycle_version, + to_lifecycle_version=event.to_lifecycle_version, + prior_suspension_event_id=( + UUID(str(event.prior_suspension_event_id)) + if event.prior_suspension_event_id is not None + else None + ), + occurred_at=event.occurred_at, + ) + + @staticmethod + def _view(binding: ProjectCompensationAdapterBinding) -> AdapterBindingView: + return AdapterBindingView( + adapter_binding_id=UUID(str(binding.id)), + project_id=UUID(binding.project_id), + instrument_type=binding.instrument_type, + adapter_actor_id=UUID(binding.adapter_actor_id), + route_key=binding.route_key, + status=cast(AdapterBindingStatus, binding.status), + lifecycle_version=binding.binding_lifecycle_version, + created_by=UUID(binding.created_by), + created_at=binding.created_at, + suspended_by=UUID(binding.suspended_by) if binding.suspended_by else None, + suspended_at=binding.suspended_at, + resumed_by=UUID(binding.resumed_by) if binding.resumed_by else None, + resumed_at=binding.resumed_at, + ) diff --git a/backend/app/modules/projects/api/__init__.py b/backend/app/modules/projects/api/__init__.py index 60e98952f..6a7cf07dc 100644 --- a/backend/app/modules/projects/api/__init__.py +++ b/backend/app/modules/projects/api/__init__.py @@ -1,5 +1,10 @@ """Dependency-safe public API for the PROJECTS business module.""" +from app.modules.projects.api.compensation_binding import ( + ProjectCompensationBindingEligibilityFacts, + ProjectCompensationBindingEligibilityPort, + ProjectCompensationBindingUnavailable, +) from app.modules.projects.api.locked_policy import ( CanonicalJsonObject, ProjectLockedPolicyContextFacts, @@ -14,6 +19,9 @@ __all__ = ( "CanonicalJsonObject", + "ProjectCompensationBindingEligibilityFacts", + "ProjectCompensationBindingEligibilityPort", + "ProjectCompensationBindingUnavailable", "ProjectLockedPolicyContextFacts", "ProjectLockedPolicyContextPort", "ProjectLockedPolicyContextRequest", diff --git a/backend/app/modules/projects/api/compensation_binding.py b/backend/app/modules/projects/api/compensation_binding.py new file mode 100644 index 000000000..6f12f6e6f --- /dev/null +++ b/backend/app/modules/projects/api/compensation_binding.py @@ -0,0 +1,27 @@ +"""Public PROJECTS capability for compensation-binding eligibility.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol +from uuid import UUID + + +class ProjectCompensationBindingUnavailable(RuntimeError): + """Conceal missing or ineligible compensation-binding projects.""" + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ProjectCompensationBindingEligibilityFacts: + """Exact eligible project retained under a PROJECTS-owned fence.""" + + project_id: UUID + + +class ProjectCompensationBindingEligibilityPort(Protocol): + """Lock and validate one exact project for compensation binding.""" + + async def lock_compensation_binding_project( + self, project_id: UUID + ) -> ProjectCompensationBindingEligibilityFacts: + """Retain the PROJECTS eligibility fence through the caller transaction.""" diff --git a/backend/scripts/behavior_ownership.py b/backend/scripts/behavior_ownership.py index 132aa663c..a7d19f30f 100644 --- a/backend/scripts/behavior_ownership.py +++ b/backend/scripts/behavior_ownership.py @@ -107,6 +107,22 @@ "backend/app/modules/authorization/submission_creation_authorization.py", } ) +ARCH_CP02_ADAPTER_BINDING_TARGETS = frozenset( + { + "backend/app/modules/actors/api/compensation_adapter.py", + "backend/app/modules/compensation/api/adapter_bindings.py", + "backend/app/modules/compensation/repository.py", + "backend/app/modules/compensation/service.py", + "backend/app/modules/projects/api/compensation_binding.py", + } +) +ARCH_CP02_CON_LIFECYCLE_TARGETS = frozenset( + { + "backend/app/modules/compensation/api/adapter_bindings.py", + "backend/app/modules/compensation/repository.py", + "backend/app/modules/compensation/service.py", + } +) POL_03A_CALLABLE_TARGETS = frozenset( { "backend/app/modules/authorization/api/project_guide_compilation.py", @@ -207,6 +223,8 @@ def changed_callable_names(root: Path, base_sha: str, head_sha: str, target: str def group_for_target(target: str) -> str: """Assign one exact population group without wildcard authority.""" + if target in ARCH_CP02_CON_LIFECYCLE_TARGETS: + return "lifecycle" if "/authorization/" in target or target.endswith("/auth.py"): return "auth" if "/artifacts/" in target or any( @@ -296,6 +314,7 @@ def _validate_additive_partition_transition( | ARCH_02F_SUBMISSION_COMPOSITION_TARGETS | ARCH_02G_AUTH_PREPARATION_TARGETS | ARCH_02H_AUTH_CONSUMPTION_TARGETS + | ARCH_CP02_ADAPTER_BINDING_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 f3a848d99..e8eef4666 100644 --- a/backend/scripts/run_test_lanes.py +++ b/backend/scripts/run_test_lanes.py @@ -108,6 +108,14 @@ class TestLane: "tests/test_ci_test_lanes.py", "tests/test_config.py", "tests/test_compensation.py", + "tests/compensation/test_adapter_binding_api.py", + "tests/compensation/test_adapter_binding_authorization_failures.py", + "tests/compensation/test_adapter_binding_database_guards.py", + "tests/compensation/test_adapter_binding_partition.py", + "tests/compensation/test_adapter_binding_owner_fences.py", + "tests/compensation/test_adapter_binding_persistence.py", + "tests/compensation/test_adapter_binding_recovery.py", + "tests/compensation/test_adapter_binding_service.py", "tests/test_contributions.py", "tests/test_coverage_contract.py", "tests/test_external_service_adapters.py", diff --git a/backend/tests/adapter_binding_fixtures.py b/backend/tests/adapter_binding_fixtures.py new file mode 100644 index 000000000..4e86243b9 --- /dev/null +++ b/backend/tests/adapter_binding_fixtures.py @@ -0,0 +1,144 @@ +"""Shared isolated PostgreSQL fixtures for compensation adapter-binding tests.""" + +from collections.abc import Awaitable, Callable, Iterator +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import asyncpg +import pytest + +from app.core.config import get_settings +from app.db import session as db_session +from app.modules.actors.models import ActorIdentityLink, ActorProfile +from app.modules.compensation.models import CompensationAdapterBindingLifecycleEvent +from project_create_fixtures import insert_historical_project +from scripts.schema_baseline_manifest import build_manifest, canonical_bytes + +BindingSeed = Callable[[], Awaitable[tuple[UUID, UUID, UUID]]] + + +def created_binding_events( + project_id: str, actor_profile_id: str, *binding_ids: UUID +) -> list[CompensationAdapterBindingLifecycleEvent]: + """Build canonical created events for pre-existing test binding seeds.""" + return [ + CompensationAdapterBindingLifecycleEvent( + id=uuid4(), operation_id=uuid4(), request_digest="sha256:" + "0" * 64, + project_id=project_id, adapter_binding_id=binding_id, + event_type="created", actor_profile_id=actor_profile_id, + from_status=None, to_status="active", + from_lifecycle_version=0, to_lifecycle_version=1, + ) + for binding_id in binding_ids + ] + + +async def seed_nonempty_0003_adapter_binding(database_url: str) -> None: + """Seed one FK-valid legacy binding without disabling system triggers.""" + connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) + try: + await connection.execute( + "insert into actor_profiles " + "(id,actor_kind,status,provisioning_method,created_by) values " + "('00000000-0000-0000-0000-000000000003','human','active'," + "'automatic_first_access','00000000-0000-0000-0000-000000000003'); " + "insert into actor_identity_links " + "(id,actor_profile_id,issuer,subject,subject_kind,status,linked_by," + "last_verified_at) values " + "('00000000-0000-0000-0000-000000000004'," + "'00000000-0000-0000-0000-000000000003','https://migration.test'," + "'adapter-preflight','human','active'," + "'00000000-0000-0000-0000-000000000003',statement_timestamp()); " + "alter table projects disable trigger project_creation_custody; " + "insert into projects (id,name,slug,status) values " + "('00000000-0000-0000-0000-000000000002','Migration preflight'," + "'migration-preflight','draft'); " + "alter table projects enable trigger project_creation_custody; " + "alter table project_compensation_adapter_bindings disable trigger " + "project_compensation_binding_update_guard; " + "insert into project_compensation_adapter_bindings " + "(id,project_id,instrument_type,adapter_actor_id,route_key,created_by) values " + "('00000000-0000-0000-0000-000000000001'," + "'00000000-0000-0000-0000-000000000002','money'," + "'00000000-0000-0000-0000-000000000003','adapter.primary'," + "'00000000-0000-0000-0000-000000000003'); " + "alter table project_compensation_adapter_bindings enable trigger " + "project_compensation_binding_update_guard" + ) + finally: + await connection.close() + + +async def snapshot_nonempty_0003_adapter_binding(database_url: str) -> tuple[bytes, list[str]]: + """Capture the full schema and exact legacy seed rows.""" + connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) + try: + rows: list[str] = [] + for table in ( + "actor_profiles", "actor_identity_links", "projects", + "project_compensation_adapter_bindings", "alembic_version", + ): + records = await connection.fetch( + f'SELECT row_to_json(t)::text value FROM public."{table}" t ORDER BY 1' + ) + rows.extend(f"{table}|{record['value']}" for record in records) + return canonical_bytes(await build_manifest(database_url)), rows + finally: + await connection.close() + + +@pytest.fixture +def compensation_database_env( + monkeypatch: pytest.MonkeyPatch, clean_postgres_database: str +) -> Iterator[str]: + monkeypatch.setenv("WORKSTREAM_DATABASE_URL", clean_postgres_database) + get_settings.cache_clear() + yield clean_postgres_database + get_settings.cache_clear() + + +@pytest.fixture +def binding_seed() -> BindingSeed: + async def seed() -> tuple[UUID, UUID, UUID]: + project_id, adapter_id, actor_id = uuid4(), uuid4(), uuid4() + async with db_session.get_session_factory()() as session: + session.add_all( + ( + ActorProfile( + id=str(actor_id), actor_kind="human", status="active", + provisioning_method="automatic_first_access", created_by=str(actor_id), + ), + ActorProfile( + id=str(adapter_id), actor_kind="human", status="active", + provisioning_method="automatic_first_access", + # Neutral FK row only. The separate CP02 marker simulates + # the future ACTORS-owned positive eligibility decision. + created_by=str(actor_id), + ), + ) + ) + await session.flush() + session.add_all( + ( + ActorIdentityLink( + id=str(uuid4()), actor_profile_id=str(actor_id), + issuer="https://compensation.test", subject=f"creator-{actor_id}", + subject_kind="human", status="active", linked_by=str(actor_id), + last_verified_at=datetime.now(UTC), + ), + ActorIdentityLink( + id=str(uuid4()), actor_profile_id=str(adapter_id), + issuer="https://compensation.test", subject=f"adapter-{adapter_id}", + subject_kind="human", status="active", linked_by=str(actor_id), + last_verified_at=datetime.now(UTC), + ), + ) + ) + await insert_historical_project( + session, project_id=str(project_id), name="Compensation project", + slug=f"compensation-{str(project_id)[:8]}", + ) + await session.commit() + return project_id, adapter_id, actor_id + + return seed diff --git a/backend/tests/adapter_binding_test_support.py b/backend/tests/adapter_binding_test_support.py new file mode 100644 index 000000000..50c13b7bc --- /dev/null +++ b/backend/tests/adapter_binding_test_support.py @@ -0,0 +1,136 @@ +"""Strict CP02 test doubles shared by adapter-binding behavior tests.""" + +import asyncio +from dataclasses import dataclass +from uuid import UUID + +from app.modules.actors.api import ( + CompensationAdapterActorEligibilityFacts, + CompensationAdapterActorUnavailable, +) +from app.modules.compensation.api import ( + AdapterBindingMutationAuthorizationFacts, + AdapterBindingReadRequest, + AdapterBindingUnavailable, +) +from app.modules.compensation.service import AdapterBindingService +from app.modules.projects.api import ( + ProjectCompensationBindingEligibilityFacts, + ProjectCompensationBindingUnavailable, +) + + +class Eligibility: + def __init__(self, *, project_available: bool = True, actor_available: bool = True) -> None: + self.project_available = project_available + self.actor_available = actor_available + self.calls: list[str] = [] + + async def lock_compensation_binding_project( + self, project_id: UUID + ) -> ProjectCompensationBindingEligibilityFacts: + self.calls.append("project") + if not self.project_available: + raise ProjectCompensationBindingUnavailable("project_unavailable") + return ProjectCompensationBindingEligibilityFacts(project_id=project_id) + + async def lock_compensation_adapter_actor( + self, adapter_actor_id: UUID + ) -> CompensationAdapterActorEligibilityFacts: + self.calls.append("actor") + if not self.actor_available: + raise CompensationAdapterActorUnavailable("actor_unavailable") + return CompensationAdapterActorEligibilityFacts(adapter_actor_id=adapter_actor_id) + + +@dataclass(slots=True) +class Prepared: + facts: AdapterBindingMutationAuthorizationFacts + transaction: object + consumed: bool = False + closed: bool = False + + +class Authorization: + def __init__(self) -> None: + self.prepared = 0 + self.consumed = 0 + self.closed = 0 + self.read_authorized = 0 + self._session = None + self._prepared: dict[int, Prepared] = {} + self.read_available = True + + def bind_session(self, session) -> None: + self._session = session + + def _transaction(self) -> object: + assert self._session is not None + transaction = self._session.sync_session.get_transaction() + assert transaction is not None + return transaction + + async def authorize_adapter_binding_read(self, request: AdapterBindingReadRequest) -> None: + del request + self.read_authorized += 1 + if not self.read_available: + raise AdapterBindingUnavailable("read_denied") + + async def prepare_adapter_binding_mutation( + self, facts: AdapterBindingMutationAuthorizationFacts + ) -> object: + self.prepared += 1 + prepared = Prepared(facts=facts, transaction=self._transaction()) + self._prepared[id(prepared)] = prepared + return prepared + + async def consume_adapter_binding_mutation( + self, prepared: object, facts: AdapterBindingMutationAuthorizationFacts + ) -> UUID: + assert type(prepared) is Prepared + assert self._prepared.get(id(prepared)) is prepared + assert prepared.facts == facts + assert not prepared.consumed and not prepared.closed + assert prepared.transaction is self._transaction() + prepared.consumed = True + self.consumed += 1 + return facts.actor_profile_id + + def close_adapter_binding_mutation(self, prepared: object) -> None: + assert type(prepared) is Prepared + assert self._prepared.get(id(prepared)) is prepared + assert not prepared.closed + prepared.closed = True + self.closed += 1 + + +class CloseFailureAuthorization(Authorization): + def close_adapter_binding_mutation(self, prepared: object) -> None: + super().close_adapter_binding_mutation(prepared) + raise AdapterBindingUnavailable("close_failed") + + +class BlockingAuthorization(Authorization): + def __init__(self) -> None: + super().__init__() + self.entered = asyncio.Event() + self.release = asyncio.Event() + + async def consume_adapter_binding_mutation( + self, prepared: object, facts: AdapterBindingMutationAuthorizationFacts + ) -> UUID: + self.entered.set() + await self.release.wait() + return await super().consume_adapter_binding_mutation(prepared, facts) + + +def service(session, authorization: Authorization, eligibility: Eligibility | None = None): + authorization.bind_session(session) + eligibility = eligibility or Eligibility() + return AdapterBindingService( + session, + read_authorization=authorization, + mutation_authorization=authorization, + projects=eligibility, + actors=eligibility, + ) diff --git a/backend/tests/authorization/guide_compilation/test_migration_contract.py b/backend/tests/authorization/guide_compilation/test_migration_contract.py index 371d5210d..8e8ab0bb4 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)) == ( - "0003_submission_lineage", + "0004_compensation_adapter_binding_lifecycle", 1, 1, 1, diff --git a/backend/tests/compensation/test_adapter_binding_api.py b/backend/tests/compensation/test_adapter_binding_api.py new file mode 100644 index 000000000..753d27b0b --- /dev/null +++ b/backend/tests/compensation/test_adapter_binding_api.py @@ -0,0 +1,71 @@ +"""Focused public-contract proof for hidden adapter-binding behavior.""" + +from dataclasses import FrozenInstanceError +from uuid import uuid4 + +import pytest + +from app.modules.compensation.api import ( + AdapterBindingCreateRequest, + AdapterBindingReadRequest, + AdapterBindingResumeRequest, + AdapterBindingSuspendRequest, +) + + +@pytest.mark.parametrize( + "route_key", ("", "1adapter", "adapter/path", "adapter..secret", "ü", "a" * 121) +) +def test_create_request_rejects_noncanonical_route_key(route_key: str) -> None: + with pytest.raises(ValueError, match="route_key"): + AdapterBindingCreateRequest( + operation_id=uuid4(), + actor_profile_id=uuid4(), + project_id=uuid4(), + instrument_type="money", + adapter_actor_id=uuid4(), + route_key=route_key, + ) + + +def test_mutation_requests_are_immutable_and_require_positive_version() -> None: + with pytest.raises(ValueError, match="positive integer"): + AdapterBindingResumeRequest( + operation_id=uuid4(), + actor_profile_id=uuid4(), + project_id=uuid4(), + adapter_binding_id=uuid4(), + expected_lifecycle_version=0, + ) + request = AdapterBindingResumeRequest( + operation_id=uuid4(), + actor_profile_id=uuid4(), + project_id=uuid4(), + adapter_binding_id=uuid4(), + expected_lifecycle_version=1, + ) + with pytest.raises(FrozenInstanceError): + request.expected_lifecycle_version = 2 # type: ignore[misc] + + +@pytest.mark.parametrize( + ("request_type", "values"), + ( + (AdapterBindingCreateRequest, {"operation_id": "bad", "instrument_type": "money", "adapter_actor_id": uuid4(), "route_key": "adapter.primary"}), + (AdapterBindingReadRequest, {"adapter_binding_id": "bad"}), + (AdapterBindingSuspendRequest, {"operation_id": uuid4(), "adapter_binding_id": "bad", "expected_lifecycle_version": 1}), + (AdapterBindingResumeRequest, {"operation_id": uuid4(), "adapter_binding_id": "bad", "expected_lifecycle_version": 1}), + ), +) +def test_public_requests_reject_non_uuid_selectors(request_type, values) -> None: + with pytest.raises(ValueError, match="must be a UUID"): + request_type(actor_profile_id=uuid4(), project_id=uuid4(), **values) + + +def test_create_request_rejects_unknown_instrument_type() -> None: + with pytest.raises(ValueError, match="instrument_type"): + AdapterBindingCreateRequest( + operation_id=uuid4(), actor_profile_id=uuid4(), project_id=uuid4(), + instrument_type="credits", # type: ignore[arg-type] + adapter_actor_id=uuid4(), route_key="adapter.primary", + ) diff --git a/backend/tests/compensation/test_adapter_binding_authorization_failures.py b/backend/tests/compensation/test_adapter_binding_authorization_failures.py new file mode 100644 index 000000000..9e57f0b4a --- /dev/null +++ b/backend/tests/compensation/test_adapter_binding_authorization_failures.py @@ -0,0 +1,175 @@ +"""Fail-closed AUTH participant proofs for hidden adapter-binding mutations.""" + +from __future__ import annotations + +from typing import Literal +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import func, select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import session as db_session +from app.modules.compensation.api import ( + AdapterBindingCreateRequest, + AdapterBindingMutationAuthorizationFacts, + AdapterBindingUnavailable, +) +from app.modules.compensation.models import ( + CompensationAdapterBindingLifecycleEvent, + ProjectCompensationAdapterBinding, +) +from app.modules.compensation.repository import AdapterBindingRepository +from adapter_binding_fixtures import BindingSeed +from adapter_binding_test_support import Authorization, Prepared, service + +pytest_plugins = ("adapter_binding_fixtures",) + +FailureMode = Literal["denial", "exception", "wrong_actor"] + + +class _ParticipantAuthorization(Authorization): + """Stage transaction-local evidence and inject one consume outcome.""" + + def __init__(self, mode: FailureMode | None = None) -> None: + super().__init__() + self.mode = mode + self.last_prepared: Prepared | None = None + + async def prepare_adapter_binding_mutation( + self, facts: AdapterBindingMutationAuthorizationFacts + ) -> object: + prepared = await super().prepare_adapter_binding_mutation(facts) + assert type(prepared) is Prepared + self.last_prepared = prepared + return prepared + + async def consume_adapter_binding_mutation( + self, prepared: object, facts: AdapterBindingMutationAuthorizationFacts + ) -> UUID: + assert type(prepared) is Prepared and not prepared.closed + assert self._session is not None + assert await self._session.scalar( + select(func.count()).select_from(ProjectCompensationAdapterBinding) + ) == 0 + assert await self._session.scalar( + select(func.count()).select_from(CompensationAdapterBindingLifecycleEvent) + ) == 0 + await self._session.execute( + text("insert into cp02_staged_authorization_effects values (:operation_id)"), + {"operation_id": str(facts.operation_id)}, + ) + if self.mode == "denial": + self.consumed += 1 + raise AdapterBindingUnavailable("consume_denied") + if self.mode == "exception": + self.consumed += 1 + raise RuntimeError("consume_failed") + actor = await super().consume_adapter_binding_mutation(prepared, facts) + return uuid4() if self.mode == "wrong_actor" else actor + + +class _FailingRepository(AdapterBindingRepository): + """Fail after the binding insert has reached PostgreSQL.""" + + async def add_binding_and_event(self, binding, event) -> None: + del event + self._session.add(binding) + await self._session.flush() + raise RuntimeError("product_write_failed") + + +async def _install_participant_probe(session) -> None: + async with session.begin(): + await session.execute( + text( + "create temporary table if not exists cp02_staged_authorization_effects " + "(operation_id uuid primary key) on commit preserve rows" + ) + ) + await session.execute(text("delete from cp02_staged_authorization_effects")) + + +async def _assert_no_effect(session) -> None: + async with session.begin(): + assert await session.scalar( + select(func.count()).select_from(ProjectCompensationAdapterBinding) + ) == 0 + assert await session.scalar( + select(func.count()).select_from(CompensationAdapterBindingLifecycleEvent) + ) == 0 + assert await session.scalar( + text("select count(*) from cp02_staged_authorization_effects") + ) == 0 + + +@pytest.mark.parametrize("mode", ("denial", "exception", "wrong_actor")) +@pytest.mark.asyncio +async def test_consume_failure_closes_once_and_creates_no_effect( + compensation_database_env: str, + binding_seed: BindingSeed, + mode: FailureMode, +) -> None: + project_id, adapter_id, actor_id = await binding_seed() + authorization = _ParticipantAuthorization(mode) + expected = RuntimeError if mode == "exception" else AdapterBindingUnavailable + async with ( + db_session.get_engine().connect() as connection, + AsyncSession(bind=connection, expire_on_commit=False) as session, + ): + await _install_participant_probe(session) + with pytest.raises(expected): + async with session.begin(): + await service(session, authorization).create( + AdapterBindingCreateRequest( + operation_id=uuid4(), actor_profile_id=actor_id, + project_id=project_id, instrument_type="money", + adapter_actor_id=adapter_id, route_key="adapter.primary", + ) + ) + assert authorization.last_prepared is not None + assert authorization.last_prepared.closed + assert authorization.closed == 1 + await _assert_no_effect(session) + with pytest.raises(AssertionError): + async with session.begin(): + await authorization.consume_adapter_binding_mutation( + authorization.last_prepared, authorization.last_prepared.facts + ) + + +@pytest.mark.asyncio +async def test_product_failure_rolls_back_closed_authority_and_all_effects( + compensation_database_env: str, + binding_seed: BindingSeed, +) -> None: + project_id, adapter_id, actor_id = await binding_seed() + authorization = _ParticipantAuthorization() + async with ( + db_session.get_engine().connect() as connection, + AsyncSession(bind=connection, expire_on_commit=False) as session, + ): + await _install_participant_probe(session) + binding_service = service(session, authorization) + binding_service._repository = _FailingRepository(session) + with pytest.raises(RuntimeError, match="product_write_failed"): + async with session.begin(): + await binding_service.create( + AdapterBindingCreateRequest( + operation_id=uuid4(), actor_profile_id=actor_id, + project_id=project_id, instrument_type="money", + adapter_actor_id=adapter_id, route_key="adapter.primary", + ) + ) + assert authorization.last_prepared is not None + assert authorization.last_prepared.consumed + assert authorization.last_prepared.closed + assert (authorization.prepared, authorization.consumed, authorization.closed) == ( + 1, 1, 1, + ) + await _assert_no_effect(session) + with pytest.raises(AssertionError): + async with session.begin(): + await authorization.consume_adapter_binding_mutation( + authorization.last_prepared, authorization.last_prepared.facts + ) diff --git a/backend/tests/compensation/test_adapter_binding_database_guards.py b/backend/tests/compensation/test_adapter_binding_database_guards.py new file mode 100644 index 000000000..f306e8ab7 --- /dev/null +++ b/backend/tests/compensation/test_adapter_binding_database_guards.py @@ -0,0 +1,300 @@ +"""Direct PostgreSQL negative matrix for adapter-binding lifecycle custody.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import delete, select, text, update +from sqlalchemy.exc import DBAPIError + +from app.db import session as db_session +from app.modules.compensation.api import ( + AdapterBindingCreateRequest, + AdapterBindingResumeRequest, + AdapterBindingSuspendRequest, +) +from app.modules.compensation.models import ( + CompensationAdapterBindingLifecycleEvent, + ProjectCompensationAdapterBinding, +) +from adapter_binding_fixtures import BindingSeed +from adapter_binding_test_support import Authorization, service + +pytest_plugins = ("adapter_binding_fixtures",) + + +async def _create_and_suspend( + project_id: UUID, adapter_id: UUID, actor_id: UUID, route_key: str +) -> tuple[UUID, UUID]: + authorization = Authorization() + async with db_session.get_session_factory()() as session: + binding_service = service(session, authorization) + async with session.begin(): + created = await binding_service.create( + AdapterBindingCreateRequest( + operation_id=uuid4(), actor_profile_id=actor_id, + project_id=project_id, instrument_type="money", + adapter_actor_id=adapter_id, route_key=route_key, + ) + ) + async with session.begin(): + suspended = await binding_service.suspend( + AdapterBindingSuspendRequest( + operation_id=uuid4(), actor_profile_id=actor_id, + project_id=project_id, adapter_binding_id=created.adapter_binding_id, + expected_lifecycle_version=1, + ) + ) + return created.adapter_binding_id, suspended.event_id + + +@pytest.mark.parametrize( + "changes", + ( + {"status": "active", "binding_lifecycle_version": 1}, + { + "status": "suspended", "binding_lifecycle_version": 3, + "suspended_by": "actor", "suspended_at": datetime.now(UTC), + }, + {"status": "retired", "binding_lifecycle_version": 2}, + {"project_id": "replacement"}, + {"instrument_type": "project_points"}, + {"adapter_actor_id": "actor"}, + {"created_by": "adapter"}, + {"route_key": "adapter.changed"}, + {"id": "replacement"}, + {"created_at": datetime.now(UTC)}, + {"retired_by": "actor"}, + {"retired_at": datetime.now(UTC)}, + ), +) +@pytest.mark.asyncio +async def test_database_rejects_active_same_state_skip_retired_and_identity_changes( + compensation_database_env: str, + binding_seed: BindingSeed, + changes: dict[str, object], +) -> None: + project_id, adapter_id, actor_id = await binding_seed() + authorization = Authorization() + async with db_session.get_session_factory()() as session: + binding_service = service(session, authorization) + async with session.begin(): + created = await binding_service.create( + AdapterBindingCreateRequest( + operation_id=uuid4(), actor_profile_id=actor_id, + project_id=project_id, instrument_type="money", + adapter_actor_id=adapter_id, route_key="adapter.primary", + ) + ) + resolved = { + key: ( + str(actor_id) if value == "actor" + else str(adapter_id) if value == "adapter" + else str(uuid4()) if value == "replacement" + else value + ) + for key, value in changes.items() + } + with pytest.raises(DBAPIError): + async with session.begin_nested(): + await session.execute( + update(ProjectCompensationAdapterBinding) + .where(ProjectCompensationAdapterBinding.id == created.adapter_binding_id) + .values(**resolved) + ) + + +@pytest.mark.asyncio +async def test_database_rejects_suspended_to_suspended_transition( + compensation_database_env: str, + binding_seed: BindingSeed, +) -> None: + project_id, adapter_id, actor_id = await binding_seed() + binding_id, _ = await _create_and_suspend( + project_id, adapter_id, actor_id, "adapter.primary" + ) + async with db_session.get_session_factory()() as session: + with pytest.raises(DBAPIError): + async with session.begin_nested(): + await session.execute( + update(ProjectCompensationAdapterBinding) + .where(ProjectCompensationAdapterBinding.id == binding_id) + .values(binding_lifecycle_version=3) + ) + + +@pytest.mark.asyncio +async def test_database_rejects_suspended_to_active_version_skip( + compensation_database_env: str, + binding_seed: BindingSeed, +) -> None: + project_id, adapter_id, actor_id = await binding_seed() + binding_id, _ = await _create_and_suspend( + project_id, adapter_id, actor_id, "adapter.primary" + ) + async with db_session.get_session_factory()() as session: + with pytest.raises(DBAPIError): + async with session.begin_nested(): + await session.execute( + update(ProjectCompensationAdapterBinding) + .where(ProjectCompensationAdapterBinding.id == binding_id) + .values( + status="active", binding_lifecycle_version=4, + suspended_by=None, suspended_at=None, + resumed_by=str(actor_id), + ) + ) + + +@pytest.mark.asyncio +async def test_database_rejects_older_same_binding_suspension_reference( + compensation_database_env: str, + binding_seed: BindingSeed, +) -> None: + project_id, adapter_id, actor_id = await binding_seed() + authorization = Authorization() + async with db_session.get_session_factory()() as session: + binding_service = service(session, authorization) + async with session.begin(): + created = await binding_service.create( + AdapterBindingCreateRequest( + operation_id=uuid4(), actor_profile_id=actor_id, + project_id=project_id, instrument_type="money", + adapter_actor_id=adapter_id, route_key="adapter.primary", + ) + ) + async with session.begin(): + first = await binding_service.suspend( + AdapterBindingSuspendRequest( + operation_id=uuid4(), actor_profile_id=actor_id, + project_id=project_id, adapter_binding_id=created.adapter_binding_id, + expected_lifecycle_version=1, + ) + ) + async with session.begin(): + await binding_service.resume( + AdapterBindingResumeRequest( + operation_id=uuid4(), actor_profile_id=actor_id, + project_id=project_id, adapter_binding_id=created.adapter_binding_id, + expected_lifecycle_version=2, + ) + ) + async with session.begin(): + await binding_service.suspend( + AdapterBindingSuspendRequest( + operation_id=uuid4(), actor_profile_id=actor_id, + project_id=project_id, adapter_binding_id=created.adapter_binding_id, + expected_lifecycle_version=3, + ) + ) + with pytest.raises(DBAPIError): + async with session.begin_nested(): + await session.execute( + update(ProjectCompensationAdapterBinding) + .where( + ProjectCompensationAdapterBinding.id + == created.adapter_binding_id + ) + .values( + status="active", binding_lifecycle_version=5, + suspended_by=None, suspended_at=None, + resumed_by=str(actor_id), + ) + ) + session.add( + CompensationAdapterBindingLifecycleEvent( + id=uuid4(), operation_id=uuid4(), + request_digest="sha256:" + "0" * 64, + project_id=str(project_id), + adapter_binding_id=created.adapter_binding_id, + event_type="resumed", actor_profile_id=str(actor_id), + from_status="suspended", to_status="active", + from_lifecycle_version=4, to_lifecycle_version=5, + prior_suspension_event_id=first.event_id, + ) + ) + await session.flush() + + +@pytest.mark.parametrize("prior_case", ("missing", "cross_binding", "forged_actor")) +@pytest.mark.asyncio +async def test_database_rejects_invalid_resume_lineage_or_attribution( + compensation_database_env: str, + binding_seed: BindingSeed, + prior_case: str, +) -> None: + project_id, adapter_id, actor_id = await binding_seed() + first_id, first_suspension_id = await _create_and_suspend( + project_id, adapter_id, actor_id, "adapter.first" + ) + binding_id, suspension_id = first_id, first_suspension_id + if prior_case == "cross_binding": + binding_id, _ = await _create_and_suspend( + project_id, adapter_id, actor_id, "adapter.second" + ) + assert binding_id != first_id + suspension_id = first_suspension_id + elif prior_case == "missing": + suspension_id = uuid4() + + async with db_session.get_session_factory()() as session: + with pytest.raises(DBAPIError): + async with session.begin_nested(): + await session.execute( + update(ProjectCompensationAdapterBinding) + .where(ProjectCompensationAdapterBinding.id == binding_id) + .values( + status="active", binding_lifecycle_version=3, + suspended_by=None, suspended_at=None, + resumed_by=str(actor_id), + ) + ) + session.add( + CompensationAdapterBindingLifecycleEvent( + id=uuid4(), operation_id=uuid4(), + request_digest="sha256:" + "0" * 64, + project_id=str(project_id), adapter_binding_id=binding_id, + event_type="resumed", + actor_profile_id=str( + adapter_id if prior_case == "forged_actor" else actor_id + ), + from_status="suspended", to_status="active", + from_lifecycle_version=2, to_lifecycle_version=3, + prior_suspension_event_id=suspension_id, + ) + ) + await session.flush() + binding = await session.scalar( + select(ProjectCompensationAdapterBinding).where( + ProjectCompensationAdapterBinding.id == binding_id + ) + ) + assert binding is not None + assert ( + binding.status, binding.binding_lifecycle_version, + binding.resumed_by, binding.resumed_at, + ) == ("suspended", 2, None, None) + + +@pytest.mark.parametrize("mutation", ("update", "delete", "truncate")) +@pytest.mark.asyncio +async def test_database_rejects_every_lifecycle_event_mutation( + compensation_database_env: str, + binding_seed: BindingSeed, + mutation: str, +) -> None: + project_id, adapter_id, actor_id = await binding_seed() + await _create_and_suspend(project_id, adapter_id, actor_id, "adapter.primary") + statement = { + "update": update(CompensationAdapterBindingLifecycleEvent).values( + request_digest="sha256:" + "1" * 64 + ), + "delete": delete(CompensationAdapterBindingLifecycleEvent), + "truncate": text("truncate compensation_adapter_binding_lifecycle_events"), + }[mutation] + async with db_session.get_session_factory()() as session: + with pytest.raises(DBAPIError): + async with session.begin_nested(): + await session.execute(statement) diff --git a/backend/tests/compensation/test_adapter_binding_owner_fences.py b/backend/tests/compensation/test_adapter_binding_owner_fences.py new file mode 100644 index 000000000..faa4ed1d9 --- /dev/null +++ b/backend/tests/compensation/test_adapter_binding_owner_fences.py @@ -0,0 +1,418 @@ +"""PostgreSQL proof that CP02 owner-capability fakes retain eligibility fences.""" + +import asyncio + +from collections.abc import Awaitable, Callable +from contextlib import suppress +from dataclasses import dataclass +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import delete, func, select, text, update +from sqlalchemy.exc import DBAPIError + +from app.db import session as db_session +from app.modules.actors.api import ( + CompensationAdapterActorEligibilityFacts, + CompensationAdapterActorUnavailable, +) +from app.modules.actors.models import ActorIdentityLink, ActorProfile +from app.modules.actors.service_identities import ServiceIdentity +from app.modules.compensation.api import ( + AdapterBindingCreateRequest, + AdapterBindingConflict, + AdapterBindingMutationAuthorizationFacts, + AdapterBindingResumeRequest, + AdapterBindingSuspendRequest, +) +from app.modules.compensation.service import AdapterBindingService +from app.modules.compensation.models import ( + CompensationAdapterBindingLifecycleEvent, + ProjectCompensationAdapterBinding, +) +from app.modules.projects.api import ( + ProjectCompensationBindingEligibilityFacts, + ProjectCompensationBindingUnavailable, +) +from app.modules.projects.models import Project + +BindingSeed = Callable[[], Awaitable[tuple[UUID, UUID, UUID]]] +pytest_plugins = ("adapter_binding_fixtures",) + + +@dataclass(frozen=True, slots=True) +class _CompensationAdapterEligibilityMarker: + actor_id: UUID + + +class _OwnerFences: + def __init__(self, session, marker: _CompensationAdapterEligibilityMarker) -> None: + self.session = session + self.marker = marker + + async def lock_compensation_binding_project( + self, project_id: UUID + ) -> ProjectCompensationBindingEligibilityFacts: + row = await self.session.scalar( + select(Project).where(Project.id == str(project_id)).with_for_update() + ) + if row is None or row.status not in {"draft", "active"}: + raise ProjectCompensationBindingUnavailable("project_unavailable") + return ProjectCompensationBindingEligibilityFacts(project_id=project_id) + + async def lock_compensation_adapter_actor( + self, adapter_actor_id: UUID + ) -> CompensationAdapterActorEligibilityFacts: + row = await self.session.scalar( + select(ActorProfile) + .where(ActorProfile.id == str(adapter_actor_id)) + .with_for_update() + ) + if ( + row is None + or row.status != "active" + or row.service_identity is not None + or adapter_actor_id != self.marker.actor_id + ): + raise CompensationAdapterActorUnavailable("actor_unavailable") + return CompensationAdapterActorEligibilityFacts(adapter_actor_id=adapter_actor_id) + + +class _BlockingAuthorization: + def __init__(self) -> None: + self.entered = asyncio.Event() + self.release = asyncio.Event() + self.prepared = 0 + + async def authorize_adapter_binding_read(self, request) -> None: + del request + + async def prepare_adapter_binding_mutation( + self, facts: AdapterBindingMutationAuthorizationFacts + ) -> object: + self.prepared += 1 + return facts + + async def consume_adapter_binding_mutation( + self, prepared: object, facts: AdapterBindingMutationAuthorizationFacts + ) -> UUID: + assert prepared is facts + self.entered.set() + await self.release.wait() + return facts.actor_profile_id + + def close_adapter_binding_mutation(self, prepared: object) -> None: + del prepared + + +async def _prepare_suspended( + project_id: UUID, adapter_id: UUID, actor_id: UUID +) -> UUID: + authorization = _BlockingAuthorization() + authorization.release.set() + async with db_session.get_session_factory()() as session: + service = AdapterBindingService( + session, read_authorization=authorization, + mutation_authorization=authorization, + projects=_OwnerFences(session, _CompensationAdapterEligibilityMarker(adapter_id)), + actors=_OwnerFences(session, _CompensationAdapterEligibilityMarker(adapter_id)), + ) + async with session.begin(): + created = await service.create( + AdapterBindingCreateRequest( + operation_id=uuid4(), actor_profile_id=actor_id, + project_id=project_id, instrument_type="money", + adapter_actor_id=adapter_id, route_key="adapter.primary", + ) + ) + async with session.begin(): + await service.suspend( + AdapterBindingSuspendRequest( + operation_id=uuid4(), actor_profile_id=actor_id, + project_id=project_id, adapter_binding_id=created.adapter_binding_id, + expected_lifecycle_version=1, + ) + ) + return created.adapter_binding_id + + +@pytest.mark.parametrize("operation", ("create", "resume")) +@pytest.mark.parametrize("locked_owner", ("project", "actor")) +@pytest.mark.asyncio +async def test_owner_rows_remain_locked_through_protected_mutation( + compensation_database_env: str, + binding_seed: BindingSeed, + operation: str, + locked_owner: str, +) -> None: + project_id, adapter_id, actor_id = await binding_seed() + binding_id = ( + await _prepare_suspended(project_id, adapter_id, actor_id) + if operation == "resume" + else None + ) + authorization = _BlockingAuthorization() + + async def mutate() -> None: + async with db_session.get_session_factory()() as session: + owners = _OwnerFences( + session, _CompensationAdapterEligibilityMarker(adapter_id) + ) + service = AdapterBindingService( + session, read_authorization=authorization, + mutation_authorization=authorization, projects=owners, actors=owners, + ) + async with session.begin(): + if operation == "create": + await service.create( + AdapterBindingCreateRequest( + operation_id=uuid4(), actor_profile_id=actor_id, + project_id=project_id, instrument_type="money", + adapter_actor_id=adapter_id, route_key="adapter.primary", + ) + ) + else: + assert binding_id is not None + await service.resume( + AdapterBindingResumeRequest( + operation_id=uuid4(), actor_profile_id=actor_id, + project_id=project_id, adapter_binding_id=binding_id, + expected_lifecycle_version=2, + ) + ) + + async def eligibility_change() -> None: + async with db_session.get_session_factory()() as session: + with pytest.raises(DBAPIError): + async with session.begin(): + await session.execute(text("SET LOCAL lock_timeout = '100ms'")) + model, value = ( + (Project, project_id) + if locked_owner == "project" + else (ActorProfile, adapter_id) + ) + await session.scalar(select(model).where(model.id == str(value)).with_for_update()) + + mutation = asyncio.create_task(mutate()) + entered = asyncio.create_task(authorization.entered.wait()) + try: + done, _ = await asyncio.wait( + {entered, mutation}, timeout=30, return_when=asyncio.FIRST_COMPLETED + ) + if mutation in done: + await mutation + raise AssertionError("mutation completed before authorization") + assert entered in done, "mutation did not reach authorization" + await eligibility_change() + authorization.release.set() + await mutation + finally: + authorization.release.set() + entered.cancel() + mutation.cancel() + with suppress(asyncio.CancelledError): + await entered + with suppress(asyncio.CancelledError): + await mutation + + +@pytest.mark.parametrize("operation", ("create", "resume")) +@pytest.mark.parametrize("ineligible_owner", ("project", "actor")) +@pytest.mark.asyncio +async def test_committed_owner_ineligibility_denies_before_authorization( + compensation_database_env: str, + binding_seed: BindingSeed, + operation: str, + ineligible_owner: str, +) -> None: + project_id, adapter_id, actor_id = await binding_seed() + binding_id = ( + await _prepare_suspended(project_id, adapter_id, actor_id) + if operation == "resume" + else None + ) + async with db_session.get_session_factory()() as session: + async with session.begin(): + if ineligible_owner == "project": + project = await session.get(Project, str(project_id)) + assert project is not None + project.status = "closed" + else: + actor = await session.get(ActorProfile, str(adapter_id)) + assert actor is not None + actor.status = "deactivated" + actor.deactivated_by = str(actor_id) + actor.deactivated_at = datetime.now(UTC) + actor.deactivation_reason = "compensation adapter disabled" + + authorization = _BlockingAuthorization() + authorization.release.set() + async with db_session.get_session_factory()() as session: + owners = _OwnerFences(session, _CompensationAdapterEligibilityMarker(adapter_id)) + service = AdapterBindingService( + session, read_authorization=authorization, + mutation_authorization=authorization, projects=owners, actors=owners, + ) + with pytest.raises(AdapterBindingConflict): + async with session.begin(): + if operation == "create": + await service.create( + AdapterBindingCreateRequest( + operation_id=uuid4(), actor_profile_id=actor_id, + project_id=project_id, instrument_type="money", + adapter_actor_id=adapter_id, route_key="adapter.primary", + ) + ) + else: + assert binding_id is not None + await service.resume( + AdapterBindingResumeRequest( + operation_id=uuid4(), actor_profile_id=actor_id, + project_id=project_id, adapter_binding_id=binding_id, + expected_lifecycle_version=2, + ) + ) + assert authorization.prepared == 0 + async with db_session.get_session_factory()() as session: + binding_count = await session.scalar( + select(func.count()).select_from(ProjectCompensationAdapterBinding) + ) + event_count = await session.scalar( + select(func.count()).select_from(CompensationAdapterBindingLifecycleEvent) + ) + binding = ( + await session.get(ProjectCompensationAdapterBinding, binding_id) + if binding_id is not None + else None + ) + expected_count = 0 if operation == "create" else 1 + assert binding_count == expected_count and event_count == expected_count * 2 + if binding is not None: + assert (binding.status, binding.binding_lifecycle_version) == ("suspended", 2) + + +@pytest.mark.asyncio +async def test_database_rejects_event_changes_and_binding_identity_mutation( + compensation_database_env: str, + binding_seed: BindingSeed, +) -> None: + project_id, adapter_id, actor_id = await binding_seed() + binding_id = await _prepare_suspended(project_id, adapter_id, actor_id) + async with db_session.get_session_factory()() as session: + for statement in ( + update(CompensationAdapterBindingLifecycleEvent).values(request_digest="sha256:" + "1" * 64), + delete(CompensationAdapterBindingLifecycleEvent), + update(ProjectCompensationAdapterBinding) + .where(ProjectCompensationAdapterBinding.id == binding_id) + .values(route_key="adapter.changed"), + ): + with pytest.raises(DBAPIError): + async with session.begin_nested(): + await session.execute(statement) + + +@pytest.mark.asyncio +async def test_active_replacement_blocks_resume_of_suspended_binding( + compensation_database_env: str, + binding_seed: BindingSeed, +) -> None: + project_id, adapter_id, actor_id = await binding_seed() + suspended_id = await _prepare_suspended(project_id, adapter_id, actor_id) + authorization = _BlockingAuthorization() + authorization.release.set() + async with db_session.get_session_factory()() as session: + owners = _OwnerFences(session, _CompensationAdapterEligibilityMarker(adapter_id)) + service = AdapterBindingService( + session, read_authorization=authorization, + mutation_authorization=authorization, projects=owners, actors=owners, + ) + async with session.begin(): + await service.create( + AdapterBindingCreateRequest( + operation_id=uuid4(), actor_profile_id=actor_id, + project_id=project_id, instrument_type="money", + adapter_actor_id=adapter_id, route_key="adapter.replacement", + ) + ) + with pytest.raises(AdapterBindingConflict): + async with session.begin(): + await service.resume( + AdapterBindingResumeRequest( + operation_id=uuid4(), actor_profile_id=actor_id, + project_id=project_id, adapter_binding_id=suspended_id, + expected_lifecycle_version=2, + ) + ) + assert authorization.prepared == 1 + + +@pytest.mark.asyncio +async def test_existing_art_service_identity_cannot_substitute_for_cp03_registration( + compensation_database_env: str, + binding_seed: BindingSeed, +) -> None: + project_id, _, actor_id = await binding_seed() + art_actor_id = uuid4() + async with db_session.get_session_factory()() as session: + async with session.begin(): + session.add( + ActorProfile( + id=str(art_actor_id), actor_kind="service", status="active", + provisioning_method="manual_service_provisioning", + service_identity=ServiceIdentity.ARTIFACT_VERIFIER.value, + created_by=str(actor_id), + ) + ) + await session.flush() + session.add( + ActorIdentityLink( + id=str(uuid4()), actor_profile_id=str(art_actor_id), + issuer="https://compensation.test", subject=f"art-{art_actor_id}", + subject_kind="service", status="active", linked_by=str(actor_id), + ) + ) + authorization = _BlockingAuthorization() + authorization.release.set() + async with db_session.get_session_factory()() as session: + owners = _OwnerFences( + session, _CompensationAdapterEligibilityMarker(art_actor_id) + ) + with pytest.raises(AdapterBindingConflict): + async with session.begin(): + await AdapterBindingService( + session, read_authorization=authorization, + mutation_authorization=authorization, projects=owners, actors=owners, + ).create( + AdapterBindingCreateRequest( + operation_id=uuid4(), actor_profile_id=actor_id, + project_id=project_id, instrument_type="money", + adapter_actor_id=art_actor_id, route_key="adapter.primary", + ) + ) + assert authorization.prepared == 0 + + +@pytest.mark.asyncio +async def test_unmarked_human_actor_cannot_substitute_for_owner_eligibility( + compensation_database_env: str, + binding_seed: BindingSeed, +) -> None: + project_id, _, actor_id = await binding_seed() + authorization = _BlockingAuthorization() + authorization.release.set() + async with db_session.get_session_factory()() as session: + owners = _OwnerFences(session, _CompensationAdapterEligibilityMarker(uuid4())) + with pytest.raises(AdapterBindingConflict): + async with session.begin(): + await AdapterBindingService( + session, read_authorization=authorization, + mutation_authorization=authorization, projects=owners, actors=owners, + ).create( + AdapterBindingCreateRequest( + operation_id=uuid4(), actor_profile_id=actor_id, + project_id=project_id, instrument_type="money", + adapter_actor_id=actor_id, route_key="adapter.primary", + ) + ) + assert authorization.prepared == 0 diff --git a/backend/tests/compensation/test_adapter_binding_partition.py b/backend/tests/compensation/test_adapter_binding_partition.py new file mode 100644 index 000000000..05aff75d8 --- /dev/null +++ b/backend/tests/compensation/test_adapter_binding_partition.py @@ -0,0 +1,23 @@ +"""Exact behavior-partition custody proof for CP02 additions.""" + +from scripts import behavior_ownership as ownership + + +def test_cp02_partition_additions_are_exact_and_owner_classified() -> None: + assert ownership.ARCH_CP02_ADAPTER_BINDING_TARGETS == { + "backend/app/modules/actors/api/compensation_adapter.py", + "backend/app/modules/compensation/api/adapter_bindings.py", + "backend/app/modules/compensation/repository.py", + "backend/app/modules/compensation/service.py", + "backend/app/modules/projects/api/compensation_binding.py", + } + assert { + target: ownership.group_for_target(target) + for target in ownership.ARCH_CP02_ADAPTER_BINDING_TARGETS + } == { + "backend/app/modules/actors/api/compensation_adapter.py": "shared", + "backend/app/modules/compensation/api/adapter_bindings.py": "lifecycle", + "backend/app/modules/compensation/repository.py": "lifecycle", + "backend/app/modules/compensation/service.py": "lifecycle", + "backend/app/modules/projects/api/compensation_binding.py": "lifecycle", + } diff --git a/backend/tests/compensation/test_adapter_binding_persistence.py b/backend/tests/compensation/test_adapter_binding_persistence.py new file mode 100644 index 000000000..b06a0910b --- /dev/null +++ b/backend/tests/compensation/test_adapter_binding_persistence.py @@ -0,0 +1,30 @@ +"""Focused persistence-contract proof for adapter-binding lifecycle truth.""" + +import hashlib +from uuid import UUID + +from app.db.base import Base +from app.modules.compensation.repository import operation_advisory_lock_key + + +def test_operation_fence_key_is_exact_signed_sha256_prefix() -> None: + operation_id = UUID("12345678-1234-5678-1234-567812345678") + prefix = hashlib.sha256(operation_id.bytes).digest()[:8] + expected = int.from_bytes(prefix, "big", signed=True) + assert operation_advisory_lock_key(operation_id) == expected + + +def test_lifecycle_event_metadata_is_append_only_shape() -> None: + table = Base.metadata.tables["compensation_adapter_binding_lifecycle_events"] + assert set(table.columns.keys()) == { + "id", "operation_id", "request_digest", "project_id", "adapter_binding_id", + "event_type", "actor_profile_id", "from_status", "to_status", + "from_lifecycle_version", "to_lifecycle_version", + "prior_suspension_event_id", "occurred_at", + } + assert {constraint.name for constraint in table.constraints} >= { + "operation_id", + "binding_version", + "ck_compensation_adapter_binding_lifecycle_events_request_digest", + "ck_compensation_adapter_binding_lifecycle_events_transition_shape", + } diff --git a/backend/tests/compensation/test_adapter_binding_recovery.py b/backend/tests/compensation/test_adapter_binding_recovery.py new file mode 100644 index 000000000..ab84ce449 --- /dev/null +++ b/backend/tests/compensation/test_adapter_binding_recovery.py @@ -0,0 +1,122 @@ +"""Idempotent recovery proof for adapter-binding lifecycle mutations.""" + +import asyncio +from collections.abc import Awaitable, Callable +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import func, select + +from adapter_binding_test_support import Authorization, BlockingAuthorization, service +from app.db import session as db_session +from app.modules.compensation.api import ( + AdapterBindingConflict, + AdapterBindingCreateRequest, + AdapterBindingResumeRequest, + AdapterBindingSuspendRequest, +) +from app.modules.compensation.models import CompensationAdapterBindingLifecycleEvent + +BindingSeed = Callable[[], Awaitable[tuple[UUID, UUID, UUID]]] +pytest_plugins = ("adapter_binding_fixtures",) + + +async def _create(project_id: UUID, adapter_id: UUID, actor_id: UUID): + authorization = Authorization() + async with db_session.get_session_factory()() as session: + async with session.begin(): + result = await service(session, authorization).create( + AdapterBindingCreateRequest( + operation_id=uuid4(), actor_profile_id=actor_id, + project_id=project_id, instrument_type="money", + adapter_actor_id=adapter_id, route_key="adapter.primary", + ) + ) + return result + + +async def _request_for_transition( + transition: str, project_id: UUID, adapter_id: UUID, actor_id: UUID +): + created = await _create(project_id, adapter_id, actor_id) + if transition == "suspend": + return AdapterBindingSuspendRequest( + operation_id=uuid4(), actor_profile_id=actor_id, project_id=project_id, + adapter_binding_id=created.adapter_binding_id, expected_lifecycle_version=1, + ) + authorization = Authorization() + async with db_session.get_session_factory()() as session: + async with session.begin(): + await service(session, authorization).suspend( + AdapterBindingSuspendRequest( + operation_id=uuid4(), actor_profile_id=actor_id, + project_id=project_id, adapter_binding_id=created.adapter_binding_id, + expected_lifecycle_version=1, + ) + ) + return AdapterBindingResumeRequest( + operation_id=uuid4(), actor_profile_id=actor_id, project_id=project_id, + adapter_binding_id=created.adapter_binding_id, expected_lifecycle_version=2, + ) + + +@pytest.mark.parametrize("transition", ("suspend", "resume")) +@pytest.mark.asyncio +async def test_concurrent_duplicate_transition_recovers_one_effect( + compensation_database_env: str, + binding_seed: BindingSeed, + transition: str, +) -> None: + project_id, adapter_id, actor_id = await binding_seed() + request = await _request_for_transition(transition, project_id, adapter_id, actor_id) + winner_auth, loser_auth = BlockingAuthorization(), Authorization() + + async def run(authorization: Authorization): + async with db_session.get_session_factory()() as session: + async with session.begin(): + return await getattr(service(session, authorization), transition)(request) + + winner = asyncio.create_task(run(winner_auth)) + await winner_auth.entered.wait() + loser = asyncio.create_task(run(loser_auth)) + with pytest.raises(TimeoutError): + await asyncio.wait_for(asyncio.shield(loser), timeout=0.1) + winner_auth.release.set() + first, second = await asyncio.gather(winner, loser) + assert first == second + assert winner_auth.prepared + loser_auth.prepared == 1 + async with db_session.get_session_factory()() as session: + count = await session.scalar( + select(func.count()).select_from(CompensationAdapterBindingLifecycleEvent).where( + CompensationAdapterBindingLifecycleEvent.operation_id == request.operation_id + ) + ) + assert count == 1 + + +@pytest.mark.asyncio +async def test_duplicate_mismatch_and_read_denial_are_concealed_without_mutation_prep( + compensation_database_env: str, + binding_seed: BindingSeed, +) -> None: + project_id, adapter_id, actor_id = await binding_seed() + request = await _request_for_transition("suspend", project_id, adapter_id, actor_id) + authorization = Authorization() + async with db_session.get_session_factory()() as session: + binding_service = service(session, authorization) + async with session.begin(): + await binding_service.suspend(request) + prepared = authorization.prepared + mismatch = AdapterBindingSuspendRequest( + operation_id=request.operation_id, actor_profile_id=actor_id, + project_id=project_id, adapter_binding_id=request.adapter_binding_id, + expected_lifecycle_version=2, + ) + with pytest.raises(AdapterBindingConflict): + async with session.begin(): + await binding_service.suspend(mismatch) + authorization.read_available = False + with pytest.raises(AdapterBindingConflict): + async with session.begin(): + await binding_service.suspend(request) + assert authorization.prepared == prepared diff --git a/backend/tests/compensation/test_adapter_binding_service.py b/backend/tests/compensation/test_adapter_binding_service.py new file mode 100644 index 000000000..ce9eada5c --- /dev/null +++ b/backend/tests/compensation/test_adapter_binding_service.py @@ -0,0 +1,485 @@ +"""Focused PostgreSQL proof for the hidden adapter-binding service.""" + +import asyncio + +from collections.abc import Awaitable, Callable +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import func, select +from sqlalchemy.exc import DBAPIError + +from app.db import session as db_session +from app.modules.compensation.api import ( + AdapterBindingCreateRequest, + AdapterBindingConflict, + AdapterBindingReadRequest, + AdapterBindingResumeRequest, + AdapterBindingSuspendRequest, + AdapterBindingUnavailable, +) +from app.modules.compensation.models import ( + CompensationAdapterBindingLifecycleEvent, + ProjectCompensationAdapterBinding, +) +from app.modules.compensation.service import AdapterBindingService +from adapter_binding_test_support import ( + Authorization as _Authorization, + BlockingAuthorization as _BlockingAuthorization, + CloseFailureAuthorization as _CloseFailureAuthorization, + Eligibility as _Eligibility, + Prepared as _Prepared, + service as _service, +) +BindingSeed = Callable[[], Awaitable[tuple[UUID, UUID, UUID]]] +pytest_plugins = ("adapter_binding_fixtures",) + + +@pytest.mark.asyncio +async def test_create_suspend_resume_persists_contiguous_immutable_history( + compensation_database_env: str, + binding_seed: BindingSeed, +) -> None: + project_id, adapter_id, actor_id = await binding_seed() + authorization = _Authorization() + async with db_session.get_session_factory()() as session: + service = _service(session, authorization) + async with session.begin(): + created = await service.create( + AdapterBindingCreateRequest( + operation_id=uuid4(), actor_profile_id=actor_id, project_id=project_id, + instrument_type="money", adapter_actor_id=adapter_id, + route_key="adapter.primary", + ) + ) + async with session.begin(): + suspended = await service.suspend( + AdapterBindingSuspendRequest( + operation_id=uuid4(), actor_profile_id=actor_id, project_id=project_id, + adapter_binding_id=created.adapter_binding_id, + expected_lifecycle_version=1, + ) + ) + async with session.begin(): + resumed = await service.resume( + AdapterBindingResumeRequest( + operation_id=uuid4(), actor_profile_id=actor_id, project_id=project_id, + adapter_binding_id=created.adapter_binding_id, + expected_lifecycle_version=2, + ) + ) + view = await service.read( + AdapterBindingReadRequest( + actor_profile_id=actor_id, + project_id=project_id, + adapter_binding_id=created.adapter_binding_id, + ) + ) + assert resumed.prior_suspension_event_id == suspended.event_id + assert (authorization.prepared, authorization.consumed, authorization.closed) == (3, 3, 3) + async with db_session.get_session_factory()() as session: + events = ( + await session.scalars( + select(CompensationAdapterBindingLifecycleEvent).order_by( + CompensationAdapterBindingLifecycleEvent.to_lifecycle_version + ) + ) + ).all() + binding = await session.get( + ProjectCompensationAdapterBinding, created.adapter_binding_id + ) + assert [event.event_type for event in events] == ["created", "suspended", "resumed"] + assert binding is not None and (binding.status, binding.binding_lifecycle_version) == ( + "active", 3 + ) + assert binding.resumed_by == str(actor_id) + assert binding.resumed_at is not None + assert view.resumed_by == actor_id + assert view.resumed_at == binding.resumed_at + + +@pytest.mark.asyncio +async def test_exact_duplicate_recovers_without_second_mutation_authorization( + compensation_database_env: str, + binding_seed: BindingSeed, +) -> None: + project_id, adapter_id, actor_id = await binding_seed() + authorization = _Authorization() + request = AdapterBindingCreateRequest( + operation_id=uuid4(), actor_profile_id=actor_id, project_id=project_id, + instrument_type="money", adapter_actor_id=adapter_id, route_key="adapter.primary", + ) + async with db_session.get_session_factory()() as session: + service = _service(session, authorization) + async with session.begin(): + original = await service.create(request) + async with session.begin(): + recovered = await service.create(request) + assert recovered == original + assert (authorization.prepared, authorization.consumed, authorization.closed) == (1, 1, 1) + + +@pytest.mark.asyncio +async def test_close_failure_prevents_product_state_and_event( + compensation_database_env: str, + binding_seed: BindingSeed, +) -> None: + project_id, adapter_id, actor_id = await binding_seed() + authorization = _CloseFailureAuthorization() + async with db_session.get_session_factory()() as session: + service = _service(session, authorization) + with pytest.raises(AdapterBindingUnavailable, match="close_failed"): + async with session.begin(): + await service.create( + AdapterBindingCreateRequest( + operation_id=uuid4(), actor_profile_id=actor_id, + project_id=project_id, instrument_type="money", + adapter_actor_id=adapter_id, route_key="adapter.primary", + ) + ) + assert (authorization.prepared, authorization.consumed, authorization.closed) == (1, 1, 1) + async with db_session.get_session_factory()() as session: + assert await session.scalar(select(ProjectCompensationAdapterBinding.id)) is None + assert await session.scalar(select(CompensationAdapterBindingLifecycleEvent.id)) is None + + +@pytest.mark.asyncio +async def test_owner_denial_precedes_actor_lookup_and_authorization( + compensation_database_env: str, + binding_seed: BindingSeed, +) -> None: + project_id, adapter_id, actor_id = await binding_seed() + authorization = _Authorization() + eligibility = _Eligibility(project_available=False) + async with db_session.get_session_factory()() as session: + service = _service(session, authorization, eligibility) + with pytest.raises(AdapterBindingConflict, match="compensation_adapter_binding_not_found"): + async with session.begin(): + await service.create( + AdapterBindingCreateRequest( + operation_id=uuid4(), actor_profile_id=actor_id, + project_id=project_id, instrument_type="money", + adapter_actor_id=adapter_id, route_key="adapter.primary", + ) + ) + assert eligibility.calls == ["project"] + assert (authorization.prepared, authorization.consumed, authorization.closed) == (0, 0, 0) + + +@pytest.mark.asyncio +async def test_defensively_rejects_tampered_request_before_authorization( + compensation_database_env: str, + binding_seed: BindingSeed, +) -> None: + project_id, adapter_id, actor_id = await binding_seed() + request = AdapterBindingCreateRequest( + operation_id=uuid4(), actor_profile_id=actor_id, project_id=project_id, + instrument_type="money", adapter_actor_id=adapter_id, route_key="adapter.primary", + ) + object.__setattr__(request, "instrument_type", "credits") + authorization = _Authorization() + async with db_session.get_session_factory()() as session: + with pytest.raises(ValueError, match="instrument_type"): + async with session.begin(): + await _service(session, authorization).create(request) + assert authorization.prepared == 0 + + +@pytest.mark.asyncio +async def test_prepared_fake_rejects_copy_replay_and_transaction_replacement( + compensation_database_env: str, + binding_seed: BindingSeed, +) -> None: + project_id, adapter_id, actor_id = await binding_seed() + authorization = _Authorization() + async with db_session.get_session_factory()() as session: + service = _service(session, authorization) + async with session.begin(): + await service.create( + AdapterBindingCreateRequest( + operation_id=uuid4(), actor_profile_id=actor_id, + project_id=project_id, instrument_type="money", + adapter_actor_id=adapter_id, route_key="adapter.primary", + ) + ) + prepared = next(iter(authorization._prepared.values())) + copied = _Prepared(prepared.facts, prepared.transaction) + async with session.begin(): + with pytest.raises(AssertionError): + await authorization.consume_adapter_binding_mutation(copied, prepared.facts) + with pytest.raises(AssertionError): + await authorization.consume_adapter_binding_mutation(prepared, prepared.facts) + async with session.begin(): + replaced_transaction = await authorization.prepare_adapter_binding_mutation( + prepared.facts + ) + async with session.begin(): + with pytest.raises(AssertionError): + await authorization.consume_adapter_binding_mutation( + replaced_transaction, prepared.facts + ) + + +@pytest.mark.asyncio +async def test_production_default_denies_before_product_state( + compensation_database_env: str, + binding_seed: BindingSeed, +) -> None: + project_id, adapter_id, actor_id = await binding_seed() + async with db_session.get_session_factory()() as session: + service = AdapterBindingService( + session, projects=_Eligibility(), actors=_Eligibility() + ) + with pytest.raises(AdapterBindingUnavailable): + async with session.begin(): + await service.create( + AdapterBindingCreateRequest( + operation_id=uuid4(), actor_profile_id=actor_id, + project_id=project_id, instrument_type="money", + adapter_actor_id=adapter_id, route_key="adapter.primary", + ) + ) + async with db_session.get_session_factory()() as session: + assert await session.scalar(select(ProjectCompensationAdapterBinding.id)) is None + assert await session.scalar(select(CompensationAdapterBindingLifecycleEvent.id)) is None + + +@pytest.mark.asyncio +async def test_concurrent_duplicate_waits_then_recovers_one_effect( + compensation_database_env: str, + binding_seed: BindingSeed, +) -> None: + project_id, adapter_id, actor_id = await binding_seed() + request = AdapterBindingCreateRequest( + operation_id=uuid4(), actor_profile_id=actor_id, project_id=project_id, + instrument_type="money", adapter_actor_id=adapter_id, route_key="adapter.primary", + ) + winner_auth = _BlockingAuthorization() + loser_auth = _Authorization() + + async def run(authorization: _Authorization): + async with db_session.get_session_factory()() as session: + async with session.begin(): + return await _service(session, authorization).create(request) + + winner = asyncio.create_task(run(winner_auth)) + await winner_auth.entered.wait() + loser = asyncio.create_task(run(loser_auth)) + await asyncio.sleep(0.05) + assert not loser.done() + winner_auth.release.set() + first, second = await asyncio.gather(winner, loser) + assert first == second + assert winner_auth.prepared + loser_auth.prepared == 1 + async with db_session.get_session_factory()() as session: + assert len((await session.scalars(select(ProjectCompensationAdapterBinding))).all()) == 1 + assert len( + (await session.scalars(select(CompensationAdapterBindingLifecycleEvent))).all() + ) == 1 + + +@pytest.mark.asyncio +async def test_concurrent_distinct_creates_allow_one_active_binding( + compensation_database_env: str, + binding_seed: BindingSeed, +) -> None: + project_id, adapter_id, actor_id = await binding_seed() + winner_auth = _BlockingAuthorization() + loser_auth = _Authorization() + + async def run(operation_id: UUID, route_key: str, authorization: _Authorization): + async with db_session.get_session_factory()() as session: + try: + async with session.begin(): + return await _service(session, authorization).create( + AdapterBindingCreateRequest( + operation_id=operation_id, actor_profile_id=actor_id, + project_id=project_id, instrument_type="money", + adapter_actor_id=adapter_id, route_key=route_key, + ) + ) + except AdapterBindingConflict: + return "conflict" + + winner = asyncio.create_task(run(uuid4(), "adapter.first", winner_auth)) + await winner_auth.entered.wait() + loser = asyncio.create_task(run(uuid4(), "adapter.second", loser_auth)) + await asyncio.sleep(0.05) + assert not loser.done() + winner_auth.release.set() + results = await asyncio.gather(winner, loser) + assert sum(result == "conflict" for result in results) == 1 + assert winner_auth.prepared + loser_auth.prepared == 1 + async with db_session.get_session_factory()() as session: + assert await session.scalar( + select(func.count()).select_from(ProjectCompensationAdapterBinding) + ) == 1 + assert await session.scalar( + select(func.count()).select_from(CompensationAdapterBindingLifecycleEvent) + ) == 1 + + +@pytest.mark.asyncio +async def test_database_rejects_missing_event_and_mismatched_attribution( + compensation_database_env: str, + binding_seed: BindingSeed, +) -> None: + project_id, adapter_id, actor_id = await binding_seed() + binding_id = uuid4() + async with db_session.get_session_factory()() as session: + with pytest.raises(DBAPIError, match="transition requires lifecycle event"): + async with session.begin(): + session.add( + ProjectCompensationAdapterBinding( + id=binding_id, project_id=str(project_id), instrument_type="money", + adapter_actor_id=str(adapter_id), route_key="adapter.primary", + status="active", binding_lifecycle_version=1, + created_by=str(actor_id), + ) + ) + + binding_id = uuid4() + async with db_session.get_session_factory()() as session: + with pytest.raises(DBAPIError, match="attribution mismatch"): + async with session.begin(): + session.add( + ProjectCompensationAdapterBinding( + id=binding_id, project_id=str(project_id), + instrument_type="money", adapter_actor_id=str(adapter_id), + route_key="adapter.primary", status="active", + binding_lifecycle_version=1, created_by=str(actor_id), + ) + ) + await session.flush() + session.add( + CompensationAdapterBindingLifecycleEvent( + id=uuid4(), operation_id=uuid4(), + request_digest="sha256:" + "0" * 64, + project_id=str(project_id), adapter_binding_id=binding_id, + event_type="created", actor_profile_id=str(adapter_id), + from_status=None, to_status="active", + from_lifecycle_version=0, to_lifecycle_version=1, + ) + ) + await session.flush() + + +@pytest.mark.asyncio +async def test_absent_and_unauthorized_reads_are_equally_concealed( + compensation_database_env: str, + binding_seed: BindingSeed, +) -> None: + project_id, adapter_id, actor_id = await binding_seed() + authorization = _Authorization() + async with db_session.get_session_factory()() as session: + service = _service(session, authorization) + async with session.begin(): + created = await service.create( + AdapterBindingCreateRequest( + operation_id=uuid4(), actor_profile_id=actor_id, + project_id=project_id, instrument_type="money", + adapter_actor_id=adapter_id, route_key="adapter.primary", + ) + ) + absent_request = AdapterBindingReadRequest( + actor_profile_id=actor_id, project_id=project_id, + adapter_binding_id=uuid4(), + ) + with pytest.raises(AdapterBindingConflict) as absent: + await service.read(absent_request) + authorization.read_available = False + with pytest.raises(AdapterBindingConflict) as unauthorized: + await service.read( + AdapterBindingReadRequest( + actor_profile_id=actor_id, project_id=project_id, + adapter_binding_id=created.adapter_binding_id, + ) + ) + assert str(absent.value) == str(unauthorized.value) == "compensation_adapter_binding_not_found" + + +@pytest.mark.asyncio +async def test_tampered_read_selector_denies_before_read_authorization( + compensation_database_env: str, + binding_seed: BindingSeed, +) -> None: + project_id, _, actor_id = await binding_seed() + request = AdapterBindingReadRequest( + actor_profile_id=actor_id, project_id=project_id, adapter_binding_id=uuid4() + ) + object.__setattr__(request, "adapter_binding_id", "bad") + authorization = _Authorization() + async with db_session.get_session_factory()() as session: + with pytest.raises(ValueError, match="adapter_binding_id must be a UUID"): + await _service(session, authorization).read(request) + assert authorization.read_authorized == 0 + + +@pytest.mark.parametrize("transition", ("suspend", "resume")) +@pytest.mark.asyncio +async def test_concurrent_transition_allows_one_version_advance( + compensation_database_env: str, + binding_seed: BindingSeed, + transition: str, +) -> None: + project_id, adapter_id, actor_id = await binding_seed() + setup_auth = _Authorization() + async with db_session.get_session_factory()() as session: + service = _service(session, setup_auth) + async with session.begin(): + created = await service.create( + AdapterBindingCreateRequest( + operation_id=uuid4(), actor_profile_id=actor_id, + project_id=project_id, instrument_type="money", + adapter_actor_id=adapter_id, route_key="adapter.primary", + ) + ) + if transition == "resume": + async with session.begin(): + await service.suspend( + AdapterBindingSuspendRequest( + operation_id=uuid4(), actor_profile_id=actor_id, + project_id=project_id, adapter_binding_id=created.adapter_binding_id, + expected_lifecycle_version=1, + ) + ) + + async def attempt(operation_id: UUID) -> str: + authorization = _Authorization() + async with db_session.get_session_factory()() as session: + service = _service(session, authorization) + try: + async with session.begin(): + request_type = ( + AdapterBindingSuspendRequest + if transition == "suspend" + else AdapterBindingResumeRequest + ) + request = request_type( + operation_id=operation_id, actor_profile_id=actor_id, + project_id=project_id, adapter_binding_id=created.adapter_binding_id, + expected_lifecycle_version=1 if transition == "suspend" else 2, + ) + await getattr(service, transition)(request) + return "advanced" + except AdapterBindingConflict: + return "conflict" + + assert sorted(await asyncio.gather(attempt(uuid4()), attempt(uuid4()))) == [ + "advanced", "conflict" + ] + async with db_session.get_session_factory()() as session: + binding = await session.get( + ProjectCompensationAdapterBinding, created.adapter_binding_id + ) + events = ( + await session.scalars( + select(CompensationAdapterBindingLifecycleEvent).where( + CompensationAdapterBindingLifecycleEvent.adapter_binding_id + == created.adapter_binding_id + ) + ) + ).all() + expected_version = 2 if transition == "suspend" else 3 + assert binding is not None and binding.binding_lifecycle_version == expected_version + assert len(events) == expected_version diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 992d2f883..009854acf 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 = "f7e1ea00e7fecdf68b6294a0f57ebdf5deb3b05cbf42058c720b8770fbdd29a0" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "a1cf97a3230da9f72a442ddf0118a325b6ab9042685270d5c8487e0007fe86e9" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", @@ -51,6 +51,7 @@ "checker_policies", "checker_results", "checker_runs", + "compensation_adapter_binding_lifecycle_events", "contribution_award_definitions", "contribution_policies", "contribution_policy_versions", @@ -107,6 +108,7 @@ "audit_events", "authority_control", "authority_idempotency_records", + "compensation_adapter_binding_lifecycle_events", "guide_mutation_idempotency_records", "guide_sufficiency_mutation_idempotency_records", "guide_source_snapshot_items", diff --git a/backend/tests/projects/guide_compilation/test_migration_contract.py b/backend/tests/projects/guide_compilation/test_migration_contract.py index e07585f1b..33ea6f3bc 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)) == ( - "0003_submission_lineage", + "0004_compensation_adapter_binding_lifecycle", True, 4, 1, diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index b5e8a1d9d..76ee0ecf8 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -12,6 +12,10 @@ import asyncpg import pytest +from adapter_binding_fixtures import ( + seed_nonempty_0003_adapter_binding, + snapshot_nonempty_0003_adapter_binding, +) from scripts.schema_baseline_manifest import ( APPLICATION_ACL_PRINCIPALS, build_manifest, @@ -20,7 +24,7 @@ ) from scripts.schema_baseline_sql import split_sql_statements -HEAD_REVISION = "0003_submission_lineage" +HEAD_REVISION = "0004_compensation_adapter_binding_lifecycle" BASELINE_REVISION = "0001_v01_baseline" RECREATE_GUIDANCE = "Workstream v0.1 requires a fresh database; recreate this database" pytestmark = pytest.mark.postgres_schema_contract @@ -80,6 +84,7 @@ def test_v01_graph_has_one_root_and_head() -> None: assert [revision.revision for revision in revisions] == [ HEAD_REVISION, + "0003_submission_lineage", "0002_admission_version", BASELINE_REVISION, ] @@ -98,7 +103,6 @@ def test_fresh_database_matches_committed_manifest( command.upgrade(config, BASELINE_REVISION) expected = _manifest_path().read_bytes() actual = canonical_bytes(asyncio.run(build_manifest(isolated_database_env))) - assert hashlib.sha256(actual).hexdigest() == hashlib.sha256(expected).hexdigest() assert actual == expected @@ -187,6 +191,95 @@ async def contract() -> tuple[bool, str, list[str], str, set[str], str]: assert package_nullable == "YES" +def test_current_head_installs_compensation_binding_lifecycle( + isolated_database_env: str, migration_lock +) -> None: + config = _alembic_config() + with migration_lock(): + asyncio.run( + _execute(isolated_database_env, "drop schema public cascade; create schema public") + ) + command.upgrade(config, HEAD_REVISION) + + async def contract() -> tuple[bool, int, set[str], set[str], set[str]]: + connection = await asyncpg.connect(isolated_database_env.replace("+asyncpg", "")) + try: + table_exists = await connection.fetchval( + "select to_regclass('public.compensation_adapter_binding_lifecycle_events') " + "is not null" + ) + revision_length = await connection.fetchval( + "select character_maximum_length from information_schema.columns " + "where table_schema='public' and table_name='alembic_version' " + "and column_name='version_num'" + ) + triggers = await connection.fetch( + "select tgname from pg_trigger t join pg_class c on c.oid=t.tgrelid " + "where c.relname=any($1::text[]) and not t.tgisinternal", + ["project_compensation_adapter_bindings", + "compensation_adapter_binding_lifecycle_events"], + ) + functions = await connection.fetch( + "select proname from pg_proc p join pg_namespace n on n.oid=p.pronamespace " + "where n.nspname='public' and proname=any($1::text[])", + ["enforce_compensation_binding_lifecycle", + "guard_compensation_binding_lifecycle_event", + "reject_compensation_binding_lifecycle_event_change", + "require_compensation_binding_lifecycle_event"], + ) + binding_checks = await connection.fetch( + "select conname from pg_constraint c join pg_class t on t.oid=c.conrelid " + "where t.relname='project_compensation_adapter_bindings' " + "and c.contype='c' order by conname" + ) + return ( + bool(table_exists), + revision_length, + {row["tgname"] for row in triggers}, + {row["proname"] for row in functions}, + {row["conname"] for row in binding_checks}, + ) + finally: + await connection.close() + + exists, revision_length, triggers, functions, binding_checks = asyncio.run(contract()) + assert exists is True + assert revision_length == 64 + assert triggers >= { + "project_compensation_binding_update_guard", + "compensation_binding_event_insert_guard", + "compensation_binding_event_change_guard", + "compensation_binding_event_truncate_guard", + "compensation_binding_lifecycle_event_required", + } + assert functions == {"enforce_compensation_binding_lifecycle", "guard_compensation_binding_lifecycle_event", "reject_compensation_binding_lifecycle_event_change", "require_compensation_binding_lifecycle_event"} + assert { + "ck_project_compensation_adapter_bindings_status", + "ck_project_compensation_adapter_bindings_lifecycle_shape", + "ck_project_compensation_adapter_bindings_lifecycle_timestamps", + } <= binding_checks + assert not { + "ck_project_compensation_adapter_bindings_ck_project_com_95ba", + "ck_project_compensation_adapter_bindings_ck_project_com_da73", + "ck_project_compensation_adapter_bindings_ck_project_com_ade1", + } & binding_checks + +def test_0004_nonempty_binding_preflight_leaves_0003_unchanged( + isolated_database_env: str, migration_lock +) -> None: + config = _alembic_config() + with migration_lock(): + asyncio.run( + _execute(isolated_database_env, "drop schema public cascade; create schema public") + ) + command.upgrade(config, "0003_submission_lineage") + asyncio.run(seed_nonempty_0003_adapter_binding(isolated_database_env)) + before = asyncio.run(snapshot_nonempty_0003_adapter_binding(isolated_database_env)) + with pytest.raises(RuntimeError, match="requires a fresh database"): + command.upgrade(config, HEAD_REVISION) + + assert asyncio.run(snapshot_nonempty_0003_adapter_binding(isolated_database_env)) == before + def test_manifest_covers_every_required_object_class() -> None: manifest = json.loads(_manifest_path().read_text(encoding="utf-8")) assert manifest["format"] == "workstream-v01-schema-manifest-1" @@ -246,7 +339,7 @@ def test_acl_principals_are_closed_and_owner_mapping_is_role_name_independent() canonical_acl_principal("unexpected_role", "database_owner") -def test_every_acl_principal_is_effective_on_the_installed_baseline( +def test_every_acl_principal_is_effective_on_the_installed_head( isolated_database_env: str, ) -> None: async def acl_results() -> tuple[int, int]: @@ -299,7 +392,7 @@ async def acl_results() -> tuple[int, int]: await connection.close() ineffective, total = asyncio.run(acl_results()) - manifest = json.loads(_manifest_path().read_text()) + manifest = asyncio.run(build_manifest(isolated_database_env)) assert ineffective == 0 assert total == len(manifest["acl"]) diff --git a/backend/tests/test_compensation.py b/backend/tests/test_compensation.py index 9a77aab0d..03ab2e5ba 100644 --- a/backend/tests/test_compensation.py +++ b/backend/tests/test_compensation.py @@ -2,21 +2,18 @@ from __future__ import annotations -import asyncio from collections.abc import Iterator from datetime import UTC, datetime from uuid import uuid4 from pydantic import ValidationError import pytest -from sqlalchemy import select, update -from sqlalchemy.exc import DBAPIError, IntegrityError +from sqlalchemy.exc import DBAPIError from app.core.config import get_settings from app.db import session as db_session from app.db.base import Base from app.modules.actors.models import ActorIdentityLink, ActorProfile -from app.modules.actors.service_identities import ServiceIdentity from app.modules.compensation.models import ProjectCompensationAdapterBinding from app.modules.compensation.schemas import ( CompensationInstrumentType, @@ -56,11 +53,10 @@ async def _seed_binding_facts() -> tuple[str, str, str]: ), ActorProfile( id=adapter_actor_id, - actor_kind="service", + actor_kind="human", status="active", - provisioning_method="manual_service_provisioning", - # Structural FK fixture only; 03A exposes no creation behavior. - service_identity=ServiceIdentity.ARTIFACT_VERIFIER.value, + provisioning_method="automatic_first_access", + # Neutral FK fixture only; CP03 owns the exact service identity. created_by=creator_id, ), ] @@ -83,9 +79,10 @@ async def _seed_binding_facts() -> tuple[str, str, str]: actor_profile_id=adapter_actor_id, issuer="https://compensation.test", subject=f"adapter-{adapter_actor_id}", - subject_kind="service", + subject_kind="human", status="active", linked_by=creator_id, + last_verified_at=now, ), ] ) @@ -116,18 +113,6 @@ def _binding_input( ) -def _structural_binding( - value: ProjectCompensationAdapterBindingInput, -) -> ProjectCompensationAdapterBinding: - facts = value.model_dump() - facts["instrument_type"] = value.instrument_type.value - return ProjectCompensationAdapterBinding( - **facts, - status="active", - binding_lifecycle_version=1, - ) - - def test_binding_model_is_registered_without_secret_or_provider_columns() -> None: """Metadata exposes only canonical non-secret binding facts.""" table = Base.metadata.tables["project_compensation_adapter_bindings"] @@ -143,6 +128,8 @@ def test_binding_model_is_registered_without_secret_or_provider_columns() -> Non "created_at", "suspended_by", "suspended_at", + "resumed_by", + "resumed_at", "retired_by", "retired_at", } @@ -195,7 +182,7 @@ async def test_database_rejects_invalid_lifecycle_shape( ProjectCompensationAdapterBinding( **values, status=status, - binding_lifecycle_version=2, + binding_lifecycle_version=1 if status == "suspended" else 2, suspended_by=creator_id if status == "suspended" else None, suspended_at=now if status == "suspended" else None, retired_by=creator_id if status == "retired" else None, @@ -239,61 +226,3 @@ async def test_database_rejects_noncanonical_route_keys( ) ) await session.flush() - - -@pytest.mark.asyncio -async def test_database_defers_all_binding_updates_until_behavior_chunks( - compensation_database_env: str, -) -> None: - project_id, actor_id, creator_id = await _seed_binding_facts() - value = _binding_input(project_id, actor_id, creator_id) - async with db_session.get_session_factory()() as session: - session.add(_structural_binding(value)) - await session.commit() - - async with db_session.get_session_factory()() as session: - with pytest.raises(DBAPIError): - await session.execute( - update(ProjectCompensationAdapterBinding) - .where(ProjectCompensationAdapterBinding.id == value.id) - .values( - status="suspended", - binding_lifecycle_version=2, - suspended_by=creator_id, - suspended_at=datetime.now(UTC), - ) - ) - - async with db_session.get_session_factory()() as session: - with pytest.raises(DBAPIError): - await session.execute( - update(ProjectCompensationAdapterBinding) - .where(ProjectCompensationAdapterBinding.id == value.id) - .values(route_key="adapter.changed") - ) - - -@pytest.mark.asyncio -async def test_active_binding_duplicate_race_has_one_winner( - compensation_database_env: str, -) -> None: - project_id, actor_id, creator_id = await _seed_binding_facts() - - async def create(route_key: str) -> str: - async with db_session.get_session_factory()() as session: - try: - value = _binding_input(project_id, actor_id, creator_id, route_key=route_key) - session.add(_structural_binding(value)) - await session.commit() - return "created" - except IntegrityError: - await session.rollback() - return "conflict" - - assert sorted(await asyncio.gather(create("adapter.one"), create("adapter.two"))) == [ - "conflict", - "created", - ] - async with db_session.get_session_factory()() as session: - rows = (await session.scalars(select(ProjectCompensationAdapterBinding))).all() - assert len(rows) == 1 diff --git a/backend/tests/test_contributions.py b/backend/tests/test_contributions.py index ac416686f..22586f0b4 100644 --- a/backend/tests/test_contributions.py +++ b/backend/tests/test_contributions.py @@ -13,6 +13,7 @@ from sqlalchemy import delete, text, update from sqlalchemy.exc import DBAPIError, IntegrityError +from adapter_binding_fixtures import created_binding_events from app.core.config import get_settings from app.db import session as db_session from app.db.base import Base @@ -128,6 +129,13 @@ async def _seed_project() -> tuple[str, str, str, UUID, UUID]: ), ] ) + # Created-event custody validates the persisted binding state. + await session.flush() + session.add_all( + created_binding_events( + project_id, creator_id, money_binding_id, points_binding_id + ) + ) session.add_all( [ ProjectCompensationUnit( diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index 8eaedfcaa..fedc34902 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -1177,18 +1177,19 @@ Fields: - `instrument_type`: `money | project_points` - `adapter_actor_id` - `route_key` -- `status`: `active | suspended | retired` -- `binding_lifecycle_version`, starting at 1 and incrementing once per future - authorized lifecycle transition -- creation, suspension, and retirement actor/timestamp fields +- `status`: `active | suspended`; retirement remains a future lifecycle extension +- `binding_lifecycle_version`, starting at 1 and incrementing once per valid + active-to-suspended or suspended-to-active transition +- creation, suspension, resume, and retirement actor/timestamp fields; only the + current suspension or resume attribution is populated after version 1 `route_key` is a non-secret 1-120 character ASCII identifier matching `^[A-Za-z][A-Za-z0-9._:-]{0,119}$`; traversal pairs, whitespace, path/URL/query syntax, controls, and Unicode are forbidden. Provider endpoints, credentials, and tokens are deployment secrets, not domain fields. At most one binding is -active per project and instrument. Persistence rejects all updates until the -owning lifecycle behavior chunks install their authorized transition guards; -until then, only active version-1 rows with null lifecycle fields may be inserted. +active per project and instrument. CP02 installs hidden, route-unreachable +create/read/suspend/resume behavior and immutable transition events while AUTH +actions remain unavailable. Retirement is not implemented. ## ProjectLesson diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index acc8667bd..97c2bea1b 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -135,10 +135,9 @@ review. Their presence does not change the implemented-on-`main` list above. merged through WS-ARCH-001-02H. 2. CP01A and CP01B have registered exact adapter-binding and ContributionPolicy authority while keeping all nine actions unavailable. CP01C corrects the - unavailable binding facts before CP02. CP02 now has a proposed executable - current-main contract; after this planning correction merges and receives - explicit human approval, the order is CP02 hidden adapter-binding behavior, - CP03 adapter-binding activation, CP04 hidden ContributionPolicy behavior, + unavailable binding facts before CP02. CP02 is complete on merge with hidden, + route-unreachable adapter-binding behavior and immutable lifecycle history. + The remaining order is CP03 adapter-binding activation, CP04 hidden ContributionPolicy behavior, then CP05 ContributionPolicy activation; expose CON validation; then bind one exact published, complete, binding-valid ContributionPolicyVersion at guide activation, and diff --git a/docs/spec_contribution_compensation.md b/docs/spec_contribution_compensation.md index 4d44873c1..edfb371eb 100644 --- a/docs/spec_contribution_compensation.md +++ b/docs/spec_contribution_compensation.md @@ -221,21 +221,25 @@ project_id instrument_type: money | project_points adapter_actor_id route_key -status: active | suspended | retired +status: active | suspended binding_lifecycle_version created_by created_at suspended_by suspended_at +resumed_by +resumed_at retired_by retired_at ``` At most one binding is active for each project and instrument. -`binding_lifecycle_version` starts at 1 and, once lifecycle behavior is -authorized, increments exactly once per valid transition. This persistence -chunk permits only active version-1 inserts and rejects all updates until those -behavior guards exist. `route_key` is a +`binding_lifecycle_version` starts at 1 and increments exactly once per valid +active-to-suspended or suspended-to-active transition. CP02 installs the hidden +transition guards and immutable created/suspended/resumed lifecycle history; +the current transition projection stores database-timestamped suspend or resume +attribution so PostgreSQL can verify the matching immutable event independently. +Retirement remains future and unavailable. `route_key` is a 1-120 character non-secret ASCII domain routing identifier matching `^[A-Za-z][A-Za-z0-9._:-]{0,119}$`; it rejects traversal pairs (`..`), whitespace, slashes, URL/query syntax, `@`, control characters, and Unicode. @@ -768,6 +772,15 @@ canonical `contribution.policy.*` namespace while mapping to the existing `compensation.policy.manage` PermissionId. AUTH must approve exact identifiers, principals, contexts, mappings, custodians, and activation in its own chunks. +CP02 implements the route-unreachable CON behavior behind those four binding +actions. Mutations require a caller-owned root transaction, a canonical +operation digest, a PostgreSQL transaction advisory fence, exact owner facts, +and an opaque prepare/consume/close authorization participant before product +mutation. Every create, suspend, or resume appends one immutable lifecycle +event atomically. Exact duplicate recovery requires fresh read authorization; +changed or unauthorized recovery is concealed. Production composition remains +deny-default and the actions remain unavailable until CP03. + | ActionId | PermissionId | Principal / target | Protocol | Feature owner | |---|---|---|---:|---| | `outbox.dispatch` | proposed `outbox.dispatch` | fixed dispatcher / claimed event | T | CON-02B |