diff --git a/.agent-loop/CURRENT_STATE.md b/.agent-loop/CURRENT_STATE.md index e5bcd7597..66f5078ef 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-CP02 establish unavailable authority facts and hidden CON adapter-binding behavior; CP03 is split with executable CP03A/CP03B contracts complete on merge; PLAN4 planned incremental debt retirement | Implement CP03A owner identity/eligibility, then CP03B exact Finance Authority activation; 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 | CP03A is complete on merge with exact target identity and owner eligibility; after that merge, implement CP03B exact Finance Authority activation; 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; CP02 is merged with hidden adapter-binding lifecycle behavior; CP03 is split with executable CP03A/CP03B contracts complete on merge; ContributionPolicy persistence, shared lifecycle audit, and unavailable AUTH registrations are merged | Implement CP03A target identity/owner eligibility, then CP03B exact Finance Authority 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 hidden adapter-binding behavior, ContributionPolicy persistence, shared lifecycle audit, and unavailable AUTH registrations are merged | CP03A target identity and owner eligibility are complete on merge; after that merge, implement CP03B exact Finance Authority 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 | @@ -116,7 +116,8 @@ non-executable until their current-main contracts are expanded. exact lifecycle history, and deny-default production composition. - WS-ARCH-001-CP03 is a planned split/non-executable parent. CP03A and CP03B have executable contracts complete on merge and are the next ordered bounded boundaries. -- WS-ARCH-001-CP03A is planned with an executable implementation contract. +- WS-ARCH-001-CP03A is complete on merge with exact target identity and owner + eligibility while all four actions remain unavailable. - WS-ARCH-001-CP03B is planned with an executable implementation contract after merged CP03A. - WS-ARCH-001-CP04 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 a74cb1697..d16b3d6a7 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 @@ -23,7 +23,7 @@ | `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 | Complete on merge; route-unreachable and deny-default while actions remain unavailable | | `WS-ARCH-001-CP03` | Adapter-binding activation coordination parent | L1 | Planned split into CP03A/CP03B; non-executable | -| `WS-ARCH-001-CP03A` | Closed adapter target identity and PROJECTS/ACTORS owner eligibility | L1 | Planned executable contract; implementation next | +| `WS-ARCH-001-CP03A` | Closed adapter target identity and PROJECTS/ACTORS owner eligibility | L1 | Complete on merge; actions remain unavailable | | `WS-ARCH-001-CP03B` | AUTH exact Finance Authority adapter-binding activation | L1 | Planned executable contract; follows merged CP03A | | `WS-ARCH-001-CP04` | CON hidden ContributionPolicy behavior | L1 | Proposed skeleton after merged CP03B evidence | | `WS-ARCH-001-CP05` | AUTH exact ContributionPolicy activation | L1 | Proposed skeleton after CP04 evidence | 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 ebaafd027..b5c6e1bb9 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 @@ -79,12 +79,14 @@ match exact binding identity and lifecycle generation without a unit alias. - 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 a planned split/non-executable parent. CP03A's executable - contract specifies the closed compensation-adapter target identity and - PROJECTS/ACTORS owner eligibility while actions remain unavailable. CP03B's - executable contract specifies and requires proof of exact Finance Authority - activation without adding a route or adjacent compensation authority. -- WS-ARCH-001-CP03A is planned with an executable implementation contract. +- WS-ARCH-001-CP03 is a planned split/non-executable parent. CP03A implements + the closed compensation-adapter target identity and PROJECTS/ACTORS owner + eligibility while actions remain unavailable. CP03B's executable contract + specifies and requires proof of exact Finance Authority activation without + adding a route or adjacent compensation authority. +- WS-ARCH-001-CP03A is complete on merge: the closed target-only identity and + PROJECTS/ACTORS owner eligibility are implemented while all binding actions + remain unavailable. - WS-ARCH-001-CP03B is planned with an executable implementation contract after merged CP03A. - WS-ARCH-001-CP04 is proposed: hidden ContributionPolicy behavior. diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-CP03A-owner-eligibility.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-CP03A-owner-eligibility.md index 3898d3436..c9c5f4643 100644 --- a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-CP03A-owner-eligibility.md +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-CP03A-owner-eligibility.md @@ -2,8 +2,8 @@ ## Merge state -- Outcome on merge: `planned` -- Future implementation target: complete while binding actions remain unavailable. +- Outcome on merge: `complete` +- Binding actions remain unavailable; CP03B is the next activation boundary. ## Parent initiative @@ -87,6 +87,7 @@ backend/app/modules/projects/api/__init__.py backend/app/modules/authorization/service_actor_schemas.py (closed identity parity only) backend/app/modules/authorization/service_actor_service.py (provisioning admission parity only) backend/app/modules/authorization/catalogue.py (separate target-only identities from action-bearing fixed services) +backend/app/modules/authorization/kernel.py (target-only service identity fail-closed lookup only) backend/app/modules/compensation/service.py (owner-adapter injection only) backend/app/api/deps/authorization.py (composition only; no route) backend/app/main.py (composition only; no route) @@ -98,7 +99,11 @@ backend/tests/compensation/test_adapter_binding_owner_fences.py backend/tests/compensation/test_adapter_binding_authorization_integration.py backend/tests/test_auth.py (controlled provisioning parity only) backend/tests/test_authorization.py (closed identity/matrix separation only) +backend/tests/authorization/test_adapter_binding_registration.py (target-only kernel denial only) +backend/tests/authorization/guide_compilation/test_migration_contract.py (current-head parity only) +backend/tests/projects/guide_compilation/test_migration_contract.py (current-head parity only) backend/tests/test_alembic.py +backend/tests/migrations/test_compensation_adapter_identity.py backend/tests/test_database_reset.py backend/tests/conftest.py (exact current-schema fingerprint/reset parity only) backend/alembic/baseline/v01_approved_manifest_delta.json (generated current-head parity only; do not rewrite 0001) @@ -108,8 +113,8 @@ backend/alembic/baseline/v01_schema.sql (generated current-head parity only; do backend/scripts/behavior_ownership.py backend/scripts/run_test_lanes.py .ci/behavior-ownership/partition.v1.json -.ci/behavior-ownership/actors/compensation-adapter-eligibility.json -.ci/behavior-ownership/projects/compensation-binding-eligibility.json +.ci/behavior-ownership/shared/compensation-adapter-eligibility.json +.ci/behavior-ownership/lifecycle/compensation-binding-eligibility.json .ci/module-boundaries/private-edge-debt.v1.json (remove only its corrected API-composition edge to ACTORS service identities) .agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json (generated parity only) .agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/IMPORT_LEDGER.md (remove only the corrected three AUTH-module service-identity entries; it does not inventory API composition) @@ -128,6 +133,7 @@ backend/scripts/run_test_lanes.py .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md .agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/AUTHORIZATION_HANDOFF.md .agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/CHUNK_MAP.md +.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/CONFORMANCE_MATRIX.md .agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/STATUS.md docs/architecture_data_model.md docs/spec_authorization_service.md @@ -158,6 +164,10 @@ compatibility support for old identities or deployed rows identities are explicit separate sets. The target identity belongs only to the former; catalogue import succeeds without an empty or invented matrix row, and runtime service-action admission rejects it. +- [ ] Every AUTH kernel service-matrix lookup treats a closed target-only + identity with no matrix row as permission-not-granted; it never raises a + missing-key exception or creates denial/allowed evidence outside the + existing canonical decision path. - [ ] Migration `0005_compensation_adapter_identity` updates only the closed ActorProfile service-identity constraint and refuses downgrade without mutation when the new identity is referenced. @@ -204,8 +214,8 @@ compatibility support for old identities or deployed rows ## Verification commands ```bash -(cd backend && .venv/bin/python -m ruff check app/modules/actors/service_identities.py app/modules/actors/api/service_identities.py app/modules/actors/models.py app/modules/actors/compensation_adapter.py app/modules/actors/api/compensation_adapter.py app/modules/actors/api/__init__.py app/modules/projects/compensation_binding.py app/modules/projects/api/compensation_binding.py app/modules/projects/api/__init__.py app/modules/authorization/service_actor_schemas.py app/modules/authorization/service_actor_service.py app/modules/authorization/catalogue.py app/modules/compensation/service.py app/api/deps/authorization.py app/main.py alembic/env.py alembic/versions/0005_compensation_adapter_identity.py scripts/behavior_ownership.py scripts/run_test_lanes.py tests/actors/test_compensation_adapter_eligibility.py tests/projects/test_compensation_binding_eligibility.py tests/compensation/test_adapter_binding_owner_fences.py tests/compensation/test_adapter_binding_authorization_integration.py tests/test_auth.py tests/test_authorization.py tests/test_alembic.py tests/test_database_reset.py tests/conftest.py) -(cd backend && export WORKSTREAM_TEST_DATABASE_URL="${WORKSTREAM_TEST_DATABASE_URL:?set WORKSTREAM_TEST_DATABASE_URL}" && .venv/bin/python -m pytest -q tests/actors/test_compensation_adapter_eligibility.py tests/projects/test_compensation_binding_eligibility.py tests/compensation/test_adapter_binding_owner_fences.py tests/compensation/test_adapter_binding_authorization_integration.py tests/test_alembic.py tests/test_database_reset.py --cov=app.modules.actors.compensation_adapter --cov=app.modules.projects.compensation_binding --cov-fail-under=90) +(cd backend && .venv/bin/python -m ruff check app/modules/actors/service_identities.py app/modules/actors/api/service_identities.py app/modules/actors/models.py app/modules/actors/compensation_adapter.py app/modules/actors/api/compensation_adapter.py app/modules/actors/api/__init__.py app/modules/projects/compensation_binding.py app/modules/projects/api/compensation_binding.py app/modules/projects/api/__init__.py app/modules/authorization/service_actor_schemas.py app/modules/authorization/service_actor_service.py app/modules/authorization/catalogue.py app/modules/authorization/kernel.py app/modules/compensation/service.py app/api/deps/authorization.py app/main.py alembic/env.py alembic/versions/0005_compensation_adapter_identity.py scripts/behavior_ownership.py scripts/run_test_lanes.py tests/actors/test_compensation_adapter_eligibility.py tests/projects/test_compensation_binding_eligibility.py tests/compensation/test_adapter_binding_owner_fences.py tests/compensation/test_adapter_binding_authorization_integration.py tests/migrations/test_compensation_adapter_identity.py tests/authorization/test_adapter_binding_registration.py tests/test_auth.py tests/test_authorization.py tests/test_alembic.py tests/test_database_reset.py tests/conftest.py) +(cd backend && export WORKSTREAM_TEST_DATABASE_URL="${WORKSTREAM_TEST_DATABASE_URL:?set WORKSTREAM_TEST_DATABASE_URL}" && .venv/bin/python -m pytest -q tests/actors/test_compensation_adapter_eligibility.py tests/projects/test_compensation_binding_eligibility.py tests/compensation/test_adapter_binding_owner_fences.py tests/compensation/test_adapter_binding_authorization_integration.py tests/migrations/test_compensation_adapter_identity.py tests/test_alembic.py tests/test_database_reset.py --cov=app.modules.actors.compensation_adapter --cov=app.modules.projects.compensation_binding --cov-fail-under=90) (cd backend && export WORKSTREAM_TEST_DATABASE_URL="${WORKSTREAM_TEST_DATABASE_URL:?set WORKSTREAM_TEST_DATABASE_URL}" && .venv/bin/python -m pytest -q tests/test_auth.py -k service_actor) (cd backend && .venv/bin/python -m pytest -q tests/test_authorization.py -k "service_actor or service_identity or service_action_matrix") (cd backend && .venv/bin/python -m scripts.test_structure_boundary validate --policy ../.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_POLICY.md --ledger ../.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json) diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-CP03A-external-review-response.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-CP03A-external-review-response.md new file mode 100644 index 000000000..fcc5e4b7d --- /dev/null +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-CP03A-external-review-response.md @@ -0,0 +1,32 @@ +# WS-ARCH-001-CP03A External Review Response + +## Comments addressed + +- CodeRabbit's current-state finding was valid. CP03A is now described as + complete on merge in the remaining boundary, not as durable behavior already + present on `main`. +- CodeRabbit's migration-head finding was valid. Current `main` remains scoped + to `0004_compensation_adapter_binding_lifecycle` until CP03A merges, while the + CP03A branch advances the graph to `0005_compensation_adapter_identity` on + merge. Historical `0050`, `0053`, and `0055` identifiers are not active graph + heads. + +## Comments deferred + +None. + +## Human decisions needed + +None beyond the repository-required approval and merge decision. + +## Commands rerun + +- `python3 scripts/check_stale_workstream_wording.py` +- `python3 scripts/check_stale_authorization_docs.py` +- `python3 scripts/check_markdown_links.py` +- `python3 scripts/check_chunk_state_sync.py` +- `git diff --check` + +## Remaining risks + +None introduced by these documentation-only merge-awareness corrections. diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-CP03A-implementation-review-evidence.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-CP03A-implementation-review-evidence.md new file mode 100644 index 000000000..ac09fe5a1 --- /dev/null +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-CP03A-implementation-review-evidence.md @@ -0,0 +1,53 @@ +# WS-ARCH-001-CP03A Implementation Review Evidence + +## Scope reviewed + +The complete CP03A implementation: ACTORS-owned closed identity vocabulary and +adapter eligibility, PROJECTS-owned project eligibility, target-only AUTH +matrix handling, migration `0005`, focused PostgreSQL and unit proof, boundary +ledgers, semantic-lane ownership, and canonical state/documentation updates. + +## Deterministic evidence + +- touched-code Ruff: pass; +- 32 focused ACTORS/PROJECTS/kernel tests: pass; +- 11 focused service identity/matrix tests: pass; +- isolated PostgreSQL migration, real owner-fence, committed-ineligibility, + identity-link revocation, and controlled provisioning proof: pass; +- canonical semantic-lane collection: pass; +- module-boundary, behavior-ownership, and test-structure gates: pass; +- stale authorization/workstream wording, Markdown links, chunk-state sync, + and diff checks: pass. + +GitHub Actions owns the complete PostgreSQL matrix and repository coverage. The +local machine does not run the full suite. + +## Findings fixed + +Review found and fixed the migration naming-convention expansion, stale schema +fingerprint, missing semantic-lane assignments, missing referenced-identity +downgrade proof, incomplete real owner-lock evidence, missing controlled target +provisioning proof, missing exact identity-link race proof, stale wording, and +one allowed-file mismatch. No CI threshold, test selection, action availability, +or authorization rule was weakened. + +## Internal review disposition + +- Architecture: pass with low transitional re-export risk. +- Security/authorization: pass after real profile/link/project fence proof. +- Product/operations: pass; the target is not the Finance Authority caller. +- QA: pass after migration, fingerprint, scope, and PostgreSQL corrections. +- Test delta: pass; no skip, xfail, removal, or assertion weakening. +- CI integrity: pass after semantic-lane reconciliation. +- Senior engineering: pass after stale wording and real-fence correction. +- Reuse/dedup: pass with low intra-PROJECTS query reuse risk. +- Documentation: pass after canonical action-bearing/target-only identity + wording and state reconciliation. + +## Residual risk + +`app.modules.actors.service_identities` remains a transitional private +re-export for untouched frozen imports. New and touched consumers must use +`app.modules.actors.api`. CP03B must add the real AUTH read/PREP composition and +activate only the four Finance Authority binding actions; CP03A grants no +action to the adapter target. diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-CP03A-pr-trust-bundle.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-CP03A-pr-trust-bundle.md new file mode 100644 index 000000000..e0aff3541 --- /dev/null +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-CP03A-pr-trust-bundle.md @@ -0,0 +1,51 @@ +# WS-ARCH-001-CP03A PR Trust Bundle + +## Outcome + +Add exactly one target-only service identity, +`workstream.compensation.adapter`, and real ACTORS/PROJECTS owner eligibility +for CP02 binding create/resume. Keep every adapter-binding action unavailable. + +## Design + +ACTORS owns the closed identity vocabulary and locks the exact active service +profile plus exact active service identity link. PROJECTS locks the exact +eligible project. CON continues consuming only injected public ports and owns +the rule that create/resume require both fences while suspend does not. AUTH +imports the public ACTORS vocabulary and treats a target-only identity without +a matrix row as permission-not-granted. + +## Scope exclusions + +No public route, evaluator, Finance Authority activation, service-action matrix +row for the adapter target, provider behavior, credential, ContributionPolicy, +award, fulfillment, callback, delivery, reconciliation, or reputation behavior +is added. + +## Proof + +Focused unit tests cover exact identities, concealed owner denials, matrix +separation, and kernel fail-closed behavior. Isolated PostgreSQL tests cover +migration acceptance/rejection and downgrade refusal, controlled provisioning +with zero grants, real project, profile, and identity-link fence retention for create/resume, +and denial before AUTH when ineligibility or revocation commits first. All new +tests belong to canonical semantic lanes. Repository boundary, structure, +stale-wording, link, chunk-state, and diff gates pass. + +The full PostgreSQL suite and coverage run in GitHub Actions. No local full +suite is run on the user's slow machine. + +## External review + +CodeRabbit's two merge-awareness findings were valid and addressed. The +current-state ledger now keeps CP03A out of durable `main` state until merge, +and the CON handoff/matrix distinguish current `main` migration `0004` from +the CP03A branch migration `0005`. Historical `0050`, `0053`, and `0055` +identifiers are explicitly not active graph heads. + +## Human review focus + +Confirm the new identity is a binding target only, has no service-action matrix +row, and cannot execute an action. Confirm ACTORS/PROJECTS retain their locks +through the caller-owned transaction and CP03B remains the only activation +successor. Only an authorized human may approve and merge this PR. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/ACTIVATION_CUSTODY.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/ACTIVATION_CUSTODY.md index 38c62e61c..141451700 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/ACTIVATION_CUSTODY.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/ACTIVATION_CUSTODY.md @@ -68,15 +68,19 @@ gated `artifact.verification_job.retry` remains planned and cannot be activated by read/status proof. The historical transfer added no migration because owner and availability are typed metadata. WS-XINT-002-01 -reconciles PostgreSQL parity through migration `0036`. The current catalogue -has 73 PermissionIds, 111 ActionIds, 57 active actions, and 54 planned actions, -with fourteen fixed-service identities and twenty-two matrix memberships. +reconciles PostgreSQL parity through migration `0036`. On the CP03A branch, the +current catalogue has 73 PermissionIds, 111 ActionIds, 57 active actions, and +54 planned actions. Its closed registry contains fifteen service identities: +fourteen action-bearing identities with twenty-three matrix memberships plus +the target-only `workstream.compensation.adapter` identity. CP01A contributes four planned/unavailable adapter-binding actions and CP01B contributes five planned/unavailable ContributionPolicy actions; neither changes fixed-service identity or matrix membership. CP01C corrects only the unavailable adapter-binding resource facts before CP02. It does not change catalogue custody, action identifiers, fixed-service identity, availability, -or matrix membership. +or matrix membership. CP03A establishes that target-only identity and owner +eligibility without a service-action matrix row; all four binding actions +remain unavailable until CP03B. ## REV custody transfer diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md index 29ddd3faa..8e8cff244 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md @@ -29,7 +29,7 @@ sequence. | `WS-ARCH-001-CP01B` | ContributionPolicy unavailable registration | L1 | Complete on merge; excludes binding behavior, evaluator, and activation | | `WS-ARCH-001-CP01C` | Adapter-binding fact correction | L1 | Complete on merge; unavailable facts match binding identity and lifecycle generation | | `WS-ARCH-001-CP03` | Adapter-binding activation parent | L1 | Split into CP03A/CP03B; non-executable | -| `WS-ARCH-001-CP03A` | Adapter target identity and owner eligibility prerequisite | L1 | Executable contract complete on merge; actions remain unavailable | +| `WS-ARCH-001-CP03A` | Adapter target identity and owner eligibility prerequisite | L1 | Complete on merge; actions remain unavailable | | `WS-ARCH-001-CP03B` | Exact Finance Authority adapter-binding activation | L1 | Executable contract complete on merge; follows CP03A | | `WS-ARCH-001-CP05` | Exact ContributionPolicy activation | L1 | Proposed after merged CP04 hidden proof | | `WS-AUTH-001-PLAN` | Authorization Service Planning | L0 | Merged through PR #91 as `ad6d644` | diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md index 15d3ef80f..057d7b232 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md @@ -47,7 +47,7 @@ not current start requirements. complete on merge and separately registers policy actions while unavailable; CP01C is complete on merge with corrected unavailable binding identity and lifecycle-generation facts; CP03 is split. - CP03A's executable contract adds only the target identity and owner + CP03A is complete on merge with only the target identity and owner eligibility while actions remain unavailable; CP03B's implementation then activates only CP02's four exact Finance Authority boundaries. CP05 later activates only CP04's merged hidden policy behavior. Fulfillment callback authority diff --git a/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/IMPORT_LEDGER.md b/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/IMPORT_LEDGER.md index 17f1d99e0..edaa0f2e6 100644 --- a/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/IMPORT_LEDGER.md +++ b/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/IMPORT_LEDGER.md @@ -131,7 +131,6 @@ backend/app/modules/authorization/admin_service.py app.modules.audit.schemas app.modules.audit.service backend/app/modules/authorization/catalogue.py - app.modules.actors.service_identities backend/app/modules/authorization/kernel.py app.modules.audit.schemas app.modules.audit.service @@ -171,11 +170,9 @@ backend/app/modules/authorization/service.py app.modules.audit.schemas app.modules.audit.service backend/app/modules/authorization/service_actor_schemas.py - app.modules.actors.service_identities backend/app/modules/authorization/service_actor_service.py app.modules.actors.models app.modules.actors.repository - app.modules.actors.service_identities app.modules.audit.schemas app.modules.audit.service ``` diff --git a/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json b/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json index c3019f11d..ccc86cbda 100644 --- a/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json +++ b/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json @@ -2,11 +2,11 @@ "entries": [ { "capability": "unassigned_legacy_auth", - "content_sha256": "b0ea9af1158200255cffa1a39216500024e50f6d5c64e7f6fdceb399af3db32f", - "end_line": 1583, + "content_sha256": "7e4412e571fe5ddb9e926f29b52757de61db7e2744e9d8568174058142eb1e1e", + "end_line": 1578, "hard_limit": 1200, "kind": "production_file", - "observed_lines": 1583, + "observed_lines": 1578, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -38,23 +38,23 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "0a1e990ef039786066276283bc519669a9274aceb2949e1d320f93e75203926e", - "end_line": 1180, + "content_sha256": "06659e0445fba8a7380790f965ff852a4456858b6c0027e6a9f73cd18497dd1a", + "end_line": 1181, "hard_limit": 100, "kind": "production_function", - "observed_lines": 120, + "observed_lines": 117, "path": "backend/app/modules/authorization/catalogue.py", "qualified_symbol": "_index_service_actions", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 1061 + "start_line": 1065 }, { "capability": "unassigned_legacy_auth", - "content_sha256": "8d244d50995874f504894c53f39a2cf465f22407a4cb6e508b808cf3d8241067", - "end_line": 597, + "content_sha256": "6f9a2b99f3e9ae1768742ad5fd0ddc58faf13f30110a0be83585a721474f8d20", + "end_line": 595, "hard_limit": 100, "kind": "production_function", - "observed_lines": 203, + "observed_lines": 201, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": "AuthorizationService._prepare_prelocked", "removal_chunk": "WS-AUTH-003-CLOSE", @@ -62,27 +62,27 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "dd54f160d45efecadc0cdcb33e7271c17e34945d3c6cdbc7b1736381c28eb7a5", - "end_line": 1098, + "content_sha256": "4f1b221fd8ff04984a74950f57089a6f3b0aec7e932c2b3fa8430bb073fa7729", + "end_line": 1093, "hard_limit": 100, "kind": "production_function", - "observed_lines": 165, + "observed_lines": 162, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": "AuthorizationService._require_prelocked", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 934 + "start_line": 932 }, { "capability": "unassigned_legacy_auth", "content_sha256": "8cb806aad12f5010d1e8875c4ba31eee43856a120e338d31a02d87e1ea30ad0c", - "end_line": 1583, + "end_line": 1578, "hard_limit": 100, "kind": "production_function", "observed_lines": 125, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": "AuthorizationService._stage_decision", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 1459 + "start_line": 1454 }, { "capability": "unassigned_legacy_auth", @@ -206,11 +206,11 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "16ebbaa1b66f08bf3bc395353f43505856aed1b937148f96b988377c028c4e39", - "end_line": 7424, + "content_sha256": "6689d4b1b88e0bddbfb85f67ab077c75011fa172a28a95b8449109d957fced24", + "end_line": 7423, "hard_limit": 1200, "kind": "test_file", - "observed_lines": 7424, + "observed_lines": 7423, "path": "backend/tests/test_auth.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -218,11 +218,11 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "7df452b12e549934000e32205899b5d390809582e91f675f8c3bfd08a641df57", - "end_line": 13403, + "content_sha256": "b1edc7300f5d7e9bc253d114b013490f194d75fc6cc726593fe78b7fff5dacb3", + "end_line": 13401, "hard_limit": 1200, "kind": "test_file", - "observed_lines": 13403, + "observed_lines": 13401, "path": "backend/tests/test_authorization.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -435,50 +435,50 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "b0cb3554dd7f531648e4202302059b8416ca7ed2fdcc6529460f0acb092b82de", - "end_line": 7404, + "end_line": 7403, "hard_limit": 120, "kind": "test_function", "observed_lines": 927, "path": "backend/tests/test_auth.py", "qualified_symbol": "test_actor_identity_link_lifecycle_real_postgres_concurrency", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 6478 + "start_line": 6477 }, { "capability": "unassigned_legacy_auth", "content_sha256": "15c7147c11fa1802f43480b38a3678ac1a2586320ae2f0013622f1d63b2a3806", - "end_line": 5961, + "end_line": 5960, "hard_limit": 120, "kind": "test_function", "observed_lines": 535, "path": "backend/tests/test_auth.py", "qualified_symbol": "test_actor_identity_link_lifecycle_real_postgres_matrix", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 5427 + "start_line": 5426 }, { "capability": "unassigned_legacy_auth", "content_sha256": "49243ef3a0b0b4b24e5033b3e0ad75eee17c7fc2abd2014224a595ed8c6c9ade", - "end_line": 6475, + "end_line": 6474, "hard_limit": 120, "kind": "test_function", "observed_lines": 491, "path": "backend/tests/test_auth.py", "qualified_symbol": "test_actor_profile_lifecycle_real_postgres_concurrency", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 5985 + "start_line": 5984 }, { "capability": "unassigned_legacy_auth", "content_sha256": "eca9eeff229ee37c103188f9a31e278773a69e96f2fd354662723f840cf8f16a", - "end_line": 5424, + "end_line": 5423, "hard_limit": 120, "kind": "test_function", "observed_lines": 482, "path": "backend/tests/test_auth.py", "qualified_symbol": "test_actor_profile_lifecycle_real_postgres_matrix", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 4943 + "start_line": 4942 }, { "capability": "unassigned_legacy_auth", @@ -495,26 +495,26 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "8ea087b906726103295a019967f8c38e42b55deb4e912fda3a41289fda23f35d", - "end_line": 4268, + "end_line": 4267, "hard_limit": 120, "kind": "test_function", "observed_lines": 647, "path": "backend/tests/test_auth.py", "qualified_symbol": "test_controlled_service_actor_provisioning_includes_project_setup_and_is_atomic", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 3622 + "start_line": 3621 }, { "capability": "unassigned_legacy_auth", "content_sha256": "d26732dd125f8c2062b5a4b45b59fbccd283ee04939a9da7be1576235524292f", - "end_line": 4679, + "end_line": 4678, "hard_limit": 120, "kind": "test_function", "observed_lines": 409, "path": "backend/tests/test_auth.py", "qualified_symbol": "test_service_actor_provisioning_failure_and_authority_races_are_atomic", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 4271 + "start_line": 4270 }, { "capability": "unassigned_legacy_auth", @@ -531,50 +531,50 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "951b44cc07e36002118fe93b7974e8d65851b0e2c3cec89a3031cbe42b014e2d", - "end_line": 8509, + "end_line": 8507, "hard_limit": 120, "kind": "test_function", "observed_lines": 140, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_actor_lifecycle_service_applies_success_and_guards_conflicts", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 8370 + "start_line": 8368 }, { "capability": "unassigned_legacy_auth", "content_sha256": "569084d6de89ff9eae71d526fc6c157aea7638f55ca93ad128b721fbda339be6", - "end_line": 8999, + "end_line": 8997, "hard_limit": 120, "kind": "test_function", "observed_lines": 122, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_admin_resource_digest_alone_rejects_substituted_role_and_disposition", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 8878 + "start_line": 8876 }, { "capability": "unassigned_legacy_auth", "content_sha256": "90b8b670b4d277209617cc1aa794188b4fb3cd9d894b69d5dc3d0adfc885f6ed", - "end_line": 9227, + "end_line": 9225, "hard_limit": 120, "kind": "test_function", "observed_lines": 132, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_admin_revoke_stages_complete_state_and_evidence", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 9096 + "start_line": 9094 }, { "capability": "unassigned_legacy_auth", "content_sha256": "78d9bf3df08e5633e9a75720b1e4bf5b7d7b24019bc392b4cb7496a9ac5e5e8e", - "end_line": 11055, + "end_line": 11053, "hard_limit": 120, "kind": "test_function", "observed_lines": 122, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_authorization_locks_refresh_cached_actor_lifecycle_state", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 10934 + "start_line": 10932 }, { "capability": "unassigned_legacy_auth", @@ -591,74 +591,74 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "df1df6ec2ba6aa55445e21cfcf4e4e3ad9664c9504313484a495b6a6df1e9047", - "end_line": 3469, + "end_line": 3467, "hard_limit": 120, "kind": "test_function", "observed_lines": 126, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_identity_link_lifecycle_route_preserves_outcome_transaction_contract", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 3344 + "start_line": 3342 }, { "capability": "unassigned_legacy_auth", "content_sha256": "05d1b020ecff0f9bc0a0567adc07f5b31a2f9dfb7828ae3ad34d4e1e7757797c", - "end_line": 8662, + "end_line": 8660, "hard_limit": 120, "kind": "test_function", "observed_lines": 151, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_identity_link_lifecycle_service_applies_success_and_guards_conflicts", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 8512 + "start_line": 8510 }, { "capability": "unassigned_legacy_auth", "content_sha256": "c5d6d0d480ced964354915614f15202c0159bfbb281658da0d44186dffd17848", - "end_line": 7457, + "end_line": 7455, "hard_limit": 120, "kind": "test_function", "observed_lines": 142, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_prepared_actor_authority_crossed_mutations_complete_in_both_orders", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 7316 + "start_line": 7314 }, { "capability": "unassigned_legacy_auth", "content_sha256": "ebb76e63671195aa4d806ac602bfe58cb22bf82d00c8a70ab186785f3acd7f9b", - "end_line": 7798, + "end_line": 7796, "hard_limit": 120, "kind": "test_function", "observed_lines": 336, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_prepared_crosses_real_lifecycle_service_transactions", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 7463 + "start_line": 7461 }, { "capability": "unassigned_legacy_auth", "content_sha256": "9ea4fc0ddbab4c7262a43bc3f498ee1afea3318e9a0b6c93c87763f9f22aae9c", - "end_line": 7288, + "end_line": 7286, "hard_limit": 120, "kind": "test_function", "observed_lines": 518, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_prepared_postgresql_failure_and_cancellation_are_atomic", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 6771 + "start_line": 6769 }, { "capability": "unassigned_legacy_auth", "content_sha256": "46e0b031394da6fee856e48615732a17ff8d2276d9cedfb0ecaa3a6879410adb", - "end_line": 4674, + "end_line": 4672, "hard_limit": 120, "kind": "test_function", "observed_lines": 157, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_11c2_reads_require_exact_admin_context_and_role_allowlist", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 4518 + "start_line": 4516 }, { "capability": "unassigned_legacy_auth", @@ -675,38 +675,38 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "105667302ed6e8f2fd16ea7e95d642e72e41514673e1152503536e0520f9c362", - "end_line": 10930, + "end_line": 10928, "hard_limit": 120, "kind": "test_function", "observed_lines": 204, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_read_permissions_have_postgresql_role_scope_matrix", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 10727 + "start_line": 10725 }, { "capability": "unassigned_legacy_auth", "content_sha256": "756b7f99f9a743284934b4d85ce263617710d9b8119792ccb4526a1de04070a1", - "end_line": 12432, + "end_line": 12430, "hard_limit": 120, "kind": "test_function", "observed_lines": 233, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_role_and_all_operation_mappings_commit_one_linked_pair", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 12200 + "start_line": 12198 }, { "capability": "unassigned_legacy_auth", "content_sha256": "2e1d7db74ddb955b90a0ee12e4fb72b651a0f85d2746c76e09079c05b0b85252", - "end_line": 13403, + "end_line": 13401, "hard_limit": 120, "kind": "test_function", "observed_lines": 681, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_role_issue_postgresql_prep_binds_target_role_and_scope", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 12723 + "start_line": 12721 }, { "capability": "unassigned_legacy_auth", @@ -735,14 +735,14 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "05621e885ed2f88d0ba1a072c1f263fc923939f7ce755a514e9872112aa830a1", - "end_line": 11949, + "end_line": 11947, "hard_limit": 120, "kind": "test_function", "observed_lines": 163, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_service_actor_replay_fails_closed_on_committed_state_drift", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 11787 + "start_line": 11785 }, { "capability": "unassigned_legacy_auth", @@ -1023,14 +1023,14 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "1a0a9f3e2be6965e29f76aa74272ccfa2fe4b99e4e0c3bde5ec8ba2c374b369b", - "end_line": 11263, + "end_line": 11261, "hard_limit": 100, "kind": "test_helper", "observed_lines": 169, "path": "backend/tests/test_authorization.py", "qualified_symbol": "_operation_success", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 11095 + "start_line": 11093 }, { "capability": "unassigned_legacy_auth", 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 cf3e4f94d..4df553118 100644 --- a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/AUTHORIZATION_HANDOFF.md +++ b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/AUTHORIZATION_HANDOFF.md @@ -6,12 +6,13 @@ Current `main` contains the AUTH actor, grant, fixed-service, prepared-mutation, project-guide, policy-mutation, and REV-readiness foundations plus merged REV PLAN4, ART foundations, and CP02 hidden CON adapter-binding behavior. The old `0052_legacy_intake_removal` identifier is -historical merge evidence. Current main ends at +historical merge evidence. Until CP03A merges, current `main` ends at `0004_compensation_adapter_binding_lifecycle`, whose tracked predecessor is -`0003_submission_lineage`. No later numbered migration is part of the active -tracked graph. CP02's four binding actions -remain unavailable. CP03 is split: CP03A installs the target identity and owner -eligibility while actions stay unavailable; CP03B activates only the four exact +`0003_submission_lineage`. The CP03A branch advances that graph to +`0005_compensation_adapter_identity` on merge. Older `0050`, `0053`, and `0055` +identifiers are historical pre-baseline merge evidence, not active graph heads. +CP02's four binding actions remain unavailable. CP03A installs the target +identity and owner eligibility while actions stay unavailable; CP03B activates only the four exact Finance Authority boundaries. No public CON route, ContributionPolicy behavior, or outbox-dispatcher authority is active. 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 374984565..f190c6ade 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 @@ -15,7 +15,7 @@ signed-loop records do not make behavior live. | `03B` | Contribution-policy persistence | Merged PR #274; migration 0055 | | `WS-ARCH-001-CP02` | Hidden adapter-binding lifecycle behavior | Complete on merge; actions remain unavailable | | `WS-ARCH-001-CP03` | Adapter-binding activation parent | Split into CP03A/CP03B; non-executable | -| `WS-ARCH-001-CP03A` | Adapter target identity and owner eligibility | Executable contract complete on merge; actions remain unavailable | +| `WS-ARCH-001-CP03A` | Adapter target identity and owner eligibility | Complete on merge; actions remain unavailable | | `WS-ARCH-001-CP03B` | Exact Finance Authority adapter-binding activation | Executable contract complete on merge; follows CP03A | | `PLAN5` | Historical complete-context human needs-revision rebase reconciliation | Merged PR #270; continuing-TaskAssignment rebase retained, independent reviewer-selection wording superseded by current PLAN2 | diff --git a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/CONFORMANCE_MATRIX.md b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/CONFORMANCE_MATRIX.md index 68d5c0404..ef6e24230 100644 --- a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/CONFORMANCE_MATRIX.md +++ b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/CONFORMANCE_MATRIX.md @@ -5,7 +5,7 @@ | Canonical policy model | 01,03B,CP04/CP05 | ContributionPolicy/version/rules/definitions; explicit unpaid; immutable publish; one active policy; NUMERIC(38,18) decimal-string bounds; ISO 4217 money units; project-scoped points units; stable binding references | CON-11 | | Adapter binding | 03A,CP02/CP03A/CP03B,03D/08B,10B | CP02 proves the four hidden read/create/suspend/resume behaviors; CP03A proves exact target identity and owner eligibility; CP03B owns only exact human Finance Authority activation proof for those four actions; policy definitions and awards reference matching project/instrument identity while the binding stores no policy/award identifiers; 03D/08B own existing-award callback/replay proof; 10B owns retirement refusal for active policy, unfinished frozen work, or unfulfilled award dependencies | CON-11 + joint live drill | | Legacy clean cut | CP09 after WS-ARCH-001-03C activation | zero semantic consumers before schema removal; clean current-baseline parity; no alias/fallback/backfill | CON-11 | -| Authorization | AUTH + each feature | current AUTH actor/grant/fixed-service/PREP and REV-readiness foundations, with the active tracked migration graph through `0004_compensation_adapter_binding_lifecycle` (`0003_submission_lineage` predecessor); older ART `0050` references are historical pre-baseline evidence, not an active graph head; provisioning grants no service execution; CON mappings, identities, and static rows are absent on current main; every future artifact requires exact AUTH-owned registration and activation evidence, exact grant/static row, fixed-service admission, prepared handle bound to session/action/actor-ref/idempotency/request digest with substitution non-consumption, planned denial, and no local role logic | AUTH activation + CON-11 | +| Authorization | AUTH + each feature | until CP03A merges, current `main` ends at `0004_compensation_adapter_binding_lifecycle` (`0003_submission_lineage` predecessor); the CP03A branch advances the tracked graph to `0005_compensation_adapter_identity` on merge; older `0050`, `0053`, and `0055` identifiers are historical pre-baseline merge evidence, not active graph heads; target-only adapter provisioning grants no service execution or matrix row; every future artifact requires exact AUTH-owned registration and activation evidence, exact grant/static row where applicable, fixed-service admission where applicable, prepared handle bound to session/action/actor-ref/idempotency/request digest with substitution non-consumption, planned denial, and no local role logic | AUTH activation + CON-11 | | Final acceptance | REV + 03C,07 | accept creates one immutable FinalAcceptance per task/Review/Submission; needs_revision/reject create none; no create API/action, reopen, replacement, or adjudication path | joint live drill | | Contribution cardinality | 03C,07 + REV | one completed_review per valid Review with direct Review/lease lineage; one accepted_submission per FinalAcceptance with assignment lineage; mutually exclusive sources; revision Reviews distinct; automated outcomes create none | joint live drill | | Policy lock and human revision rebase | CP06-CP08, ARCH-03B,07 + PROJECT/TASK/REV | one guide-bound version; exact `WorkstreamTask.locked_contribution_policy_version_id`, `TaskAssignment.submitter_contribution_policy_version_id`, `Submission.contribution_policy_version_id`, and `ReviewLease.reviewer_contribution_policy_version_id` lineage; no claim-time lookup or publication-time drift; human needs_revision keeps/rebases/blocks the complete next-task context atomically and preserves completed history | joint live drill | 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 cf45e6dad..f9eb2c6b2 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 @@ -84,8 +84,8 @@ historical-row backfill or compatibility behavior. facts. Callback/fulfillment authority stays separate. - CP02 is merged with hidden, route-unreachable adapter-binding behavior and immutable lifecycle history while all four AUTH actions remain unavailable. - CP03 is split: CP03A's executable target-identity/owner-eligibility contract - precedes CP03B's executable Finance Authority activation contract. CP04-CP05 + CP03A is complete on merge with target identity and owner eligibility while + actions remain unavailable. CP03B owns Finance Authority activation. CP04-CP05 remain non-executable policy behavior/activation skeletons. - CP06-CP09: validation, owner schema lineage, and clean legacy removal wait for the preceding public behavior. No historical-row classification is required @@ -118,8 +118,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. Implement reviewed CP03A while all -binding actions remain unavailable, then CP03B to activate exactly those four -actions for covered Finance Authority. CP02 remains hidden and route-unreachable; +participant after REV's earlier gates merge. Implement CP03B to activate exactly +those four actions for covered Finance Authority. CP02 remains hidden and route-unreachable; neither child adds a route. Open pull requests determine transient CON work. diff --git a/.ci/behavior-ownership/lifecycle/compensation-binding-eligibility.json b/.ci/behavior-ownership/lifecycle/compensation-binding-eligibility.json new file mode 100644 index 000000000..b5c820ace --- /dev/null +++ b/.ci/behavior-ownership/lifecycle/compensation-binding-eligibility.json @@ -0,0 +1,18 @@ +{ + "behavior_id": "projects.compensation.binding.eligibility", + "boundaries": ["postgresql", "lock"], + "callables": [ + "app.modules.projects.compensation_binding.ProjectCompensationBindingEligibility.__init__", + "app.modules.projects.compensation_binding.ProjectCompensationBindingEligibility.lock_compensation_binding_project" + ], + "group": "lifecycle", + "outcomes": ["return", "mapped_error"], + "reviewed_by": ["WS-ARCH-001-CP03A required reviewers"], + "schema": "workstream.behavior-ownership.v1", + "status": "reviewed", + "target": "backend/app/modules/projects/compensation_binding.py", + "tests": [ + "backend/tests/projects/test_compensation_binding_eligibility.py::test_non_retired_project_is_eligible", + "backend/tests/projects/test_compensation_binding_eligibility.py::test_absent_or_retired_project_is_concealed" + ] +} diff --git a/.ci/behavior-ownership/partition.v1.json b/.ci/behavior-ownership/partition.v1.json index 37f0625cf..d2e0819ee 100644 --- a/.ci/behavior-ownership/partition.v1.json +++ b/.ci/behavior-ownership/partition.v1.json @@ -140,6 +140,14 @@ "group": "shared", "target": "backend/app/modules/actors/api/compensation_adapter.py" }, + { + "group": "shared", + "target": "backend/app/modules/actors/api/service_identities.py" + }, + { + "group": "shared", + "target": "backend/app/modules/actors/compensation_adapter.py" + }, { "group": "shared", "target": "backend/app/modules/actors/legacy_classification.py" @@ -600,6 +608,10 @@ "group": "lifecycle", "target": "backend/app/modules/projects/authorization_reads.py" }, + { + "group": "lifecycle", + "target": "backend/app/modules/projects/compensation_binding.py" + }, { "group": "lifecycle", "target": "backend/app/modules/projects/create_repository.py" @@ -873,7 +885,7 @@ "target": "backend/scripts/week2_api_e2e.py" } ], - "authority_digest": "d6a735d82f7a8437aabee54badc111f2a5bb8e0e27fdd266a078d4325cf0779c", + "authority_digest": "c66c2a45108fd07fc3041cc8c14e67dd8d2bf7959dca1cad64ced5af536a7d9a", "protected_base_commit": "7676ce4347db0c9694962a9b587a20765e16eac6", "schema": "workstream.behavior-ownership-partition.v1" } diff --git a/.ci/behavior-ownership/shared/compensation-adapter-eligibility.json b/.ci/behavior-ownership/shared/compensation-adapter-eligibility.json new file mode 100644 index 000000000..06301a23d --- /dev/null +++ b/.ci/behavior-ownership/shared/compensation-adapter-eligibility.json @@ -0,0 +1,20 @@ +{ + "behavior_id": "actors.compensation.adapter.eligibility", + "boundaries": ["postgresql", "lock"], + "callables": [ + "app.modules.actors.compensation_adapter.CompensationAdapterActorEligibility.__init__", + "app.modules.actors.compensation_adapter.CompensationAdapterActorEligibility.lock_compensation_adapter_actor" + ], + "group": "shared", + "outcomes": ["return", "mapped_error"], + "reviewed_by": ["WS-ARCH-001-CP03A required reviewers"], + "schema": "workstream.behavior-ownership.v1", + "status": "reviewed", + "target": "backend/app/modules/actors/compensation_adapter.py", + "tests": [ + "backend/tests/actors/test_compensation_adapter_eligibility.py::test_exact_active_adapter_profile_and_link_are_eligible", + "backend/tests/actors/test_compensation_adapter_eligibility.py::test_ineligible_profiles_are_concealed", + "backend/tests/actors/test_compensation_adapter_eligibility.py::test_ineligible_identity_links_are_concealed", + "backend/tests/actors/test_compensation_adapter_eligibility.py::test_every_action_bearing_service_identity_is_ineligible" + ] +} diff --git a/.ci/module-boundaries/private-edge-debt.v1.json b/.ci/module-boundaries/private-edge-debt.v1.json index 1652cc348..daffc15fb 100644 --- a/.ci/module-boundaries/private-edge-debt.v1.json +++ b/.ci/module-boundaries/private-edge-debt.v1.json @@ -109,12 +109,6 @@ "imported_private_path": "app.modules.actors.service", "repair_owner": "WS-ARCH-001-03" }, - { - "source_file": "backend/app/api/deps/authorization.py", - "target_module": "actors", - "imported_private_path": "app.modules.actors.service_identities", - "repair_owner": "WS-ARCH-001-03" - }, { "source_file": "backend/app/api/deps/authorization.py", "target_module": "api_controls", diff --git a/backend/alembic/env.py b/backend/alembic/env.py index cbf1898a5..98f85b49d 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 = "0004_compensation_adapter_binding_lifecycle" +_CURRENT_HEAD_REVISION = "0005_compensation_adapter_identity" _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/0005_compensation_adapter_identity.py b/backend/alembic/versions/0005_compensation_adapter_identity.py new file mode 100644 index 000000000..1abc211d6 --- /dev/null +++ b/backend/alembic/versions/0005_compensation_adapter_identity.py @@ -0,0 +1,43 @@ +"""Add the target-only compensation adapter service identity.""" + +from alembic import op + +revision = "0005_compensation_adapter_identity" +down_revision = "0004_compensation_adapter_binding_lifecycle" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_constraint( + op.f("ck_actor_profiles_kind_service_identity"), + "actor_profiles", + type_="check", + ) + op.create_check_constraint( + op.f("ck_actor_profiles_kind_service_identity"), + "actor_profiles", + "(actor_kind = 'human' and service_identity is null) or " + "(actor_kind = 'service' and service_identity in (" + "'workstream.artifact.verifier'," + "'workstream.artifact.put_resolver'," + "'workstream.artifact.scheduler'," + "'workstream.artifact.binding'," + "'workstream.artifact.guide_reader'," + "'workstream.artifact.materializer'," + "'workstream.artifact.checker_output'," + "'workstream.project.setup'," + "'workstream.review.preference_expiry'," + "'workstream.review.lease_expiry'," + "'workstream.review.authority_invalidation_reconciliation'," + "'workstream.review.reconciliation'," + "'workstream.review.artifact_reference_reconciliation'," + "'workstream.review.projection'," + "'workstream.compensation.adapter'))", + ) + + +def downgrade() -> None: + raise RuntimeError( + "Workstream v0.1 migrations cannot be downgraded; recreate the database" + ) diff --git a/backend/app/api/deps/authorization.py b/backend/app/api/deps/authorization.py index f14a08b90..8eb98c0c9 100644 --- a/backend/app/api/deps/authorization.py +++ b/backend/app/api/deps/authorization.py @@ -21,6 +21,7 @@ from app.api.deps.api_controls import enforce_authorization_read_rate_limit from app.core.api_controls import StructuredHTTPException, request_ids from app.db.session import get_db_session +from app.modules.actors.api import ServiceIdentity from app.modules.actors.service import ( ActorRegistryError, ActorService, @@ -46,7 +47,6 @@ ProjectDiagnosticReadResourceContext, ServiceAuthorizationContext, ) -from app.modules.actors.service_identities import ServiceIdentity from app.modules.authorization.catalogue import ActionId from app.schemas.auth import AuthVerificationResult diff --git a/backend/app/modules/actors/api/__init__.py b/backend/app/modules/actors/api/__init__.py index 7d2eccef2..cc4520e99 100644 --- a/backend/app/modules/actors/api/__init__.py +++ b/backend/app/modules/actors/api/__init__.py @@ -5,9 +5,17 @@ CompensationAdapterActorEligibilityPort, CompensationAdapterActorUnavailable, ) +from app.modules.actors.api.service_identities import ( + SERVICE_IDENTITIES, + SERVICE_IDENTITY_VALUES, + ServiceIdentity, +) __all__ = ( "CompensationAdapterActorEligibilityFacts", "CompensationAdapterActorEligibilityPort", "CompensationAdapterActorUnavailable", + "SERVICE_IDENTITIES", + "SERVICE_IDENTITY_VALUES", + "ServiceIdentity", ) diff --git a/backend/app/modules/actors/api/service_identities.py b/backend/app/modules/actors/api/service_identities.py new file mode 100644 index 000000000..194981c4f --- /dev/null +++ b/backend/app/modules/actors/api/service_identities.py @@ -0,0 +1,38 @@ +"""Public closed identity vocabulary owned by ACTORS.""" + +from enum import StrEnum, unique + + +@unique +class ServiceIdentity(StrEnum): + """Stable local names for the closed v0.1 service principals.""" + + ARTIFACT_VERIFIER = "workstream.artifact.verifier" + ARTIFACT_PUT_RESOLVER = "workstream.artifact.put_resolver" + ARTIFACT_SCHEDULER = "workstream.artifact.scheduler" + ARTIFACT_BINDING = "workstream.artifact.binding" + ARTIFACT_GUIDE_READER = "workstream.artifact.guide_reader" + ARTIFACT_MATERIALIZER = "workstream.artifact.materializer" + ARTIFACT_CHECKER_OUTPUT = "workstream.artifact.checker_output" + PROJECT_SETUP = "workstream.project.setup" + REVIEW_PREFERENCE_EXPIRY = "workstream.review.preference_expiry" + REVIEW_LEASE_EXPIRY = "workstream.review.lease_expiry" + REVIEW_AUTHORITY_INVALIDATION_RECONCILIATION = ( + "workstream.review.authority_invalidation_reconciliation" + ) + REVIEW_RECONCILIATION = "workstream.review.reconciliation" + REVIEW_ARTIFACT_REFERENCE_RECONCILIATION = ( + "workstream.review.artifact_reference_reconciliation" + ) + REVIEW_PROJECTION = "workstream.review.projection" + COMPENSATION_ADAPTER = "workstream.compensation.adapter" + + +SERVICE_IDENTITIES = frozenset(ServiceIdentity) +SERVICE_IDENTITY_VALUES = tuple(identity.value for identity in ServiceIdentity) + +__all__ = ( + "SERVICE_IDENTITIES", + "SERVICE_IDENTITY_VALUES", + "ServiceIdentity", +) diff --git a/backend/app/modules/actors/compensation_adapter.py b/backend/app/modules/actors/compensation_adapter.py new file mode 100644 index 000000000..ba2173057 --- /dev/null +++ b/backend/app/modules/actors/compensation_adapter.py @@ -0,0 +1,60 @@ +"""ACTORS-owned compensation-adapter eligibility implementation.""" + +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.actors.api import ( + CompensationAdapterActorEligibilityFacts, + CompensationAdapterActorUnavailable, + ServiceIdentity, +) +from app.modules.actors.models import ActorIdentityLink, ActorProfile + + +class CompensationAdapterActorEligibility: + """Retain exact adapter profile/link locks in the caller transaction.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def lock_compensation_adapter_actor( + self, adapter_actor_id: UUID + ) -> CompensationAdapterActorEligibilityFacts: + """Lock and conceal every missing or ineligible adapter identity.""" + profile = await self._session.scalar( + select(ActorProfile) + .where(ActorProfile.id == str(adapter_actor_id)) + .with_for_update() + .execution_options(populate_existing=True) + ) + if ( + profile is None + or profile.actor_kind != "service" + or profile.status != "active" + or profile.service_identity != ServiceIdentity.COMPENSATION_ADAPTER.value + ): + raise CompensationAdapterActorUnavailable( + "compensation_adapter_actor_unavailable" + ) + + link = await self._session.scalar( + select(ActorIdentityLink) + .where(ActorIdentityLink.actor_profile_id == profile.id) + .with_for_update() + .execution_options(populate_existing=True) + ) + if ( + link is None + or link.subject_kind != "service" + or link.status != "active" + ): + raise CompensationAdapterActorUnavailable( + "compensation_adapter_actor_unavailable" + ) + return CompensationAdapterActorEligibilityFacts( + adapter_actor_id=adapter_actor_id + ) diff --git a/backend/app/modules/actors/service_identities.py b/backend/app/modules/actors/service_identities.py index 617340ae4..b2ceb0230 100644 --- a/backend/app/modules/actors/service_identities.py +++ b/backend/app/modules/actors/service_identities.py @@ -1,31 +1,13 @@ -"""Closed Workstream identities for internal service ActorProfiles.""" - -from __future__ import annotations - -from enum import StrEnum, unique - - -@unique -class ServiceIdentity(StrEnum): - """Stable local names for the fixed v0.1 internal service principals.""" - - ARTIFACT_VERIFIER = "workstream.artifact.verifier" - ARTIFACT_PUT_RESOLVER = "workstream.artifact.put_resolver" - ARTIFACT_SCHEDULER = "workstream.artifact.scheduler" - ARTIFACT_BINDING = "workstream.artifact.binding" - ARTIFACT_GUIDE_READER = "workstream.artifact.guide_reader" - ARTIFACT_MATERIALIZER = "workstream.artifact.materializer" - ARTIFACT_CHECKER_OUTPUT = "workstream.artifact.checker_output" - PROJECT_SETUP = "workstream.project.setup" - REVIEW_PREFERENCE_EXPIRY = "workstream.review.preference_expiry" - REVIEW_LEASE_EXPIRY = "workstream.review.lease_expiry" - REVIEW_AUTHORITY_INVALIDATION_RECONCILIATION = ( - "workstream.review.authority_invalidation_reconciliation" - ) - REVIEW_RECONCILIATION = "workstream.review.reconciliation" - REVIEW_ARTIFACT_REFERENCE_RECONCILIATION = "workstream.review.artifact_reference_reconciliation" - REVIEW_PROJECTION = "workstream.review.projection" - - -SERVICE_IDENTITIES = frozenset(ServiceIdentity) -SERVICE_IDENTITY_VALUES = tuple(identity.value for identity in ServiceIdentity) +"""Transitional ACTORS-internal import surface for the public vocabulary.""" + +from app.modules.actors.api.service_identities import ( + SERVICE_IDENTITIES, + SERVICE_IDENTITY_VALUES, + ServiceIdentity, +) + +__all__ = ( + "SERVICE_IDENTITIES", + "SERVICE_IDENTITY_VALUES", + "ServiceIdentity", +) diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index 9e9e9a612..2ac990c22 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -6,7 +6,7 @@ from enum import StrEnum, unique from types import MappingProxyType -from app.modules.actors.service_identities import SERVICE_IDENTITIES, ServiceIdentity +from app.modules.actors.api import SERVICE_IDENTITIES, ServiceIdentity @unique @@ -1002,6 +1002,10 @@ def _index_actions( ServiceIdentity.REVIEW_PROJECTION: frozenset({ActionId.REVIEW_PROJECTION_REBUILD}), } +ACTION_BEARING_SERVICE_IDENTITIES = SERVICE_IDENTITIES - { + ServiceIdentity.COMPENSATION_ADAPTER +} + _EXPECTED_SERVICE_ACTION_MEMBERSHIPS = frozenset( (identity, action) for identity, action in ( @@ -1151,14 +1155,11 @@ def _index_service_actions( ActionOwner.AUTH_REV_12, ), } - if set(rows) != SERVICE_IDENTITIES: + if set(rows) != ACTION_BEARING_SERVICE_IDENTITIES: raise RuntimeError("service action matrix identity mismatch") if any(not actions for actions in rows.values()): raise RuntimeError("service action matrix row mismatch") - if ( - frozenset((i, a) for i, actions in rows.items() for a in actions) - != _EXPECTED_SERVICE_ACTION_MEMBERSHIPS - ): + if frozenset((i, a) for i, actions in rows.items() for a in actions) != _EXPECTED_SERVICE_ACTION_MEMBERSHIPS: raise RuntimeError("service action matrix row mismatch") if not FUTURE_INTENT_REQUIRED_ACTIONS.isdisjoint( action for actions in rows.values() for action in actions diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index c8b21cee3..9a98d183f 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -409,10 +409,8 @@ async def _prepare_prelocked( raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.UNKNOWN_ACTION) context, grant = self._context, None if isinstance(context, ServiceAuthorizationContext): - if action_id not in SERVICE_ACTIONS_BY_IDENTITY[context.service_identity]: - raise PreparedAuthorizationUnsupported( - AuthorizationDenialCode.PERMISSION_NOT_GRANTED - ) + if action_id not in SERVICE_ACTIONS_BY_IDENTITY.get(context.service_identity, ()): + raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.PERMISSION_NOT_GRANTED) if action.availability is not ActionAvailability.ACTIVE: raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.ACTION_UNAVAILABLE) expected_resource = _ARTIFACT_INTERNAL_RESOURCES.get(action_id) @@ -969,10 +967,7 @@ async def _require_prelocked( expected_resource = _ARTIFACT_INTERNAL_RESOURCES.get(action_id) if denial is None and action.availability is not ActionAvailability.ACTIVE: denial = AuthorizationDenialCode.ACTION_UNAVAILABLE - if ( - denial is None - and action_id not in SERVICE_ACTIONS_BY_IDENTITY[context.service_identity] - ): + if denial is None and action_id not in SERVICE_ACTIONS_BY_IDENTITY.get(context.service_identity, ()): denial = AuthorizationDenialCode.PERMISSION_NOT_GRANTED setup_match = project_setup_resource_matches( action_id, resource_context, authority.scope_project_id @@ -1156,7 +1151,7 @@ async def _service_denial( return lifecycle, context, False if action is None: return AuthorizationDenialCode.UNKNOWN_ACTION, context, False - if requested_action not in SERVICE_ACTIONS_BY_IDENTITY[context.service_identity]: + if requested_action not in SERVICE_ACTIONS_BY_IDENTITY.get(context.service_identity, ()): return AuthorizationDenialCode.PERMISSION_NOT_GRANTED, context, False if action.availability is not ActionAvailability.ACTIVE: return AuthorizationDenialCode.ACTION_UNAVAILABLE, context, False @@ -1170,7 +1165,7 @@ async def _service_denial( return lifecycle, refreshed, True if ( refreshed.service_identity is not context.service_identity - or action.action_id not in SERVICE_ACTIONS_BY_IDENTITY[refreshed.service_identity] + or action.action_id not in SERVICE_ACTIONS_BY_IDENTITY.get(refreshed.service_identity, ()) or ACTION_BY_ID[action.action_id].availability is not ActionAvailability.ACTIVE ): return AuthorizationDenialCode.PERMISSION_NOT_GRANTED, refreshed, True diff --git a/backend/app/modules/authorization/service_actor_schemas.py b/backend/app/modules/authorization/service_actor_schemas.py index 10504b106..30f430a00 100644 --- a/backend/app/modules/authorization/service_actor_schemas.py +++ b/backend/app/modules/authorization/service_actor_schemas.py @@ -8,7 +8,7 @@ from pydantic import AfterValidator, BaseModel, ConfigDict, Field -from app.modules.actors.service_identities import ServiceIdentity +from app.modules.actors.api import ServiceIdentity _STRICT = ConfigDict(extra="forbid", hide_input_in_errors=True) diff --git a/backend/app/modules/authorization/service_actor_service.py b/backend/app/modules/authorization/service_actor_service.py index 234bbf63a..7747b5011 100644 --- a/backend/app/modules/authorization/service_actor_service.py +++ b/backend/app/modules/authorization/service_actor_service.py @@ -7,9 +7,9 @@ from sqlalchemy.ext.asyncio import AsyncSession +from app.modules.actors.api import ServiceIdentity from app.modules.actors.models import ActorIdentityLink, ActorProfile from app.modules.actors.repository import ActorRepository -from app.modules.actors.service_identities import ServiceIdentity from app.modules.audit.schemas import ( ActorReferenceKind, AuthorityAuditEventInput, diff --git a/backend/app/modules/projects/compensation_binding.py b/backend/app/modules/projects/compensation_binding.py new file mode 100644 index 000000000..207348c12 --- /dev/null +++ b/backend/app/modules/projects/compensation_binding.py @@ -0,0 +1,37 @@ +"""PROJECTS-owned compensation-binding eligibility implementation.""" + +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.projects.api import ( + ProjectCompensationBindingEligibilityFacts, + ProjectCompensationBindingUnavailable, +) +from app.modules.projects.models import Project + + +class ProjectCompensationBindingEligibility: + """Retain one eligible project lock in the caller transaction.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def lock_compensation_binding_project( + self, project_id: UUID + ) -> ProjectCompensationBindingEligibilityFacts: + """Lock the exact non-retired project or return one concealed denial.""" + project = await self._session.scalar( + select(Project) + .where(Project.id == str(project_id)) + .with_for_update() + .execution_options(populate_existing=True) + ) + if project is None or project.status not in {"draft", "active"}: + raise ProjectCompensationBindingUnavailable( + "project_compensation_binding_unavailable" + ) + return ProjectCompensationBindingEligibilityFacts(project_id=project_id) diff --git a/backend/scripts/behavior_ownership.py b/backend/scripts/behavior_ownership.py index a7d19f30f..f4dc0e21e 100644 --- a/backend/scripts/behavior_ownership.py +++ b/backend/scripts/behavior_ownership.py @@ -123,6 +123,13 @@ "backend/app/modules/compensation/service.py", } ) +ARCH_CP03A_OWNER_ELIGIBILITY_TARGETS = frozenset( + { + "backend/app/modules/actors/api/service_identities.py", + "backend/app/modules/actors/compensation_adapter.py", + "backend/app/modules/projects/compensation_binding.py", + } +) POL_03A_CALLABLE_TARGETS = frozenset( { "backend/app/modules/authorization/api/project_guide_compilation.py", @@ -315,6 +322,7 @@ def _validate_additive_partition_transition( | ARCH_02G_AUTH_PREPARATION_TARGETS | ARCH_02H_AUTH_CONSUMPTION_TARGETS | ARCH_CP02_ADAPTER_BINDING_TARGETS + | ARCH_CP03A_OWNER_ELIGIBILITY_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 e8eef4666..a84ebab82 100644 --- a/backend/scripts/run_test_lanes.py +++ b/backend/scripts/run_test_lanes.py @@ -110,6 +110,7 @@ class TestLane: "tests/test_compensation.py", "tests/compensation/test_adapter_binding_api.py", "tests/compensation/test_adapter_binding_authorization_failures.py", + "tests/compensation/test_adapter_binding_authorization_integration.py", "tests/compensation/test_adapter_binding_database_guards.py", "tests/compensation/test_adapter_binding_partition.py", "tests/compensation/test_adapter_binding_owner_fences.py", @@ -133,6 +134,7 @@ class TestLane: "tests/test_guide_pptx.py", "tests/test_local_artifact_store.py", "tests/test_merge_test_lane_evidence.py", + "tests/migrations/test_compensation_adapter_identity.py", "tests/test_mutation_policy.py", "tests/test_s3_artifact_store.py", "tests/test_submission_archive.py", @@ -140,6 +142,7 @@ class TestLane: "tests/test_submission_manifest.py", "tests/test_test_lane_evidence.py", "tests/test_actors.py", + "tests/actors/test_compensation_adapter_eligibility.py", "tests/test_api_rate_controls.py", "tests/test_audit.py", "tests/test_auth.py", @@ -165,6 +168,7 @@ class TestLane: "tests/test_outbox.py", "tests/test_policy_identity_lineage.py", "tests/test_project_policy_mutations.py", + "tests/projects/test_compensation_binding_eligibility.py", "tests/test_review_authorization_contracts.py", ) diff --git a/backend/tests/actors/test_compensation_adapter_eligibility.py b/backend/tests/actors/test_compensation_adapter_eligibility.py new file mode 100644 index 000000000..e5f152ff0 --- /dev/null +++ b/backend/tests/actors/test_compensation_adapter_eligibility.py @@ -0,0 +1,111 @@ +"""Focused ACTORS owner-eligibility behavior for compensation adapters.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from app.modules.actors.api import ( + SERVICE_IDENTITIES, + CompensationAdapterActorUnavailable, + ServiceIdentity, +) +from app.modules.actors.compensation_adapter import CompensationAdapterActorEligibility +from app.modules.authorization.catalogue import SERVICE_ACTIONS_BY_IDENTITY +from app.modules.authorization.service_actor_schemas import ServiceActorProvisionBody + + +def _profile(actor_id, **overrides): + values = { + "id": str(actor_id), + "actor_kind": "service", + "status": "active", + "service_identity": ServiceIdentity.COMPENSATION_ADAPTER.value, + } + values.update(overrides) + return SimpleNamespace(**values) + + +def _link(actor_id, **overrides): + values = { + "actor_profile_id": str(actor_id), + "subject_kind": "service", + "status": "active", + } + values.update(overrides) + return SimpleNamespace(**values) + + +def test_target_identity_is_provisionable_but_has_no_service_actions() -> None: + body = ServiceActorProvisionBody.model_validate( + { + "service_identity": ServiceIdentity.COMPENSATION_ADAPTER.value, + "subject": "compensation-adapter-target", + "reason": "Register the exact compensation adapter target", + } + ) + + assert body.service_identity is ServiceIdentity.COMPENSATION_ADAPTER + assert body.service_identity not in SERVICE_ACTIONS_BY_IDENTITY + + +@pytest.mark.asyncio +async def test_exact_active_adapter_profile_and_link_are_eligible() -> None: + actor_id = uuid4() + session = SimpleNamespace(scalar=AsyncMock(side_effect=[_profile(actor_id), _link(actor_id)])) + + facts = await CompensationAdapterActorEligibility(session).lock_compensation_adapter_actor( + actor_id + ) + + assert facts.adapter_actor_id == actor_id + assert session.scalar.await_count == 2 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "profile", + [ + None, + _profile(uuid4(), actor_kind="human"), + _profile(uuid4(), status="suspended"), + ], +) +async def test_ineligible_profiles_are_concealed(profile) -> None: + session = SimpleNamespace(scalar=AsyncMock(return_value=profile)) + + with pytest.raises(CompensationAdapterActorUnavailable): + await CompensationAdapterActorEligibility(session).lock_compensation_adapter_actor(uuid4()) + + session.scalar.assert_awaited_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("identity", SERVICE_IDENTITIES - {ServiceIdentity.COMPENSATION_ADAPTER}) +async def test_every_action_bearing_service_identity_is_ineligible(identity) -> None: + session = SimpleNamespace( + scalar=AsyncMock(return_value=_profile(uuid4(), service_identity=identity.value)) + ) + + with pytest.raises(CompensationAdapterActorUnavailable): + await CompensationAdapterActorEligibility(session).lock_compensation_adapter_actor(uuid4()) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "link", + [ + None, + _link(uuid4(), subject_kind="human"), + _link(uuid4(), status="revoked"), + ], +) +async def test_ineligible_identity_links_are_concealed(link) -> None: + actor_id = uuid4() + session = SimpleNamespace(scalar=AsyncMock(side_effect=[_profile(actor_id), link])) + + with pytest.raises(CompensationAdapterActorUnavailable): + await CompensationAdapterActorEligibility(session).lock_compensation_adapter_actor(actor_id) + + assert session.scalar.await_count == 2 diff --git a/backend/tests/authorization/guide_compilation/test_migration_contract.py b/backend/tests/authorization/guide_compilation/test_migration_contract.py index 8e8ab0bb4..92dfcec1f 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)) == ( - "0004_compensation_adapter_binding_lifecycle", + "0005_compensation_adapter_identity", 1, 1, 1, diff --git a/backend/tests/authorization/test_adapter_binding_registration.py b/backend/tests/authorization/test_adapter_binding_registration.py index 45e6301a9..bf9d91dbb 100644 --- a/backend/tests/authorization/test_adapter_binding_registration.py +++ b/backend/tests/authorization/test_adapter_binding_registration.py @@ -3,6 +3,8 @@ from __future__ import annotations from dataclasses import FrozenInstanceError +from types import SimpleNamespace +from unittest.mock import AsyncMock from uuid import uuid4 import pytest @@ -24,6 +26,17 @@ PermissionId, resolve_executable_action, ) +from app.modules.actors.api import ServiceIdentity +from app.modules.authorization.kernel import AuthorizationService +from app.modules.authorization.runtime import ( + ActorKind, + ActorStatus, + AuthorizationDenialCode, + AuthorizationDenied, + IdentityLinkStatus, + ProjectReadResourceContext, + ServiceAuthorizationContext, +) _ACTIONS = { ActionId.COMPENSATION_ADAPTER_BINDING_READ, @@ -46,6 +59,35 @@ def test_cp01a_registers_only_exact_planned_binding_actions() -> None: assert all(_ACTIONS.isdisjoint(actions) for actions in SERVICE_ACTIONS_BY_IDENTITY.values()) +@pytest.mark.asyncio +async def test_target_only_identity_denies_without_matrix_key_error() -> None: + actor_id, project_id = uuid4(), uuid4() + context = ServiceAuthorizationContext( + actor_profile_id=actor_id, + actor_kind=ActorKind.SERVICE, + actor_status=ActorStatus.ACTIVE, + identity_link_id=uuid4(), + identity_link_status=IdentityLinkStatus.ACTIVE, + service_identity=ServiceIdentity.COMPENSATION_ADAPTER, + request_id=uuid4(), + correlation_id=uuid4(), + ) + service = AuthorizationService(object(), context) # type: ignore[arg-type] + service._audit = SimpleNamespace(add_authority_event=AsyncMock()) # type: ignore[assignment] + resource = ProjectReadResourceContext( + resource_type="project", + resource_id=project_id, + scope_project_id=project_id, + project_exists=True, + project_status="active", + ) + + with pytest.raises(AuthorizationDenied) as exc_info: + await service.require(ActionId.PROJECT_READ, resource) + + assert exc_info.value.decision.denial_code is AuthorizationDenialCode.PERMISSION_NOT_GRANTED + + def test_cp01c_public_facts_are_immutable_and_validate_lifecycle_state() -> None: project_id = uuid4() binding_id = uuid4() diff --git a/backend/tests/compensation/test_adapter_binding_authorization_integration.py b/backend/tests/compensation/test_adapter_binding_authorization_integration.py new file mode 100644 index 000000000..1f06020b4 --- /dev/null +++ b/backend/tests/compensation/test_adapter_binding_authorization_integration.py @@ -0,0 +1,99 @@ +"""CP03A composition proof while adapter-binding AUTH remains unavailable.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from sqlalchemy import func, select + +from app.db import session as db_session +from app.modules.actors.api import ServiceIdentity +from app.modules.actors.compensation_adapter import CompensationAdapterActorEligibility +from app.modules.actors.models import ActorIdentityLink, ActorProfile +from app.modules.compensation.api import AdapterBindingCreateRequest, AdapterBindingUnavailable +from app.modules.compensation.models import ProjectCompensationAdapterBinding +from app.modules.compensation.service import AdapterBindingService +from app.modules.projects.compensation_binding import ProjectCompensationBindingEligibility +from project_create_fixtures import insert_historical_project + +pytest_plugins = ("adapter_binding_fixtures",) + + +@pytest.mark.asyncio +async def test_real_owner_eligibility_does_not_activate_binding_authority( + compensation_database_env: str, +) -> None: + project_id, authority_actor_id, adapter_actor_id = uuid4(), uuid4(), uuid4() + async with db_session.get_session_factory()() as session: + async with session.begin(): + session.add_all( + ( + ActorProfile( + id=str(authority_actor_id), + actor_kind="human", + status="active", + provisioning_method="automatic_first_access", + created_by=str(authority_actor_id), + ), + ActorProfile( + id=str(adapter_actor_id), + actor_kind="service", + status="active", + provisioning_method="manual_service_provisioning", + service_identity=ServiceIdentity.COMPENSATION_ADAPTER.value, + created_by=str(authority_actor_id), + ), + ) + ) + await session.flush() + session.add_all( + ( + ActorIdentityLink( + id=str(uuid4()), + actor_profile_id=str(authority_actor_id), + issuer="https://compensation.test", + subject=f"authority-{authority_actor_id}", + subject_kind="human", + status="active", + linked_by=str(authority_actor_id), + last_verified_at=datetime.now(UTC), + ), + ActorIdentityLink( + id=str(uuid4()), + actor_profile_id=str(adapter_actor_id), + issuer="https://compensation.test", + subject=f"adapter-{adapter_actor_id}", + subject_kind="service", + status="active", + linked_by=str(authority_actor_id), + ), + ) + ) + await insert_historical_project( + session, + project_id=str(project_id), + name="CP03A project", + slug=f"cp03a-{str(project_id)[:8]}", + ) + + with pytest.raises(AdapterBindingUnavailable): + async with session.begin(): + await AdapterBindingService( + session, + projects=ProjectCompensationBindingEligibility(session), + actors=CompensationAdapterActorEligibility(session), + ).create( + AdapterBindingCreateRequest( + operation_id=uuid4(), + actor_profile_id=authority_actor_id, + project_id=project_id, + instrument_type="money", + adapter_actor_id=adapter_actor_id, + route_key="adapter.primary", + ) + ) + + count = await session.scalar( + select(func.count()).select_from(ProjectCompensationAdapterBinding) + ) + assert count == 0 diff --git a/backend/tests/compensation/test_adapter_binding_owner_fences.py b/backend/tests/compensation/test_adapter_binding_owner_fences.py index faa4ed1d9..2e5a027a3 100644 --- a/backend/tests/compensation/test_adapter_binding_owner_fences.py +++ b/backend/tests/compensation/test_adapter_binding_owner_fences.py @@ -1,4 +1,4 @@ -"""PostgreSQL proof that CP02 owner-capability fakes retain eligibility fences.""" +"""PostgreSQL proof for CP03A real owner fences and retained CP02 guards.""" import asyncio @@ -18,7 +18,8 @@ CompensationAdapterActorUnavailable, ) from app.modules.actors.models import ActorIdentityLink, ActorProfile -from app.modules.actors.service_identities import ServiceIdentity +from app.modules.actors.api import ServiceIdentity +from app.modules.actors.compensation_adapter import CompensationAdapterActorEligibility from app.modules.compensation.api import ( AdapterBindingCreateRequest, AdapterBindingConflict, @@ -36,6 +37,7 @@ ProjectCompensationBindingUnavailable, ) from app.modules.projects.models import Project +from app.modules.projects.compensation_binding import ProjectCompensationBindingEligibility BindingSeed = Callable[[], Awaitable[tuple[UUID, UUID, UUID]]] pytest_plugins = ("adapter_binding_fixtures",) @@ -107,16 +109,17 @@ def close_adapter_binding_mutation(self, prepared: object) -> None: async def _prepare_suspended( - project_id: UUID, adapter_id: UUID, actor_id: UUID + project_id: UUID, adapter_id: UUID, actor_id: UUID, *, real_owners: bool = False ) -> UUID: 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=_OwnerFences(session, _CompensationAdapterEligibilityMarker(adapter_id)), - actors=_OwnerFences(session, _CompensationAdapterEligibilityMarker(adapter_id)), + projects=ProjectCompensationBindingEligibility(session) if real_owners else owners, + actors=CompensationAdapterActorEligibility(session) if real_owners else owners, ) async with session.begin(): created = await service.create( @@ -137,8 +140,31 @@ async def _prepare_suspended( return created.adapter_binding_id +async def _create_adapter_target(actor_id: UUID) -> UUID: + adapter_id = uuid4() + async with db_session.get_session_factory()() as session: + async with session.begin(): + session.add( + ActorProfile( + id=str(adapter_id), actor_kind="service", status="active", + provisioning_method="manual_service_provisioning", + service_identity=ServiceIdentity.COMPENSATION_ADAPTER.value, + created_by=str(actor_id), + ) + ) + await session.flush() + session.add( + ActorIdentityLink( + id=str(uuid4()), actor_profile_id=str(adapter_id), + issuer="https://compensation.test", subject=f"target-{adapter_id}", + subject_kind="service", status="active", linked_by=str(actor_id), + ) + ) + return adapter_id + + @pytest.mark.parametrize("operation", ("create", "resume")) -@pytest.mark.parametrize("locked_owner", ("project", "actor")) +@pytest.mark.parametrize("locked_owner", ("project", "actor", "identity_link")) @pytest.mark.asyncio async def test_owner_rows_remain_locked_through_protected_mutation( compensation_database_env: str, @@ -146,9 +172,10 @@ async def test_owner_rows_remain_locked_through_protected_mutation( operation: str, locked_owner: str, ) -> None: - project_id, adapter_id, actor_id = await binding_seed() + project_id, _, actor_id = await binding_seed() + adapter_id = await _create_adapter_target(actor_id) binding_id = ( - await _prepare_suspended(project_id, adapter_id, actor_id) + await _prepare_suspended(project_id, adapter_id, actor_id, real_owners=True) if operation == "resume" else None ) @@ -156,12 +183,11 @@ async def test_owner_rows_remain_locked_through_protected_mutation( 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, + mutation_authorization=authorization, + projects=ProjectCompensationBindingEligibility(session), + actors=CompensationAdapterActorEligibility(session), ) async with session.begin(): if operation == "create": @@ -187,12 +213,15 @@ async def eligibility_change() -> None: 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()) + if locked_owner == "project": + statement = select(Project).where(Project.id == str(project_id)) + elif locked_owner == "actor": + statement = select(ActorProfile).where(ActorProfile.id == str(adapter_id)) + else: + statement = select(ActorIdentityLink).where( + ActorIdentityLink.actor_profile_id == str(adapter_id) + ) + await session.scalar(statement.with_for_update()) mutation = asyncio.create_task(mutate()) entered = asyncio.create_task(authorization.entered.wait()) @@ -218,7 +247,7 @@ async def eligibility_change() -> None: @pytest.mark.parametrize("operation", ("create", "resume")) -@pytest.mark.parametrize("ineligible_owner", ("project", "actor")) +@pytest.mark.parametrize("ineligible_owner", ("project", "actor", "identity_link")) @pytest.mark.asyncio async def test_committed_owner_ineligibility_denies_before_authorization( compensation_database_env: str, @@ -226,9 +255,10 @@ async def test_committed_owner_ineligibility_denies_before_authorization( operation: str, ineligible_owner: str, ) -> None: - project_id, adapter_id, actor_id = await binding_seed() + project_id, _, actor_id = await binding_seed() + adapter_id = await _create_adapter_target(actor_id) binding_id = ( - await _prepare_suspended(project_id, adapter_id, actor_id) + await _prepare_suspended(project_id, adapter_id, actor_id, real_owners=True) if operation == "resume" else None ) @@ -238,21 +268,33 @@ async def test_committed_owner_ineligibility_denies_before_authorization( project = await session.get(Project, str(project_id)) assert project is not None project.status = "closed" - else: + elif ineligible_owner == "actor": 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" + else: + link = await session.scalar( + select(ActorIdentityLink).where( + ActorIdentityLink.actor_profile_id == str(adapter_id) + ) + ) + assert link is not None + link.status = "revoked" + link.revoked_by = str(actor_id) + link.revoked_at = datetime.now(UTC) + link.revoked_reason = "compensation adapter credential revoked" 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, + mutation_authorization=authorization, + projects=ProjectCompensationBindingEligibility(session), + actors=CompensationAdapterActorEligibility(session), ) with pytest.raises(AdapterBindingConflict): async with session.begin(): diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 009854acf..47b0cf90a 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 = "a1cf97a3230da9f72a442ddf0118a325b6ab9042685270d5c8487e0007fe86e9" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "8fd7b3e4626f97a998712aa5132d35a91fffd30b1d2e438bf94f7f92c0d5a146" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", diff --git a/backend/tests/migrations/test_compensation_adapter_identity.py b/backend/tests/migrations/test_compensation_adapter_identity.py new file mode 100644 index 000000000..0abe7ab41 --- /dev/null +++ b/backend/tests/migrations/test_compensation_adapter_identity.py @@ -0,0 +1,99 @@ +"""Focused PostgreSQL contract for migration 0005 adapter identity.""" + +import asyncio +from pathlib import Path + +from alembic import command +from alembic.config import Config +import asyncpg +import pytest + + +def _config() -> Config: + return Config(Path(__file__).resolve().parents[2] / "alembic.ini") + + +async def _execute(database_url: str, statement: str) -> None: + connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) + try: + await connection.execute(statement) + finally: + await connection.close() + + +async def _snapshot(database_url: str) -> tuple[str, str, str]: + connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) + try: + definition = await connection.fetchval( + "select pg_get_constraintdef(c.oid) from pg_constraint c " + "join pg_class t on t.oid=c.conrelid where t.relname='actor_profiles' " + "and c.conname='ck_actor_profiles_kind_service_identity'" + ) + actor = await connection.fetchval( + "select row_to_json(p)::text from actor_profiles p " + "where id='00000000-0000-0000-0000-000000000005'" + ) + revision = await connection.fetchval("select version_num from alembic_version") + return definition, actor, revision + finally: + await connection.close() + + +async def _insert_adapter_actor(database_url: str) -> None: + connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) + try: + async with connection.transaction(): + await connection.execute( + "insert into actor_profiles " + "(id,actor_kind,status,provisioning_method,service_identity,created_by) " + "values ('00000000-0000-0000-0000-000000000005','service','active'," + "'manual_service_provisioning','workstream.compensation.adapter'," + "'00000000-0000-0000-0000-000000000005')" + ) + await connection.execute( + "insert into actor_identity_links " + "(id,actor_profile_id,issuer,subject,subject_kind,status,linked_by) " + "values ('00000000-0000-0000-0000-000000000015'," + "'00000000-0000-0000-0000-000000000005','workstream.internal'," + "'workstream.compensation.adapter','service','active'," + "'workstream:system:bootstrap')" + ) + finally: + await connection.close() + + +def test_0005_accepts_only_the_closed_adapter_identity( + isolated_database_env: str, migration_lock +) -> None: + with migration_lock(): + asyncio.run( + _execute(isolated_database_env, "drop schema public cascade; create schema public") + ) + command.upgrade(_config(), "0005_compensation_adapter_identity") + asyncio.run(_insert_adapter_actor(isolated_database_env)) + with pytest.raises(asyncpg.CheckViolationError): + asyncio.run( + _execute( + isolated_database_env, + "insert into actor_profiles " + "(id,actor_kind,status,provisioning_method,service_identity,created_by) values " + "('00000000-0000-0000-0000-000000000006','service','active'," + "'manual_service_provisioning','workstream.compensation.unknown'," + "'00000000-0000-0000-0000-000000000005')", + ) + ) + + +def test_0005_downgrade_refuses_without_mutating_referenced_identity( + isolated_database_env: str, migration_lock +) -> None: + with migration_lock(): + asyncio.run( + _execute(isolated_database_env, "drop schema public cascade; create schema public") + ) + command.upgrade(_config(), "0005_compensation_adapter_identity") + asyncio.run(_insert_adapter_actor(isolated_database_env)) + before = asyncio.run(_snapshot(isolated_database_env)) + with pytest.raises(RuntimeError, match="cannot be downgraded"): + command.downgrade(_config(), "0004_compensation_adapter_binding_lifecycle") + assert asyncio.run(_snapshot(isolated_database_env)) == before diff --git a/backend/tests/projects/guide_compilation/test_migration_contract.py b/backend/tests/projects/guide_compilation/test_migration_contract.py index 33ea6f3bc..b1120767c 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)) == ( - "0004_compensation_adapter_binding_lifecycle", + "0005_compensation_adapter_identity", True, 4, 1, diff --git a/backend/tests/projects/test_compensation_binding_eligibility.py b/backend/tests/projects/test_compensation_binding_eligibility.py new file mode 100644 index 000000000..c9d1f3fe1 --- /dev/null +++ b/backend/tests/projects/test_compensation_binding_eligibility.py @@ -0,0 +1,39 @@ +"""Focused PROJECTS owner-eligibility behavior for compensation bindings.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from app.modules.projects.api import ProjectCompensationBindingUnavailable +from app.modules.projects.compensation_binding import ProjectCompensationBindingEligibility + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", ["draft", "active"]) +async def test_non_retired_project_is_eligible(status: str) -> None: + project_id = uuid4() + session = SimpleNamespace( + scalar=AsyncMock(return_value=SimpleNamespace(id=str(project_id), status=status)) + ) + + facts = await ProjectCompensationBindingEligibility( + session + ).lock_compensation_binding_project(project_id) + + assert facts.project_id == project_id + session.scalar.assert_awaited_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("project", [None, SimpleNamespace(status="retired")]) +async def test_absent_or_retired_project_is_concealed(project) -> None: + session = SimpleNamespace(scalar=AsyncMock(return_value=project)) + + with pytest.raises(ProjectCompensationBindingUnavailable): + await ProjectCompensationBindingEligibility( + session + ).lock_compensation_binding_project(uuid4()) + + session.scalar.assert_awaited_once() diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 76ee0ecf8..e1225167d 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -24,7 +24,7 @@ ) from scripts.schema_baseline_sql import split_sql_statements -HEAD_REVISION = "0004_compensation_adapter_binding_lifecycle" +HEAD_REVISION = "0005_compensation_adapter_identity" BASELINE_REVISION = "0001_v01_baseline" RECREATE_GUIDANCE = "Workstream v0.1 requires a fresh database; recreate this database" pytestmark = pytest.mark.postgres_schema_contract @@ -84,6 +84,7 @@ def test_v01_graph_has_one_root_and_head() -> None: assert [revision.revision for revision in revisions] == [ HEAD_REVISION, + "0004_compensation_adapter_binding_lifecycle", "0003_submission_lineage", "0002_admission_version", BASELINE_REVISION, @@ -264,6 +265,33 @@ async def contract() -> tuple[bool, int, set[str], set[str], set[str]]: "ck_project_compensation_adapter_bindings_ck_project_com_ade1", } & binding_checks + +def test_current_head_installs_compensation_adapter_identity( + 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 identity_constraint() -> str: + connection = await asyncpg.connect( + isolated_database_env.replace("+asyncpg", "") + ) + try: + return await connection.fetchval( + "select pg_get_constraintdef(c.oid) from pg_constraint c " + "join pg_class t on t.oid=c.conrelid where " + "t.relname='actor_profiles' and " + "c.conname='ck_actor_profiles_kind_service_identity'" + ) + finally: + await connection.close() + + assert "workstream.compensation.adapter" in asyncio.run(identity_constraint()) + def test_0004_nonempty_binding_preflight_leaves_0003_unchanged( isolated_database_env: str, migration_lock ) -> None: diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index eb7d9ea14..7978eb160 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -53,7 +53,7 @@ from app.modules.actors.service import ActorService from app.modules.actors.models import ActorIdentityLink, ActorProfile from app.modules.actors.repository import ActorRepository -from app.modules.actors.service_identities import ServiceIdentity +from app.modules.actors.api import ServiceIdentity from app.modules.authorization.models import ( AdminRoleGrant, AuthorityIdempotencyRecord, @@ -3542,11 +3542,10 @@ async def wait_for_transition_lock( pause_kind = None -async def test_xint003_02c_provisions_all_six_review_service_identities( +async def test_controlled_endpoint_provisions_review_and_adapter_target_identities( auth_database_env: str, rsa_signing_material: tuple[rsa.RSAPrivateKey, dict[str, Any]], ) -> None: - """The existing controlled endpoint provisions every exact REV principal.""" private_key, jwk = rsa_signing_material settings = production_verifier_settings(database_url=auth_database_env) app = create_app(settings) @@ -3563,8 +3562,8 @@ async def test_xint003_02c_provisions_all_six_review_service_identities( ServiceIdentity.REVIEW_RECONCILIATION, ServiceIdentity.REVIEW_ARTIFACT_REFERENCE_RECONCILIATION, ServiceIdentity.REVIEW_PROJECTION, + ServiceIdentity.COMPENSATION_ADAPTER, ) - async with AsyncClient( transport=ASGITransport(app=app), base_url="http://testserver" ) as client: diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index dee3bbe9c..147447155 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -58,7 +58,7 @@ from app.modules.audit.service import AuditService from app.modules.actors.models import ActorIdentityLink, ActorProfile from app.modules.actors.service import ActorService, ResolvedActor -from app.modules.actors.service_identities import SERVICE_IDENTITIES, ServiceIdentity +from app.modules.actors.api import SERVICE_IDENTITIES, ServiceIdentity from app.modules.authorization import catalogue as authorization_catalogue from app.modules.authorization import kernel as authorization_kernel from app.modules.authorization import prepared as authorization_prepared @@ -2717,7 +2717,7 @@ def test_fixed_service_action_matrix_and_activation_are_exact_and_immutable() -> }, ServiceIdentity.REVIEW_PROJECTION: {"review.projection.rebuild"}, } - assert set(SERVICE_ACTIONS_BY_IDENTITY) == SERVICE_IDENTITIES + assert set(SERVICE_ACTIONS_BY_IDENTITY) == SERVICE_IDENTITIES - {ServiceIdentity.COMPENSATION_ADAPTER} assert { identity: {action.value for action in actions} for identity, actions in SERVICE_ACTIONS_BY_IDENTITY.items() @@ -2731,9 +2731,7 @@ def test_fixed_service_action_matrix_and_activation_are_exact_and_immutable() -> ACTION_BY_ID[action].availability is ActionAvailability.PLANNED for action in FUTURE_INTENT_REQUIRED_ACTIONS ) - assert FUTURE_INTENT_REQUIRED_ACTIONS.isdisjoint( - set().union(*SERVICE_ACTIONS_BY_IDENTITY.values()) - ) + assert FUTURE_INTENT_REQUIRED_ACTIONS.isdisjoint(set().union(*SERVICE_ACTIONS_BY_IDENTITY.values())) project_setup_actions = SERVICE_ACTIONS_BY_IDENTITY[ServiceIdentity.PROJECT_SETUP] assert { action: ( @@ -6645,11 +6643,11 @@ async def test_project_setup_service_matrix_wrong_identity_denies_before_availab action_id: ActionId, owning_identity: ServiceIdentity, ): - wrong_identity = next( - identity - for identity in ServiceIdentity - if identity is not owning_identity - and action_id not in SERVICE_ACTIONS_BY_IDENTITY.get(identity, frozenset()) + wrong_identity = ServiceIdentity.COMPENSATION_ADAPTER + assert wrong_identity is not owning_identity + assert ( + action_id + not in SERVICE_ACTIONS_BY_IDENTITY.get(wrong_identity, frozenset()) ) context = _runtime_context( actor_kind=ActorKind.SERVICE, diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index fedc34902..596e95092 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -89,6 +89,12 @@ profile records `effective=false -> effective=true` for that profile projection without asserting that its identity link, grants, or fixed-service admission make the whole actor effective. +The closed service identity `workstream.compensation.adapter` identifies only +an eligible adapter-binding target. It has no service-action matrix row and no +action authority. ACTORS locks the exact active service profile and its active +service identity link before returning immutable eligibility facts; generic +service kind and every other fixed-service identity are insufficient. + ### ActorIdentityLink An identity link binds one canonical external issuer and opaque subject to one diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index c0e4d9aff..df4f9e40f 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -651,12 +651,14 @@ actor, project, role, and cause event before a consumer changes product state. Revoking one role must leave the other project roles and all AdminRoleGrants unchanged. -The closed registry now has fourteen fixed-service identities and twenty-three -matrix memberships: seven ART identities, project setup, and six exact REV +The closed registry now has fifteen fixed-service identities: fourteen +action-bearing identities with twenty-three matrix memberships, plus the +target-only `workstream.compensation.adapter` identity. The action-bearing +set comprises seven ART identities, project setup, and six exact REV identities. Missing provisioned rows deny without stopping the application. -The REV actions remain unavailable, so registry membership alone grants no -authority. Do not create a shared review service or a database service-grant -table. +The target-only identity has no matrix membership, and the REV actions remain +unavailable, so registry membership alone grants no authority. Do not create a +shared review service or a database service-grant table. Historically, AUTH-12B extended the registry to an eighth identity, `workstream.project.setup`, now with exactly five static memberships: @@ -751,6 +753,12 @@ IDs, subject kind, lifecycle state, and timestamps. Neither response includes issuer, subject, contact email, reason, lifecycle actor, token/claim, grant, assignment, or service-action-matrix data. +Controlled provisioning may create the target-only +`workstream.compensation.adapter` profile and service identity link. Operators +must not add a service-action matrix row for it: it is a binding target, not an +action-bearing service principal. CP03A leaves all adapter-binding actions unavailable; CP03B +is the separate Finance Authority activation boundary. + The kernel locks and revalidates the human caller profile, exact link, and matched system grant before target lookup and holds those locks through disclosure and commit. It does not acquire `AuthorityControl` or lock a distinct diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index 9c0bc6fc3..4e08068f4 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -137,8 +137,8 @@ review. Their presence does not change the implemented-on-`main` list above. authority while keeping all nine actions unavailable. CP01C corrects the unavailable binding facts before CP02. CP02 is complete on merge with hidden, route-unreachable adapter-binding behavior and immutable lifecycle history. - CP03 is split with executable CP03A/CP03B contracts complete on merge. The - remaining order is CP03A target identity/owner eligibility, CP03B activation + CP03A is complete on merge with the target identity and owner eligibility + while all four actions remain unavailable. The remaining order is CP03B activation for an authenticated human Finance Authority covering the exact project, CP04 hidden ContributionPolicy behavior, then CP05 ContributionPolicy activation; diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 0209b9b0d..28f8d6927 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -75,10 +75,12 @@ Required concepts: A profile status is a guard, not a grant. Active humans receive only self profile capability until an administrative or exact-project grant exists. -For a service, the profile is the stable local principal. Its immutable -`service_identity` selects one closed typed service-action matrix row; it is -never inferred from display data, token claims, issuer, or subject. Profile ID, -service identity, and external credential binding remain separate concepts. +For a service, the profile is the stable local identity. Its immutable +`service_identity` is either action-bearing with one closed typed service-action +matrix row or target-only with no matrix row and no executable authority. The +identity kind is never inferred from display data, token claims, issuer, or +subject. Profile ID, service identity, and external credential binding remain +separate concepts. ### ActorIdentityLink @@ -276,7 +278,7 @@ counts, mappings, owners, and availability remain unchanged. WS-ARCH-001-CP03 is split after merged CP02 hidden behavior. CP03A adds only the closed target identity `workstream.compensation.adapter` and real PROJECTS/ACTORS eligibility adapters; it adds no service-matrix membership and -keeps all four binding actions unavailable. CP03B then installs the exact +is complete on merge while keeping all four binding actions unavailable. CP03B then installs the exact read/PREP adapter for an authenticated human Finance Authority covering the exact project and activates only those four actions. AUTH-10A added five project-role read/manage rows; diff --git a/docs/spec_contribution_compensation.md b/docs/spec_contribution_compensation.md index 697bc3ede..846eff707 100644 --- a/docs/spec_contribution_compensation.md +++ b/docs/spec_contribution_compensation.md @@ -659,11 +659,16 @@ AUTH alone owns: - `PermissionId` and `ActionId` catalogues and stable mappings; - `ActionOwner` activation custody; - human grants and fixed-service admission; -- ServiceIdentity and the static service-action matrix; +- action-bearing fixed-service admission and the static service-action matrix; - typed principal and resource-context contracts; - prepared mutation handles and evaluator dispatch; - authorization decisions, evidence, invalidation, and availability. +ACTORS owns and publicly exposes the closed ActorProfile `ServiceIdentity` +vocabulary. AUTH consumes that vocabulary for controlled provisioning and owns +which identities are action-bearing; a target-only identity does not acquire a +service-action matrix row or execution authority. + CON owns canonical resource loading, product guards, hidden behavior, and feature tests. Neither subsystem imports the other's repositories or mutates the other's state. @@ -721,9 +726,9 @@ failed substitution attempt does not consume an otherwise valid handle. Reads use request-scoped authorization plus canonical pre-filtered loaders and concealment. No authorization or grant cache survives the request. -### Fixed services +### Service identities -The only fixed-service admission path is: +An action-bearing fixed service uses this admission path: ```text verified service token @@ -744,16 +749,23 @@ release readiness, but MUST NOT prevent application startup or Access Administrator provisioning. Startup MAY fail on closed catalogue, matrix, context, evaluator, or active-behavior parity drift. +A closed target-only service identity is not an action-bearing fixed service. +It may be provisioned as a typed product resource, but it has no static matrix +row, AUTH-09E runtime admission, action, permission, or executable feature +authority. `workstream.compensation.adapter` is the only current target-only +identity; it is eligible to be referenced by an adapter binding and cannot act +as the Finance Authority caller. + Merged AUTH-09B provides the human-administrator-controlled `POST /api/v1/service-actors` provisioning route and activates only `actor.service.provision`. Provisioning creates an ActorProfile and exact ActorIdentityLink for an already-approved closed ServiceIdentity; it creates no role, grant, database action assignment, runtime admission, or executable -feature authority. The current closed identities and static rows cover ART -services only. Every future CON dispatcher, delivery, callback, reconciliation, -or rebuild identity therefore still requires its own human-approved feature -manifest followed by AUTH-owned identity/matrix registration, provisioning, -AUTH-09E admission, evaluator integration, and exact action activation. +feature authority. Every future action-bearing CON dispatcher, delivery, +callback, reconciliation, or rebuild identity requires its own human-approved +feature manifest followed by AUTH-owned identity/matrix registration, +provisioning, AUTH-09E admission, evaluator integration, and exact action +activation. Target-only identity registration never satisfies that path. ### Surface mappings @@ -781,11 +793,11 @@ 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 through CP03A. -CP03 is split to preserve owner and activation custody. CP03A first registers +CP03 is split to preserve owner and activation custody. CP03A registers the closed target-only `workstream.compensation.adapter` service identity and installs PROJECTS/ACTORS owner-held eligibility adapters; it grants that target no action or service-matrix membership, and all four binding actions remain -unavailable. CP03B then installs the AUTH read/PREP adapter and activates only +unavailable after CP03A completes. CP03B then installs the AUTH read/PREP adapter and activates only the four exact binding actions for an authenticated human Finance Authority covering the exact project. Neither child adds a public route, provider behavior, or ContributionPolicy authority.