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 6f8add569..f61006686 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,8 +68,8 @@ 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 live catalogue has -71 PermissionIds, 96 ActionIds, 43 active actions, and 53 planned actions, with -eight fixed-service identities and sixteen matrix memberships. +71 PermissionIds, 100 ActionIds, 45 active actions, and 55 planned actions, with +fourteen fixed-service identities and twenty-two matrix memberships. ## REV custody transfer @@ -88,13 +88,16 @@ unchanged until each exact XINT-003 activation wave. | `WS-AUTH-001-REV-09A` | `review.finding_response_evidence.ingest` | | `WS-AUTH-001-REV-11` | `review.lease.force_release`, `review.queue.routing.override`, `review.queue.routing.correct`, `review.queue.close`, `review.reconcile.run` | | `WS-AUTH-001-REV-12` | `review.artifact_reference.reconcile`, `review.projection.rebuild` | +| `WS-XINT-003-08A` | `review.revision_context.repair`, `review.revision_obligation.close`, `review.revision_context.legacy_close` | +| `WS-XINT-003-08B` | `review.lifecycle.activation.manage` | `WS-AUTH-001-REV-CUSTODY` atomically transfers these 19 rows with exact owner -cardinalities `2/5/3/1/1/5/2` in the table order above and removes the seven -historical REV owner enum values. It changes no mapping or availability and +cardinalities `2/5/3/1/1/5/2` in the table order above. The seven historical +REV runtime owner values remain registered for those actions until their exact +activation waves replace them. It changes no mapping or availability and adds no migration. All 19 actions remain planned and unavailable; these AUTH custodian labels grant no reviewer, Operator, or service authority. The four -proposed lifecycle actions remain unregistered, and PREP remains separately +approved lifecycle actions remain planned and unavailable, and PREP remains separately human-gated. The front-loaded readiness waves are: @@ -124,8 +127,8 @@ actions are excluded. ## Front-loaded additive registration -The following values are approved boundary proposals, not registered runtime -actions on trusted `main`: +The following values are registered planned runtime actions, not active +authority: | Registration chunk | Future activation chunk | Proposed ActionId -> PermissionId | |---|---|---| diff --git a/.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/ACTION_CUSTODY.md b/.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/ACTION_CUSTODY.md index 90dc4a1ff..086a14a2a 100644 --- a/.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/ACTION_CUSTODY.md +++ b/.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/ACTION_CUSTODY.md @@ -24,10 +24,10 @@ This table is the planning source of truth for the v0.1 review and human-revisio | `review.queue.routing.override` | `review.queue.override` | Operator; canonical reason required | exact queue entry/routing state | REV | registered planned | `WS-XINT-003-08A` | | `review.queue.routing.correct` | `review.queue.override` | Operator; canonical reason required | exact invalid routing state | REV | registered planned | `WS-XINT-003-08A` | | `review.queue.close` | `review.queue.override` | Operator; canonical reason required | exact stale queue entry | REV | registered planned | `WS-XINT-003-08A` | -| `review.revision_context.repair` | `project.task.manage` | Project Manager grant for exact project | invalid revision context | REV | install unavailable in 02C | `WS-XINT-003-08A` | -| `review.revision_obligation.close` | `project.task.manage` | Project Manager grant for exact project | exact unfulfillable obligation | REV | install unavailable in 02C | `WS-XINT-003-08A` | -| `review.revision_context.legacy_close` | `operations.reconcile.run` | Operator; canonical reason required | exact legacy revision context | REV | install unavailable in 02C | `WS-XINT-003-08A` | -| `review.lifecycle.activation.manage` | `operations.reconcile.run` | Operator; exact phase and reason | lifecycle release controller | REV | install unavailable in 02C | `WS-XINT-003-08B` | +| `review.revision_context.repair` | `project.task.manage` | Project Manager grant for exact project | invalid revision context | REV | registered planned/unavailable | `WS-XINT-003-08A` | +| `review.revision_obligation.close` | `project.task.manage` | Project Manager grant for exact project | exact unfulfillable obligation | REV | registered planned/unavailable | `WS-XINT-003-08A` | +| `review.revision_context.legacy_close` | `operations.reconcile.run` | Operator; canonical reason required | exact legacy revision context | REV | registered planned/unavailable | `WS-XINT-003-08A` | +| `review.lifecycle.activation.manage` | `operations.reconcile.run` | Operator; exact phase and reason | lifecycle release controller | REV | registered planned/unavailable | `WS-XINT-003-08B` | | `review.reconcile.run` | `operations.reconcile.run` | one of two fixed reconciler identities | invalidation or general reconciliation batch | REV | registered planned | `WS-XINT-003-08B` | | `review.artifact_reference.reconcile` | `operations.reconcile.run` | fixed artifact-reference reconciler only | bounded review artifact reference batch | REV | registered planned | `WS-XINT-003-08B` | | `review.projection.rebuild` | `operations.projection.rebuild` | fixed projection rebuilder only | derived review projection batch | REV | registered planned | `WS-XINT-003-08B` | diff --git a/.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/REVIEW_LOG.md b/.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/REVIEW_LOG.md index b87f534f4..e95d6c7b0 100644 --- a/.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/REVIEW_LOG.md +++ b/.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/REVIEW_LOG.md @@ -129,3 +129,26 @@ round trip passes. PR #248 merged the completed chunk as `25fc27c4` on 2026-08-03. Backend, Agent Gates, and CodeRabbit passed on the final PR head. No review/revision lifecycle action was activated, and no successor chunk starts automatically. + +## WS-XINT-003-02C AUTH catalogue and principal readiness + +Architecture, security/auth, product/operations, QA, senior engineering, CI +integrity, reuse/dedup, test-delta, and docs reviewers examined the bounded 02C +implementation. Valid findings corrected the focused async-test selector, +canonical catalogue counts and fixed-service documentation, exact PostgreSQL +constraint-closure proof, no-grant provisioning proof, and operator-facing +0049 downgrade guidance. The four new actions remain planned/unavailable; the +six REV identities are registry and matrix values only, with no seeded +principal or lifecycle behavior. + +Focused catalogue/service/custody tests pass (33 tests). Changed-module +coverage is 100.00 percent for `service_identities.py` and 97.89 percent for +`catalogue.py`. Ruff, mypy, collection, stale authorization/review scans, and +Markdown links pass. Local PostgreSQL execution is unavailable because this +worktree has no `WORKSTREAM_TEST_DATABASE_URL`; the exact 16 database-backed +tests collect cleanly and remain assigned to hosted schema and semantic lanes. + +The first hosted semantic lanes exposed the expected post-0049 public-schema +fingerprint change before running product tests. The chunk contract now permits +only that exact `tests/conftest.py` fingerprint update; no reset allow-list or +schema-integrity behavior changed. diff --git a/.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/STATUS.md b/.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/STATUS.md index 10b52d306..08b01e250 100644 --- a/.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/STATUS.md +++ b/.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/STATUS.md @@ -28,9 +28,9 @@ REV-owned semantics with AUTH-owned mutation authorization. - Runtime owner XINT-002-07 has one approved v0.1 sub-wave: 07A packet materialization. Evidence binding remains planned/unavailable and 07B is reserved pending separate REV-owned intent. -- Registered review/revision lifecycle actions remain planned; four - lifecycle/recovery actions remain missing until front-loaded 02C registers - them unavailable. The superseded 08R placeholder is never executable. The +- All 23 registered review/revision lifecycle actions remain planned; 02C adds + the four recovery/lifecycle rows and six fixed-service identities without + provisioning or authority. The superseded 08R placeholder is never executable. The two policy mutation actions activated by 02B are setup authority, not lifecycle activation. @@ -49,9 +49,9 @@ REV-owned semantics with AUTH-owned mutation authorization. ## Next step -Complete the reviewed AUTH-readiness amendment, then request implementation of -WS-XINT-003-02C. 02C and 02D front-load the complete unavailable catalogue, -fixed-service matrix, and fail-closed PREP contracts that REV needs before it -begins its full lifecycle implementation. Do not infer review lifecycle +Complete WS-XINT-003-02C evidence and review, then merge it before 02D. 02C and +02D front-load the complete unavailable catalogue, fixed-service matrix, and +fail-closed PREP contracts that REV needs before it begins its full lifecycle +implementation. Do not infer review lifecycle activation from readiness: later action activation still requires exact merged REV behavior and integrated proof. diff --git a/.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/chunks/WS-XINT-003-02C-auth-catalogue-principal-readiness.md b/.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/chunks/WS-XINT-003-02C-auth-catalogue-principal-readiness.md index e5b034555..f6b6384da 100644 --- a/.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/chunks/WS-XINT-003-02C-auth-catalogue-principal-readiness.md +++ b/.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/chunks/WS-XINT-003-02C-auth-catalogue-principal-readiness.md @@ -2,8 +2,8 @@ ## Status -Proposed planning contract. Refresh exact migration head and verification -commands from current `main` before implementation. +Current-main implementation contract refreshed from `ac52da6b`. Implementation +and internal review are complete; hosted exact-head evidence is pending. ## Parent initiative @@ -30,18 +30,26 @@ review SLA. ## Allowed files -Refresh to exact current-main paths within: - ```text +backend/app/modules/actors/service_identities.py backend/app/modules/authorization/catalogue.py -backend/app/modules/authorization/runtime.py -backend/app/modules/authorization/ -backend/alembic/versions/.py -backend/tests/ +backend/alembic/versions/0049_rev_auth_readiness.py +backend/tests/test_authorization.py +backend/tests/test_alembic.py +backend/tests/test_auth.py +backend/tests/conftest.py (exact post-0049 public-schema fingerprint only) docs/spec_authorization_service.md +docs/spec_review_lifecycle.md docs/operations_authorization_service.md docs/operations_roles_permissions.md -.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/** +.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/ACTION_CUSTODY.md +.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/CHUNK_MAP.md +.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/DECISIONS.md +.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/DISCOVERY.md +.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/REVIEW_LOG.md +.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/STATUS.md +.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/reviews/WS-XINT-003-02C-*.md +.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/chunks/WS-XINT-003-02C-auth-catalogue-principal-readiness.md .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/ACTIVATION_CUSTODY.md ``` @@ -77,15 +85,78 @@ docs/operations_roles_permissions.md existing ART/AUTH or project authorization row. - `review.finding_evidence.ingest` and `review.finding_response_evidence.ingest` receive an explicit - future-intent-required unavailable classification (or an equivalently closed - catalogue invariant) that ordinary v0.1 activation cannot select. + `FUTURE_INTENT_REQUIRED_ACTIONS` catalogue invariant, remain planned, appear + in no fixed-service matrix row, and cannot be selected by ordinary v0.1 + activation. +- The exact post-02C code catalogue is 71 PermissionIds, 100 ActionIds, 45 + active actions, and 55 planned actions. No existing active action changes. +- The new runtime activation custodians are exact: XINT-003-08A owns + `review.revision_context.repair`, `review.revision_obligation.close`, and + `review.revision_context.legacy_close`; XINT-003-08B owns + `review.lifecycle.activation.manage`. Their new owner cardinalities are 3 and + 1. Existing 19 REV action owner rows/cardinalities remain unchanged until + their individual activation waves. +- The exact fixed-service registry is 14 identities and the matrix is 14 rows + with 22 memberships. The two reconciliation identities are separate rows + sharing only `review.reconcile.run`. +- `0049_rev_auth_readiness` follows `0048_policy_authority`, adds only the four + action/permission evidence pairs and six service-identity constraint values, + and seeds no ActorProfile, ActorIdentityLink, grant, authority, route, or job. +- Downgrade locks evidence and actor tables first, refuses every direct or + idempotency-linked use of the four actions, refuses use of every new service + identity, and otherwise restores the exact 0048 constraints. +- Tests named `test_xint003_02c_rev_auth_readiness_schema_and_roundtrip`, + `test_xint003_02c_rev_auth_readiness_guarded_action_evidence_downgrade`, and + `test_xint003_02c_rev_auth_readiness_guarded_identity_downgrade` prove the + empty round trip and every action/evidence-shape/identity refusal. +- `test_xint003_02c_provisions_all_six_review_service_identities` exercises the + canonical service-actor API for every new identity without granting usable + lifecycle authority. ## Verification commands -Refresh exact paths at implementation start, then include Ruff, focused unit -and PostgreSQL catalogue/migration/service-matrix tests, changed-subsystem -coverage at or above 90 percent, and hosted Backend coverage preserving the -repository-wide 78 percent floor. +```bash +(cd backend && .venv/bin/ruff check \ + app/modules/actors/service_identities.py \ + app/modules/authorization/catalogue.py \ + alembic/versions/0049_rev_auth_readiness.py \ + tests/test_authorization.py tests/test_alembic.py tests/test_auth.py) +(cd backend && PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/pytest \ + -p pytest_asyncio.plugin -q \ + tests/test_authorization.py \ + -k 'closed_permission_and_action_catalogue or fixed_service or rev_custody') +(cd backend && PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/pytest \ + -p pytest_asyncio.plugin -q \ + tests/test_auth.py -k 'service_actor or xint003_02c') +(cd backend && .venv/bin/coverage erase && \ + PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/coverage run -m pytest \ + -p pytest_asyncio.plugin -q \ + tests/test_authorization.py tests/test_auth.py \ + -k 'closed_permission_and_action_catalogue or fixed_service or rev_custody or service_actor or xint003_02c' && \ + .venv/bin/coverage report \ + --include='app/modules/actors/service_identities.py' \ + --precision=2 --fail-under=90 && \ + .venv/bin/coverage report \ + --include='app/modules/authorization/catalogue.py' \ + --precision=2 --fail-under=90) +(cd backend && WORKSTREAM_TEST_ADMIN_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/postgres \ + PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/python scripts/run_test_lanes.py \ + --lane schema_contracts_a --metadata-dir .ci/xint-003-02c/schema-a \ + --summary-json .ci/xint-003-02c/schema-a.json --timeout-seconds 1200) +(cd backend && WORKSTREAM_TEST_ADMIN_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/postgres \ + PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/python scripts/run_test_lanes.py \ + --lane schema_contracts_b --metadata-dir .ci/xint-003-02c/schema-b \ + --summary-json .ci/xint-003-02c/schema-b.json --timeout-seconds 1200) +python3 scripts/check_stale_authorization_docs.py +python3 scripts/check_stale_review_contracts.py +python3 scripts/check_markdown_links.py +python3 scripts/check_stale_workstream_wording.py +git diff --check +``` + +GitHub's Backend workflow runs the complete sharded suite and combined coverage +report, preserving the repository-wide 78 percent floor. Changed authorization +and actor modules must remain at or above 90 percent coverage. ## Required reviewers diff --git a/.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/reviews/WS-XINT-003-02C-external-review-response.md b/.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/reviews/WS-XINT-003-02C-external-review-response.md new file mode 100644 index 000000000..d43f84b95 --- /dev/null +++ b/.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/reviews/WS-XINT-003-02C-external-review-response.md @@ -0,0 +1,30 @@ +# External Review Response: WS-XINT-003-02C + +## Hosted CI + +The first Backend run exposed the expected public-schema fingerprint change +from migration 0049. The exact observed fingerprint replaced the pre-0049 +value; no reset allow-list or integrity behavior changed. + +The next schema-B lane exposed two fixture-only omissions: historical +action-parity tests needed to exclude the new 0049 owners while exercising the +0021/0022 snapshots, and each new service-profile downgrade fixture needed its +mandatory identity link. Both fixture lifecycles now match their migration +era; runtime constraints were not relaxed. + +Schema-A then confirmed PostgreSQL renders `actor_kind` literals with a +different cast from the service-identity allow-list. The exact-value assertion +continues parsing every `character varying` allow-list literal (including any +unexpected namespace), while correctly excluding the separately cast +`human`/`service` kind predicates. + +## CodeRabbit + +Two documentation findings were valid and fixed: + +- the original nineteen REV actions still retain their historical runtime + `ActionOwner` values until their exact activation waves replace them; +- the 02C contract status now records completed implementation/internal review + and pending hosted exact-head evidence. + +No runtime or authorization behavior was relaxed in either correction. diff --git a/.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/reviews/WS-XINT-003-02C-internal-review.md b/.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/reviews/WS-XINT-003-02C-internal-review.md new file mode 100644 index 000000000..b3846d1de --- /dev/null +++ b/.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/reviews/WS-XINT-003-02C-internal-review.md @@ -0,0 +1,34 @@ +# Internal Review: WS-XINT-003-02C + +## Scope + +Final review of the availability-neutral REV authorization catalogue, +fixed-service registry/matrix, and PostgreSQL parity migration. + +## Results + +- Architecture: PASS WITH LOW RISKS; 02C stays separate from PREP and REV behavior. +- Security/auth: PASS WITH LOW RISKS; no grant, route, job, or active authority is added. +- Product/operations: PASS WITH LOW RISKS; Project Manager, Operator, and service custody remain distinct. +- QA: PASS WITH LOW RISKS after no-grant provisioning proof was added. +- Senior engineering: PASS WITH LOW RISKS after current catalogue wording was corrected. +- CI integrity: PASS WITH LOW RISKS after focused selectors explicitly loaded async support and selected 02C tests. +- Test delta: PASS WITH LOW RISKS after exact database constraint closure replaced presence-only assertions. +- Reuse/dedup: PASS; existing catalogue, matrix, migration, and fixture patterns remain the sole abstractions. +- Docs: PASS WITH LOW RISKS after the complete fourteen-identity matrix and 0049 operator guidance were added. + +No blocking finding remains. All reviewer sessions completed. + +## Deterministic evidence + +- Ruff and mypy pass on the changed backend surface. +- 33 focused catalogue, service-matrix, custody, and documentation tests pass. +- Changed-module coverage is 100.00 percent for `service_identities.py` and + 97.89 percent for `catalogue.py`. +- All 16 exact database/API tests collect successfully. +- Stale authorization/review scans, Markdown links, and whitespace checks pass. + +The worktree has no `WORKSTREAM_TEST_DATABASE_URL`; PostgreSQL execution, the +six-principal API case, schema lanes, and repository-wide 78-percent coverage +remain assigned to hosted GitHub Actions on the exact PR head. + diff --git a/.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/reviews/WS-XINT-003-02C-pr-trust-bundle.md b/.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/reviews/WS-XINT-003-02C-pr-trust-bundle.md new file mode 100644 index 000000000..8fad49c09 --- /dev/null +++ b/.agent-loop/initiatives/WS-XINT-003-rev-auth-end-to-end/reviews/WS-XINT-003-02C-pr-trust-bundle.md @@ -0,0 +1,46 @@ +# PR Trust Bundle: WS-XINT-003-02C + +## Intent and scope + +Register the complete unavailable REV authorization vocabulary and exact fixed +service principals before REV implementation begins. This chunk adds four +planned actions, six closed identities, six static matrix rows, and database +parity only. + +## Design and safety + +- Catalogue totals become 71 permissions, 100 actions, 45 active, and 55 planned. +- The fixed-service registry becomes fourteen rows with twenty-two memberships. +- Two separate reconciliation identities share only `review.reconcile.run`; + future REV code must derive their modes server-side. +- Evidence-ingest actions remain planned, outside the service matrix, and + protected by `FUTURE_INTENT_REQUIRED_ACTIONS`. +- Migration 0049 seeds no principal or authority and refuses unsafe downgrade + after direct/linked action evidence or use of any new identity. + +## Exclusions + +No REV queue, lease, finding, decision, revision, recovery, projection, +lifecycle behavior, PREP protocol, route, worker, provider I/O, or action +activation is included. + +## Evidence + +- Ruff: pass. +- Mypy: pass. +- Focused tests: 33 passed. +- Changed-module coverage: 100.00 and 97.89 percent. +- Exact DB/API collection: 16 tests collected. +- Internal architecture, security, product, QA, senior, CI, test-delta, reuse, + and docs review: pass; valid findings resolved. +- Markdown links, stale review contracts, and diff whitespace: pass. + +Hosted GitHub Actions must provide PostgreSQL schema/API execution, full-suite +coverage (repository 78 percent and changed authorization/actor subsystems 90 +percent), and the final exact-head merge evidence. + +## Human review focus + +Verify the four action/permission/owner triples, the six identity-to-action +rows, the exact 0048-to-0049 constraint transformation, and that no availability +or product behavior changed. diff --git a/backend/alembic/versions/0049_rev_auth_readiness.py b/backend/alembic/versions/0049_rev_auth_readiness.py new file mode 100644 index 000000000..33c45582f --- /dev/null +++ b/backend/alembic/versions/0049_rev_auth_readiness.py @@ -0,0 +1,157 @@ +"""register unavailable REV authorization actions and service identities + +Revision ID: 0049_rev_auth_readiness +Revises: 0048_policy_authority +Create Date: 2026-08-03 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0049_rev_auth_readiness" +down_revision = "0048_policy_authority" +branch_labels = depends_on = None + +_ACTIONS = ( + ("review.revision_context.repair", "project.task.manage"), + ("review.revision_obligation.close", "project.task.manage"), + ("review.revision_context.legacy_close", "operations.reconcile.run"), + ("review.lifecycle.activation.manage", "operations.reconcile.run"), +) +_HISTORICAL_IDENTITIES = ( + "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", +) +_REV_IDENTITIES = ( + "workstream.review.preference_expiry", + "workstream.review.lease_expiry", + "workstream.review.authority_invalidation_reconciliation", + "workstream.review.reconciliation", + "workstream.review.artifact_reference_reconciliation", + "workstream.review.projection", +) + + +def _action_definition() -> str: + return ( + op.get_bind() + .execute( + sa.text( + "select pg_get_constraintdef(oid) from pg_constraint " + "where conrelid='audit_events'::regclass " + "and conname='ck_audit_events_authorization_action_evidence'" + ) + ) + .scalar_one() + ) + + +def _replace_action_definition(definition: str) -> None: + op.drop_constraint("authorization_action_evidence", "audit_events", type_="check") + op.execute( + "alter table audit_events add constraint " + f"ck_audit_events_authorization_action_evidence {definition}" + ) + + +def _pair_token(action: str, permission: str) -> str: + return ( + f"(((action_id)::text = '{action}'::text) AND " + f"((permission_id)::text = '{permission}'::text))" + ) + + +def _rewrite_action_registry(*, add: bool) -> None: + definition = _action_definition() + additions = " OR " + " OR ".join(_pair_token(*pair) for pair in _ACTIONS) + marker = _pair_token("review.projection.rebuild", "operations.projection.rebuild") + if add: + if definition.count(marker) != 2 or any( + _pair_token(*pair) in definition for pair in _ACTIONS + ): + raise RuntimeError("unexpected REV authorization action registry definition") + definition = definition.replace(marker, marker + additions) + else: + if definition.count(additions) != 2: + raise RuntimeError("unexpected REV authorization action registry definition") + definition = definition.replace(additions, "") + _replace_action_definition(definition) + + +def _identity_tokens(values: tuple[str, ...]) -> str: + return ",".join(f"'{value}'" for value in values) + + +def _replace_identity_constraint(values: tuple[str, ...]) -> None: + op.drop_constraint("kind_service_identity", "actor_profiles", type_="check") + op.create_check_constraint( + "kind_service_identity", + "actor_profiles", + "(actor_kind='human' and service_identity is null) or " + f"(actor_kind='service' and service_identity in ({_identity_tokens(values)}))", + ) + + +def _lock_protected_state() -> None: + bind = op.get_bind() + bind.execute(sa.text("lock table authority_idempotency_records in share row exclusive mode")) + bind.execute(sa.text("lock table audit_events in access exclusive mode")) + bind.execute(sa.text("lock table actor_profiles in access exclusive mode")) + + +def _has_action_evidence() -> bool: + actions = [action for action, _ in _ACTIONS] + return bool( + op.get_bind() + .execute( + sa.text( + "select exists(select 1 from audit_events where action_id = any(:actions)) or " + "exists(select 1 from authority_idempotency_records record " + "join audit_events event on event.idempotency_reference=record.id " + "where event.action_id = any(:actions))" + ), + {"actions": actions}, + ) + .scalar_one() + ) + + +def _has_rev_service_identity() -> bool: + return bool( + op.get_bind() + .execute( + sa.text( + "select exists(select 1 from actor_profiles " + "where service_identity = any(:identities))" + ), + {"identities": list(_REV_IDENTITIES)}, + ) + .scalar_one() + ) + + +def upgrade() -> None: + """Register unavailable REV authority without creating any principal.""" + _lock_protected_state() + _rewrite_action_registry(add=True) + _replace_identity_constraint((*_HISTORICAL_IDENTITIES, *_REV_IDENTITIES)) + + +def downgrade() -> None: + """Restore 0048 only when no new action or identity has been used.""" + _lock_protected_state() + if _has_action_evidence(): + raise RuntimeError("cannot downgrade non-empty REV authorization action evidence") + if _has_rev_service_identity(): + raise RuntimeError("cannot downgrade in-use REV service identities") + _rewrite_action_registry(add=False) + _replace_identity_constraint(_HISTORICAL_IDENTITIES) diff --git a/backend/app/modules/actors/service_identities.py b/backend/app/modules/actors/service_identities.py index 749175b32..617340ae4 100644 --- a/backend/app/modules/actors/service_identities.py +++ b/backend/app/modules/actors/service_identities.py @@ -17,6 +17,14 @@ class ServiceIdentity(StrEnum): 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) diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index 1b5114d08..a21565887 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -170,6 +170,10 @@ class ActionId(StrEnum): REVIEW_RECONCILE_RUN = "review.reconcile.run" REVIEW_ARTIFACT_REFERENCE_RECONCILE = "review.artifact_reference.reconcile" REVIEW_PROJECTION_REBUILD = "review.projection.rebuild" + REVIEW_REVISION_CONTEXT_REPAIR = "review.revision_context.repair" + REVIEW_REVISION_OBLIGATION_CLOSE = "review.revision_obligation.close" + REVIEW_REVISION_CONTEXT_LEGACY_CLOSE = "review.revision_context.legacy_close" + REVIEW_LIFECYCLE_ACTIVATION_MANAGE = "review.lifecycle.activation.manage" ARTIFACT_BINDING_READ = "artifact.binding.read" ARTIFACT_REPLICA_READ = "artifact.replica.read" ARTIFACT_RECEIPT_READ = "artifact.receipt.read" @@ -238,6 +242,8 @@ class ActionOwner(StrEnum): XINT_002_04B = "WS-XINT-002-04B" XINT_002_05A = "WS-XINT-002-05A" XINT_002_07 = "WS-XINT-002-07" + XINT_003_08A = "WS-XINT-003-08A" + XINT_003_08B = "WS-XINT-003-08B" @unique @@ -611,6 +617,26 @@ def _active( PermissionId.OPERATIONS_PROJECTION_REBUILD, ActionOwner.AUTH_REV_12, ), + _planned( + ActionId.REVIEW_REVISION_CONTEXT_REPAIR, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.XINT_003_08A, + ), + _planned( + ActionId.REVIEW_REVISION_OBLIGATION_CLOSE, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.XINT_003_08A, + ), + _planned( + ActionId.REVIEW_REVISION_CONTEXT_LEGACY_CLOSE, + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.XINT_003_08A, + ), + _planned( + ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.XINT_003_08B, + ), _planned( ActionId.ARTIFACT_BINDING_READ, PermissionId.ARTIFACT_BINDING_READ, @@ -725,6 +751,12 @@ def _active( PERMISSION_IDS = frozenset(PermissionId) ACTION_IDS = frozenset(ActionId) +FUTURE_INTENT_REQUIRED_ACTIONS = frozenset( + { + ActionId.REVIEW_FINDING_EVIDENCE_INGEST, + ActionId.REVIEW_FINDING_RESPONSE_EVIDENCE_INGEST, + } +) NEW_PERMISSION_IDS = frozenset( { PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, @@ -767,7 +799,7 @@ def _index_actions( ): raise RuntimeError("authorization action catalogue contains an invalid row") indexed = {definition.action_id: definition for definition in definitions} - if len(PERMISSION_IDS) != 71 or len(ACTION_IDS) != 96: + if len(PERMISSION_IDS) != 71 or len(ACTION_IDS) != 100: raise RuntimeError("authorization catalogue count mismatch") if len(indexed) != len(definitions) or set(indexed) != ACTION_IDS: raise RuntimeError("authorization action catalogue is incomplete") @@ -828,6 +860,11 @@ def _index_actions( raise RuntimeError("authorization active action boundary mismatch") if set(definitions) != set(ACTION_DEFINITIONS): raise RuntimeError("authorization action metadata mismatch") + if any( + indexed[action].availability is not ActionAvailability.PLANNED + for action in FUTURE_INTENT_REQUIRED_ACTIONS + ): + raise RuntimeError("future-intent action availability mismatch") if {definition.owner for definition in definitions} != set(ActionOwner): raise RuntimeError("authorization action owner catalogue is incomplete") return MappingProxyType(indexed) @@ -865,6 +902,16 @@ def _index_actions( ActionId.PROJECT_SETUP_RUN_UPDATE, } ), + ServiceIdentity.REVIEW_PREFERENCE_EXPIRY: frozenset({ActionId.REVIEW_PREFERENCE_EXPIRY_RUN}), + ServiceIdentity.REVIEW_LEASE_EXPIRY: frozenset({ActionId.REVIEW_LEASE_EXPIRY_RUN}), + ServiceIdentity.REVIEW_AUTHORITY_INVALIDATION_RECONCILIATION: frozenset( + {ActionId.REVIEW_RECONCILE_RUN} + ), + ServiceIdentity.REVIEW_RECONCILIATION: frozenset({ActionId.REVIEW_RECONCILE_RUN}), + ServiceIdentity.REVIEW_ARTIFACT_REFERENCE_RECONCILIATION: frozenset( + {ActionId.REVIEW_ARTIFACT_REFERENCE_RECONCILE} + ), + ServiceIdentity.REVIEW_PROJECTION: frozenset({ActionId.REVIEW_PROJECTION_REBUILD}), } @@ -903,6 +950,18 @@ def _index_service_actions( ActionId.PROJECT_SETUP_RUN_UPDATE, } ), + ServiceIdentity.REVIEW_PREFERENCE_EXPIRY: frozenset( + {ActionId.REVIEW_PREFERENCE_EXPIRY_RUN} + ), + ServiceIdentity.REVIEW_LEASE_EXPIRY: frozenset({ActionId.REVIEW_LEASE_EXPIRY_RUN}), + ServiceIdentity.REVIEW_AUTHORITY_INVALIDATION_RECONCILIATION: frozenset( + {ActionId.REVIEW_RECONCILE_RUN} + ), + ServiceIdentity.REVIEW_RECONCILIATION: frozenset({ActionId.REVIEW_RECONCILE_RUN}), + ServiceIdentity.REVIEW_ARTIFACT_REFERENCE_RECONCILIATION: frozenset( + {ActionId.REVIEW_ARTIFACT_REFERENCE_RECONCILE} + ), + ServiceIdentity.REVIEW_PROJECTION: frozenset({ActionId.REVIEW_PROJECTION_REBUILD}), } expected_metadata = { ActionId.ARTIFACT_VERIFICATION_EXECUTE: ( @@ -969,11 +1028,35 @@ def _index_service_actions( PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12B2, ), + ActionId.REVIEW_PREFERENCE_EXPIRY_RUN: ( + PermissionId.OPERATIONS_TIMER_RUN, + ActionOwner.AUTH_REV_06, + ), + ActionId.REVIEW_LEASE_EXPIRY_RUN: ( + PermissionId.OPERATIONS_TIMER_RUN, + ActionOwner.AUTH_REV_06, + ), + ActionId.REVIEW_RECONCILE_RUN: ( + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.AUTH_REV_11, + ), + ActionId.REVIEW_ARTIFACT_REFERENCE_RECONCILE: ( + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.AUTH_REV_12, + ), + ActionId.REVIEW_PROJECTION_REBUILD: ( + PermissionId.OPERATIONS_PROJECTION_REBUILD, + ActionOwner.AUTH_REV_12, + ), } if set(rows) != SERVICE_IDENTITIES: raise RuntimeError("service action matrix identity mismatch") if rows != expected_rows: raise RuntimeError("service action matrix row mismatch") + if not FUTURE_INTENT_REQUIRED_ACTIONS.isdisjoint( + action for actions in rows.values() for action in actions + ): + raise RuntimeError("future-intent action cannot enter the service matrix") for action, (permission, owner) in expected_metadata.items(): definition = ACTION_BY_ID[action] expected_availability = ( diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 684369e0f..bbcca7c07 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 = "0098edcd29ab2c7317fcd598e266742e592aa6bc571d3df0ed025ae150ebd22c" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "6a48efeb65a6ae14944ebd74c0e0ccf27827073fe67cba13ad6072d41c47b993" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index c96bf0980..00878334a 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -73,7 +73,7 @@ snapshot_existing_service_rows, ) -HEAD_REVISION = "0048_policy_authority" +HEAD_REVISION = "0049_rev_auth_readiness" pytestmark = pytest.mark.postgres_schema_contract @@ -133,6 +133,12 @@ def test_service_identity_migration_contract_is_frozen_from_application_modules( assert tuple(identity.value for identity in ServiceIdentity) == ( *FROZEN_SERVICE_IDENTITY_VALUES, "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", ) @@ -3976,6 +3982,8 @@ def test_authorization_action_evidence_constraints_and_guarded_downgrade( *_PROJECT_MUTATION_OWNERS, ActionOwner.XINT_002_05A, ActionOwner.XINT_002_07, + ActionOwner.XINT_003_08A, + ActionOwner.XINT_003_08B, } ), ) @@ -4136,6 +4144,8 @@ def test_bootstrap_admin_grant_schema_is_immutable_and_guarded( *_PROJECT_MUTATION_OWNERS, ActionOwner.XINT_002_05A, ActionOwner.XINT_002_07, + ActionOwner.XINT_003_08A, + ActionOwner.XINT_003_08B, } ), ) @@ -11909,6 +11919,218 @@ def test_xint003_02b_policy_authority_schema_and_roundtrip( } +_XINT003_02C_ACTIONS = ( + ("review.revision_context.repair", "project.task.manage"), + ("review.revision_obligation.close", "project.task.manage"), + ("review.revision_context.legacy_close", "operations.reconcile.run"), + ("review.lifecycle.activation.manage", "operations.reconcile.run"), +) +_XINT003_02C_IDENTITIES = tuple( + identity.value + for identity in ( + ServiceIdentity.REVIEW_PREFERENCE_EXPIRY, + ServiceIdentity.REVIEW_LEASE_EXPIRY, + ServiceIdentity.REVIEW_AUTHORITY_INVALIDATION_RECONCILIATION, + ServiceIdentity.REVIEW_RECONCILIATION, + ServiceIdentity.REVIEW_ARTIFACT_REFERENCE_RECONCILIATION, + ServiceIdentity.REVIEW_PROJECTION, + ) +) + + +def test_xint003_02c_rev_auth_readiness_schema_and_roundtrip( + isolated_database_env: str, migration_lock +) -> None: + """0049 admits exact planned evidence and principals without seeding authority.""" + config = _alembic_config() + with migration_lock(): + try: + command.downgrade(config, "0048_policy_authority") + prior = asyncio.run(_xint003_02c_readiness_state(isolated_database_env)) + command.upgrade(config, "head") + upgraded = asyncio.run(_xint003_02c_readiness_state(isolated_database_env)) + command.downgrade(config, "0048_policy_authority") + restored = asyncio.run(_xint003_02c_readiness_state(isolated_database_env)) + command.upgrade(config, "head") + repeated = asyncio.run(_xint003_02c_readiness_state(isolated_database_env)) + finally: + command.upgrade(config, "head") + + additions = " OR " + " OR ".join( + _xint003_02c_pair_token(action, permission) for action, permission in _XINT003_02C_ACTIONS + ) + assert prior["profiles"] == upgraded["profiles"] == 0 + assert upgraded["action_definition"].count(additions) == 2 + assert upgraded["action_definition"].replace(additions, "") == prior["action_definition"] + historical_identities = (*FROZEN_SERVICE_IDENTITY_VALUES, ServiceIdentity.PROJECT_SETUP.value) + assert prior["identity_values"] == historical_identities + assert upgraded["identity_values"] == (*historical_identities, *_XINT003_02C_IDENTITIES) + assert restored == prior + assert repeated == upgraded + + +@pytest.mark.parametrize(("action_id", "permission_id"), _XINT003_02C_ACTIONS) +@pytest.mark.parametrize("evidence_shape", ("direct", "idempotency_linked")) +def test_xint003_02c_rev_auth_readiness_guarded_action_evidence_downgrade( + isolated_database_env: str, + migration_lock, + action_id: str, + permission_id: str, + evidence_shape: str, +) -> None: + """Every newly admitted action pair blocks vocabulary removal once used.""" + config = _alembic_config() + event_id = "" + record_id = str(uuid4()) + with migration_lock(): + try: + command.upgrade(config, "head") + if evidence_shape == "direct": + event_id = asyncio.run( + _insert_authorization_action_event_for( + isolated_database_env, action_id, permission_id + ) + ) + else: + actor_id, target_id = str(uuid4()), str(uuid4()) + asyncio.run( + _insert_committed_authority_idempotency( + isolated_database_env, record_id, actor_id, target_id + ) + ) + event_id = asyncio.run( + _insert_linked_authorization_action_event( + isolated_database_env, + record_id=record_id, + actor_id=actor_id, + action_id=action_id, + permission_id=permission_id, + ) + ) + with pytest.raises( + RuntimeError, + match="cannot downgrade non-empty REV authorization action evidence", + ): + command.downgrade(config, "0048_policy_authority") + assert asyncio.run(_current_revision(isolated_database_env)) == HEAD_REVISION + finally: + asyncio.run(_remove_authority_audit_fixture(isolated_database_env, event_id=event_id)) + if evidence_shape == "idempotency_linked": + asyncio.run( + _remove_authority_idempotency_fixture( + isolated_database_env, record_id, orphan_event=None + ) + ) + command.upgrade(config, "head") + + +@pytest.mark.parametrize("service_identity", _XINT003_02C_IDENTITIES) +def test_xint003_02c_rev_auth_readiness_guarded_identity_downgrade( + isolated_database_env: str, migration_lock, service_identity: str +) -> None: + """Every newly admitted fixed principal blocks removal while in use.""" + config = _alembic_config() + actor_id = str(uuid4()) + with migration_lock(): + try: + command.upgrade(config, "head") + asyncio.run( + _insert_rev_service_actor( + isolated_database_env, + actor_id=actor_id, + service_identity=service_identity, + ) + ) + with pytest.raises( + RuntimeError, match="cannot downgrade in-use REV service identities" + ): + command.downgrade(config, "0048_policy_authority") + assert asyncio.run(_current_revision(isolated_database_env)) == HEAD_REVISION + finally: + asyncio.run(_remove_fixed_service_actor(isolated_database_env, actor_id)) + command.upgrade(config, "head") + + +async def _xint003_02c_readiness_state(database_url: str) -> dict[str, object]: + engine = create_async_engine(database_url) + try: + async with engine.connect() as connection: + action_definition = str( + await connection.scalar( + text( + "select pg_get_constraintdef(oid) from pg_constraint where " + "conname='ck_audit_events_authorization_action_evidence'" + ) + ) + ) + identity_definition = str( + await connection.scalar( + text( + "select pg_get_constraintdef(oid) from pg_constraint where " + "conname='ck_actor_profiles_kind_service_identity'" + ) + ) + ) + profiles = int( + await connection.scalar( + text( + "select count(*) from actor_profiles where " + "service_identity=any(:identities)" + ), + {"identities": list(_XINT003_02C_IDENTITIES)}, + ) + or 0 + ) + return { + "action_definition": action_definition, + "identity_values": tuple( + re.findall(r"'([^']+)'::character varying", identity_definition) + ), + "profiles": profiles, + } + finally: + await engine.dispose() + + +def _xint003_02c_pair_token(action: str, permission: str) -> str: + return ( + f"(((action_id)::text = '{action}'::text) AND " + f"((permission_id)::text = '{permission}'::text))" + ) + + +async def _insert_rev_service_actor( + database_url: str, *, actor_id: str, service_identity: str +) -> None: + engine = create_async_engine(database_url) + try: + async with engine.begin() as connection: + await connection.execute( + text( + "insert into actor_profiles " + "(id,actor_kind,status,provisioning_method,service_identity,created_by) " + "values (:id,'service','active','manual_service_provisioning'," + ":identity,:id)" + ), + {"id": actor_id, "identity": service_identity}, + ) + await connection.execute( + text( + "insert into actor_identity_links " + "(id,actor_profile_id,issuer,subject,subject_kind,status,linked_by) " + "values (:id,:actor,'https://identity.test',:subject,'service'," + "'active',:actor)" + ), + { + "id": str(uuid4()), + "actor": actor_id, + "subject": service_identity, + }, + ) + finally: + await engine.dispose() + + async def _xint003_02b_authority_shape(database_url: str) -> dict[str, int | bool]: engine = create_async_engine(database_url) provenance = { diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index da1853962..df705d5a3 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -3543,6 +3543,83 @@ async def wait_for_transition_lock( pause_kind = None +async def test_xint003_02c_provisions_all_six_review_service_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) + app.state.auth_verifier = FlowAuthVerifier(settings, jwks_transport=jwks_transport(jwk)) + admin_token = issue_asymmetric_token( + private_key, + claims={"sub": "xint003-02c-admin", "jti": "xint003-02c-admin-token"}, + ) + headers = {"Authorization": f"Bearer {admin_token}"} + identities = ( + ServiceIdentity.REVIEW_PREFERENCE_EXPIRY, + ServiceIdentity.REVIEW_LEASE_EXPIRY, + ServiceIdentity.REVIEW_AUTHORITY_INVALIDATION_RECONCILIATION, + ServiceIdentity.REVIEW_RECONCILIATION, + ServiceIdentity.REVIEW_ARTIFACT_REFERENCE_RECONCILIATION, + ServiceIdentity.REVIEW_PROJECTION, + ) + + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://testserver" + ) as client: + profile = await client.get("/api/v1/actors/me", headers=headers) + assert profile.status_code == 200 + assert (await run_admin_bootstrap(UUID(profile.json()["actor_profile_id"]), execute=True))[ + 0 + ] == 0 + for identity in identities: + response = await client.post( + "/api/v1/service-actors", + headers={**headers, "Idempotency-Key": str(uuid4())}, + json={ + "service_identity": identity.value, + "subject": f"xint003-02c:{identity.value}", + "reason": "Provision the exact fixed REV service principal", + }, + ) + assert response.status_code == 201, response.text + assert response.json()["service_identity"] == identity.value + + async with db_session.get_session_factory()() as session: + rows = tuple( + ( + await session.execute( + select(ActorProfile.id, ActorProfile.service_identity).where( + ActorProfile.service_identity.in_( + tuple(identity.value for identity in identities) + ) + ) + ) + ).all() + ) + actor_ids = tuple(row.id for row in rows) + admin_grants = int( + await session.scalar( + select(func.count()) + .select_from(AdminRoleGrant) + .where(AdminRoleGrant.target_actor_profile_id.in_(actor_ids)) + ) + or 0 + ) + project_grants = int( + await session.scalar( + select(func.count()) + .select_from(ProjectRoleGrant) + .where(ProjectRoleGrant.actor_profile_id.in_(actor_ids)) + ) + or 0 + ) + assert {row.service_identity for row in rows} == {identity.value for identity in identities} + assert admin_grants == project_grants == 0 + + async def test_controlled_service_actor_provisioning_includes_project_setup_and_is_atomic( auth_database_env: str, rsa_signing_material: tuple[rsa.RSAPrivateKey, dict[str, Any]], diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 0a50faac9..6c0e73df2 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -102,6 +102,7 @@ ACTION_BY_ID, ACTION_DEFINITIONS, ACTION_IDS, + FUTURE_INTENT_REQUIRED_ACTIONS, HISTORICAL_PERMISSION_IDS, NEW_PERMISSION_IDS, PERMISSION_IDS, @@ -1755,6 +1756,26 @@ async def consume_rate() -> None: "WS-AUTH-001-REV-12", "planned", ), + "review.revision_context.repair": ( + "project.task.manage", + "WS-XINT-003-08A", + "planned", + ), + "review.revision_obligation.close": ( + "project.task.manage", + "WS-XINT-003-08A", + "planned", + ), + "review.revision_context.legacy_close": ( + "operations.reconcile.run", + "WS-XINT-003-08A", + "planned", + ), + "review.lifecycle.activation.manage": ( + "operations.reconcile.run", + "WS-XINT-003-08B", + "planned", + ), } @@ -1951,7 +1972,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> assert {item.value for item in HISTORICAL_PERMISSION_IDS} == historical_permissions assert {item.value for item in NEW_PERMISSION_IDS} == new_permissions assert {item.value for item in PERMISSION_IDS} == historical_permissions | new_permissions - assert len(ACTION_IDS) == len(ACTION_DEFINITIONS) == len(ACTION_BY_ID) == 96 + assert len(ACTION_IDS) == len(ACTION_DEFINITIONS) == len(ACTION_BY_ID) == 100 assert set(ACTION_BY_ID) == ACTION_IDS assert {definition.owner for definition in ACTION_DEFINITIONS} == set(ActionOwner) assert { @@ -2065,6 +2086,8 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> ActionOwner.AUTH_REV_09A, ActionOwner.AUTH_REV_11, ActionOwner.AUTH_REV_12, + ActionOwner.XINT_003_08A, + ActionOwner.XINT_003_08B, } } == { ActionOwner.AUTH_REV_05: 2, @@ -2074,14 +2097,10 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> ActionOwner.AUTH_REV_09A: 1, ActionOwner.AUTH_REV_11: 5, ActionOwner.AUTH_REV_12: 2, + ActionOwner.XINT_003_08A: 3, + ActionOwner.XINT_003_08B: 1, } assert all(not owner.value.startswith("WS-REV-") for owner in ActionOwner) - assert { - "review.revision_context.repair", - "review.revision_context.legacy_close", - "review.revision_obligation.close", - "review.lifecycle.activation.manage", - }.isdisjoint(action.value for action in ACTION_IDS) assert ( sum( definition.availability is ActionAvailability.ACTIVE @@ -2094,7 +2113,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> definition.availability is ActionAvailability.PLANNED for definition in ACTION_DEFINITIONS ) - == 51 + == 55 ) assert resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id is ( PermissionId.ACTOR_PROFILE_READ_SELF @@ -2526,13 +2545,32 @@ def test_fixed_service_action_matrix_and_activation_are_exact_and_immutable() -> "project.post_submit_checker_policy.derive", "project.setup_run.update", }, + ServiceIdentity.REVIEW_PREFERENCE_EXPIRY: {"review.preference_expiry.run"}, + ServiceIdentity.REVIEW_LEASE_EXPIRY: {"review.lease_expiry.run"}, + ServiceIdentity.REVIEW_AUTHORITY_INVALIDATION_RECONCILIATION: {"review.reconcile.run"}, + ServiceIdentity.REVIEW_RECONCILIATION: {"review.reconcile.run"}, + ServiceIdentity.REVIEW_ARTIFACT_REFERENCE_RECONCILIATION: { + "review.artifact_reference.reconcile" + }, + ServiceIdentity.REVIEW_PROJECTION: {"review.projection.rebuild"}, } assert set(SERVICE_ACTIONS_BY_IDENTITY) == SERVICE_IDENTITIES assert { identity: {action.value for action in actions} for identity, actions in SERVICE_ACTIONS_BY_IDENTITY.items() } == expected - assert sum(map(len, SERVICE_ACTIONS_BY_IDENTITY.values())) == 16 + assert sum(map(len, SERVICE_ACTIONS_BY_IDENTITY.values())) == 22 + assert FUTURE_INTENT_REQUIRED_ACTIONS == { + ActionId.REVIEW_FINDING_EVIDENCE_INGEST, + ActionId.REVIEW_FINDING_RESPONSE_EVIDENCE_INGEST, + } + assert all( + 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()) + ) project_setup_actions = SERVICE_ACTIONS_BY_IDENTITY[ServiceIdentity.PROJECT_SETUP] assert { action: ( @@ -2667,12 +2705,12 @@ def test_art_custody_documentation_matches_the_independent_activation_fixture() encoding="utf-8" ) assert "all 22 ART rows to ten exact activation custodians" in operations - assert "all 19 REV\nrows to seven exact AUTH custodians" in operations + assert "the original 19 REV\nrows to seven exact AUTH custodians" in operations assert "transfer adds no migration; the later WS-XINT-002-01" in operations assert "does not grant Operator" in operations assert "verification retry remains independently gated" in operations assert ( - "71 PermissionIds, 96 ActionIds, 43 active actions, and\n53 planned actions" in operations + "71 PermissionIds, 100 ActionIds, 45 active actions, and\n55 planned actions" in operations ) @@ -2696,6 +2734,8 @@ def test_rev_custody_documentation_matches_the_independent_catalogue_fixture() - "WS-AUTH-001-REV-09A": 1, "WS-AUTH-001-REV-11": 5, "WS-AUTH-001-REV-12": 2, + "WS-XINT-003-08A": 3, + "WS-XINT-003-08B": 1, } for document in custody_documents: parsed = _parse_custody_table(document, set(expected_custody)) @@ -2727,10 +2767,9 @@ def test_rev_custody_documentation_matches_the_independent_catalogue_fixture() - operations = (repository_root / "docs/operations_authorization_service.md").read_text( encoding="utf-8" ) - assert "all 19 REV\nrows to seven exact AUTH custodians" in operations - assert "all 19 REV actions remain planned and unavailable" in operations - assert "The REV transfer\nadds no migration" in operations - assert "four proposed REV lifecycle actions remain\nunregistered" in operations + assert "the original 19 REV\nrows to seven exact AUTH custodians" in operations + assert "all 23 REV actions remain planned and unavailable" in operations + assert "registers the four additional actions" in operations @pytest.mark.parametrize( diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 2fb50cffb..564f77691 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -685,18 +685,23 @@ Actions not named by a completed activation chunk remain planned and non-executable. The target post-custody invariant is that planned runtime entries contain only action, permission, exact AUTH activation owner, and availability. The availability-neutral custody -reconciliation assigns all 22 ART rows to ten exact activation custodians and all 19 REV +reconciliation assigns all 22 ART rows to ten exact activation custodians and the original 19 REV rows to seven exact AUTH custodians without changing mappings or planned availability. The REV owner cardinalities are `2/5/3/1/1/5/2` for `WS-AUTH-001-REV-05`, `WS-AUTH-001-REV-06`, `WS-AUTH-001-REV-07`, `WS-AUTH-001-REV-08`, `WS-AUTH-001-REV-09A`, `WS-AUTH-001-REV-11`, and `WS-AUTH-001-REV-12`. Custodian labels grant no reviewer, Operator, or service -authority; all 19 REV actions remain planned and unavailable. The REV transfer -adds no migration, registration, evaluator, route, job, service identity, or -lifecycle behavior, and the four proposed REV lifecycle actions remain -unregistered. +authority; all 23 REV actions remain planned and unavailable. WS-XINT-003-02C +registers the four additional actions and six closed service identities but +adds no evaluator, route, job, principal row, or lifecycle behavior. +Migration `0049_rev_auth_readiness` takes protected locks on authority +idempotency evidence, audit evidence, and actor profiles before replacing the +closed constraints. It seeds no ActorProfile, identity link, grant, route, or +job. Downgrade refuses after any new action has direct or idempotency-linked +audit evidence, or while any new REV service identity is in use; otherwise it +restores the exact `0048` constraints. Their owning feature must publish the approved principal/resource/guard/surface/ -transaction contract before registration or activation, but those foreign facts +transaction contract before activation, but those foreign facts do not become free-form catalogue fields. Startup validation failure is a release blocker, not a reason to relax catalogue checks. @@ -709,8 +714,8 @@ reconciliation uses migration `0036`. The REV transfer adds no migration. The ART transfer does not grant Operator authority; its `OPERATOR` suffix denotes only future activation custody, and verification retry remains independently gated from read/status actions. -Catalogue totals are 71 PermissionIds, 96 ActionIds, 43 active actions, and -53 planned actions. AUTH-11C2 activates three current effective-policy and +Catalogue totals are 71 PermissionIds, 100 ActionIds, 45 active actions, and +55 planned actions. AUTH-11C2 activates three current effective-policy and active-guide reads in addition to AUTH-11C1's six diagnostic reads. The exact route mapping is in `docs/spec_authorization_service.md`. WS-XINT-002-04A activates Project Manager guide-source ingest, and WS-XINT-002-04B activates @@ -878,14 +883,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 first fixed-service set remains seven artifact identities and eleven matrix -memberships from AUTH-09A. Missing provisioned rows deny without stopping the -application. New REV/CON service identities require an exact owning-feature -manifest followed by AUTH-owned enum/constraint/matrix, provisioning, admission, -and cross-service denial proof. Do not create a shared review service or a -database service-grant table. +The closed registry now has fourteen fixed-service identities and twenty-two +matrix memberships: seven ART identities, project setup, and six exact REV +identities. Missing provisioned rows deny without stopping the application. +The REV actions remain unavailable, so registry membership alone grants no +authority. Do not create a shared review service or a database service-grant +table. -AUTH-12B extends the current live registry to an eighth identity, +Historically, AUTH-12B extended the registry to an eighth identity, `workstream.project.setup`, with exactly four static memberships: `project.guide_sufficiency.run`, `project.submission_artifact_policy.derive`, diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 902910b13..ccff57611 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -240,8 +240,9 @@ their matching typed/SQL audit parity without making them executable. The closed action registry contained 78 rows after AUTH-11C2: 37 active actions and 41 planned rows before AUTH-12A. AUTH-12A added eighteen planned project-mutation rows, producing the historical 96-row state of 37 active and -59 planned. Later project-mutation and ART activation chunks advance the current -state to 43 active and 53 planned without adding identifiers. +59 planned. Later project-mutation and ART activation chunks advanced the +pre-02C state to 45 active and 51 planned. WS-XINT-003-02C adds four planned +REV rows, producing the current 100-row state of 45 active and 55 planned. AUTH-10A added five project-role read/manage rows; AUTH-10B owns and activates the three reads, while AUTH-10C owns and activates the two reason-bound, idempotent project-role mutations. AUTH-11A adds eleven @@ -261,17 +262,17 @@ other registry rows cover three planned Operator recovery actions and the ART catalogue: 16 planned, three active foundation-service actions, active `artifact.guide_source.ingest`, and active fixed-service guide binding/read; the remaining rows cover canonical -`submission.create`, and 19 review actions. An action becomes active only when +`submission.create`, and 23 review actions. An action becomes active only when its feature owner has merged the canonical resource composer, guards, surface or command declaration, behavior tests, and transaction-local revalidation where required, and its dedicated AUTH activation custodian has integrated the exact evaluator and changed availability. Both halves are mandatory; registry or feature presence alone never grants authority. -The four proposed REV lifecycle actions are not part of the current runtime -registry. `artifact.review_evidence.binding.create` is registered but planned -and unavailable. Any later REV registration adds exactly four planned and zero -active actions while retaining 71 PermissionIds; it stays blocked until +WS-XINT-003-02C registers the four approved REV recovery/lifecycle actions as +planned and unavailable. `artifact.review_evidence.binding.create` is also +registered but planned and unavailable. Registration adds no evaluator, route, +job, principal row, or lifecycle authority; activation remains blocked until complete feature-owned typed and transaction manifests exist. AUTH-07B activates `actor.profile.read_self` and `actor.profile.update_self`. @@ -323,10 +324,12 @@ Chunk 01 changes no runtime owner or availability. | `WS-AUTH-001-REV-09A` | `review.finding_response_evidence.ingest` | | `WS-AUTH-001-REV-11` | `review.lease.force_release`, `review.queue.routing.override`, `review.queue.routing.correct`, `review.queue.close`, `review.reconcile.run` | | `WS-AUTH-001-REV-12` | `review.artifact_reference.reconcile`, `review.projection.rebuild` | +| `WS-XINT-003-08A` | `review.revision_context.repair`, `review.revision_obligation.close`, `review.revision_context.legacy_close` | +| `WS-XINT-003-08B` | `review.lifecycle.activation.manage` | -This table is the trusted pre-WS-XINT-003-02C custody baseline. All 19 actions -remain planned and unavailable on that baseline, and the four approved REV -lifecycle actions remain unregistered there. The current delivery path is +The first seven rows preserve the trusted pre-WS-XINT-003-02C custody baseline. +Those 19 actions remain planned and unavailable, while 02C registers the four approved REV +recovery/lifecycle actions under XINT-003-08A/08B custody. The delivery path is front-loaded `WS-XINT-003-02C` unavailable catalogue/principal/matrix readiness followed by `WS-XINT-003-02D` closed PREP/read contract readiness. Neither wave implements REV behavior or activates a lifecycle action; later XINT activation @@ -354,6 +357,10 @@ uses the canonical custody map and exact merged REV proof. | `review.reconcile.run` | `operations.reconcile.run` | `WS-AUTH-001-REV-11` | | `review.artifact_reference.reconcile` | `operations.reconcile.run` | `WS-AUTH-001-REV-12` | | `review.projection.rebuild` | `operations.projection.rebuild` | `WS-AUTH-001-REV-12` | +| `review.revision_context.repair` | `project.task.manage` | `WS-XINT-003-08A` | +| `review.revision_obligation.close` | `project.task.manage` | `WS-XINT-003-08A` | +| `review.revision_context.legacy_close` | `operations.reconcile.run` | `WS-XINT-003-08A` | +| `review.lifecycle.activation.manage` | `operations.reconcile.run` | `WS-XINT-003-08B` | Initial and revision submission use the same `submission.create` action, permission, and route. Revision preparation is an internal participant and @@ -505,9 +512,16 @@ closed: | `workstream.artifact.materializer` | `artifact.pre_submit.checker_input.materialize`, `artifact.post_submit.checker_input.materialize`, `artifact.review_packet.materialize` | | `workstream.artifact.checker_output` | `artifact.checker_output.write` | | `workstream.project.setup` | `project.guide_sufficiency.run`, `project.submission_artifact_policy.derive`, `project.post_submit_checker_policy.derive`, `project.setup_run.update` | - -`workstream.project.setup` is the eighth current fixed identity. All four of -its actions remain planned and unavailable in AUTH-12B. Registration makes the +| `workstream.review.preference_expiry` | `review.preference_expiry.run` | +| `workstream.review.lease_expiry` | `review.lease_expiry.run` | +| `workstream.review.authority_invalidation_reconciliation` | `review.reconcile.run` | +| `workstream.review.reconciliation` | `review.reconcile.run` | +| `workstream.review.artifact_reference_reconciliation` | `review.artifact_reference.reconcile` | +| `workstream.review.projection` | `review.projection.rebuild` | + +`workstream.project.setup` was the eighth fixed identity when AUTH-12B merged; +02C expands the current registry to fourteen identities. All four project-setup +actions and all six REV rows remain planned and unavailable. Registration makes the identity selectable by the existing controlled provisioning route but creates no ActorProfile, ActorIdentityLink, role, grant, or executable authority by itself; migration `0043_project_setup_service` only expands the closed database diff --git a/docs/spec_review_lifecycle.md b/docs/spec_review_lifecycle.md index 1d6b7877a..6b2146e04 100644 --- a/docs/spec_review_lifecycle.md +++ b/docs/spec_review_lifecycle.md @@ -587,14 +587,15 @@ memberships. AUTH-09B activates `actor.service.provision` for identities already in AUTH's closed registry. AUTH-09C activates `actor.profile.read` and `actor.identity_link.read`; AUTH-09D-A activates `actor.profile.suspend`, `actor.profile.reactivate`, and `actor.profile.deactivate`. These merges do not -activate a review action or contain any of REV's six future service identities. +activate a review action or provision any of REV's six registered service identities. -Before WS-XINT-003-02C, the trusted-main review lifecycle baseline depends on 24 -unavailable actions: +The pre-WS-XINT-003-02C review lifecycle baseline identified 24 unavailable +actions: - registered planned `submission.create`; - 19 registered planned review actions; and -- four approved but unregistered REV actions defined below. +- four then-unregistered approved REV actions, now registered but unavailable, + defined below. The registered planned `artifact.review_evidence.binding.create -> artifact.binding.create` service action is separate, unavailable, and not one