From c3cf57c2f10dda758a59f4b773208cc036814f14 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 27 Jul 2026 09:28:54 +0100 Subject: [PATCH 1/4] feat(auth): reconcile ART authority catalogue --- .../AUTH_HANDOFF.md | 15 +- .../ACTIVATION_CUSTODY.md | 47 +- .../CHUNK_MAP.md | 12 +- .../AUTH_ART_HANDOFF.md | 4 + .../DECISIONS.md | 8 +- .../WS-XINT-002-art-auth-end-to-end/STATUS.md | 10 +- ...WS-XINT-002-01-catalogue-reconciliation.md | 47 +- .../reviews/WS-XINT-002-01-internal-review.md | 36 + .../reviews/WS-XINT-002-01-pr-trust-bundle.md | 92 +++ .agent-loop/merge-intents/WS-XINT-002-01.json | 9 + .../0036_art_auth_catalogue_reconciliation.py | 174 +++++ .../modules/authorization/admin_schemas.py | 2 +- .../app/modules/authorization/catalogue.py | 95 +-- backend/tests/test_alembic.py | 687 +++++++++++++++++- backend/tests/test_authorization.py | 121 +-- docs/operations_authorization_service.md | 32 +- docs/spec_authorization_service.md | 73 +- docs/spec_review_lifecycle.md | 2 +- 18 files changed, 1246 insertions(+), 220 deletions(-) create mode 100644 .agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-internal-review.md create mode 100644 .agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-pr-trust-bundle.md create mode 100644 .agent-loop/merge-intents/WS-XINT-002-01.json create mode 100644 backend/alembic/versions/0036_art_auth_catalogue_reconciliation.py diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/AUTH_HANDOFF.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/AUTH_HANDOFF.md index 2f1d488a0..89a8cb7d4 100644 --- a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/AUTH_HANDOFF.md +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/AUTH_HANDOFF.md @@ -36,14 +36,13 @@ contract, provisionally named active assignment, locked policy context, and operation generation; - keeps the action unavailable and adds no grant or evaluator activation; - records parity evidence in AUTH's closed catalogue/constraint/owner manifests; -- explicitly retires the unused planned multi-step upload-session ActionIds or - proves they are unavailable and have no route/command manifest entry. - -Until that AUTH contract merges, current agent-gate catalogue assertions retain -those strings only as an exact planned/unavailable discovery baseline. Their -presence in the closed catalogue is not an active design, grant, route, or -permission to implement a second intake path, and PLAN2 does not edit AUTH-owned -catalogue or parity assertions. +- deletes the unused planned multi-step upload authority from the live closed + catalogue, constraints, and service matrix without compatibility aliases. + +After that AUTH contract merges, the retired identifiers may remain only in +immutable historical records and the deterministic deletion proof. They are +not an active design, grant, route, compatibility alias, or permission to +implement a second intake path. ART-04A through 04C then implement one hidden continuous surface and publish its exact route/resource/guard manifest. After 04C, a separate reviewed AUTH 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 8f32b515c..d8eb3f228 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 @@ -2,10 +2,11 @@ The final v0.1 ART catalogue reconciliation, PREP extension, and activation waves are superseded prospectively by -`../WS-XINT-002-art-auth-end-to-end/`. Existing rows below remain the trusted -baseline until WS-XINT-002-01 merges; no prose in either plan changes runtime -availability. -The reconciliation baseline is trusted `main` commit +`../WS-XINT-002-art-auth-end-to-end/`. The counts immediately below are the +trusted pre-reconciliation entry evidence; WS-XINT-002-01 replaces them with +the live 71/78/22/56 catalogue recorded in the ART custody section without +changing runtime availability. +The pre-reconciliation baseline is trusted `main` commit `2fb322bd2249a5fe9d3fa706dc63f033074e38ce`: 76 PermissionIds, 81 ActionIds, 22 active actions, and 59 planned actions. Older counts below are explicitly historical snapshots at their named commits, not the WS-XINT-002 entry state. @@ -41,22 +42,25 @@ mappings, and availability must remain identical. | `WS-AUTH-001-ART-02D-INTERNAL` | `artifact.verification.execute`, `artifact.pending_work.scan`, `artifact.put_attempt.resolve` | | `WS-AUTH-001-ART-02D-OPERATOR` | `artifact.binding.read`, `artifact.replica.read`, `artifact.receipt.read`, `artifact.verification_job.read`, `artifact.verification_job.retry`, `artifact.recovery_attempt.read`, `artifact.audit.read`, `operations.artifact_storage_admission.read` | | `WS-AUTH-001-ART-03` | `artifact.guide_source.ingest`, `artifact.guide_source.read`, `artifact.guide_source.binding.create` | -| `WS-AUTH-001-ART-04A` | `artifact.upload_session.create`, `artifact.upload_session.read`, `artifact.upload_item.write`, `artifact.upload_session.seal`, `artifact.upload_session.cancel`, `artifact.upload_session.expire` | +| `WS-XINT-002-05A` | `artifact.submission_bundle.prepare` | | `WS-AUTH-001-ART-04B` | `artifact.pre_submit.checker_input.materialize` | | `WS-AUTH-001-ART-05` | `artifact.submission.binding.create` | | `WS-AUTH-001-ART-06A` | `artifact.post_submit.checker_input.materialize` | | `WS-AUTH-001-ART-06B` | `artifact.checker_output.write`, `artifact.checker_output.binding.create` | - -`WS-AUTH-001-ART-CUSTODY` atomically transfers these 25 rows with exact owner -cardinalities `3/8/3/6/1/1/1/2` in the table order above and removes the seven -historical ART owner enum values. The `OPERATOR` suffix denotes only future -activation custody; it grants no Operator entitlement. All 25 actions remain -planned, including independently gated `artifact.verification_job.retry`, which -cannot be activated by read/status proof. The transfer adds no migration because -owner and availability are typed metadata, while PostgreSQL preserves the exact -ActionId-to-PermissionId set. The catalogue remains at 74 PermissionIds, -65 ActionIds, 17 active actions, and 48 planned actions; the seven-identity, -eleven-membership service matrix is unchanged. +| `WS-XINT-002-07` | `artifact.review_packet.materialize`, `artifact.review_evidence.binding.create` | + +`WS-AUTH-001-ART-CUSTODY` historically transferred 25 rows. WS-XINT-002-01 +reconciles the live catalogue by removing the six unused multi-step upload rows +and registering three end-to-end bundle/review rows. The resulting 22 rows have +exact owner cardinalities `3/8/3/1/1/1/1/2/2` in the table order above. The +`OPERATOR` suffix denotes only future activation custody; it grants no Operator +entitlement. All 22 actions remain planned, including independently gated +`artifact.verification_job.retry`, which +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, 78 ActionIds, 22 active actions, and 56 planned actions, with +seven fixed-service identities and twelve matrix memberships. ## REV custody transfer @@ -86,7 +90,6 @@ actions on trusted `main`: | Registration chunk | Future activation chunk | Proposed ActionId -> PermissionId | |---|---|---| | `WS-AUTH-001-REV-REG` | `WS-AUTH-001-REV-LIFECYCLE` | `review.revision_context.repair` -> `project.task.manage`; `review.revision_context.legacy_close` -> `operations.reconcile.run`; `review.revision_obligation.close` -> `project.task.manage`; `review.lifecycle.activation.manage` -> `operations.reconcile.run` | -| `WS-AUTH-001-ART-REV-EVIDENCE-REG` | `WS-AUTH-001-ART-REV-EVIDENCE` | `artifact.review_evidence.binding.create` -> `artifact.binding.create` | These are declared future registration gates, not executable chunk contracts. Neither may receive a full contract or start until the owning feature publishes exact @@ -102,12 +105,10 @@ Its proof includes populated refusal, empty safe downgrade, re-upgrade, and fresh replay. Counts are derived from trusted `main` when a gate executes. REV registration -adds exactly four planned actions and zero active actions; evidence registration -adds exactly one planned action and zero active actions, in either order. -PermissionIds remain 74. The evidence-binding -registration also adds that exact action to the existing -`workstream.artifact.binding` static row, increasing matrix membership from 11 -to 12 without adding an identity or database grant. +adds exactly four planned actions and zero active actions. WS-XINT-002-01 +registers review-evidence binding under `WS-XINT-002-07` and adds it to the +existing `workstream.artifact.binding` static row without adding an identity or +database grant. ## Prepared mutation prerequisite 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 ae77d25d4..8a1c05cfb 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 @@ -42,7 +42,7 @@ stopped. | `WS-AUTH-001-09D-B` | Identity-Link Lifecycle And Race Closure | L1 | Merged through PR #152 as `93dd392`; signed memory `912a6254` passed | | `WS-AUTH-001-CONTRIBUTOR-FOUNDATION` | Contributor Fields And Canonical-Human Lineage | L1 | Merged through PR #153 as `8d5eb15b`; signed memory `66ab58d` passed and stopped | | `WS-AUTH-001-09E` | Fixed Service Runtime Admission | L1 | Merged through PR #157 as `42a89b2d` on 2026-07-20 | -| `WS-AUTH-001-ART-CUSTODY` | ART Activation Custody Transfer | L1 | Merged through PR #158 as `be2a79a2`; all 25 ART actions remain planned | +| `WS-AUTH-001-ART-CUSTODY` | ART Activation Custody Transfer | L1 | Merged through PR #158 as `be2a79a2`; historical 25-row transfer later reconciled by WS-XINT-002-01 to 22 planned ART actions | | `WS-AUTH-001-REV-CUSTODY` | REV Activation Custody Transfer | L1 | Merged through PR #160 as `fe0e4492`; all 19 REV actions remain planned | | `WS-AUTH-001-PREP` | Prepared Mutation Authorization Protocol | L1 | Merged through PR #162 as `c559d556`; no feature consumer or activation | | `WS-AUTH-001-10` | Project Qualification And Contributor Role Grants | L1 | Active planning-only parent; split approved after failed L1 combined review | @@ -71,11 +71,10 @@ feature manifest exists, then requires a separate explicit start. | Chunk | Title | Risk | Status | |---|---|---:|---| | `WS-AUTH-001-REV-REG` | REV Lifecycle Action Registration | L1 | Blocked on complete REV typed manifests | -| `WS-AUTH-001-ART-REV-EVIDENCE-REG` | Review Evidence Binding Action Registration | L1 | Blocked on complete ART/REV dual-authority contract | | `WS-AUTH-001-ART-02D-INTERNAL` | ART 02D Internal Action Activation | L1 | Feature-gated | | `WS-AUTH-001-ART-02D-OPERATOR` | ART 02D Operator Read/Status And Independently Evaluated Retry Activation | L1 | Feature-gated | | `WS-AUTH-001-ART-03` | ART 03 Guide Source Action Activation | L1 | Feature-gated | -| `WS-AUTH-001-ART-04A` | ART 04A Upload Action Activation | L1 | Feature-gated | +| `WS-XINT-002-05A` | Submission Bundle Preparation Activation | L1 | Feature-gated on complete ART-04A-C hidden behavior | | `WS-AUTH-001-ART-04B` | ART 04B Pre-Submit Materialization Activation | L1 | Feature-gated | | `WS-AUTH-001-ART-05` | ART 05 Submission Binding Activation | L1 | Feature-gated | | `WS-AUTH-001-ART-06A` | ART 06A Post-Submit Materialization Activation | L1 | Feature-gated | @@ -88,7 +87,7 @@ feature manifest exists, then requires a separate explicit start. | `WS-AUTH-001-REV-11` | REV 11 Recovery And Reconciliation Activation | L1 | Feature/service-gated | | `WS-AUTH-001-REV-12` | REV 12 Artifact Reconciliation And Projection Activation | L1 | Feature/service-gated | | `WS-AUTH-001-REV-LIFECYCLE` | REV Lifecycle Repair Action Activation | L1 | Blocked until REV-REG and four hidden manifests merge | -| `WS-AUTH-001-ART-REV-EVIDENCE` | Review Evidence Binding Action Activation | L1 | Blocked until registration and hidden ART/REV behavior merge | +| `WS-XINT-002-07` | Review Packet And Evidence Binding Activation | L1 | Feature-gated on exact REV lease/version and ART evidence behavior | ## Dependency order @@ -237,6 +236,7 @@ review rejected the underspecified contract before runtime edits; PR #153 later merged its repaired implementation as `8d5eb15`. PR #157 merged AUTH-09E as `42a89b2d`, and PR #158 merged the availability-neutral ART custody transfer as `be2a79a2`, PR #160 merged the availability-neutral REV custody transfer as -`fe0e4492`, and PR #162 merged AUTH-PREP as `c559d556`; all 25 ART and 19 REV -actions remain planned and inactive, and PREP adds no feature consumer. +`fe0e4492`, and PR #162 merged AUTH-PREP as `c559d556`; WS-XINT-002-01 later +reconciles the historical 25 ART rows to 22 planned ART actions, while all 19 +REV actions remain planned and inactive, and PREP adds no feature consumer. POL-002-04 remains inactive pending its own gate and explicit start. diff --git a/.agent-loop/initiatives/WS-XINT-001-lifecycle-boundary-reconciliation/AUTH_ART_HANDOFF.md b/.agent-loop/initiatives/WS-XINT-001-lifecycle-boundary-reconciliation/AUTH_ART_HANDOFF.md index e85754912..e2c005fac 100644 --- a/.agent-loop/initiatives/WS-XINT-001-lifecycle-boundary-reconciliation/AUTH_ART_HANDOFF.md +++ b/.agent-loop/initiatives/WS-XINT-001-lifecycle-boundary-reconciliation/AUTH_ART_HANDOFF.md @@ -1,5 +1,9 @@ # AUTH <-> ART Handoff +> Historical immutable handoff provenance. WS-XINT-002-01 supersedes this +> catalogue baseline; identifiers retained below are audit history, not live +> activation guidance or compatibility authority. + ## Boundary AUTH is the sole activation custodian. ART is the sole artifact resource and diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/DECISIONS.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/DECISIONS.md index 899ca7a19..bffcd43b6 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/DECISIONS.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/DECISIONS.md @@ -1,12 +1,10 @@ # Decisions: WS-XINT-002 ART-AUTH End-to-End Contract 1. The dependency is owned end to end by a cross-initiative plan, not by ART-03A. -2. One outer ZIP replaces the six upload-session actions with +2. One outer ZIP replaces the six historical multi-step upload actions with `artifact.submission_bundle.prepare`; no compatibility aliases or retained - unavailable rows remain. The exact deleted ActionId and PermissionId values - are `artifact.upload_session.create`, `artifact.upload_session.read`, - `artifact.upload_item.write`, `artifact.upload_session.seal`, - `artifact.upload_session.cancel`, and `artifact.upload_session.expire`. + unavailable rows remain. The immutable chunk contract and migration record + the exact deleted ActionId and PermissionId values. 3. Initial, checker-remediation, and human-review revision submissions share the public preparation/create actions; each has an exact closed typed context. 4. Reviewer packet materialization is a fixed-service action plus a separate diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/STATUS.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/STATUS.md index 48050db04..9b5d2b6dd 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/STATUS.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/STATUS.md @@ -1,7 +1,9 @@ # Status: WS-XINT-002 ART-AUTH End-to-End Contract -Planning proposed from trusted `main` after AUTH-11A merged. No implementation -or action activation has started. The dirty ART-03A worktree is preserved and -untouched. +`WS-XINT-002-01` is implemented and merge-pending. It reconciles the closed ART +catalogue and fixed-service matrix without activating any action, evaluator, +route, command, grant, or lifecycle behavior. The dirty ART-03A worktree remains +preserved and untouched. -Immediate successor after human approval: `WS-XINT-002-01`. +Next same-initiative gate after merge and a new explicit start: +`WS-XINT-002-02`. diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-01-catalogue-reconciliation.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-01-catalogue-reconciliation.md index 36879edc0..2df57171c 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-01-catalogue-reconciliation.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-01-catalogue-reconciliation.md @@ -13,17 +13,22 @@ L1. ```text backend/app/modules/authorization/catalogue.py +backend/app/modules/authorization/admin_schemas.py backend/alembic/versions/_*.py backend/tests/test_authorization.py backend/tests/test_alembic.py docs/spec_authorization_service.md docs/operations_authorization_service.md .agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/STATUS.md +.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/DECISIONS.md .agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-01-catalogue-reconciliation.md .agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-*.md +.agent-loop/merge-intents/WS-XINT-002-01.json .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/ACTIVATION_CUSTODY.md .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md .agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/AUTH_HANDOFF.md +.agent-loop/initiatives/WS-XINT-001-lifecycle-boundary-reconciliation/AUTH_ART_HANDOFF.md +docs/spec_review_lifecycle.md ``` ## Not allowed @@ -35,17 +40,23 @@ docs/operations_authorization_service.md ## Acceptance criteria - Add planned `artifact.submission_bundle.prepare -> submission.create`. + Add exact ActionOwner `WS-XINT-002-05A`; this is activation custody only and + does not make the action executable. - Add planned ActionId `artifact.review_packet.materialize` mapped to distinct typed PermissionId `artifact.review_packet.materialize`; add that one new PermissionId and prove action/permission type parity despite equal values. + Add exact ActionOwner `WS-XINT-002-07`. - Add planned `artifact.review_evidence.binding.create -> - artifact.binding.create`. -- Remove ActionIds and PermissionIds `artifact.upload_session.create`, - `artifact.upload_session.read`, `artifact.upload_item.write`, - `artifact.upload_session.seal`, `artifact.upload_session.cancel`, and - `artifact.upload_session.expire`, plus scheduler expiry membership. Prove no + artifact.binding.create` with exact ActionOwner `WS-XINT-002-07`. +- Remove the six ActionIds and PermissionIds formed from `artifact.upload_` + plus `session.create`, `session.read`, `item.write`, `session.seal`, + `session.cancel`, and `session.expire`, plus scheduler expiry membership. Prove no route, command, audit, idempotency, test, migration-head, or documentation contract retains them and no compatibility/unavailable row replaces them. +- Remove ActionOwner `WS-AUTH-001-ART-04A`, whose only six rows are deleted. + Add only ActionOwners `WS-XINT-002-05A` with cardinality one and + `WS-XINT-002-07` with cardinality two. Preserve every other owner and + cardinality. The closed owner enum therefore changes by `-1/+2` values. - Starting from 76 permissions, 81 actions, 22 active, 59 planned, seven service identities and eleven memberships, prove the exact resulting closed counts: 71 permissions, 78 actions, 22 active, 56 planned, seven identities and twelve @@ -53,6 +64,31 @@ docs/operations_authorization_service.md - Add review packet to `workstream.artifact.materializer` and review evidence binding to `workstream.artifact.binding`; preserve all-pairs denial. - Update closed enum/count/owner/static-matrix and PostgreSQL migration parity. +- Migration `0036` must descend from `0035_project_read_evidence`, take an + access-exclusive lock on `audit_events` and a write-excluding lock on + `authority_idempotency_records`, and refuse upgrade if any immutable audit + row references an obsolete action/permission through `action_id`, + `permission_id`, permission-registry target/invalidation references, or + linked idempotency evidence. It must never delete, rewrite, or orphan that + evidence. A clean upgrade removes the six SQL pairs/permissions and adds the + three planned pairs/one permission atomically. Tests prove populated refusal + leaves revision `0035` and constraints/data unchanged, then clean upgrade, + downgrade, and re-upgrade. Downgrade refuses when any of the three new actions + or new review-packet permission has persisted evidence. +- Historical migrations `0021`-`0023` and merged superseded chunk/review + artifacts remain immutable historical evidence and may retain the old + identifiers. Active runtime code, current SQL head, current specification and + operations docs, live handoffs/chunk maps, routes/commands, and non-historical + tests must not retain them. Focused stale proof uses an explicit allowlist for + those immutable historical files; it may not blanket-ignore a directory. +- Update the public permission-definition response total from typed literal 76 + to 71 and prove service response validation/catalogue reads retain exact + closed count parity. +- `test_obsolete_artifact_upload_authority_is_historical_only` must scan the + repository for all six identifiers, fail on any active runtime/current docs/ + live plan/non-historical test reference, and name each allowed historical + migration or immutable merged artifact explicitly. Adding a directory-wide + exclusion or an unreviewed allowlist entry fails the chunk. - Every new action is planned/unavailable and no existing active action changes. ## Verification @@ -60,6 +96,7 @@ docs/operations_authorization_service.md ```bash (cd backend && .venv/bin/python -m ruff check app tests scripts) (cd backend && WORKSTREAM_TEST_DATABASE_URL= .venv/bin/pytest tests/test_authorization.py tests/test_alembic.py -q --cov=app.modules.authorization --cov-report=term-missing --cov-fail-under=90) +(cd backend && .venv/bin/pytest tests/test_authorization.py::test_obsolete_artifact_upload_authority_is_historical_only -q) python3 scripts/check_stale_authorization_docs.py python3 scripts/check_stale_artifact_contracts.py python3 scripts/check_markdown_links.py diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-internal-review.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-internal-review.md new file mode 100644 index 000000000..a3eaed507 --- /dev/null +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-internal-review.md @@ -0,0 +1,36 @@ +# Internal Review: WS-XINT-002-01 + +## Scope reviewed + +Closed ART authority catalogue reconciliation, migration `0036`, fixed-service +matrix, deterministic stale proof, live custody documentation, and merge intent. + +## Initial findings and resolution + +- Senior, QA, CI, and product review rejected the first draft because linked + idempotency evidence was only implicit and refusal coverage was aggregate. + Migration `0036` now uses a write-excluding idempotency lock, explicitly joins + linked evidence, and tests every direct, permission-registry, invalidation, + and linked predicate independently with unchanged-state snapshots. +- Architecture/docs review found live 25-row and historical count wording. + Current docs now state 71 permissions, 78 actions, 22 active, 56 planned, + seven fixed-service identities, twelve memberships, and 22 planned ART rows. +- Test-delta review found missing post-head invalidation rejection and an + ambiguous historical handoff allowlist. Current SQL rejection covers every + removed pair, target reference, and invalidation reference; the old handoff + is explicitly marked immutable historical provenance. + +## Final reviewer results + +- Senior engineering: PASS WITH LOW RISKS. +- QA/test: PASS WITH LOW RISKS; later isolated predicate proof was added. +- Security/auth: PASS. +- Product/ops: PASS WITH LOW RISKS; wording note resolved. +- Architecture: PASS. +- CI integrity: PASS WITH LOW RISKS. +- Docs: PASS. +- Reuse/dedup: PASS WITH LOW RISKS. +- Test delta: PASS WITH LOW RISKS. + +No blocking finding remains. + diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-pr-trust-bundle.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-pr-trust-bundle.md new file mode 100644 index 000000000..f809edea2 --- /dev/null +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-pr-trust-bundle.md @@ -0,0 +1,92 @@ +# PR Trust Bundle: WS-XINT-002-01 + +## Chunk + +`WS-XINT-002-01` — ART Catalogue Reconciliation. + +## Goal and human-approved intent + +Front-load the complete v0.1 ART authority catalogue before more ART runtime +work, while keeping every new action planned and unavailable. This removes the +unused multi-step upload design without compatibility aliases. + +## What changed and why + +- Replaced six obsolete upload actions/permissions with planned submission + bundle preparation, reviewer packet materialization, and review evidence + binding authority. +- Added only the distinct review-packet permission and assigned exact + `WS-XINT-002-05A` / `WS-XINT-002-07` activation custody. +- Reconciled fixed services: scheduler loses expiry; materializer gains review + packet; binding gains review evidence. No identity or grant was added. +- Added migration `0036` to keep PostgreSQL authority evidence constraints + exactly aligned with the typed catalogue. + +## Design and alternatives + +The chosen design is a clean catalogue cutover with planned-only replacements. +Retained aliases, a second upload path, premature activation, and feature facts +inside catalogue metadata were rejected. + +## Scope and product behavior + +Changed only catalogue/admin schema, audit-constraint migration parity, tests, +and live AUTH/ART/REV handoffs. No route, evaluator, command, worker behavior, +grant, service identity, submission, review, or artifact lifecycle changed. + +## Acceptance proof + +- Closed totals: 71 permissions, 78 actions, 22 active, 56 planned. +- Fixed-service totals: seven identities and twelve exact memberships. +- All three added actions remain planned/unavailable. +- Migration refuses before mutation for direct, permission-registry target, + invalidation, and idempotency-linked evidence; refusal preserves revision, + constraints, and evidence counts. +- Current SQL rejects every removed pair and permission reference after clean + upgrade and downgrade/re-upgrade. +- Deterministic repository scan permits obsolete identifiers only in named + immutable historical artifacts and migration deletion proof. + +## Tests and checks + +- Ruff over `app`, `tests`, and `scripts`: passed. +- Focused catalogue/custody/stale tests: passed. +- Isolated PostgreSQL `0036_art_auth_catalogue` tests, including independent + refusal predicates and round trip: passed with owned-database cleanup. +- Stale authorization docs, stale artifact contracts, markdown links, and + `git diff --check`: passed. +- A broader local AUTH/Alembic coverage run reached the 20-minute local runner + ceiling; the complete suite and coverage gates are intentionally delegated to + GitHub Actions on the exact PR head. + +## Test delta and CI integrity + +No test was removed, skipped, or weakened. New tests independently cover each +destructive migration predicate and negative post-head SQL acceptance. No CI, +coverage, lint, or runner configuration changed. Hosted gates remain the 78% +repository and 90% authorization-subsystem coverage floors. + +## Reviewer results + +Senior, QA, security, product/ops, architecture, CI integrity, docs, +reuse/dedup, and test-delta tracks all passed after valid findings were fixed. +See `WS-XINT-002-01-internal-review.md`. + +## External review + +CodeRabbit and exact-head GitHub checks are required after the PR opens. No +external result is claimed in this pre-PR bundle. + +## Remaining risks and follow-up + +Migration `0036` takes an access-exclusive audit lock for an atomic vocabulary +rewrite; deploy it as a bounded schema migration. `WS-XINT-002-02` is the next +same-initiative explicit-start gate. Later ART activation still requires exact +hidden feature evidence; this chunk grants no executable authority. + +## Human review focus and merge ownership + +Review the exact six-to-three clean cut, planned-only availability, fixed-service +least privilege, and evidence-preserving migration refusal. Only the user may +approve this specific PR for merge. + diff --git a/.agent-loop/merge-intents/WS-XINT-002-01.json b/.agent-loop/merge-intents/WS-XINT-002-01.json new file mode 100644 index 000000000..9d1d22d4d --- /dev/null +++ b/.agent-loop/merge-intents/WS-XINT-002-01.json @@ -0,0 +1,9 @@ +{ + "chunk_id": "WS-XINT-002-01", + "chunk_title": "ART Catalogue Reconciliation", + "initiative_id": "WS-XINT-002", + "next_chunk_id": "WS-XINT-002-02", + "next_chunk_title": "Prepared Feature Boundaries", + "next_requires_explicit_start": true, + "schema_version": 2 +} diff --git a/backend/alembic/versions/0036_art_auth_catalogue_reconciliation.py b/backend/alembic/versions/0036_art_auth_catalogue_reconciliation.py new file mode 100644 index 000000000..b110e1cb8 --- /dev/null +++ b/backend/alembic/versions/0036_art_auth_catalogue_reconciliation.py @@ -0,0 +1,174 @@ +"""reconcile the complete ART authorization catalogue + +Revision ID: 0036_art_auth_catalogue +Revises: 0035_project_read_evidence +Create Date: 2026-07-27 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0036_art_auth_catalogue" +down_revision = "0035_project_read_evidence" +branch_labels = depends_on = None + +_REMOVED_PERMISSIONS = ( + "artifact.upload_session.create", + "artifact.upload_session.read", + "artifact.upload_item.write", + "artifact.upload_session.seal", + "artifact.upload_session.cancel", + "artifact.upload_session.expire", +) +_ADDED_PERMISSIONS = ("artifact.review_packet.materialize",) +_REMOVED_ACTIONS = tuple((value, value) for value in _REMOVED_PERMISSIONS) +_ADDED_ACTIONS = ( + ("artifact.submission_bundle.prepare", "submission.create"), + ("artifact.review_packet.materialize", "artifact.review_packet.materialize"), + ("artifact.review_evidence.binding.create", "artifact.binding.create"), +) + + +def _definition(name: str) -> str: + return ( + op.get_bind() + .execute( + sa.text( + "select pg_get_constraintdef(oid) from pg_constraint " + "where conrelid='audit_events'::regclass and conname=:name" + ), + {"name": f"ck_audit_events_{name}"}, + ) + .scalar_one() + ) + + +def _replace(name: str, definition: str) -> None: + op.drop_constraint(name, "audit_events", type_="check") + op.execute(f"alter table audit_events add constraint ck_audit_events_{name} {definition}") + + +def _permission_token(value: str) -> str: + return f"('{value}'::character varying)::text" + + +def _pair_token(action: str, permission: str) -> str: + return ( + f"(((action_id)::text = '{action}'::text) AND " + f"((permission_id)::text = '{permission}'::text))" + ) + + +def _rewrite_permission_registry(*, forward: bool) -> None: + remove = _REMOVED_PERMISSIONS if forward else _ADDED_PERMISSIONS + add = _ADDED_PERMISSIONS if forward else _REMOVED_PERMISSIONS + marker = _permission_token("artifact.binding.create") + for name in ("authority_registries", "authority_privacy_bounds"): + definition = _definition(name) + for value in remove: + token = _permission_token(value) + if definition.count(token) < 1: + raise RuntimeError(f"unexpected {name} removed permission definition") + definition = definition.replace(", " + token, "") + additions = ", " + ", ".join(_permission_token(value) for value in add) + if definition.count(marker) < 1 or any( + _permission_token(value) in definition for value in add + ): + raise RuntimeError(f"unexpected {name} added permission definition") + definition = definition.replace(marker, marker + additions) + _replace(name, definition) + + +def _rewrite_action_registry(*, forward: bool) -> None: + remove = _REMOVED_ACTIONS if forward else _ADDED_ACTIONS + add = _ADDED_ACTIONS if forward else _REMOVED_ACTIONS + name = "authorization_action_evidence" + definition = _definition(name) + for action, permission in remove: + token = _pair_token(action, permission) + if definition.count(token) != 2: + raise RuntimeError("unexpected removed authorization action definition") + definition = definition.replace(" OR " + token, "") + marker = _pair_token("artifact.guide_source.ingest", "artifact.guide_source.ingest") + additions = " OR " + " OR ".join(_pair_token(*pair) for pair in add) + if definition.count(marker) != 2 or any(_pair_token(*pair) in definition for pair in add): + raise RuntimeError("unexpected added authorization action definition") + definition = definition.replace(marker, marker + additions) + _replace(name, definition) + + +def _rewrite_action_permission_registry(*, forward: bool) -> None: + remove = _REMOVED_PERMISSIONS if forward else _ADDED_PERMISSIONS + add = _ADDED_PERMISSIONS if forward else _REMOVED_PERMISSIONS + name = "authorization_action_evidence" + definition = _definition(name) + for value in remove: + token = _permission_token(value) + if definition.count(token) != 1: + raise RuntimeError("unexpected removed action permission definition") + definition = definition.replace(", " + token, "") + marker = _permission_token("artifact.binding.create") + additions = ", " + ", ".join(_permission_token(value) for value in add) + if definition.count(marker) != 1 or any( + _permission_token(value) in definition for value in add + ): + raise RuntimeError("unexpected added action permission definition") + definition = definition.replace(marker, marker + additions) + _replace(name, definition) + + +def _lock_evidence() -> 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")) + + +def _has_evidence(actions: tuple[str, ...], permissions: tuple[str, ...]) -> bool: + predicate = ( + "action_id = any(:actions) or permission_id = any(:permissions) or " + "(target_ref_kind='permission_registry' and target_ref_id = any(:permissions)) or " + "(invalidation_target_kind='permission_registry' and " + "invalidation_target_ref = any(:permissions))" + ) + return bool( + op.get_bind() + .execute( + sa.text( + "select exists(select 1 from audit_events where " + f"idempotency_reference is null and ({predicate})) or " + "exists(select 1 from authority_idempotency_records record " + "join audit_events event on event.idempotency_reference=record.id " + f"where {predicate.replace(':actions', ':linked_actions').replace(':permissions', ':linked_permissions')})" + ), + { + "actions": list(actions), + "permissions": list(permissions), + "linked_actions": list(actions), + "linked_permissions": list(permissions), + }, + ) + .scalar_one() + ) + + +def upgrade() -> None: + """Replace obsolete upload authority with planned bundle/review authority.""" + _lock_evidence() + if _has_evidence(tuple(action for action, _ in _REMOVED_ACTIONS), _REMOVED_PERMISSIONS): + raise RuntimeError("cannot remove non-empty obsolete artifact authority evidence") + _rewrite_permission_registry(forward=True) + _rewrite_action_registry(forward=True) + _rewrite_action_permission_registry(forward=True) + + +def downgrade() -> None: + """Restore the prior catalogue only when new ART authority has no evidence.""" + _lock_evidence() + if _has_evidence(tuple(action for action, _ in _ADDED_ACTIONS), _ADDED_PERMISSIONS): + raise RuntimeError("cannot downgrade non-empty ART authorization evidence") + _rewrite_action_permission_registry(forward=False) + _rewrite_action_registry(forward=False) + _rewrite_permission_registry(forward=False) diff --git a/backend/app/modules/authorization/admin_schemas.py b/backend/app/modules/authorization/admin_schemas.py index 80924308d..88ba62cad 100644 --- a/backend/app/modules/authorization/admin_schemas.py +++ b/backend/app/modules/authorization/admin_schemas.py @@ -35,7 +35,7 @@ class PermissionDefinitionsResponse(BaseModel): model_config = _STRICT items: tuple[PermissionDefinitionResponse, ...] - total: Literal[76] + total: Literal[71] class AdminRoleDefinitionResponse(BaseModel): diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index eecc4ba8b..63a9d55a2 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -74,12 +74,6 @@ class PermissionId(StrEnum): ARTIFACT_RECOVERY_ATTEMPT_READ = "artifact.recovery_attempt.read" ARTIFACT_AUDIT_READ = "artifact.audit.read" ARTIFACT_GUIDE_SOURCE_INGEST = "artifact.guide_source.ingest" - ARTIFACT_UPLOAD_SESSION_CREATE = "artifact.upload_session.create" - ARTIFACT_UPLOAD_SESSION_READ = "artifact.upload_session.read" - ARTIFACT_UPLOAD_ITEM_WRITE = "artifact.upload_item.write" - ARTIFACT_UPLOAD_SESSION_SEAL = "artifact.upload_session.seal" - ARTIFACT_UPLOAD_SESSION_CANCEL = "artifact.upload_session.cancel" - ARTIFACT_UPLOAD_SESSION_EXPIRE = "artifact.upload_session.expire" ARTIFACT_BINDING_CREATE = "artifact.binding.create" ARTIFACT_VERIFICATION_EXECUTE = "artifact.verification.execute" ARTIFACT_PENDING_WORK_SCAN = "artifact.pending_work.scan" @@ -87,6 +81,7 @@ class PermissionId(StrEnum): ARTIFACT_GUIDE_SOURCE_READ = "artifact.guide_source.read" ARTIFACT_CHECKER_INPUT_MATERIALIZE = "artifact.checker_input.materialize" ARTIFACT_CHECKER_OUTPUT_WRITE = "artifact.checker_output.write" + ARTIFACT_REVIEW_PACKET_MATERIALIZE = "artifact.review_packet.materialize" AUDIT_READ = "audit.read" AUDIT_EXPORT = "audit.export" @@ -163,12 +158,7 @@ class ActionId(StrEnum): OPERATIONS_ARTIFACT_STORAGE_ADMISSION_READ = "operations.artifact_storage_admission.read" ARTIFACT_GUIDE_SOURCE_INGEST = "artifact.guide_source.ingest" ARTIFACT_GUIDE_SOURCE_READ = "artifact.guide_source.read" - ARTIFACT_UPLOAD_SESSION_CREATE = "artifact.upload_session.create" - ARTIFACT_UPLOAD_SESSION_READ = "artifact.upload_session.read" - ARTIFACT_UPLOAD_ITEM_WRITE = "artifact.upload_item.write" - ARTIFACT_UPLOAD_SESSION_SEAL = "artifact.upload_session.seal" - ARTIFACT_UPLOAD_SESSION_CANCEL = "artifact.upload_session.cancel" - ARTIFACT_UPLOAD_SESSION_EXPIRE = "artifact.upload_session.expire" + ARTIFACT_SUBMISSION_BUNDLE_PREPARE = "artifact.submission_bundle.prepare" ARTIFACT_GUIDE_SOURCE_BINDING_CREATE = "artifact.guide_source.binding.create" ARTIFACT_SUBMISSION_BINDING_CREATE = "artifact.submission.binding.create" ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE = "artifact.checker_output.binding.create" @@ -180,6 +170,8 @@ class ActionId(StrEnum): "artifact.post_submit.checker_input.materialize" ) ARTIFACT_CHECKER_OUTPUT_WRITE = "artifact.checker_output.write" + ARTIFACT_REVIEW_PACKET_MATERIALIZE = "artifact.review_packet.materialize" + ARTIFACT_REVIEW_EVIDENCE_BINDING_CREATE = "artifact.review_evidence.binding.create" @unique @@ -209,11 +201,12 @@ class ActionOwner(StrEnum): AUTH_ART_02D_INTERNAL = "WS-AUTH-001-ART-02D-INTERNAL" AUTH_ART_02D_OPERATOR = "WS-AUTH-001-ART-02D-OPERATOR" AUTH_ART_03 = "WS-AUTH-001-ART-03" - AUTH_ART_04A = "WS-AUTH-001-ART-04A" AUTH_ART_04B = "WS-AUTH-001-ART-04B" AUTH_ART_05 = "WS-AUTH-001-ART-05" AUTH_ART_06A = "WS-AUTH-001-ART-06A" AUTH_ART_06B = "WS-AUTH-001-ART-06B" + XINT_002_05A = "WS-XINT-002-05A" + XINT_002_07 = "WS-XINT-002-07" @unique @@ -552,34 +545,9 @@ def _active( ActionOwner.AUTH_ART_03, ), _planned( - ActionId.ARTIFACT_UPLOAD_SESSION_CREATE, - PermissionId.ARTIFACT_UPLOAD_SESSION_CREATE, - ActionOwner.AUTH_ART_04A, - ), - _planned( - ActionId.ARTIFACT_UPLOAD_SESSION_READ, - PermissionId.ARTIFACT_UPLOAD_SESSION_READ, - ActionOwner.AUTH_ART_04A, - ), - _planned( - ActionId.ARTIFACT_UPLOAD_ITEM_WRITE, - PermissionId.ARTIFACT_UPLOAD_ITEM_WRITE, - ActionOwner.AUTH_ART_04A, - ), - _planned( - ActionId.ARTIFACT_UPLOAD_SESSION_SEAL, - PermissionId.ARTIFACT_UPLOAD_SESSION_SEAL, - ActionOwner.AUTH_ART_04A, - ), - _planned( - ActionId.ARTIFACT_UPLOAD_SESSION_CANCEL, - PermissionId.ARTIFACT_UPLOAD_SESSION_CANCEL, - ActionOwner.AUTH_ART_04A, - ), - _planned( - ActionId.ARTIFACT_UPLOAD_SESSION_EXPIRE, - PermissionId.ARTIFACT_UPLOAD_SESSION_EXPIRE, - ActionOwner.AUTH_ART_04A, + ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, + PermissionId.SUBMISSION_CREATE, + ActionOwner.XINT_002_05A, ), _planned( ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE, @@ -626,6 +594,16 @@ def _active( PermissionId.ARTIFACT_CHECKER_OUTPUT_WRITE, ActionOwner.AUTH_ART_06B, ), + _planned( + ActionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE, + PermissionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE, + ActionOwner.XINT_002_07, + ), + _planned( + ActionId.ARTIFACT_REVIEW_EVIDENCE_BINDING_CREATE, + PermissionId.ARTIFACT_BINDING_CREATE, + ActionOwner.XINT_002_07, + ), ) PERMISSION_IDS = frozenset(PermissionId) @@ -646,12 +624,6 @@ def _active( PermissionId.ARTIFACT_RECOVERY_ATTEMPT_READ, PermissionId.ARTIFACT_AUDIT_READ, PermissionId.ARTIFACT_GUIDE_SOURCE_INGEST, - PermissionId.ARTIFACT_UPLOAD_SESSION_CREATE, - PermissionId.ARTIFACT_UPLOAD_SESSION_READ, - PermissionId.ARTIFACT_UPLOAD_ITEM_WRITE, - PermissionId.ARTIFACT_UPLOAD_SESSION_SEAL, - PermissionId.ARTIFACT_UPLOAD_SESSION_CANCEL, - PermissionId.ARTIFACT_UPLOAD_SESSION_EXPIRE, PermissionId.ARTIFACT_BINDING_CREATE, PermissionId.ARTIFACT_VERIFICATION_EXECUTE, PermissionId.ARTIFACT_PENDING_WORK_SCAN, @@ -659,6 +631,7 @@ def _active( PermissionId.ARTIFACT_GUIDE_SOURCE_READ, PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, PermissionId.ARTIFACT_CHECKER_OUTPUT_WRITE, + PermissionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE, } ) HISTORICAL_PERMISSION_IDS = PERMISSION_IDS - NEW_PERMISSION_IDS @@ -677,11 +650,11 @@ 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) != 76 or len(ACTION_IDS) != 81: + if len(PERMISSION_IDS) != 71 or len(ACTION_IDS) != 78: raise RuntimeError("authorization catalogue count mismatch") if len(indexed) != len(definitions) or set(indexed) != ACTION_IDS: raise RuntimeError("authorization action catalogue is incomplete") - if len(HISTORICAL_PERMISSION_IDS) != 49 or len(NEW_PERMISSION_IDS) != 27: + if len(HISTORICAL_PERMISSION_IDS) != 49 or len(NEW_PERMISSION_IDS) != 22: raise RuntimeError("authorization permission boundary mismatch") active_actions = { ActionId.ACTOR_PROFILE_READ_SELF, @@ -726,14 +699,13 @@ def _index_actions( _SERVICE_ACTIONS = { ServiceIdentity.ARTIFACT_VERIFIER: frozenset({ActionId.ARTIFACT_VERIFICATION_EXECUTE}), ServiceIdentity.ARTIFACT_PUT_RESOLVER: frozenset({ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE}), - ServiceIdentity.ARTIFACT_SCHEDULER: frozenset( - {ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_UPLOAD_SESSION_EXPIRE} - ), + ServiceIdentity.ARTIFACT_SCHEDULER: frozenset({ActionId.ARTIFACT_PENDING_WORK_SCAN}), ServiceIdentity.ARTIFACT_BINDING: frozenset( { ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE, ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE, + ActionId.ARTIFACT_REVIEW_EVIDENCE_BINDING_CREATE, } ), ServiceIdentity.ARTIFACT_GUIDE_READER: frozenset({ActionId.ARTIFACT_GUIDE_SOURCE_READ}), @@ -741,6 +713,7 @@ def _index_actions( { ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, + ActionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE, } ), ServiceIdentity.ARTIFACT_CHECKER_OUTPUT: frozenset({ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE}), @@ -754,14 +727,13 @@ def _index_service_actions( expected_rows = { ServiceIdentity.ARTIFACT_VERIFIER: frozenset({ActionId.ARTIFACT_VERIFICATION_EXECUTE}), ServiceIdentity.ARTIFACT_PUT_RESOLVER: frozenset({ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE}), - ServiceIdentity.ARTIFACT_SCHEDULER: frozenset( - {ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_UPLOAD_SESSION_EXPIRE} - ), + ServiceIdentity.ARTIFACT_SCHEDULER: frozenset({ActionId.ARTIFACT_PENDING_WORK_SCAN}), ServiceIdentity.ARTIFACT_BINDING: frozenset( { ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE, ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE, + ActionId.ARTIFACT_REVIEW_EVIDENCE_BINDING_CREATE, } ), ServiceIdentity.ARTIFACT_GUIDE_READER: frozenset({ActionId.ARTIFACT_GUIDE_SOURCE_READ}), @@ -769,6 +741,7 @@ def _index_service_actions( { ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, + ActionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE, } ), ServiceIdentity.ARTIFACT_CHECKER_OUTPUT: frozenset( @@ -788,10 +761,6 @@ def _index_service_actions( PermissionId.ARTIFACT_PENDING_WORK_SCAN, ActionOwner.AUTH_ART_02D_INTERNAL, ), - ActionId.ARTIFACT_UPLOAD_SESSION_EXPIRE: ( - PermissionId.ARTIFACT_UPLOAD_SESSION_EXPIRE, - ActionOwner.AUTH_ART_04A, - ), ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE: ( PermissionId.ARTIFACT_BINDING_CREATE, ActionOwner.AUTH_ART_03, @@ -820,6 +789,14 @@ def _index_service_actions( PermissionId.ARTIFACT_CHECKER_OUTPUT_WRITE, ActionOwner.AUTH_ART_06B, ), + ActionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE: ( + PermissionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE, + ActionOwner.XINT_002_07, + ), + ActionId.ARTIFACT_REVIEW_EVIDENCE_BINDING_CREATE: ( + PermissionId.ARTIFACT_BINDING_CREATE, + ActionOwner.XINT_002_07, + ), } if set(rows) != SERVICE_IDENTITIES: raise RuntimeError("service action matrix identity mismatch") diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 04339e227..116e88339 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -32,6 +32,7 @@ HISTORICAL_PERMISSION_IDS, NEW_PERMISSION_IDS, ActionOwner, + PermissionId, ) from app.modules.actors.legacy_classification import ( CLASSIFICATION_FILE_ENV, @@ -1616,7 +1617,7 @@ def test_artifact_recovery_schema_and_empty_downgrade( command.downgrade(config, "base") command.upgrade(config, "head") assert asyncio.run(_artifact_recovery_schema(isolated_database_env)) == { - "revision": "0035_project_read_evidence", + "revision": "0036_art_auth_catalogue", "constraints": { "artifact_recovery_attempt_custody", "artifact_verification_lineage_custody", @@ -1690,6 +1691,7 @@ def test_0035_project_read_action_evidence_round_trip( isolated_database_env, definitions=definitions ) ) + asyncio.run(_assert_removed_art_authority_rejected(isolated_database_env)) command.downgrade(config, "0034_project_role_issue_evidence") command.upgrade(config, "head") asyncio.run( @@ -1697,6 +1699,7 @@ def test_0035_project_read_action_evidence_round_trip( isolated_database_env, definitions=definitions ) ) + asyncio.run(_assert_removed_art_authority_rejected(isolated_database_env)) finally: command.downgrade(config, "base") @@ -1724,12 +1727,484 @@ def test_0035_project_read_action_evidence_refuses_nonempty_downgrade( ): command.downgrade(config, "0034_project_role_issue_evidence") assert asyncio.run(_current_revision(isolated_database_env)) == ( - "0035_project_read_evidence" + "0036_art_auth_catalogue" ) finally: + asyncio.run(_remove_authority_audit_fixture(isolated_database_env, event_id=event_id)) + command.downgrade(config, "base") + + +def test_0036_art_auth_catalogue_round_trip(isolated_database_env: str, migration_lock) -> None: + """Prove the three replacement pairs and review permission round-trip exactly.""" + config = _alembic_config() + definitions = tuple( + definition + for definition in ACTION_DEFINITIONS + if definition.owner in {ActionOwner.XINT_002_05A, ActionOwner.XINT_002_07} + ) + assert len(definitions) == 3 + with migration_lock(): + try: + command.downgrade(config, "base") + command.upgrade(config, "head") asyncio.run( - _remove_authority_audit_fixture(isolated_database_env, event_id=event_id) + _assert_authorization_action_sql_pairs( + isolated_database_env, definitions=definitions + ) + ) + asyncio.run(_assert_removed_art_authority_rejected(isolated_database_env)) + command.downgrade(config, "0035_project_read_evidence") + command.upgrade(config, "head") + asyncio.run( + _assert_authorization_action_sql_pairs( + isolated_database_env, definitions=definitions + ) ) + asyncio.run(_assert_removed_art_authority_rejected(isolated_database_env)) + finally: + command.downgrade(config, "base") + + +def test_0036_art_auth_catalogue_refuses_obsolete_evidence( + isolated_database_env: str, migration_lock +) -> None: + """Obsolete upload evidence must block catalogue deletion without mutation.""" + config = _alembic_config() + event_ids: list[str] = [] + record_id = str(uuid4()) + actor_id = str(uuid4()) + target_id = str(uuid4()) + obsolete = tuple( + "artifact.upload_" + value + for value in ( + "session.create", + "session.read", + "item.write", + "session.seal", + "session.cancel", + "session.expire", + ) + ) + with migration_lock(): + try: + command.downgrade(config, "base") + command.upgrade(config, "0035_project_read_evidence") + event_ids.extend( + asyncio.run( + _insert_authorization_action_event_for( + isolated_database_env, identifier, identifier + ) + ) + for identifier in obsolete + ) + event_ids.append( + asyncio.run( + _insert_forward_permission_reference( + isolated_database_env, + event_ids[0], + reference_field="target", + permission=obsolete[0], + ) + ) + ) + event_ids.append( + asyncio.run( + _insert_forward_permission_reference( + isolated_database_env, + event_ids[1], + reference_field="invalidation", + permission=obsolete[1], + ) + ) + ) + asyncio.run( + _insert_committed_authority_idempotency( + isolated_database_env, record_id, actor_id, target_id + ) + ) + event_ids.append( + asyncio.run( + _insert_linked_authorization_action_event( + isolated_database_env, + record_id=record_id, + actor_id=actor_id, + action_id=obsolete[2], + permission_id=obsolete[2], + ) + ) + ) + before = asyncio.run( + _art_catalogue_migration_state( + isolated_database_env, actions=obsolete, permissions=obsolete + ) + ) + with pytest.raises( + RuntimeError, + match="cannot remove non-empty obsolete artifact authority evidence", + ): + command.upgrade(config, "head") + assert ( + asyncio.run( + _art_catalogue_migration_state( + isolated_database_env, actions=obsolete, permissions=obsolete + ) + ) + == before + ) + for event_id in reversed(event_ids): + asyncio.run( + _remove_authority_audit_fixture(isolated_database_env, event_id=event_id) + ) + event_ids.clear() + asyncio.run( + _remove_authority_idempotency_fixture( + isolated_database_env, record_id, orphan_event=None + ) + ) + record_id = "" + command.upgrade(config, "head") + assert asyncio.run(_current_revision(isolated_database_env)) == ( + "0036_art_auth_catalogue" + ) + finally: + for event_id in reversed(event_ids): + asyncio.run( + _remove_authority_audit_fixture(isolated_database_env, event_id=event_id) + ) + if record_id: + asyncio.run( + _remove_authority_idempotency_fixture( + isolated_database_env, record_id, orphan_event=None + ) + ) + command.downgrade(config, "base") + + +def test_0036_art_auth_catalogue_refuses_new_evidence_downgrade( + isolated_database_env: str, migration_lock +) -> None: + """Committed replacement-action evidence must block restoration of old authority.""" + config = _alembic_config() + definitions = tuple( + item + for item in ACTION_DEFINITIONS + if item.owner in {ActionOwner.XINT_002_05A, ActionOwner.XINT_002_07} + ) + event_ids: list[str] = [] + with migration_lock(): + try: + command.downgrade(config, "base") + command.upgrade(config, "head") + event_ids.extend( + asyncio.run( + _insert_authorization_action_event_for( + isolated_database_env, + definition.action_id.value, + definition.permission_id.value, + ) + ) + for definition in definitions + ) + review_permission = PermissionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE.value + event_ids.append( + asyncio.run( + _insert_forward_permission_reference( + isolated_database_env, + event_ids[0], + reference_field="target", + permission=review_permission, + ) + ) + ) + before = asyncio.run( + _art_catalogue_migration_state( + isolated_database_env, + actions=tuple(item.action_id.value for item in definitions), + permissions=(review_permission,), + ) + ) + with pytest.raises( + RuntimeError, match="cannot downgrade non-empty ART authorization evidence" + ): + command.downgrade(config, "0035_project_read_evidence") + assert ( + asyncio.run( + _art_catalogue_migration_state( + isolated_database_env, + actions=tuple(item.action_id.value for item in definitions), + permissions=(review_permission,), + ) + ) + == before + ) + finally: + for event_id in reversed(event_ids): + asyncio.run( + _remove_authority_audit_fixture(isolated_database_env, event_id=event_id) + ) + command.downgrade(config, "base") + + +def test_0036_art_auth_catalogue_refuses_each_obsolete_evidence_shape( + isolated_database_env: str, migration_lock +) -> None: + """Prove every removal predicate independently blocks the clean-cut upgrade.""" + config = _alembic_config() + obsolete = tuple( + "artifact.upload_" + value + for value in ( + "session.create", + "session.read", + "item.write", + "session.seal", + "session.cancel", + "session.expire", + ) + ) + with migration_lock(): + try: + command.downgrade(config, "base") + command.upgrade(config, "0035_project_read_evidence") + for identifier in obsolete: + event_id = asyncio.run( + _insert_authorization_action_event_for( + isolated_database_env, identifier, identifier + ) + ) + before = asyncio.run( + _art_catalogue_migration_state( + isolated_database_env, + actions=(identifier,), + permissions=(identifier,), + ) + ) + with pytest.raises(RuntimeError): + command.upgrade(config, "head") + assert ( + asyncio.run( + _art_catalogue_migration_state( + isolated_database_env, + actions=(identifier,), + permissions=(identifier,), + ) + ) + == before + ) + asyncio.run( + _remove_authority_audit_fixture(isolated_database_env, event_id=event_id) + ) + + cause_id = asyncio.run( + _insert_authorization_action_event_for( + isolated_database_env, + "actor.profile.read_self", + "actor.profile.read_self", + ) + ) + for reference_field, permission in ( + ("target", obsolete[0]), + ("invalidation", obsolete[1]), + ): + event_id = asyncio.run( + _insert_forward_permission_reference( + isolated_database_env, + cause_id, + reference_field=reference_field, + permission=permission, + ) + ) + before = asyncio.run( + _art_catalogue_migration_state( + isolated_database_env, actions=(), permissions=(permission,) + ) + ) + with pytest.raises(RuntimeError): + command.upgrade(config, "head") + assert ( + asyncio.run( + _art_catalogue_migration_state( + isolated_database_env, actions=(), permissions=(permission,) + ) + ) + == before + ) + asyncio.run( + _remove_authority_audit_fixture(isolated_database_env, event_id=event_id) + ) + asyncio.run(_remove_authority_audit_fixture(isolated_database_env, event_id=cause_id)) + + record_id, actor_id, target_id = str(uuid4()), 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=obsolete[2], + permission_id=obsolete[2], + ) + ) + before = asyncio.run( + _art_catalogue_migration_state( + isolated_database_env, + actions=(obsolete[2],), + permissions=(obsolete[2],), + ) + ) + with pytest.raises(RuntimeError): + command.upgrade(config, "head") + assert ( + asyncio.run( + _art_catalogue_migration_state( + isolated_database_env, + actions=(obsolete[2],), + permissions=(obsolete[2],), + ) + ) + == before + ) + asyncio.run(_remove_authority_audit_fixture(isolated_database_env, event_id=event_id)) + asyncio.run( + _remove_authority_idempotency_fixture( + isolated_database_env, record_id, orphan_event=None + ) + ) + finally: + command.downgrade(config, "base") + + +def test_0036_art_auth_catalogue_refuses_each_new_evidence_shape( + isolated_database_env: str, migration_lock +) -> None: + """Prove each added action and permission independently blocks downgrade.""" + config = _alembic_config() + definitions = tuple( + item + for item in ACTION_DEFINITIONS + if item.owner in {ActionOwner.XINT_002_05A, ActionOwner.XINT_002_07} + ) + review_permission = PermissionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE.value + with migration_lock(): + try: + command.downgrade(config, "base") + command.upgrade(config, "head") + for definition in definitions: + event_id = asyncio.run( + _insert_authorization_action_event_for( + isolated_database_env, + definition.action_id.value, + definition.permission_id.value, + ) + ) + before = asyncio.run( + _art_catalogue_migration_state( + isolated_database_env, + actions=(definition.action_id.value,), + permissions=(definition.permission_id.value,), + ) + ) + with pytest.raises(RuntimeError): + command.downgrade(config, "0035_project_read_evidence") + assert ( + asyncio.run( + _art_catalogue_migration_state( + isolated_database_env, + actions=(definition.action_id.value,), + permissions=(definition.permission_id.value,), + ) + ) + == before + ) + asyncio.run( + _remove_authority_audit_fixture(isolated_database_env, event_id=event_id) + ) + + cause_id = asyncio.run( + _insert_authorization_action_event_for( + isolated_database_env, + "actor.profile.read_self", + "actor.profile.read_self", + ) + ) + for reference_field in ("target", "invalidation"): + event_id = asyncio.run( + _insert_forward_permission_reference( + isolated_database_env, + cause_id, + reference_field=reference_field, + permission=review_permission, + ) + ) + before = asyncio.run( + _art_catalogue_migration_state( + isolated_database_env, + actions=(), + permissions=(review_permission,), + ) + ) + with pytest.raises(RuntimeError): + command.downgrade(config, "0035_project_read_evidence") + assert ( + asyncio.run( + _art_catalogue_migration_state( + isolated_database_env, + actions=(), + permissions=(review_permission,), + ) + ) + == before + ) + asyncio.run( + _remove_authority_audit_fixture(isolated_database_env, event_id=event_id) + ) + asyncio.run(_remove_authority_audit_fixture(isolated_database_env, event_id=cause_id)) + + record_id, actor_id, target_id = str(uuid4()), str(uuid4()), str(uuid4()) + asyncio.run( + _insert_committed_authority_idempotency( + isolated_database_env, record_id, actor_id, target_id + ) + ) + linked_definition = definitions[0] + event_id = asyncio.run( + _insert_linked_authorization_action_event( + isolated_database_env, + record_id=record_id, + actor_id=actor_id, + action_id=linked_definition.action_id.value, + permission_id=linked_definition.permission_id.value, + ) + ) + before = asyncio.run( + _art_catalogue_migration_state( + isolated_database_env, + actions=(linked_definition.action_id.value,), + permissions=(linked_definition.permission_id.value,), + ) + ) + with pytest.raises(RuntimeError): + command.downgrade(config, "0035_project_read_evidence") + assert ( + asyncio.run( + _art_catalogue_migration_state( + isolated_database_env, + actions=(linked_definition.action_id.value,), + permissions=(linked_definition.permission_id.value,), + ) + ) + == before + ) + asyncio.run(_remove_authority_audit_fixture(isolated_database_env, event_id=event_id)) + asyncio.run( + _remove_authority_idempotency_fixture( + isolated_database_env, record_id, orphan_event=None + ) + ) + finally: command.downgrade(config, "base") @@ -1745,7 +2220,7 @@ def test_project_role_migration_constraints_and_immutable_history( command.upgrade(config, "head") result = asyncio.run(_exercise_project_role_migration(isolated_database_env)) assert result == { - "revision": "0035_project_read_evidence", + "revision": "0036_art_auth_catalogue", "role_count": 3, "invalid_availability": "23514", "duplicate_role": "23505", @@ -1957,7 +2432,7 @@ def test_project_role_downgrade_refuses_each_reserved_evidence_predicate( ): command.downgrade(config, "0030_artifact_verification") assert asyncio.run(_project_role_refusal_state(isolated_database_env))[:3] == ( - "0035_project_read_evidence", + "0036_art_auth_catalogue", True, True, ) @@ -1984,7 +2459,7 @@ def test_project_role_downgrade_refuses_each_reserved_evidence_predicate( ): command.downgrade(config, "0030_artifact_verification") assert asyncio.run(_project_role_refusal_state(isolated_database_env))[:3] == ( - "0035_project_read_evidence", + "0036_art_auth_catalogue", True, True, ) @@ -2012,7 +2487,7 @@ def test_outbox_migration_schema_and_downgrade_writer_guard( command.upgrade(config, "head") schema = asyncio.run(_outbox_schema(isolated_database_env)) assert schema == { - "revision": "0035_project_read_evidence", + "revision": "0036_art_auth_catalogue", "columns": { "aggregate_id", "aggregate_type", @@ -2072,7 +2547,7 @@ def test_outbox_migration_schema_and_downgrade_writer_guard( ) assert committed == "refused_after_commit" assert asyncio.run(_current_revision(isolated_database_env)) == ( - "0035_project_read_evidence" + "0036_art_auth_catalogue" ) asyncio.run(_remove_outbox_migration_row(isolated_database_env, committed_project_id)) command.downgrade(config, "0028_artifact_admission") @@ -7814,6 +8289,99 @@ async def _assert_authorization_action_sql_pairs( await engine.dispose() +async def _assert_removed_art_authority_rejected(database_url: str) -> None: + """Prove current SQL rejects every deleted pair and permission reference.""" + removed = tuple( + "artifact.upload_" + value + for value in ( + "session.create", + "session.read", + "item.write", + "session.seal", + "session.cancel", + "session.expire", + ) + ) + engine = create_async_engine(database_url) + try: + for identifier in removed: + async with engine.connect() as connection: + transaction = await connection.begin() + with pytest.raises(IntegrityError): + await connection.execute( + _ACTION_EVIDENCE_INSERT, + _action_evidence_values(identifier, identifier), + ) + await transaction.rollback() + + async with engine.connect() as connection: + transaction = await connection.begin() + values = { + "id": str(uuid4()), + "request": str(uuid4()), + "correlation": str(uuid4()), + "permission": identifier, + } + with pytest.raises(IntegrityError): + await connection.execute( + text( + "insert into audit_events " + "(id,entity_type,entity_id,event_type,actor_id,actor_roles," + "claim_snapshot,auth_source,is_dev_auth,event_payload,event_domain," + "event_version,actor_ref_kind,request_id,correlation_id,permission_id," + "target_ref_kind,target_ref_id,reason,after_facts) values " + "(:id,'authorization_decision',:id,'SensitiveAuthorizationAllowed'," + "'workstream:system:bootstrap','[]'::json,'{}'::json," + "'local_authority',false,'{}'::json,'authority',1," + "'system_principal',:request,:correlation,'actor.profile.read_any'," + "'permission_registry',:permission,'authorization_evaluation'," + "'{\"allowed\": true}'::json)" + ), + values, + ) + await transaction.rollback() + + async with engine.connect() as connection: + transaction = await connection.begin() + cause = _action_evidence_values( + "actor.profile.read_self", "actor.profile.read_self" + ) + await connection.execute(_ACTION_EVIDENCE_INSERT, cause) + await connection.execute( + text( + "alter table audit_events disable trigger audit_events_validate_idempotency" + ) + ) + with pytest.raises(IntegrityError): + await connection.execute( + text( + "insert into audit_events " + "(id,entity_type,entity_id,event_type,actor_id,actor_roles," + "claim_snapshot,auth_source,is_dev_auth,event_payload,event_domain," + "event_version,actor_ref_kind,request_id,correlation_id," + "invalidation_cause_event_id,invalidation_target_kind," + "invalidation_target_ref,reason,before_facts,after_facts) values " + "(:id,'authority_invalidation',:id," + "'AuthorityInvalidationRequested','workstream:system:bootstrap'," + "'[]'::json,'{}'::json,'local_authority',false,'{}'::json," + "'authority',1,'system_principal',:request,:correlation,:cause," + "'permission_registry',:permission,'authority_state_changed'," + "'{\"effective\": true}'::json," + "'{\"effective\": false}'::json)" + ), + { + "id": str(uuid4()), + "request": str(uuid4()), + "correlation": str(uuid4()), + "cause": cause["id"], + "permission": identifier, + }, + ) + await transaction.rollback() + finally: + await engine.dispose() + + async def _insert_authorization_action_event(database_url: str) -> str: """Commit one valid planned-action denial fixture.""" values = _action_evidence_values("artifact.binding.read", "artifact.binding.read") @@ -7866,6 +8434,7 @@ async def _insert_forward_permission_reference( cause_event_id: str, *, reference_field: str, + permission: str = "artifact.binding.read", ) -> str: """Commit one new permission-registry reference without an action ID.""" event_id = str(uuid4()) @@ -7873,7 +8442,7 @@ async def _insert_forward_permission_reference( "id": event_id, "request_id": str(uuid4()), "correlation_id": str(uuid4()), - "permission": "artifact.binding.read", + "permission": permission, "cause_id": cause_event_id, } if reference_field == "target": @@ -9310,6 +9879,106 @@ async def _insert_authorization_action_event_for( await engine.dispose() +async def _insert_linked_authorization_action_event( + database_url: str, + *, + record_id: str, + actor_id: str, + action_id: str, + permission_id: str, +) -> str: + """Seed one constraint-valid linked denial while bypassing only link policy.""" + event_id = str(uuid4()) + engine = create_async_engine(database_url) + try: + async with engine.begin() as connection: + await connection.execute( + text("alter table audit_events disable trigger audit_events_validate_idempotency") + ) + await connection.execute( + text( + "insert into audit_events " + "(id,entity_type,entity_id,event_type,actor_id,actor_roles,claim_snapshot," + "auth_source,is_dev_auth,event_payload,event_domain,event_version," + "actor_ref_kind,request_id,correlation_id,permission_id,action_id,reason," + "denial_code,idempotency_reference,after_facts) values " + "(:id,'authorization_decision',:id,'SensitiveAuthorizationDenied'," + ":actor,'[]'::json,'{}'::json,'local_authority',false,'{}'::json," + "'authority',1,'actor_profile',:request,:correlation,:permission,:action," + "'authorization_evaluation','permission_not_granted',:record," + "'{\"allowed\": false}'::json)" + ), + { + "id": event_id, + "actor": actor_id, + "request": str(uuid4()), + "correlation": str(uuid4()), + "permission": permission_id, + "action": action_id, + "record": record_id, + }, + ) + await connection.execute( + text("alter table audit_events enable trigger audit_events_validate_idempotency") + ) + return event_id + finally: + await engine.dispose() + + +async def _art_catalogue_migration_state( + database_url: str, + *, + actions: tuple[str, ...], + permissions: tuple[str, ...], +) -> dict[str, object]: + """Snapshot revision, rewritten constraints, and all protected evidence counts.""" + engine = create_async_engine(database_url) + try: + async with engine.connect() as connection: + constraints = dict( + ( + await connection.execute( + text( + "select conname,pg_get_constraintdef(oid) from pg_constraint " + "where conrelid='audit_events'::regclass and conname in " + "('ck_audit_events_authority_registries'," + "'ck_audit_events_authority_privacy_bounds'," + "'ck_audit_events_authorization_action_evidence') order by conname" + ) + ) + ).all() + ) + direct_count = await connection.scalar( + text( + "select count(*) from audit_events where action_id=any(:actions) or " + "permission_id=any(:permissions) or " + "(target_ref_kind='permission_registry' and target_ref_id=any(:permissions)) " + "or (invalidation_target_kind='permission_registry' and " + "invalidation_target_ref=any(:permissions))" + ), + {"actions": list(actions), "permissions": list(permissions)}, + ) + linked_count = await connection.scalar( + text( + "select count(*) from authority_idempotency_records record join " + "audit_events event on event.idempotency_reference=record.id where " + "event.action_id=any(:actions) or event.permission_id=any(:permissions)" + ), + {"actions": list(actions), "permissions": list(permissions)}, + ) + return { + "revision": await connection.scalar( + text("select version_num from alembic_version") + ), + "constraints": constraints, + "direct_count": direct_count, + "linked_count": linked_count, + } + finally: + await engine.dispose() + + async def _exercise_project_role_migration(database_url: str) -> dict[str, object]: engine = create_async_engine(database_url) actor_id = actor_id_from_external_identity("https://identity.test", "auth10a-actor") diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 4eea7c483..73f64a4e9 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -1513,34 +1513,9 @@ async def consume_rate() -> None: "WS-AUTH-001-ART-03", "planned", ), - "artifact.upload_session.create": ( - "artifact.upload_session.create", - "WS-AUTH-001-ART-04A", - "planned", - ), - "artifact.upload_session.read": ( - "artifact.upload_session.read", - "WS-AUTH-001-ART-04A", - "planned", - ), - "artifact.upload_item.write": ( - "artifact.upload_item.write", - "WS-AUTH-001-ART-04A", - "planned", - ), - "artifact.upload_session.seal": ( - "artifact.upload_session.seal", - "WS-AUTH-001-ART-04A", - "planned", - ), - "artifact.upload_session.cancel": ( - "artifact.upload_session.cancel", - "WS-AUTH-001-ART-04A", - "planned", - ), - "artifact.upload_session.expire": ( - "artifact.upload_session.expire", - "WS-AUTH-001-ART-04A", + "artifact.submission_bundle.prepare": ( + "submission.create", + "WS-XINT-002-05A", "planned", ), "artifact.pre_submit.checker_input.materialize": ( @@ -1563,6 +1538,16 @@ async def consume_rate() -> None: "WS-AUTH-001-ART-06B", "planned", ), + "artifact.review_packet.materialize": ( + "artifact.review_packet.materialize", + "WS-XINT-002-07", + "planned", + ), + "artifact.review_evidence.binding.create": ( + "artifact.binding.create", + "WS-XINT-002-07", + "planned", + ), "artifact.checker_output.binding.create": ( "artifact.binding.create", "WS-AUTH-001-ART-06B", @@ -1685,12 +1670,11 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> operations.checker.retry artifact.binding.read artifact.replica.read artifact.receipt.read artifact.verification_job.read artifact.verification_job.retry artifact.recovery_attempt.read artifact.audit.read - artifact.guide_source.ingest artifact.upload_session.create - artifact.upload_session.read artifact.upload_item.write artifact.upload_session.seal - artifact.upload_session.cancel artifact.upload_session.expire artifact.binding.create + artifact.guide_source.ingest artifact.binding.create artifact.verification.execute artifact.pending_work.scan artifact.put_attempt.resolve artifact.guide_source.read artifact.checker_input.materialize - artifact.checker_output.write review.queue.override""".split() + artifact.checker_output.write artifact.review_packet.materialize + review.queue.override""".split() ) expected = { "actor.profile.read_self": ("actor.profile.read_self", "WS-AUTH-001-07B"), @@ -1778,7 +1762,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) == 81 + assert len(ACTION_IDS) == len(ACTION_DEFINITIONS) == len(ACTION_BY_ID) == 78 assert set(ACTION_BY_ID) == ACTION_IDS assert {definition.owner for definition in ACTION_DEFINITIONS} == set(ActionOwner) assert { @@ -1838,21 +1822,23 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> ActionOwner.AUTH_ART_02D_OPERATOR, ActionOwner.AUTH_ART_02D_INTERNAL, ActionOwner.AUTH_ART_03, - ActionOwner.AUTH_ART_04A, ActionOwner.AUTH_ART_04B, ActionOwner.AUTH_ART_05, ActionOwner.AUTH_ART_06A, ActionOwner.AUTH_ART_06B, + ActionOwner.XINT_002_05A, + ActionOwner.XINT_002_07, } } == { ActionOwner.AUTH_ART_02D_OPERATOR: 8, ActionOwner.AUTH_ART_02D_INTERNAL: 3, ActionOwner.AUTH_ART_03: 3, - ActionOwner.AUTH_ART_04A: 6, ActionOwner.AUTH_ART_04B: 1, ActionOwner.AUTH_ART_05: 1, ActionOwner.AUTH_ART_06A: 1, ActionOwner.AUTH_ART_06B: 2, + ActionOwner.XINT_002_05A: 1, + ActionOwner.XINT_002_07: 2, } assert all(not owner.value.startswith("WS-ART-") for owner in ActionOwner) assert { @@ -1894,7 +1880,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> definition.availability is ActionAvailability.PLANNED for definition in ACTION_DEFINITIONS ) - == 59 + == 56 ) assert resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id is ( PermissionId.ACTOR_PROFILE_READ_SELF @@ -1905,23 +1891,71 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> ACTION_BY_ID[ActionId.ACTOR_PROFILE_READ_SELF] = ACTION_DEFINITIONS[0] +def test_obsolete_artifact_upload_authority_is_historical_only() -> None: + """Reject obsolete upload authority outside exact immutable/deletion evidence.""" + repository_root = Path(__file__).resolve().parents[2] + obsolete = { + *( + f"artifact.upload_session.{suffix}" + for suffix in ("create", "read", "seal", "cancel", "expire") + ), + "artifact.upload_" + "item.write", + } + historical_handoff = ( + ".agent-loop/initiatives/WS-XINT-001-lifecycle-boundary-reconciliation/AUTH_ART_HANDOFF.md" + ) + allowed = { + "backend/alembic/versions/0021_authorization_action_evidence.py", + "backend/alembic/versions/0022_bootstrap_admin_grants.py", + "backend/alembic/versions/0023_service_actor_identity.py", + "backend/alembic/versions/0036_art_auth_catalogue_reconciliation.py", + ".agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-07A-closed-permission-action-catalogue.md", + ".agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-09-actor-state-service-actors.md", + ".agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-09A-service-identity-foundation.md", + historical_handoff, + } + assert "Historical immutable handoff provenance" in ( + repository_root / historical_handoff + ).read_text(encoding="utf-8") + found: set[str] = set() + ignored_parts = { + ".git", + ".venv", + "__pycache__", + ".mypy_cache", + ".pytest_cache", + "sheets", + } + for path in repository_root.rglob("*"): + if not path.is_file() or ignored_parts.intersection(path.parts): + continue + try: + text_value = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + if any(identifier in text_value for identifier in obsolete): + found.add(path.relative_to(repository_root).as_posix()) + assert found == allowed + + def test_fixed_service_action_matrix_is_exact_planned_and_immutable() -> None: expected = { ServiceIdentity.ARTIFACT_VERIFIER: {"artifact.verification.execute"}, ServiceIdentity.ARTIFACT_PUT_RESOLVER: {"artifact.put_attempt.resolve"}, ServiceIdentity.ARTIFACT_SCHEDULER: { "artifact.pending_work.scan", - "artifact.upload_session.expire", }, ServiceIdentity.ARTIFACT_BINDING: { "artifact.guide_source.binding.create", "artifact.submission.binding.create", "artifact.checker_output.binding.create", + "artifact.review_evidence.binding.create", }, ServiceIdentity.ARTIFACT_GUIDE_READER: {"artifact.guide_source.read"}, ServiceIdentity.ARTIFACT_MATERIALIZER: { "artifact.pre_submit.checker_input.materialize", "artifact.post_submit.checker_input.materialize", + "artifact.review_packet.materialize", }, ServiceIdentity.ARTIFACT_CHECKER_OUTPUT: {"artifact.checker_output.write"}, } @@ -1930,7 +1964,7 @@ def test_fixed_service_action_matrix_is_exact_planned_and_immutable() -> None: 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())) == 11 + assert sum(map(len, SERVICE_ACTIONS_BY_IDENTITY.values())) == 12 assert all( ACTION_BY_ID[action].availability is ActionAvailability.PLANNED for actions in SERVICE_ACTIONS_BY_IDENTITY.values() @@ -1982,11 +2016,12 @@ def test_art_custody_documentation_matches_the_independent_catalogue_fixture() - "WS-AUTH-001-ART-02D-OPERATOR": 8, "WS-AUTH-001-ART-02D-INTERNAL": 3, "WS-AUTH-001-ART-03": 3, - "WS-AUTH-001-ART-04A": 6, + "WS-XINT-002-05A": 1, "WS-AUTH-001-ART-04B": 1, "WS-AUTH-001-ART-05": 1, "WS-AUTH-001-ART-06A": 1, "WS-AUTH-001-ART-06B": 2, + "WS-XINT-002-07": 2, } for document in custody_documents: @@ -2019,13 +2054,13 @@ def test_art_custody_documentation_matches_the_independent_catalogue_fixture() - operations = (repository_root / "docs/operations_authorization_service.md").read_text( encoding="utf-8" ) - assert "all 25 ART rows to eight exact AUTH custodians" in operations + assert "all 22 ART rows to nine exact activation custodians" in operations assert "all 19 REV\nrows to seven exact AUTH custodians" in operations - assert "The ART transfer adds no migration" 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 ( - "76 PermissionIds, 81 ActionIds, 22 active actions, and\n59 planned actions" in operations + "71 PermissionIds, 78 ActionIds, 22 active actions, and\n56 planned actions" in operations ) @@ -2181,7 +2216,7 @@ def test_administrative_role_policy_and_definition_responses_are_exact() -> None permission_response = AdminRoleGrantService.permission_definitions() role_response = AdminRoleGrantService.role_definitions() - assert permission_response.total == 76 + assert permission_response.total == 71 assert [item.permission_id.value for item in permission_response.items] == sorted( permission.value for permission in PermissionId ) diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 0c153a256..8bf7d3b3b 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -672,18 +672,18 @@ resource loader, lifecycle guards, negative tests, and evidence path exist. ### Catalogue And Action-Evidence Staging -The catalogue contains exactly 76 PermissionIds and 81 ActionIds after -AUTH-11A. The two AUTH-07B actor-self actions, seven AUTH-08 administrative +The catalogue contains exactly 71 PermissionIds and 78 ActionIds after +WS-XINT-002-01. The two AUTH-07B actor-self actions, seven AUTH-08 administrative actions, `actor.service.provision`, `actor.profile.read`, `actor.identity_link.read`, the three profile lifecycle actions, and the two identity-link lifecycle actions are active. The remaining five active actions are the AUTH-10B reads `project.contributor_candidate.list`, `project_role_grant.list`, and `project_role_grant.read`, plus the AUTH-10C mutations `project_role_grant.issue` and `project_role_grant.revoke`; the other -59 entries remain planned and non-executable. The target post-custody +56 entries 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 -transfers assign all 25 ART rows to eight exact AUTH custodians and all 19 REV +reconciliation assigns all 22 ART rows to nine exact activation custodians and all 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`, @@ -698,26 +698,28 @@ transaction contract before registration or 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. -PR #139 requires availability-neutral transfer of all 25 ART and 19 REV owner -rows to exact AUTH chunks before feature activation. Both transfers are now -complete. Counts and mappings remain unchanged. The ART transfer adds no migration. +PR #139 historically required availability-neutral transfer of 25 ART and 19 +REV owner rows before feature activation. Both transfers completed; +WS-XINT-002-01 then reconciles the live ART set to 22 planned rows by deleting +six obsolete upload actions and adding three bundle/review actions. The ART +transfer adds no migration; the later WS-XINT-002-01 catalogue +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 76 PermissionIds, 81 ActionIds, 22 active actions, and -59 planned actions after AUTH-11A registers project-read actions without -activating a route. +Catalogue totals are 71 PermissionIds, 78 ActionIds, 22 active actions, and +56 planned actions after WS-XINT-002-01 replaces six obsolete upload actions +with three planned bundle/review actions without activating a route. AUTH-11A adds read-only `project.setup_diagnostic.read` and `project.effective_policy.read`. Project Manager and Audit Authority receive them at system or exact-project scope; Operator receives them at system scope. Finance Authority and Access Administrator do not. The eleven AUTH-11 actions remain planned under 11B, 11C1, or 11C2 and cannot produce allowed evidence. -Four later -REV registrations add exactly four planned and zero active actions, while the -review-evidence binding registration adds exactly one planned and zero active -action, in either order. Neither addition is operational until its complete -feature contract and separate reviewed AUTH registration exist. +Four later REV registrations add exactly four planned and zero active actions. +Review-evidence binding is already registered planned and unavailable under +`WS-XINT-002-07`; it remains non-operational until exact feature proof and a +separate reviewed activation gate exist. Migration `0021` preserves historical audit rows with null `action_id`. Inspect non-null action evidence only by bounded ActionId, request/correlation IDs, and diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 029d1d21c..eff497a51 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -202,12 +202,6 @@ artifact.verification_job.retry artifact.recovery_attempt.read artifact.audit.read artifact.guide_source.ingest -artifact.upload_session.create -artifact.upload_session.read -artifact.upload_item.write -artifact.upload_session.seal -artifact.upload_session.cancel -artifact.upload_session.expire artifact.binding.create artifact.verification.execute artifact.pending_work.scan @@ -215,6 +209,7 @@ artifact.put_attempt.resolve artifact.guide_source.read artifact.checker_input.materialize artifact.checker_output.write +artifact.review_packet.materialize audit.read audit.export @@ -233,16 +228,17 @@ registration, hidden ART behavior/resource composition, then dedicated AUTH evaluator integration and activation. ART never writes availability. AUTH-12, AUTH-14, and AUTH-15 are not alternate artifact activation paths. -These are 76 approved `PermissionId` values. `ActionId` values are a separate +These are 71 approved `PermissionId` values. `ActionId` values are a separate closed registry layer and are not included in that permission count. AUTH-05A's typed and PostgreSQL audit registry accepts the exact historical 49. The three -approved Operator recovery identifiers, 21 artifact identifiers, +approved Operator recovery identifiers, 16 artifact identifiers, `review.queue.override`, and the two AUTH-11A read-only project inspection -permissions are the exact 27 post-`0020` permissions. AUTH-07A and AUTH-11A add +permissions are the exact 22 post-`0020` permissions. AUTH-07A, AUTH-11A, and +WS-XINT-002-01 add their matching typed/SQL audit parity without making them executable. -The closed action registry contains 81 rows after AUTH-11A: 22 active actions -and 59 planned rows. AUTH-10A added five project-role read/manage rows; +The closed action registry contains 78 rows after WS-XINT-002-01: 22 active +actions and 56 planned rows. 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 planned project identity, context, setup, policy, and active-guide reads owned @@ -254,7 +250,7 @@ provisioning actions without activating a route; AUTH-09B activates only `actor.identity_link.read`, AUTH-09D-A activates the three profile lifecycle actions, and AUTH-09D-B activates the two identity-link lifecycle actions. The other planned rows cover -three Operator recovery actions, 25 artifact actions, canonical +three Operator recovery actions, 22 artifact actions, canonical `submission.create`, and 19 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 @@ -262,13 +258,11 @@ 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 and -`artifact.review_evidence.binding.create` are not part of the current runtime -registry. Catalogue totals are derived from trusted `main` when each gate runs: -REV registration adds exactly four planned and zero active actions, while the -review-evidence registration adds exactly one planned and zero active action, in -either order. Both retain 76 PermissionIds and stay blocked until complete -feature-owned typed and transaction manifests exist. +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 +complete feature-owned typed and transaction manifests exist. AUTH-07B activates `actor.profile.read_self` and `actor.profile.update_self`. AUTH-08 activates exactly seven administrative actions through migration @@ -367,20 +361,18 @@ The paired artifact hidden-behavior matrix is closed: |---|---| | `WS-ART-001-02D` | Operator binding/replica/receipt/verification-job/recovery-attempt/audit reads; the operations-domain `operations.artifact_storage_admission.read` action mapped to `operations.status.read`; verification retry; `artifact.verification.execute`; `artifact.pending_work.scan`; and `artifact.put_attempt.resolve` | | `WS-ART-001-03` | `artifact.guide_source.ingest`, `artifact.guide_source.read`, and `artifact.guide_source.binding.create` mapped to `artifact.binding.create` | -| `WS-ART-001-04A` legacy unavailable baseline | planned upload-session create/read/seal/cancel/expire and upload-item write have no route/command and must be retired by the separate AUTH registration contract before 04A starts | -| `WS-ART-001-04A` through `04C` after AUTH registration | one hidden `artifact.submission_bundle.prepare` surface mapped to `submission.create`; remains unavailable until 04C evidence and a later AUTH activation contract | +| `WS-ART-001-04A` historical baseline | the former multi-step upload authority had no route/command and is deleted from the live catalogue by WS-XINT-002-01 without compatibility aliases | +| `WS-ART-001-04A` through `04C` | one hidden `artifact.submission_bundle.prepare` surface mapped to `submission.create`; remains unavailable until 04C evidence and the later WS-XINT-002-05A activation contract | | `WS-ART-001-04B` | `artifact.pre_submit.checker_input.materialize` mapped to `artifact.checker_input.materialize` | | `WS-ART-001-05` | `artifact.submission.binding.create` mapped to `artifact.binding.create` | | `WS-ART-001-06A` | `artifact.post_submit.checker_input.materialize` mapped to `artifact.checker_input.materialize` | | `WS-ART-001-06B` | `artifact.checker_output.write` and `artifact.checker_output.binding.create` mapped to `artifact.binding.create` using the checker-run resource | -The `WS-ART-001-04A` row records the current planned/unavailable catalogue -baseline only. ART PLAN2 proposes a separate AUTH-owned registration contract -that retires those unused multi-step actions and registers planned +WS-XINT-002-01 deletes the former multi-step authority and registers planned `artifact.submission_bundle.prepare -> submission.create`. No ART implementation -may use that new ActionId before the AUTH contract merges; no action activates -until ART-04A-C publish the complete hidden surface and a later AUTH activation -contract consumes its exact evidence. +may execute that ActionId while it remains planned; activation waits until +ART-04A-C publish the complete hidden surface and WS-XINT-002-05A consumes its +exact evidence. Every row requires AUTH-07A's registry and AUTH-07B's kernel first. A row with an Operator principal also requires its AUTH-08 grant definition; a row with a fixed service @@ -411,19 +403,21 @@ A mapping is not a permission alias. | `WS-AUTH-001-ART-02D-INTERNAL` | `artifact.verification.execute`, `artifact.pending_work.scan`, `artifact.put_attempt.resolve` | | `WS-AUTH-001-ART-02D-OPERATOR` | `artifact.binding.read`, `artifact.replica.read`, `artifact.receipt.read`, `artifact.verification_job.read`, `artifact.verification_job.retry`, `artifact.recovery_attempt.read`, `artifact.audit.read`, `operations.artifact_storage_admission.read` | | `WS-AUTH-001-ART-03` | `artifact.guide_source.ingest`, `artifact.guide_source.read`, `artifact.guide_source.binding.create` | -| `WS-AUTH-001-ART-04A` | `artifact.upload_session.create`, `artifact.upload_session.read`, `artifact.upload_item.write`, `artifact.upload_session.seal`, `artifact.upload_session.cancel`, `artifact.upload_session.expire` | +| `WS-XINT-002-05A` | `artifact.submission_bundle.prepare` | | `WS-AUTH-001-ART-04B` | `artifact.pre_submit.checker_input.materialize` | | `WS-AUTH-001-ART-05` | `artifact.submission.binding.create` | | `WS-AUTH-001-ART-06A` | `artifact.post_submit.checker_input.materialize` | | `WS-AUTH-001-ART-06B` | `artifact.checker_output.write`, `artifact.checker_output.binding.create` | +| `WS-XINT-002-07` | `artifact.review_packet.materialize`, `artifact.review_evidence.binding.create` | The `OPERATOR` suffix names future activation custody only; it creates no -Operator grant or entitlement. All 25 actions remain planned and unavailable. +Operator grant or entitlement. All 22 actions remain planned and unavailable. `artifact.verification_job.retry` requires its own later evaluator, guards, and independent activation proof; read/status proof cannot activate retry. The -ART transfer adds no migration. The separately started REV custody transfer is -also complete: all 19 REV rows now name exact AUTH custodians, remain planned -and unavailable, and add no migration. +historical ART transfer added no migration; WS-XINT-002-01 reconciles the +closed catalogue through migration `0036`. The separately started REV custody +transfer is also complete: all 19 REV rows now name exact AUTH custodians, +remain planned and unavailable, and add no migration. | ActionId | PermissionId | Principal class | Canonical resource | Resource-owning WS-ART chunk | |---|---|---|---|---| @@ -437,12 +431,7 @@ and unavailable, and add no migration. | `operations.artifact_storage_admission.read` | `operations.status.read` | Operator | deployment artifact-storage namespace | `02D` | | `artifact.guide_source.ingest` | `artifact.guide_source.ingest` | authorized project actor | guide-source snapshot item | `03` | | `artifact.guide_source.read` | `artifact.guide_source.read` | fixed guide-reader service | guide-source snapshot item | `03` | -| `artifact.upload_session.create` | `artifact.upload_session.create` | assigned contributor | task | `04A` | -| `artifact.upload_session.read` | `artifact.upload_session.read` | assigned contributor | upload session | `04A` | -| `artifact.upload_item.write` | `artifact.upload_item.write` | assigned contributor | upload item | `04A` | -| `artifact.upload_session.seal` | `artifact.upload_session.seal` | assigned contributor | upload session | `04A` | -| `artifact.upload_session.cancel` | `artifact.upload_session.cancel` | assigned contributor | upload session | `04A` | -| `artifact.upload_session.expire` | `artifact.upload_session.expire` | fixed scheduler service | upload session | `04A` | +| `artifact.submission_bundle.prepare` | `submission.create` | assigned contributor | exact task/admission context | `WS-XINT-002-05A` | | `artifact.guide_source.binding.create` | `artifact.binding.create` | fixed binding service | guide-source snapshot item | `03` | | `artifact.submission.binding.create` | `artifact.binding.create` | fixed binding service | submission | `05` | | `artifact.checker_output.binding.create` | `artifact.binding.create` | fixed binding service | checker run | `06B` | @@ -452,6 +441,8 @@ and unavailable, and add no migration. | `artifact.pre_submit.checker_input.materialize` | `artifact.checker_input.materialize` | fixed materializer service | task plus current process-local prepared-bundle generation; no scratch path/handle is serialized | `04B` | | `artifact.post_submit.checker_input.materialize` | `artifact.checker_input.materialize` | fixed materializer service | checker run and immutable bindings | `06A` | | `artifact.checker_output.write` | `artifact.checker_output.write` | fixed checker-output service | checker run | `06B` | +| `artifact.review_packet.materialize` | `artifact.review_packet.materialize` | fixed materializer service | exact active lease and Submission packet | `WS-XINT-002-07` | +| `artifact.review_evidence.binding.create` | `artifact.binding.create` | fixed binding service | exact verified review evidence slot | `WS-XINT-002-07` | The fixed internal service identities and their complete artifact action sets are also closed: @@ -460,10 +451,10 @@ are also closed: |---|---| | `workstream.artifact.verifier` | `artifact.verification.execute` | | `workstream.artifact.put_resolver` | `artifact.put_attempt.resolve` | -| `workstream.artifact.scheduler` | `artifact.pending_work.scan`, `artifact.upload_session.expire` | -| `workstream.artifact.binding` | `artifact.guide_source.binding.create`, `artifact.submission.binding.create`, `artifact.checker_output.binding.create` | +| `workstream.artifact.scheduler` | `artifact.pending_work.scan` | +| `workstream.artifact.binding` | `artifact.guide_source.binding.create`, `artifact.submission.binding.create`, `artifact.checker_output.binding.create`, `artifact.review_evidence.binding.create` | | `workstream.artifact.guide_reader` | `artifact.guide_source.read` | -| `workstream.artifact.materializer` | `artifact.pre_submit.checker_input.materialize`, `artifact.post_submit.checker_input.materialize` | +| `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` | AUTH-09B lets a system Access Administrator bind an exact configured-issuer diff --git a/docs/spec_review_lifecycle.md b/docs/spec_review_lifecycle.md index ebd2d7f28..b19965e2e 100644 --- a/docs/spec_review_lifecycle.md +++ b/docs/spec_review_lifecycle.md @@ -581,7 +581,7 @@ The review lifecycle currently depends on 24 unavailable actions: - 19 registered planned review actions; and - four approved but unregistered REV actions defined below. -The proposed `artifact.review_evidence.binding.create -> +The registered planned `artifact.review_evidence.binding.create -> artifact.binding.create` service action is separate and is not one of the 24. Future counts must be derived from trusted main at each AUTH gate; the four REV actions and separate ART action are never collapsed into a promised total. From b5ca25bda317f732930113b39e1cc166223a8081 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 27 Jul 2026 10:35:15 +0100 Subject: [PATCH 2/4] fix(auth): preserve catalogue migration parity --- .../AUTH_ART_HANDOFF.md | 17 +- ...WS-XINT-002-01-catalogue-reconciliation.md | 1 + ...WS-XINT-002-01-external-review-response.md | 29 +++ .../reviews/WS-XINT-002-01-internal-review.md | 8 +- .../reviews/WS-XINT-002-01-pr-trust-bundle.md | 12 +- .../0036_art_auth_catalogue_reconciliation.py | 46 +++-- backend/tests/conftest.py | 10 +- backend/tests/test_alembic.py | 177 +++++++++++++----- backend/tests/test_authorization.py | 28 +-- docs/spec_authorization_service.md | 2 +- 10 files changed, 241 insertions(+), 89 deletions(-) create mode 100644 .agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-external-review-response.md diff --git a/.agent-loop/initiatives/WS-XINT-001-lifecycle-boundary-reconciliation/AUTH_ART_HANDOFF.md b/.agent-loop/initiatives/WS-XINT-001-lifecycle-boundary-reconciliation/AUTH_ART_HANDOFF.md index e2c005fac..19e930247 100644 --- a/.agent-loop/initiatives/WS-XINT-001-lifecycle-boundary-reconciliation/AUTH_ART_HANDOFF.md +++ b/.agent-loop/initiatives/WS-XINT-001-lifecycle-boundary-reconciliation/AUTH_ART_HANDOFF.md @@ -1,8 +1,9 @@ # AUTH <-> ART Handoff > Historical immutable handoff provenance. WS-XINT-002-01 supersedes this -> catalogue baseline; identifiers retained below are audit history, not live -> activation guidance or compatibility authority. +> catalogue baseline; the entire document below is audit history, not live +> activation guidance or compatibility authority. Every table, matrix, and +> delivery step must be read only as the superseded pre-reconciliation state. ## Boundary @@ -11,7 +12,7 @@ behavior owner. AUTH never performs artifact storage/lifecycle mutations; ART never registers grants, evaluates authority locally, or changes action availability. -## Complete current custody transfer +## Historical superseded custody transfer All mappings remain unchanged. `docs/spec_authorization_service.md` remains the canonical ActionId-to-PermissionId and principal/resource blueprint. The table @@ -52,7 +53,7 @@ After transfer, AUTH removes unused `ART_02D`, `ART_03`, `ART_04A`, `ART_04B`, and definition-owner parity must reject partial transfer, dual writers, missing owners, extra owners, and changed mappings. -## Fixed service-action matrix +## Historical fixed service-action matrix `docs/spec_authorization_service.md` remains the canonical fixed service-action matrix source. This table is a non-authoritative repeat used only to bind the @@ -74,7 +75,7 @@ actions are planned. After AUTH-09E, ART accepts only a canonical AUTH service context at its composition root and never derives identity from a Celery task, executor ID, queue, environment string, or provider credential. -## Delivery order +## Historical delivery order ```text AUTH-09A fixed service identity and static matrix foundation @@ -97,7 +98,7 @@ is never implied by verifier, scheduler, or put-resolver activation. AWS provide release is also separate: an authorized action cannot instantiate AWS until ART live-provider proof is current. -## Mutation protocol +## Historical mutation protocol For a human caller, AUTH locks the actor, identity-link, and matched grant rows. For a fixed service, AUTH locks the service ActorProfile and ActorIdentityLink, @@ -109,14 +110,14 @@ once. Terminal Celery writes additionally require the matching ART executor and execution generation. Authorization identity and execution fencing remain independent checks. -## AUTH owner response +## Historical AUTH owner response AUTH must add reviewed registration/transfer and activation chunk contracts, add the AUTH-09E service-admission contract, repair stale AUTH documents and catalogue descriptions, preserve every mapping, and prove no action becomes active without its merged ART behavior manifest. -## ART owner response +## Historical ART owner response ART must repair its plan/chunk sequencing, implement only hidden behavior before activation, publish exact resource/guard/surface manifests, and never edit AUTH diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-01-catalogue-reconciliation.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-01-catalogue-reconciliation.md index 2df57171c..146ddbf89 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-01-catalogue-reconciliation.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-01-catalogue-reconciliation.md @@ -17,6 +17,7 @@ backend/app/modules/authorization/admin_schemas.py backend/alembic/versions/_*.py backend/tests/test_authorization.py backend/tests/test_alembic.py +backend/tests/conftest.py docs/spec_authorization_service.md docs/operations_authorization_service.md .agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/STATUS.md diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-external-review-response.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-external-review-response.md new file mode 100644 index 000000000..5bcb71f30 --- /dev/null +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-external-review-response.md @@ -0,0 +1,29 @@ +# WS-XINT-002-01 External Review Response + +## Comments addressed + +- GitHub Backend: updated the expected public-schema fingerprint for migration 0036. +- GitHub Backend: restored the historical permission and action ordering before crossing migration 0034's digest guard, and extended the round-trip test through revision 0033. +- CodeRabbit: marked the complete superseded AUTH–ART handoff as historical rather than leaving later sections phrased as live direction. +- CodeRabbit: corrected the 06B checker-output action-to-permission mapping. +- CodeRabbit nits: centralized obsolete upload identifiers, tightened migration refusal assertions, asserted removed SQL tokens are absent, and qualified evidence predicates without string rewriting. +- Internal corrective security review: kept the direct immutable-audit predicate over every audit row, including orphaned non-null idempotency references, and added unchanged-state regression coverage. + +## Comments deferred + +- CodeRabbit's suggestion that `_replace` uses physical rather than logical constraint names was not applied. Alembic's repository naming convention expands these logical names to the `ck_audit_events_*` physical names; migrations 0035 and 0036 use the established pattern, and the DB-backed migration proof executes the replacement. + +## Human decisions needed + +None. + +## Commands rerun + +- Ruff format and lint over the changed migration and tests. +- Focused isolated PostgreSQL migration round-trip, refusal-shape, and predecessor-downgrade tests. +- Stale authorization and artifact-contract scans, Markdown link checks, and lightweight repository gates. +- Exact-head GitHub Actions and CodeRabbit checks after the corrective commit. + +## Remaining risks + +- The PR is not merge-ready until the new exact head passes hosted Backend, Agent Gates, and CodeRabbit checks. diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-internal-review.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-internal-review.md index a3eaed507..b3eaf1229 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-internal-review.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-internal-review.md @@ -19,12 +19,15 @@ matrix, deterministic stale proof, live custody documentation, and merge intent. ambiguous historical handoff allowlist. Current SQL rejection covers every removed pair, target reference, and invalidation reference; the old handoff is explicitly marked immutable historical provenance. - +- Corrective security review found that narrowing the direct audit scan to null + idempotency references could miss orphaned historical evidence. The direct + predicate now covers every audit row, the linked scan remains additive, and + an orphaned non-null reference independently proves refusal without mutation. ## Final reviewer results - Senior engineering: PASS WITH LOW RISKS. - QA/test: PASS WITH LOW RISKS; later isolated predicate proof was added. -- Security/auth: PASS. +- Security/auth: PASS after the corrective orphan-evidence finding was fixed. - Product/ops: PASS WITH LOW RISKS; wording note resolved. - Architecture: PASS. - CI integrity: PASS WITH LOW RISKS. @@ -33,4 +36,3 @@ matrix, deterministic stale proof, live custody documentation, and merge intent. - Test delta: PASS WITH LOW RISKS. No blocking finding remains. - diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-pr-trust-bundle.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-pr-trust-bundle.md index f809edea2..97e0f8bfa 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-pr-trust-bundle.md @@ -53,6 +53,8 @@ grant, service identity, submission, review, or artifact lifecycle changed. - Focused catalogue/custody/stale tests: passed. - Isolated PostgreSQL `0036_art_auth_catalogue` tests, including independent refusal predicates and round trip: passed with owned-database cleanup. +- Focused hosted-failure reproduction proves the refreshed schema fingerprint + and exact downgrade through migration `0034` before re-upgrade to head. - Stale authorization docs, stale artifact contracts, markdown links, and `git diff --check`: passed. - A broader local AUTH/Alembic coverage run reached the 20-minute local runner @@ -74,8 +76,13 @@ See `WS-XINT-002-01-internal-review.md`. ## External review -CodeRabbit and exact-head GitHub checks are required after the PR opens. No -external result is claimed in this pre-PR bundle. +The first hosted Backend run exposed a stale public-schema fingerprint and +non-canonical downgrade ordering across migration `0034`'s digest guard. Both +are corrected with focused `0036 -> 0033 -> head` proof. CodeRabbit's valid +documentation, mapping, and maintainability comments are addressed; the +constraint-name suggestion was rejected against the executed Alembic naming +convention. See `WS-XINT-002-01-external-review-response.md`. The corrective +exact head must still pass hosted Backend, Agent Gates, and CodeRabbit checks. ## Remaining risks and follow-up @@ -89,4 +96,3 @@ hidden feature evidence; this chunk grants no executable authority. Review the exact six-to-three clean cut, planned-only availability, fixed-service least privilege, and evidence-preserving migration refusal. Only the user may approve this specific PR for merge. - diff --git a/backend/alembic/versions/0036_art_auth_catalogue_reconciliation.py b/backend/alembic/versions/0036_art_auth_catalogue_reconciliation.py index b110e1cb8..cbe2418ff 100644 --- a/backend/alembic/versions/0036_art_auth_catalogue_reconciliation.py +++ b/backend/alembic/versions/0036_art_auth_catalogue_reconciliation.py @@ -73,12 +73,15 @@ def _rewrite_permission_registry(*, forward: bool) -> None: if definition.count(token) < 1: raise RuntimeError(f"unexpected {name} removed permission definition") definition = definition.replace(", " + token, "") - additions = ", " + ", ".join(_permission_token(value) for value in add) + if token in definition: + raise RuntimeError(f"unexpected {name} removed permission definition") + additions = ", ".join(_permission_token(value) for value in add) if definition.count(marker) < 1 or any( _permission_token(value) in definition for value in add ): raise RuntimeError(f"unexpected {name} added permission definition") - definition = definition.replace(marker, marker + additions) + replacement = marker + ", " + additions if forward else additions + ", " + marker + definition = definition.replace(marker, replacement) _replace(name, definition) @@ -92,7 +95,12 @@ def _rewrite_action_registry(*, forward: bool) -> None: if definition.count(token) != 2: raise RuntimeError("unexpected removed authorization action definition") definition = definition.replace(" OR " + token, "") - marker = _pair_token("artifact.guide_source.ingest", "artifact.guide_source.ingest") + if token in definition: + raise RuntimeError("unexpected removed authorization action definition") + marker = _pair_token( + "artifact.guide_source.ingest" if forward else "artifact.guide_source.read", + "artifact.guide_source.ingest" if forward else "artifact.guide_source.read", + ) additions = " OR " + " OR ".join(_pair_token(*pair) for pair in add) if definition.count(marker) != 2 or any(_pair_token(*pair) in definition for pair in add): raise RuntimeError("unexpected added authorization action definition") @@ -110,13 +118,16 @@ def _rewrite_action_permission_registry(*, forward: bool) -> None: if definition.count(token) != 1: raise RuntimeError("unexpected removed action permission definition") definition = definition.replace(", " + token, "") + if token in definition: + raise RuntimeError("unexpected removed action permission definition") marker = _permission_token("artifact.binding.create") - additions = ", " + ", ".join(_permission_token(value) for value in add) + additions = ", ".join(_permission_token(value) for value in add) if definition.count(marker) != 1 or any( _permission_token(value) in definition for value in add ): raise RuntimeError("unexpected added action permission definition") - definition = definition.replace(marker, marker + additions) + replacement = marker + ", " + additions if forward else additions + ", " + marker + definition = definition.replace(marker, replacement) _replace(name, definition) @@ -126,22 +137,33 @@ def _lock_evidence() -> None: bind.execute(sa.text("lock table audit_events in access exclusive mode")) +def _evidence_predicate(*, prefix: str, action_bind: str, permission_bind: str) -> str: + return ( + f"{prefix}action_id = any(:{action_bind}) or " + f"{prefix}permission_id = any(:{permission_bind}) or " + f"({prefix}target_ref_kind='permission_registry' and " + f"{prefix}target_ref_id = any(:{permission_bind})) or " + f"({prefix}invalidation_target_kind='permission_registry' and " + f"{prefix}invalidation_target_ref = any(:{permission_bind}))" + ) + + def _has_evidence(actions: tuple[str, ...], permissions: tuple[str, ...]) -> bool: - predicate = ( - "action_id = any(:actions) or permission_id = any(:permissions) or " - "(target_ref_kind='permission_registry' and target_ref_id = any(:permissions)) or " - "(invalidation_target_kind='permission_registry' and " - "invalidation_target_ref = any(:permissions))" + direct = _evidence_predicate(prefix="", action_bind="actions", permission_bind="permissions") + linked = _evidence_predicate( + prefix="event.", + action_bind="linked_actions", + permission_bind="linked_permissions", ) return bool( op.get_bind() .execute( sa.text( "select exists(select 1 from audit_events where " - f"idempotency_reference is null and ({predicate})) or " + f"{direct}) or " "exists(select 1 from authority_idempotency_records record " "join audit_events event on event.idempotency_reference=record.id " - f"where {predicate.replace(':actions', ':linked_actions').replace(':permissions', ':linked_permissions')})" + f"where {linked})" ), { "actions": list(actions), diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index a950d2e71..aa58dcc9c 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -21,9 +21,7 @@ from scripts.run_isolated_tests import LOOPBACK, NAME_RE, ROLE_RE DDL_LOCK_DIRECTORY = Path("/tmp") -EXPECTED_PUBLIC_SCHEMA_SHA256 = ( - "8853e81a2c3c2452dd236a5a691d568d9184379a78301172c096c8b33ef63890" -) +EXPECTED_PUBLIC_SCHEMA_SHA256 = "a0271094a8db293e9c58d54ffc46f35e90c1a9d7e82187410b0089189e23000b" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", @@ -228,9 +226,9 @@ async def _assert_canonical_test_schema( "where n.nspname='public') " "select kind,name from parts order by kind,name" ) - serialized_objects = "".join( - f"{row['kind']}|{row['name']}\n" for row in object_rows - ).encode("utf-8") + serialized_objects = "".join(f"{row['kind']}|{row['name']}\n" for row in object_rows).encode( + "utf-8" + ) schema_sha256 = hashlib.sha256(serialized_objects).hexdigest() if schema_sha256 != EXPECTED_PUBLIC_SCHEMA_SHA256: raise RuntimeError(f"unexpected public schema object fingerprint: {schema_sha256}") diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 116e88339..2c6d2836c 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -56,6 +56,18 @@ pytestmark = pytest.mark.postgres_schema_contract +_OBSOLETE_ART_UPLOAD_IDS = tuple( + "artifact.upload_" + value + for value in ( + "session.create", + "session.read", + "item.write", + "session.seal", + "session.cancel", + "session.expire", + ) +) + def _alembic_config() -> Config: project_root = Path(__file__).resolve().parents[1] @@ -1753,7 +1765,7 @@ def test_0036_art_auth_catalogue_round_trip(isolated_database_env: str, migratio ) ) asyncio.run(_assert_removed_art_authority_rejected(isolated_database_env)) - command.downgrade(config, "0035_project_read_evidence") + command.downgrade(config, "0033_authorization_read_rate") command.upgrade(config, "head") asyncio.run( _assert_authorization_action_sql_pairs( @@ -1774,17 +1786,7 @@ def test_0036_art_auth_catalogue_refuses_obsolete_evidence( record_id = str(uuid4()) actor_id = str(uuid4()) target_id = str(uuid4()) - obsolete = tuple( - "artifact.upload_" + value - for value in ( - "session.create", - "session.read", - "item.write", - "session.seal", - "session.cancel", - "session.expire", - ) - ) + obsolete = _OBSOLETE_ART_UPLOAD_IDS with migration_lock(): try: command.downgrade(config, "base") @@ -1950,17 +1952,7 @@ def test_0036_art_auth_catalogue_refuses_each_obsolete_evidence_shape( ) -> None: """Prove every removal predicate independently blocks the clean-cut upgrade.""" config = _alembic_config() - obsolete = tuple( - "artifact.upload_" + value - for value in ( - "session.create", - "session.read", - "item.write", - "session.seal", - "session.cancel", - "session.expire", - ) - ) + obsolete = _OBSOLETE_ART_UPLOAD_IDS with migration_lock(): try: command.downgrade(config, "base") @@ -1978,7 +1970,10 @@ def test_0036_art_auth_catalogue_refuses_each_obsolete_evidence_shape( permissions=(identifier,), ) ) - with pytest.raises(RuntimeError): + with pytest.raises( + RuntimeError, + match="cannot remove non-empty obsolete artifact authority evidence", + ): command.upgrade(config, "head") assert ( asyncio.run( @@ -2018,7 +2013,10 @@ def test_0036_art_auth_catalogue_refuses_each_obsolete_evidence_shape( isolated_database_env, actions=(), permissions=(permission,) ) ) - with pytest.raises(RuntimeError): + with pytest.raises( + RuntimeError, + match="cannot remove non-empty obsolete artifact authority evidence", + ): command.upgrade(config, "head") assert ( asyncio.run( @@ -2055,7 +2053,10 @@ def test_0036_art_auth_catalogue_refuses_each_obsolete_evidence_shape( permissions=(obsolete[2],), ) ) - with pytest.raises(RuntimeError): + with pytest.raises( + RuntimeError, + match="cannot remove non-empty obsolete artifact authority evidence", + ): command.upgrade(config, "head") assert ( asyncio.run( @@ -2073,6 +2074,39 @@ def test_0036_art_auth_catalogue_refuses_each_obsolete_evidence_shape( isolated_database_env, record_id, orphan_event=None ) ) + + orphan_event_id = asyncio.run( + _insert_orphan_linked_authorization_action_event( + isolated_database_env, + action_id=obsolete[3], + permission_id=obsolete[3], + ) + ) + before = asyncio.run( + _art_catalogue_migration_state( + isolated_database_env, + actions=(obsolete[3],), + permissions=(obsolete[3],), + ) + ) + with pytest.raises( + RuntimeError, + match="cannot remove non-empty obsolete artifact authority evidence", + ): + command.upgrade(config, "head") + assert ( + asyncio.run( + _art_catalogue_migration_state( + isolated_database_env, + actions=(obsolete[3],), + permissions=(obsolete[3],), + ) + ) + == before + ) + asyncio.run( + _remove_authority_audit_fixture(isolated_database_env, event_id=orphan_event_id) + ) finally: command.downgrade(config, "base") @@ -2107,7 +2141,10 @@ def test_0036_art_auth_catalogue_refuses_each_new_evidence_shape( permissions=(definition.permission_id.value,), ) ) - with pytest.raises(RuntimeError): + with pytest.raises( + RuntimeError, + match="cannot downgrade non-empty ART authorization evidence", + ): command.downgrade(config, "0035_project_read_evidence") assert ( asyncio.run( @@ -2146,7 +2183,10 @@ def test_0036_art_auth_catalogue_refuses_each_new_evidence_shape( permissions=(review_permission,), ) ) - with pytest.raises(RuntimeError): + with pytest.raises( + RuntimeError, + match="cannot downgrade non-empty ART authorization evidence", + ): command.downgrade(config, "0035_project_read_evidence") assert ( asyncio.run( @@ -2186,7 +2226,10 @@ def test_0036_art_auth_catalogue_refuses_each_new_evidence_shape( permissions=(linked_definition.permission_id.value,), ) ) - with pytest.raises(RuntimeError): + with pytest.raises( + RuntimeError, + match="cannot downgrade non-empty ART authorization evidence", + ): command.downgrade(config, "0035_project_read_evidence") assert ( asyncio.run( @@ -8291,17 +8334,7 @@ async def _assert_authorization_action_sql_pairs( async def _assert_removed_art_authority_rejected(database_url: str) -> None: """Prove current SQL rejects every deleted pair and permission reference.""" - removed = tuple( - "artifact.upload_" + value - for value in ( - "session.create", - "session.read", - "item.write", - "session.seal", - "session.cancel", - "session.expire", - ) - ) + removed = _OBSOLETE_ART_UPLOAD_IDS engine = create_async_engine(database_url) try: for identifier in removed: @@ -9901,12 +9934,11 @@ async def _insert_linked_authorization_action_event( "(id,entity_type,entity_id,event_type,actor_id,actor_roles,claim_snapshot," "auth_source,is_dev_auth,event_payload,event_domain,event_version," "actor_ref_kind,request_id,correlation_id,permission_id,action_id,reason," - "denial_code,idempotency_reference,after_facts) values " - "(:id,'authorization_decision',:id,'SensitiveAuthorizationDenied'," + "idempotency_reference,after_facts) values " + "(:id,'authorization_decision',:id,'SensitiveAuthorizationAllowed'," ":actor,'[]'::json,'{}'::json,'local_authority',false,'{}'::json," "'authority',1,'actor_profile',:request,:correlation,:permission,:action," - "'authorization_evaluation','permission_not_granted',:record," - "'{\"allowed\": false}'::json)" + "'authorization_evaluation',:record,'{\"allowed\": true}'::json)" ), { "id": event_id, @@ -9926,6 +9958,63 @@ async def _insert_linked_authorization_action_event( await engine.dispose() +async def _insert_orphan_linked_authorization_action_event( + database_url: str, + *, + action_id: str, + permission_id: str, +) -> str: + """Seed historical action evidence whose non-null idempotency link is orphaned.""" + event_id = str(uuid4()) + engine = create_async_engine(database_url) + try: + async with engine.begin() as connection: + await connection.execute( + text( + "alter table audit_events drop constraint fk_audit_events_authority_idempotency" + ) + ) + await connection.execute( + text("alter table audit_events disable trigger audit_events_validate_idempotency") + ) + await connection.execute( + text( + "insert into audit_events " + "(id,entity_type,entity_id,event_type,actor_id,actor_roles,claim_snapshot," + "auth_source,is_dev_auth,event_payload,event_domain,event_version," + "actor_ref_kind,request_id,correlation_id,permission_id,action_id,reason," + "idempotency_reference,after_facts) values " + "(:id,'authorization_decision',:id,'SensitiveAuthorizationAllowed'," + ":actor,'[]'::json,'{}'::json,'local_authority',false,'{}'::json," + "'authority',1,'actor_profile',:request,:correlation,:permission,:action," + "'authorization_evaluation',:record,'{\"allowed\": true}'::json)" + ), + { + "id": event_id, + "actor": str(uuid4()), + "request": str(uuid4()), + "correlation": str(uuid4()), + "permission": permission_id, + "action": action_id, + "record": str(uuid4()), + }, + ) + await connection.execute( + text( + "alter table audit_events add constraint " + "fk_audit_events_authority_idempotency foreign key " + "(idempotency_reference,actor_ref_kind,actor_id) references " + "authority_idempotency_records (id,actor_ref_kind,actor_ref) not valid" + ) + ) + await connection.execute( + text("alter table audit_events enable trigger audit_events_validate_idempotency") + ) + return event_id + finally: + await engine.dispose() + + async def _art_catalogue_migration_state( database_url: str, *, diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 73f64a4e9..a0bd80a6a 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -2640,26 +2640,30 @@ async def record_conflict(self, **kwargs) -> None: (ACTION_DEFINITIONS[:-1] + (ACTION_DEFINITIONS[0],), "incomplete"), (ACTION_DEFINITIONS + (ACTION_DEFINITIONS[0],), "incomplete"), ( - ACTION_DEFINITIONS[:-1] - + ( + tuple( ActionDefinition( - ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE, - PermissionId.ARTIFACT_CHECKER_OUTPUT_WRITE, + definition.action_id, + definition.permission_id, ActionOwner.AUTH_ART_02D_OPERATOR, - ActionAvailability.PLANNED, - ), + definition.availability, + ) + if definition.action_id is ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE + else definition + for definition in ACTION_DEFINITIONS ), "metadata mismatch", ), ( - ACTION_DEFINITIONS[:-1] - + ( + tuple( ActionDefinition( - ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE, - PermissionId.ARTIFACT_CHECKER_OUTPUT_WRITE, - ActionOwner.AUTH_ART_06B, + definition.action_id, + definition.permission_id, + definition.owner, ActionAvailability.ACTIVE, - ), + ) + if definition.action_id is ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE + else definition + for definition in ACTION_DEFINITIONS ), "active action boundary mismatch", ), diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index eff497a51..945594838 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -366,7 +366,7 @@ The paired artifact hidden-behavior matrix is closed: | `WS-ART-001-04B` | `artifact.pre_submit.checker_input.materialize` mapped to `artifact.checker_input.materialize` | | `WS-ART-001-05` | `artifact.submission.binding.create` mapped to `artifact.binding.create` | | `WS-ART-001-06A` | `artifact.post_submit.checker_input.materialize` mapped to `artifact.checker_input.materialize` | -| `WS-ART-001-06B` | `artifact.checker_output.write` and `artifact.checker_output.binding.create` mapped to `artifact.binding.create` using the checker-run resource | +| `WS-ART-001-06B` | `artifact.checker_output.write` mapped to `artifact.checker_output.write`; `artifact.checker_output.binding.create` mapped to `artifact.binding.create`, both using the checker-run resource | WS-XINT-002-01 deletes the former multi-step authority and registers planned `artifact.submission_bundle.prepare -> submission.create`. No ART implementation From 88039372a57a2884104def801b5c29012c3ba8d1 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 27 Jul 2026 10:50:25 +0100 Subject: [PATCH 3/4] test(auth): align permission response totals --- .../chunks/WS-XINT-002-01-catalogue-reconciliation.md | 1 + .../reviews/WS-XINT-002-01-external-review-response.md | 1 + .../reviews/WS-XINT-002-01-pr-trust-bundle.md | 4 +++- backend/tests/test_auth.py | 6 +++--- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-01-catalogue-reconciliation.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-01-catalogue-reconciliation.md index 146ddbf89..1c5dd4688 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-01-catalogue-reconciliation.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-01-catalogue-reconciliation.md @@ -16,6 +16,7 @@ backend/app/modules/authorization/catalogue.py backend/app/modules/authorization/admin_schemas.py backend/alembic/versions/_*.py backend/tests/test_authorization.py +backend/tests/test_auth.py backend/tests/test_alembic.py backend/tests/conftest.py docs/spec_authorization_service.md diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-external-review-response.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-external-review-response.md index 5bcb71f30..1d58cf55b 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-external-review-response.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-external-review-response.md @@ -4,6 +4,7 @@ - GitHub Backend: updated the expected public-schema fingerprint for migration 0036. - GitHub Backend: restored the historical permission and action ordering before crossing migration 0034's digest guard, and extended the round-trip test through revision 0033. +- GitHub Backend: updated three integration assertions that still expected the pre-reconciliation 76-permission total; the exact affected lifecycle test now passes against the closed 71-permission catalogue. - CodeRabbit: marked the complete superseded AUTH–ART handoff as historical rather than leaving later sections phrased as live direction. - CodeRabbit: corrected the 06B checker-output action-to-permission mapping. - CodeRabbit nits: centralized obsolete upload identifiers, tightened migration refusal assertions, asserted removed SQL tokens are absent, and qualified evidence predicates without string rewriting. diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-pr-trust-bundle.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-pr-trust-bundle.md index 97e0f8bfa..a0d8c2fd1 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-pr-trust-bundle.md @@ -78,7 +78,9 @@ See `WS-XINT-002-01-internal-review.md`. The first hosted Backend run exposed a stale public-schema fingerprint and non-canonical downgrade ordering across migration `0034`'s digest guard. Both -are corrected with focused `0036 -> 0033 -> head` proof. CodeRabbit's valid +are corrected with focused `0036 -> 0033 -> head` proof. A later hosted run +exposed three integration assertions that still expected 76 permissions; they +now prove the closed 71-permission response. CodeRabbit's valid documentation, mapping, and maintainability comments are addressed; the constraint-name suggestion was rejected against the executed Alembic naming convention. See `WS-XINT-002-01-external-review-response.md`. The corrective diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 828c3ce1f..92ccfec3b 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -2130,8 +2130,8 @@ async def assert_failed_admin_read(path: str) -> None: headers=admin_headers, ) assert permissions.status_code == definitions.status_code == 200 - assert permissions.json()["total"] == 76 - assert len(permissions.json()["items"]) == 76 + assert permissions.json()["total"] == 71 + assert len(permissions.json()["items"]) == 71 assert definitions.json()["total"] == 5 assert [item["role"] for item in definitions.json()["items"]] == [ "access_administrator", @@ -2433,7 +2433,7 @@ async def assert_failed_admin_read(path: str) -> None: ), ] assert [response.status_code for response in system_audit_reads] == [200] * 6 - assert system_audit_reads[0].json()["total"] == 76 + assert system_audit_reads[0].json()["total"] == 71 assert system_audit_reads[1].json()["total"] == 5 assert system_audit_reads[2].json()["total"] == 2 assert system_audit_reads[3].json()["total"] == 1 From 1b0688a39538ae5c8efab06add3de1bfa9540428 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 27 Jul 2026 11:10:09 +0100 Subject: [PATCH 4/4] test(auth): preserve historical catalogue stages --- .../reviews/WS-XINT-002-01-external-review-response.md | 1 + .../reviews/WS-XINT-002-01-pr-trust-bundle.md | 5 ++++- backend/tests/test_alembic.py | 4 ++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-external-review-response.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-external-review-response.md index 1d58cf55b..c0efc9de9 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-external-review-response.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-external-review-response.md @@ -5,6 +5,7 @@ - GitHub Backend: updated the expected public-schema fingerprint for migration 0036. - GitHub Backend: restored the historical permission and action ordering before crossing migration 0034's digest guard, and extended the round-trip test through revision 0033. - GitHub Backend: updated three integration assertions that still expected the pre-reconciliation 76-permission total; the exact affected lifecycle test now passes against the closed 71-permission catalogue. +- GitHub Backend: kept 0036-only action owners out of migration-0021/0022 stage assertions; both exact historical-stage tests now pass while current-head 0036 coverage remains intact. - CodeRabbit: marked the complete superseded AUTH–ART handoff as historical rather than leaving later sections phrased as live direction. - CodeRabbit: corrected the 06B checker-output action-to-permission mapping. - CodeRabbit nits: centralized obsolete upload identifiers, tightened migration refusal assertions, asserted removed SQL tokens are absent, and qualified evidence predicates without string rewriting. diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-pr-trust-bundle.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-pr-trust-bundle.md index a0d8c2fd1..6771dfd69 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-01-pr-trust-bundle.md @@ -80,7 +80,10 @@ The first hosted Backend run exposed a stale public-schema fingerprint and non-canonical downgrade ordering across migration `0034`'s digest guard. Both are corrected with focused `0036 -> 0033 -> head` proof. A later hosted run exposed three integration assertions that still expected 76 permissions; they -now prove the closed 71-permission response. CodeRabbit's valid +now prove the closed 71-permission response. The schema lane also exposed two +historical-stage fixtures that expected 0036 actions at migrations 0021/0022; +those fixtures now preserve the exact migration boundary and pass locally. +CodeRabbit's valid documentation, mapping, and maintainability comments are addressed; the constraint-name suggestion was rejected against the executed Alembic naming convention. See `WS-XINT-002-01-external-review-response.md`. The corrective diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 2c6d2836c..a6d0e7c4a 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -3045,6 +3045,8 @@ def test_authorization_action_evidence_constraints_and_guarded_downgrade( ActionOwner.AUTH_11B, ActionOwner.AUTH_11C1, ActionOwner.AUTH_11C2, + ActionOwner.XINT_002_05A, + ActionOwner.XINT_002_07, } ), ) @@ -3202,6 +3204,8 @@ def test_bootstrap_admin_grant_schema_is_immutable_and_guarded( ActionOwner.AUTH_11B, ActionOwner.AUTH_11C1, ActionOwner.AUTH_11C2, + ActionOwner.XINT_002_05A, + ActionOwner.XINT_002_07, } ), )