From ad45d1012c447cba81acf427d14131f0e4241603 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 21 Jul 2026 13:11:33 +0100 Subject: [PATCH 01/19] docs(auth): repair AUTH-10A implementation contract --- ...01-10A-project-role-grant-data-evidence.md | 42 ++++++++++++++----- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10A-project-role-grant-data-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10A-project-role-grant-data-evidence.md index 2e6a8b216..783cb4e6b 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10A-project-role-grant-data-evidence.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10A-project-role-grant-data-evidence.md @@ -6,8 +6,10 @@ ## Status and prerequisite -Proposed and inactive. Start only after planning parent AUTH-10 merges, signed -memory names 10A, and a fresh explicit start event activates this exact child. +Active. Planning parent AUTH-10 merged through PR #168 as +`70f9c7bcdb63680e545f661a956929379df138e4`; signed memory named 10A, and +explicit start workflow run `29828847015` activated this exact child on +2026-07-21. ## Goal @@ -40,6 +42,7 @@ backend/app/db/models.py backend/alembic/versions/0031_project_role_grants.py backend/tests/test_authorization.py backend/tests/test_alembic.py +backend/tests/test_audit.py docs/operations_authorization_service.md docs/spec_authorization_service.md .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/** @@ -99,9 +102,18 @@ transition may mutate lifecycle fields. - Partial uniqueness permits one active actor/project/exact-role row while all three distinct roles may coexist. - Regrant after revocation creates a new row; issuance provenance is immutable. -- Typed and PostgreSQL audit/idempotency validators accept only the three exact - roles and issued/revoked success events. Replacement fields/events/reasons - and `both` are absent. +- Typed and PostgreSQL audit validators retain exactly + `ProjectRoleQualificationSnapshotCaptured`, `ProjectRoleGrantIssued`, and + `ProjectRoleGrantRevoked` for project-role success, plus the existing generic + linked `AuthorityInvalidationRequested` event. Snapshot capture has a null + `idempotency_reference` and is transaction-correlated to issuance by the same + actor, target actor, project, request ID, and correlation ID; the grant-issued + event carries the pending issue idempotency reference. Grant revocation and + its linked invalidation carry the pending revoke idempotency reference. + Typed/PostgreSQL validators accept only the three exact roles. + `ProjectRoleGrantReplaced`, `authority_replacement`, replacement fields, and + `both` are absent. Audit tests convert former replacement positives into + negative rejection cases while preserving complete event-enum coverage. - 10A adds the five `ActionId` enum members and closed `ActionDefinition` rows below with `ActionAvailability.PLANNED`; it adds exact `ActionOwner.AUTH_10B` and `ActionOwner.AUTH_10C` enum values and assigns each row to its named @@ -137,9 +149,19 @@ transition may mutate lifecycle fields. independent-role request safely. No other replacement/supersession key or fuzzy reason search is implied. - Downgrade refuses before DDL when either new table contains any row or an - authority audit row has `before_facts->>'role'='adjudicator'` or - `after_facts->>'role'='adjudicator'`. Each individual predicate and their - combined form have transaction-level no-mutation proof. + `audit_events` row with `event_domain='authority'` has + `before_facts->>'role'='adjudicator'`, + `after_facts->>'role'='adjudicator'`, `action_id` equal to any of the five + 10A-registered action IDs, or `denial_code` equal to + `project_role_grant_already_revoked` or + `project_role_grant_replay_state_changed`. Each individual predicate and + their combined form have transaction-level no-mutation proof before any table + or validator DDL is changed. +- PostgreSQL checks/triggers, not only Pydantic, enforce availability-object + grammar and cardinality, available/unavailable cross-field rules, reference + bounds, Python-strip-equivalent reason byte bounds and control exclusion, + composite snapshot ownership, status/version coupling, snapshot and issuance + immutability, and the sole active-v1 to revoked-v2 lifecycle transition. - Fresh install, prior-head upgrade, downgrade/refusal, replay, constraints, immutability, and preserved unrelated history are proven on PostgreSQL. - No migration, model, schema, or fixture accepts automated creation. @@ -147,8 +169,8 @@ transition may mutate lifecycle fields. ## Verification commands ```bash -(cd backend && .venv/bin/python -m ruff check app/modules/authorization app/modules/audit tests/test_authorization.py tests/test_alembic.py) -(cd backend && WORKSTREAM_TEST_ADMIN_DATABASE_URL= .venv/bin/python scripts/run_isolated_tests.py --metadata-json --timeout-seconds 300 -- .venv/bin/python -m pytest -q tests/test_alembic.py tests/test_authorization.py -k 'project_role or qualification') +(cd backend && .venv/bin/python -m ruff check app/modules/authorization app/modules/audit tests/test_authorization.py tests/test_alembic.py tests/test_audit.py) +(cd backend && WORKSTREAM_TEST_ADMIN_DATABASE_URL= .venv/bin/python scripts/run_isolated_tests.py --metadata-json --timeout-seconds 300 -- .venv/bin/python -m pytest -q tests/test_alembic.py tests/test_authorization.py tests/test_audit.py -k 'project_role or qualification') python3 scripts/check_stale_authorization_docs.py python3 scripts/check_markdown_links.py git diff --check From 327641f7ccc0f8f5f65a199640d0d7bfdd2cd639 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 21 Jul 2026 13:13:33 +0100 Subject: [PATCH 02/19] docs(auth): execute focused audit proof --- .../chunks/WS-AUTH-001-10A-project-role-grant-data-evidence.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10A-project-role-grant-data-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10A-project-role-grant-data-evidence.md index 783cb4e6b..8fc94a5a3 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10A-project-role-grant-data-evidence.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10A-project-role-grant-data-evidence.md @@ -170,7 +170,8 @@ transition may mutate lifecycle fields. ```bash (cd backend && .venv/bin/python -m ruff check app/modules/authorization app/modules/audit tests/test_authorization.py tests/test_alembic.py tests/test_audit.py) -(cd backend && WORKSTREAM_TEST_ADMIN_DATABASE_URL= .venv/bin/python scripts/run_isolated_tests.py --metadata-json --timeout-seconds 300 -- .venv/bin/python -m pytest -q tests/test_alembic.py tests/test_authorization.py tests/test_audit.py -k 'project_role or qualification') +(cd backend && WORKSTREAM_TEST_ADMIN_DATABASE_URL= .venv/bin/python scripts/run_isolated_tests.py --metadata-json --timeout-seconds 300 -- .venv/bin/python -m pytest -q tests/test_alembic.py tests/test_authorization.py -k 'project_role or qualification') +(cd backend && WORKSTREAM_TEST_ADMIN_DATABASE_URL= .venv/bin/python scripts/run_isolated_tests.py --metadata-json --timeout-seconds 300 -- .venv/bin/python -m pytest -q tests/test_audit.py) python3 scripts/check_stale_authorization_docs.py python3 scripts/check_markdown_links.py git diff --check From 1cf90b70820153d83006e589440948dadbae90ef Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 21 Jul 2026 13:18:36 +0100 Subject: [PATCH 03/19] docs(auth): permit project evidence cleanup --- .../WS-AUTH-001-10A-project-role-grant-data-evidence.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10A-project-role-grant-data-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10A-project-role-grant-data-evidence.md index 8fc94a5a3..f311e9be1 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10A-project-role-grant-data-evidence.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10A-project-role-grant-data-evidence.md @@ -37,6 +37,7 @@ backend/app/modules/authorization/models.py backend/app/modules/authorization/schemas.py backend/app/modules/authorization/repository.py backend/app/modules/authorization/catalogue.py +backend/app/modules/authorization/service.py backend/app/modules/audit/** backend/app/db/models.py backend/alembic/versions/0031_project_role_grants.py @@ -114,6 +115,11 @@ transition may mutate lifecycle fields. `ProjectRoleGrantReplaced`, `authority_replacement`, replacement fields, and `both` are absent. Audit tests convert former replacement positives into negative rejection cases while preserving complete event-enum coverage. +- The existing dormant `AuthorityMutationService` is changed only to remove + replacement-event selection and `replaced_grant_id` matching: a project-role + issue can validate only `ProjectRoleGrantIssued`, with no matched prior grant. + This is availability-neutral evidence cleanup, not an issue/revoke product + service or callable behavior; no route invokes it until 10C. - 10A adds the five `ActionId` enum members and closed `ActionDefinition` rows below with `ActionAvailability.PLANNED`; it adds exact `ActionOwner.AUTH_10B` and `ActionOwner.AUTH_10C` enum values and assigns each row to its named From c5dca37b18a3cd501c7c1a7110a98ab46f59aad4 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 21 Jul 2026 13:21:57 +0100 Subject: [PATCH 04/19] docs(auth): require focused project evidence proof --- .../WS-AUTH-001-10A-project-role-grant-data-evidence.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10A-project-role-grant-data-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10A-project-role-grant-data-evidence.md index f311e9be1..b9416efea 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10A-project-role-grant-data-evidence.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10A-project-role-grant-data-evidence.md @@ -119,7 +119,10 @@ transition may mutate lifecycle fields. replacement-event selection and `replaced_grant_id` matching: a project-role issue can validate only `ProjectRoleGrantIssued`, with no matched prior grant. This is availability-neutral evidence cleanup, not an issue/revoke product - service or callable behavior; no route invokes it until 10C. + service or callable behavior; no route invokes it until 10C. A dedicated + `test_project_role_*` regression proves issued-only success, null prior-grant + matching, and rejection of replacement fields/events so focused selection + cannot omit this service cleanup. - 10A adds the five `ActionId` enum members and closed `ActionDefinition` rows below with `ActionAvailability.PLANNED`; it adds exact `ActionOwner.AUTH_10B` and `ActionOwner.AUTH_10C` enum values and assigns each row to its named From 18bda209f8489c43928a740ed5f2ab58e7cd5984 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 21 Jul 2026 13:51:45 +0100 Subject: [PATCH 05/19] feat(auth): add independent project role grant foundation --- .../merge-intents/WS-AUTH-001-10A.json | 9 + .../versions/0031_project_role_grants.py | 352 ++++++++++++++++++ backend/app/db/models.py | 2 + backend/app/modules/audit/schemas.py | 8 +- .../app/modules/authorization/catalogue.py | 34 +- backend/app/modules/authorization/models.py | 119 ++++++ backend/app/modules/authorization/schemas.py | 69 +++- backend/app/modules/authorization/service.py | 10 +- backend/tests/test_alembic.py | 161 +++++++- backend/tests/test_audit.py | 14 +- backend/tests/test_authorization.py | 94 +++-- docs/operations_authorization_service.md | 13 +- docs/spec_authorization_service.md | 7 +- 13 files changed, 828 insertions(+), 64 deletions(-) create mode 100644 .agent-loop/merge-intents/WS-AUTH-001-10A.json create mode 100644 backend/alembic/versions/0031_project_role_grants.py diff --git a/.agent-loop/merge-intents/WS-AUTH-001-10A.json b/.agent-loop/merge-intents/WS-AUTH-001-10A.json new file mode 100644 index 000000000..7365c2eb0 --- /dev/null +++ b/.agent-loop/merge-intents/WS-AUTH-001-10A.json @@ -0,0 +1,9 @@ +{ + "chunk_id": "WS-AUTH-001-10A", + "chunk_title": "Project Role Grant Data And Evidence Foundation", + "initiative_id": "WS-AUTH-001", + "next_chunk_id": "WS-AUTH-001-10B", + "next_chunk_title": "Project Role Grant Reads", + "next_requires_explicit_start": true, + "schema_version": 2 +} diff --git a/backend/alembic/versions/0031_project_role_grants.py b/backend/alembic/versions/0031_project_role_grants.py new file mode 100644 index 000000000..4d88805ed --- /dev/null +++ b/backend/alembic/versions/0031_project_role_grants.py @@ -0,0 +1,352 @@ +"""add independent project-role grants and qualification evidence + +Revision ID: 0031_project_role_grants +Revises: 0030_artifact_verification +Create Date: 2026-07-21 +""" + +from __future__ import annotations + +import re + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision = "0031_project_role_grants" +down_revision = "0030_artifact_verification" +branch_labels = depends_on = None + +_ACTIONS = ( + ("project.contributor_candidate.list", "project.role_grant.manage"), + ("project_role_grant.list", "project.role_grant.read"), + ("project_role_grant.read", "project.role_grant.read"), + ("project_role_grant.issue", "project.role_grant.manage"), + ("project_role_grant.revoke", "project.role_grant.manage"), +) +_DENIALS = ( + "project_role_grant_already_revoked", + "project_role_grant_replay_state_changed", +) +_STRIP = ( + "(E' \\t\\n\\r\\f\\013'||chr(28)||chr(29)||chr(30)||chr(31)||chr(133)||chr(160)" + "||chr(5760)||chr(8192)||chr(8193)||chr(8194)||chr(8195)||chr(8196)||chr(8197)" + "||chr(8198)||chr(8199)||chr(8200)||chr(8201)||chr(8202)||chr(8232)||chr(8233)" + "||chr(8239)||chr(8287)||chr(12288))" +) + + +def _constraint_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_constraint(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 _replace_action_registry(*, add: bool) -> None: + definition = _constraint_definition("authorization_action_evidence") + marker = ( + "(((action_id)::text = 'actor.service.provision'::text) AND " + "((permission_id)::text = 'actor.service.provision'::text))" + ) + additions = " OR ".join( + f"(((action_id)::text = '{action}'::text) AND ((permission_id)::text = '{permission}'::text))" + for action, permission in _ACTIONS + ) + if add: + if definition.count(marker) != 2 or any(action in definition for action, _ in _ACTIONS): + raise RuntimeError("unexpected authority action registry definition") + definition = definition.replace(marker, marker + " OR " + additions) + else: + suffix = " OR " + additions + if definition.count(suffix) != 2: + raise RuntimeError("unexpected authority action registry definition") + definition = definition.replace(suffix, "") + _replace_constraint("authorization_action_evidence", definition) + + +def _replace_authority_registries(*, add: bool) -> None: + definition = _constraint_definition("authority_registries") + if add: + obsolete = re.compile( + r"\s+OR \(\(\(event_type\)::text = 'ProjectRoleGrantReplaced'::text\) " + r"AND \(reason = 'authority_replacement'::text\)\)" + ) + if len(tuple(obsolete.finditer(definition))) != 1: + raise RuntimeError("unexpected authority registry definition") + definition = obsolete.sub("", definition, count=1) + marker = "('identity_link_conflict'::character varying)::text" + additions = ", ".join(f"('{value}'::character varying)::text" for value in _DENIALS) + if definition.count(marker) != 1 or any(value in definition for value in _DENIALS): + raise RuntimeError("unexpected authority denial registry definition") + definition = definition.replace(marker, marker + ", " + additions) + else: + additions = ", " + ", ".join( + f"('{value}'::character varying)::text" for value in _DENIALS + ) + if definition.count(additions) != 1: + raise RuntimeError("unexpected authority denial registry definition") + definition = definition.replace(additions, "") + marker = ( + "(((event_type)::text = 'ProjectRoleGrantIssued'::text) AND " + "(reason = 'authority_assignment'::text))" + ) + restored = ( + marker + " OR (((event_type)::text = 'ProjectRoleGrantReplaced'::text) " + "AND (reason = 'authority_replacement'::text))" + ) + if definition.count(marker) != 1: + raise RuntimeError("unexpected authority registry definition") + definition = definition.replace(marker, restored, 1) + _replace_constraint("authority_registries", definition) + + +def _function_definition(name: str) -> str: + return op.get_bind().execute( + sa.text("select pg_get_functiondef(cast(:name as regproc))"), {"name": name} + ).scalar_one() + + +def _replace_audit_functions(*, add: bool) -> None: + facts = _function_definition("authority_event_facts_are_safe") + linked = _function_definition("validate_linked_authority_event") + old_roles = "array['submitter','reviewer','both']" + new_roles = "array['submitter','reviewer','adjudicator']" + replacement_case = re.compile( + r"\s*when 'ProjectRoleGrantReplaced' then return.*?;(?=\s+when)", re.S + ) + if add: + if old_roles not in facts or len(replacement_case.findall(facts)) != 1: + raise RuntimeError("unexpected authority fact validator definition") + facts = facts.replace(old_roles, new_roles) + facts = replacement_case.sub("", facts, count=1) + linked_pairs = ( + ("'ProjectRoleGrantIssued','ProjectRoleGrantReplaced'", "'ProjectRoleGrantIssued'"), + ) + else: + if new_roles not in facts or replacement_case.search(facts): + raise RuntimeError("unexpected authority fact validator definition") + facts = facts.replace(new_roles, old_roles) + marker = "when 'ProjectRoleQualificationSnapshotCaptured' then" + restored = ( + "when 'ProjectRoleGrantReplaced' then return\n" + " authority_grant_facts_are_safe(before_state,array['submitter','reviewer','both'],'active',true,envelope_project_id)\n" + " and authority_grant_facts_are_safe(after_state,array['submitter','reviewer','both'],'active',true,envelope_project_id)\n" + " and before_state->>'scope_id'=after_state->>'scope_id';\n " + ) + if facts.count(marker) != 1: + raise RuntimeError("unexpected authority fact validator definition") + facts = facts.replace(marker, restored + marker) + linked_pairs = ( + ("'ProjectRoleGrantIssued'", "'ProjectRoleGrantIssued','ProjectRoleGrantReplaced'"), + ) + changed = 0 + for old, new in linked_pairs: + count = linked.count(old) + if count: + linked = linked.replace(old, new) + changed += count + if changed != 3: + raise RuntimeError("unexpected linked authority validator definition") + op.execute(facts) + op.execute(linked) + + +def _create_helpers() -> None: + statements = ( + r""" + create function project_role_reference_token_is_safe(value text) returns boolean + language sql immutable strict as $$ + select value ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,119}$' and strpos(value, '://')=0 + $$ + """, + r""" + create function project_role_reference_array_is_safe(value jsonb, uuid_only boolean) + returns boolean language sql immutable strict as $$ + select jsonb_typeof(value)='array' and jsonb_array_length(value)<=20 + and not exists ( + select 1 from jsonb_array_elements(value) item + where jsonb_typeof(item)<>'string' or + case when uuid_only then not (item #>> '{}') ~ + '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + else not project_role_reference_token_is_safe(item #>> '{}') end + ) + $$ + """, + r""" + create function project_role_availability_is_safe(value jsonb) returns boolean + language sql immutable strict as $$ + select jsonb_typeof(value)='object' and + (select count(*)=3 from jsonb_object_keys(value)) and + value ?& array['availability','reference_ids','unavailable_reason'] and + project_role_reference_array_is_safe(value->'reference_ids',false) and ( + (value->>'availability'='available' and jsonb_array_length(value->'reference_ids')>0 + and value->'unavailable_reason'='null'::jsonb) or + (value->>'availability'='unavailable' and jsonb_array_length(value->'reference_ids')=0 + and value->>'unavailable_reason' in ('not_collected','source_unavailable','no_record')) + ) + $$ + """, + r""" + create function project_role_reason_is_safe(value text) returns boolean + language plpgsql immutable strict as $$ + declare point integer; index integer; + begin + if octet_length(value) not between 1 and 500 or value <> btrim(value, """ + _STRIP + r""") then return false; end if; + for index in 1..char_length(value) loop + point := ascii(substr(value,index,1)); + if point between 0 and 31 or point between 127 and 159 + or point in (173,1536,1537,1538,1539,1757,1807,6068,6069,6070,6071,6072,6073,6158,8203,8204,8205,8206,8207,8234,8235,8236,8237,8238,8288,8289,8290,8291,8292,8293,8294,8295,8296,8297,8298,8299,8300,8301,8302,8303,65279) then + return false; + end if; + end loop; + return true; + end $$ + """ + ) + for statement in statements: + op.execute(statement) + + +def _create_history_guards() -> None: + statements = ( + """ + create function guard_project_role_snapshot_history() returns trigger language plpgsql as $$ + begin raise exception 'project-role qualification snapshots are immutable' using errcode='55000'; end $$ + """, + """ + create trigger trg_project_role_qualification_snapshots_immutable + before update or delete on project_role_qualification_snapshots + for each row execute function guard_project_role_snapshot_history() + """, + """ + create function guard_project_role_grant_history() returns trigger language plpgsql as $$ + begin + if tg_op='DELETE' then raise exception 'project-role grants are immutable history' using errcode='55000'; end if; + if (new.id,new.project_id,new.actor_profile_id,new.role,new.grant_method, + new.qualification_snapshot_id,new.granted_by_actor_profile_id, + new.granted_by_admin_role_grant_id,new.grant_reason,new.granted_at) + is distinct from + (old.id,old.project_id,old.actor_profile_id,old.role,old.grant_method, + old.qualification_snapshot_id,old.granted_by_actor_profile_id, + old.granted_by_admin_role_grant_id,old.grant_reason,old.granted_at) + or old.status<>'active' or old.version<>1 or new.status<>'revoked' or new.version<>2 + or new.revoked_by_actor_profile_id is null or new.revoked_by_admin_role_grant_id is null + or new.revoked_reason is null or new.revoked_at is null then + raise exception 'invalid project-role grant history transition' using errcode='23514'; + end if; + return new; + end $$ + """, + """ + create trigger trg_project_role_grants_history before update or delete on project_role_grants + for each row execute function guard_project_role_grant_history() + """ + ) + for statement in statements: + op.execute(statement) + + +def upgrade() -> None: + """Install exact-role history only after proving no replacement-era state exists.""" + bind = op.get_bind() + bind.execute(sa.text("lock table audit_events, authority_idempotency_records in access exclusive mode")) + blocked = bind.execute(sa.text(""" + select exists(select 1 from audit_events where event_domain='authority' and ( + before_facts->>'role'='both' or after_facts->>'role'='both' or + before_facts::jsonb ? 'replaced_grant_id' or after_facts::jsonb ? 'replaced_grant_id' or + event_type='ProjectRoleGrantReplaced' or reason='authority_replacement')) or + exists(select 1 from authority_idempotency_records where operation in + ('project_role_grant.issue','project_role_grant.revoke')) + """)).scalar_one() + if blocked: + raise RuntimeError("cannot safely upgrade replacement-era project-role evidence") + _create_helpers() + op.create_table( + "project_role_qualification_snapshots", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column("project_id", sa.String(36), sa.ForeignKey("projects.id"), nullable=False), + sa.Column("actor_profile_id", sa.String(36), sa.ForeignKey("actor_profiles.id"), nullable=False), + sa.Column("requested_role", sa.String(24), nullable=False), + sa.Column("skills_snapshot", postgresql.JSONB(), nullable=False), + sa.Column("reputation_snapshot", postgresql.JSONB(), nullable=False), + sa.Column("prior_project_work_refs", postgresql.JSONB(), nullable=False), + sa.Column("external_expertise_refs", postgresql.JSONB(), nullable=False), + sa.Column("captured_by_actor_profile_id", sa.String(36), sa.ForeignKey("actor_profiles.id"), nullable=False), + sa.Column("captured_by_admin_role_grant_id", sa.Uuid(), sa.ForeignKey("admin_role_grants.id"), nullable=False), + sa.Column("captured_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.CheckConstraint("requested_role in ('submitter','reviewer','adjudicator')", name="role"), + sa.CheckConstraint("project_role_availability_is_safe(skills_snapshot) and project_role_availability_is_safe(reputation_snapshot)", name="availability"), + sa.CheckConstraint("project_role_reference_array_is_safe(prior_project_work_refs,true)", name="prior_work_refs"), + sa.CheckConstraint("project_role_reference_array_is_safe(external_expertise_refs,false)", name="external_expertise_refs"), + sa.UniqueConstraint("id","actor_profile_id","project_id","requested_role", name="grant_reference"), + ) + op.create_index("ix_project_role_qualification_snapshots_history", "project_role_qualification_snapshots", ["project_id","actor_profile_id","requested_role","captured_at"]) + op.create_table( + "project_role_grants", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column("project_id", sa.String(36), sa.ForeignKey("projects.id"), nullable=False), + sa.Column("actor_profile_id", sa.String(36), sa.ForeignKey("actor_profiles.id"), nullable=False), + sa.Column("role", sa.String(24), nullable=False), + sa.Column("status", sa.String(16), nullable=False, server_default="active"), + sa.Column("version", sa.SmallInteger(), nullable=False, server_default="1"), + sa.Column("grant_method", sa.String(16), nullable=False, server_default="manual"), + sa.Column("qualification_snapshot_id", sa.Uuid(), nullable=False), + sa.Column("granted_by_actor_profile_id", sa.String(36), sa.ForeignKey("actor_profiles.id"), nullable=False), + sa.Column("granted_by_admin_role_grant_id", sa.Uuid(), sa.ForeignKey("admin_role_grants.id"), nullable=False), + sa.Column("grant_reason", sa.Text(), nullable=False), + sa.Column("granted_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("revoked_by_actor_profile_id", sa.String(36), sa.ForeignKey("actor_profiles.id")), + sa.Column("revoked_by_admin_role_grant_id", sa.Uuid(), sa.ForeignKey("admin_role_grants.id")), + sa.Column("revoked_reason", sa.Text()), + sa.Column("revoked_at", sa.DateTime(timezone=True)), + sa.CheckConstraint("role in ('submitter','reviewer','adjudicator')", name="role"), + sa.CheckConstraint("grant_method='manual'", name="grant_method"), + sa.CheckConstraint("project_role_reason_is_safe(grant_reason) and (revoked_reason is null or project_role_reason_is_safe(revoked_reason))", name="reason"), + sa.CheckConstraint("(status='active' and version=1 and revoked_by_actor_profile_id is null and revoked_by_admin_role_grant_id is null and revoked_reason is null and revoked_at is null) or (status='revoked' and version=2 and revoked_by_actor_profile_id is not null and revoked_by_admin_role_grant_id is not null and revoked_reason is not null and revoked_at is not null)", name="lifecycle"), + sa.ForeignKeyConstraint(["qualification_snapshot_id","actor_profile_id","project_id","role"], ["project_role_qualification_snapshots.id","project_role_qualification_snapshots.actor_profile_id","project_role_qualification_snapshots.project_id","project_role_qualification_snapshots.requested_role"], name="qualification_ownership", ondelete="RESTRICT"), + ) + op.create_index("uq_project_role_grants_active_exact_role", "project_role_grants", ["project_id","actor_profile_id","role"], unique=True, postgresql_where=sa.text("status='active'")) + op.create_index("ix_project_role_grants_project_actor_role_status", "project_role_grants", ["project_id","actor_profile_id","role","status"]) + op.create_index("ix_project_role_grants_actor_role_status", "project_role_grants", ["actor_profile_id","role","status"]) + _create_history_guards() + _replace_authority_registries(add=True) + _replace_action_registry(add=True) + _replace_audit_functions(add=True) + + +def downgrade() -> None: + """Restore 0030 only when no 10A-owned truth or evidence exists.""" + bind = op.get_bind() + bind.execute(sa.text("lock table project_role_grants, project_role_qualification_snapshots, audit_events in access exclusive mode")) + blocked = bind.execute(sa.text(""" + select exists(select 1 from project_role_grants) or + exists(select 1 from project_role_qualification_snapshots) or + exists(select 1 from audit_events where event_domain='authority' and ( + before_facts->>'role'='adjudicator' or after_facts->>'role'='adjudicator' or + action_id in ('project.contributor_candidate.list','project_role_grant.list', + 'project_role_grant.read','project_role_grant.issue','project_role_grant.revoke') or + denial_code in ('project_role_grant_already_revoked','project_role_grant_replay_state_changed'))) + """)).scalar_one() + if blocked: + raise RuntimeError("cannot downgrade project-role grant evidence") + _replace_audit_functions(add=False) + _replace_action_registry(add=False) + _replace_authority_registries(add=False) + op.drop_table("project_role_grants") + op.drop_table("project_role_qualification_snapshots") + op.execute("drop function guard_project_role_grant_history()") + op.execute("drop function guard_project_role_snapshot_history()") + op.execute("drop function project_role_availability_is_safe(jsonb)") + op.execute("drop function project_role_reference_array_is_safe(jsonb,boolean)") + op.execute("drop function project_role_reference_token_is_safe(text)") + op.execute("drop function project_role_reason_is_safe(text)") diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 8fe948bc4..ed871d369 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -19,6 +19,8 @@ AdminRoleGrant, AuthorityControl, AuthorityIdempotencyRecord, + ProjectRoleGrant, + ProjectRoleQualificationSnapshot, ) from app.modules.checkers.models import CheckerResult, CheckerRun # noqa: F401 from app.modules.outbox.models import OutboxEvent # noqa: F401 diff --git a/backend/app/modules/audit/schemas.py b/backend/app/modules/audit/schemas.py index 619cbcdfc..06dedf647 100644 --- a/backend/app/modules/audit/schemas.py +++ b/backend/app/modules/audit/schemas.py @@ -45,14 +45,15 @@ scope_not_authorized self_grant_forbidden self_role_revoke_forbidden resource_guard_denied actor_not_found grant_not_found resource_not_found actor_already_suspended actor_not_suspended actor_deactivated_terminal last_access_administrator admin_role_grant_exists - project_role_grant_exists identity_link_conflict identity_link_already_revoked + project_role_grant_exists project_role_grant_already_revoked + project_role_grant_replay_state_changed identity_link_conflict identity_link_already_revoked identity_link_not_revoked resource_project_mismatch idempotency_mismatch invalid_role_scope invalid_project_role qualification_snapshot_invalid""".split() ) _ADMIN_ROLES = frozenset( {"access_administrator", "operator", "project_manager", "finance_authority", "audit_authority"} ) -_PROJECT_ROLES = frozenset({"submitter", "reviewer", "both"}) +_PROJECT_ROLES = frozenset({"submitter", "reviewer", "adjudicator"}) _FACT_VALUES: dict[str, frozenset[str]] = { "status": frozenset({"active", "suspended", "deactivated", "revoked", "captured"}), "subject_kind": frozenset({"human", "service"}), @@ -80,7 +81,6 @@ class AuthorityEventType(StrEnum): LAST_ACCESS_ADMIN_OPERATION_DENIED = "LastAccessAdministratorOperationDenied" PROJECT_ROLE_QUALIFICATION_CAPTURED = "ProjectRoleQualificationSnapshotCaptured" PROJECT_ROLE_GRANT_ISSUED = "ProjectRoleGrantIssued" - PROJECT_ROLE_GRANT_REPLACED = "ProjectRoleGrantReplaced" PROJECT_ROLE_GRANT_REVOKED = "ProjectRoleGrantRevoked" SENSITIVE_AUTHORIZATION_ALLOWED = "SensitiveAuthorizationAllowed" SENSITIVE_AUTHORIZATION_DENIED = "SensitiveAuthorizationDenied" @@ -116,7 +116,6 @@ class ActorReferenceKind(StrEnum): AuthorityEventType.LAST_ACCESS_ADMIN_OPERATION_DENIED: {"authorization_policy_denial"}, AuthorityEventType.PROJECT_ROLE_QUALIFICATION_CAPTURED: {"qualification_evidence_captured"}, AuthorityEventType.PROJECT_ROLE_GRANT_ISSUED: {"authority_assignment"}, - AuthorityEventType.PROJECT_ROLE_GRANT_REPLACED: {"authority_replacement"}, AuthorityEventType.PROJECT_ROLE_GRANT_REVOKED: {"authority_revocation"}, AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED: {"authorization_evaluation"}, AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED: {"authorization_evaluation"}, @@ -216,7 +215,6 @@ def _event_facts_valid(event: AuthorityEventType, before: dict[str, object] | No AuthorityEventType.ADMIN_ROLE_GRANT_ISSUED: (_ADMIN_ROLES, "issued"), AuthorityEventType.ADMIN_ROLE_GRANT_REVOKED: (_ADMIN_ROLES, "revoked"), AuthorityEventType.PROJECT_ROLE_GRANT_ISSUED: (_PROJECT_ROLES, "issued"), - AuthorityEventType.PROJECT_ROLE_GRANT_REPLACED: (_PROJECT_ROLES, "replaced"), AuthorityEventType.PROJECT_ROLE_GRANT_REVOKED: (_PROJECT_ROLES, "revoked"), }[event] if action == "issued": diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index 90cd4b9c6..47c4a7c54 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -110,6 +110,11 @@ class ActionId(StrEnum): ACTOR_IDENTITY_LINK_REVOKE = "actor.identity_link.revoke" ACTOR_IDENTITY_LINK_REACTIVATE = "actor.identity_link.reactivate" ACTOR_SERVICE_PROVISION = "actor.service.provision" + PROJECT_CONTRIBUTOR_CANDIDATE_LIST = "project.contributor_candidate.list" + PROJECT_ROLE_GRANT_LIST = "project_role_grant.list" + PROJECT_ROLE_GRANT_READ = "project_role_grant.read" + PROJECT_ROLE_GRANT_ISSUE = "project_role_grant.issue" + PROJECT_ROLE_GRANT_REVOKE = "project_role_grant.revoke" OPERATIONS_TASK_START_OVERRIDE = "operations.task.start_override" OPERATIONS_SUBMISSION_GATE_REPAIR = "operations.submission_gate.repair" OPERATIONS_CHECKER_RETRY = "operations.checker.retry" @@ -172,6 +177,8 @@ class ActionOwner(StrEnum): AUTH_09C = "WS-AUTH-001-09C" AUTH_09D_A = "WS-AUTH-001-09D-A" AUTH_09D_B = "WS-AUTH-001-09D-B" + AUTH_10B = "WS-AUTH-001-10B" + AUTH_10C = "WS-AUTH-001-10C" AUTH_13 = "WS-AUTH-001-13" AUTH_14 = "WS-AUTH-001-14" AUTH_REV_05 = "WS-AUTH-001-REV-05" @@ -309,6 +316,31 @@ def _active( PermissionId.ACTOR_SERVICE_PROVISION, ActionOwner.AUTH_09B, ), + _planned( + ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, + PermissionId.PROJECT_ROLE_GRANT_MANAGE, + ActionOwner.AUTH_10B, + ), + _planned( + ActionId.PROJECT_ROLE_GRANT_LIST, + PermissionId.PROJECT_ROLE_GRANT_READ, + ActionOwner.AUTH_10B, + ), + _planned( + ActionId.PROJECT_ROLE_GRANT_READ, + PermissionId.PROJECT_ROLE_GRANT_READ, + ActionOwner.AUTH_10B, + ), + _planned( + ActionId.PROJECT_ROLE_GRANT_ISSUE, + PermissionId.PROJECT_ROLE_GRANT_MANAGE, + ActionOwner.AUTH_10C, + ), + _planned( + ActionId.PROJECT_ROLE_GRANT_REVOKE, + PermissionId.PROJECT_ROLE_GRANT_MANAGE, + ActionOwner.AUTH_10C, + ), _planned( ActionId.OPERATIONS_TASK_START_OVERRIDE, PermissionId.OPERATIONS_TASK_START_OVERRIDE, @@ -574,7 +606,7 @@ def _index_actions( ): raise RuntimeError("authorization action catalogue contains an invalid row") indexed = {definition.action_id: definition for definition in definitions} - if len(PERMISSION_IDS) != 74 or len(ACTION_IDS) != 65: + if len(PERMISSION_IDS) != 74 or len(ACTION_IDS) != 70: raise RuntimeError("authorization catalogue count mismatch") if len(indexed) != len(definitions) or set(indexed) != ACTION_IDS: raise RuntimeError("authorization action catalogue is incomplete") diff --git a/backend/app/modules/authorization/models.py b/backend/app/modules/authorization/models.py index 302f4134c..b2bfe2265 100644 --- a/backend/app/modules/authorization/models.py +++ b/backend/app/modules/authorization/models.py @@ -11,6 +11,7 @@ CheckConstraint, DateTime, ForeignKey, + ForeignKeyConstraint, Index, SmallInteger, String, @@ -20,6 +21,7 @@ text, ) from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.sql import func from app.db.base import Base @@ -209,6 +211,123 @@ class AdminRoleGrant(Base): revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) +class ProjectRoleQualificationSnapshot(Base): + """Immutable, privacy-bounded evidence captured for one exact project role.""" + + __tablename__ = "project_role_qualification_snapshots" + __table_args__ = ( + CheckConstraint("requested_role in ('submitter','reviewer','adjudicator')", name="role"), + UniqueConstraint( + "id", + "actor_profile_id", + "project_id", + "requested_role", + name="grant_reference", + ), + Index( + "ix_project_role_qualification_snapshots_history", + "project_id", + "actor_profile_id", + "requested_role", + "captured_at", + ), + ) + + id: Mapped[UUID] = mapped_column(Uuid(), primary_key=True) + project_id: Mapped[str] = mapped_column(ForeignKey("projects.id"), nullable=False) + actor_profile_id: Mapped[str] = mapped_column(ForeignKey("actor_profiles.id"), nullable=False) + requested_role: Mapped[str] = mapped_column(String(24), nullable=False) + skills_snapshot: Mapped[dict] = mapped_column(JSONB, nullable=False) + reputation_snapshot: Mapped[dict] = mapped_column(JSONB, nullable=False) + prior_project_work_refs: Mapped[list[str]] = mapped_column(JSONB, nullable=False) + external_expertise_refs: Mapped[list[str]] = mapped_column(JSONB, nullable=False) + captured_by_actor_profile_id: Mapped[str] = mapped_column( + ForeignKey("actor_profiles.id"), nullable=False + ) + captured_by_admin_role_grant_id: Mapped[UUID] = mapped_column( + ForeignKey("admin_role_grants.id"), nullable=False + ) + captured_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + + +class ProjectRoleGrant(Base): + """Immutable issuance history for one independent exact-project role.""" + + __tablename__ = "project_role_grants" + __table_args__ = ( + CheckConstraint("role in ('submitter','reviewer','adjudicator')", name="role"), + CheckConstraint("grant_method='manual'", name="grant_method"), + CheckConstraint("octet_length(grant_reason) between 1 and 500", name="grant_reason"), + CheckConstraint( + "(status='active' and version=1 and revoked_by_actor_profile_id is null " + "and revoked_by_admin_role_grant_id is null and revoked_reason is null " + "and revoked_at is null) or (status='revoked' and version=2 and " + "revoked_by_actor_profile_id is not null and " + "revoked_by_admin_role_grant_id is not null and revoked_reason is not null " + "and octet_length(revoked_reason) between 1 and 500 and revoked_at is not null)", + name="lifecycle", + ), + ForeignKeyConstraint( + ["qualification_snapshot_id", "actor_profile_id", "project_id", "role"], + [ + "project_role_qualification_snapshots.id", + "project_role_qualification_snapshots.actor_profile_id", + "project_role_qualification_snapshots.project_id", + "project_role_qualification_snapshots.requested_role", + ], + name="qualification_ownership", + ), + Index( + "uq_project_role_grants_active_exact_role", + "project_id", + "actor_profile_id", + "role", + unique=True, + postgresql_where=text("status='active'"), + ), + Index( + "ix_project_role_grants_project_actor_role_status", + "project_id", + "actor_profile_id", + "role", + "status", + ), + Index( + "ix_project_role_grants_actor_role_status", + "actor_profile_id", + "role", + "status", + ), + ) + + id: Mapped[UUID] = mapped_column(Uuid(), primary_key=True) + project_id: Mapped[str] = mapped_column(ForeignKey("projects.id"), nullable=False) + actor_profile_id: Mapped[str] = mapped_column(ForeignKey("actor_profiles.id"), nullable=False) + role: Mapped[str] = mapped_column(String(24), nullable=False) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") + version: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=1) + grant_method: Mapped[str] = mapped_column(String(16), nullable=False, default="manual") + qualification_snapshot_id: Mapped[UUID] = mapped_column(Uuid(), nullable=False) + granted_by_actor_profile_id: Mapped[str] = mapped_column( + ForeignKey("actor_profiles.id"), nullable=False + ) + granted_by_admin_role_grant_id: Mapped[UUID] = mapped_column( + ForeignKey("admin_role_grants.id"), nullable=False + ) + grant_reason: Mapped[str] = mapped_column(Text, nullable=False) + granted_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + revoked_by_actor_profile_id: Mapped[str | None] = mapped_column(ForeignKey("actor_profiles.id")) + revoked_by_admin_role_grant_id: Mapped[UUID | None] = mapped_column( + ForeignKey("admin_role_grants.id") + ) + revoked_reason: Mapped[str | None] = mapped_column(Text) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + class AuthorityControl(Base): """Irreversible singleton governing bootstrap and final-admin mutation order.""" diff --git a/backend/app/modules/authorization/schemas.py b/backend/app/modules/authorization/schemas.py index 4806ba13a..cfe17c0f3 100644 --- a/backend/app/modules/authorization/schemas.py +++ b/backend/app/modules/authorization/schemas.py @@ -65,7 +65,71 @@ class ProjectRole(StrEnum): SUBMITTER = "submitter" REVIEWER = "reviewer" - BOTH = "both" + ADJUDICATOR = "adjudicator" + + +class QualificationAvailability(StrEnum): + """Whether one qualification source produced bounded references.""" + + AVAILABLE = "available" + UNAVAILABLE = "unavailable" + + +class QualificationUnavailableReason(StrEnum): + """Closed reasons why qualification references could not be captured.""" + + NOT_COLLECTED = "not_collected" + SOURCE_UNAVAILABLE = "source_unavailable" + NO_RECORD = "no_record" + + +ReferenceToken = Annotated[ + str, + Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,119}$"), +] + + +class QualificationAvailabilitySnapshot(BaseModel): + """Privacy-bounded availability and opaque-reference evidence.""" + + model_config = _MODEL_CONFIG + + availability: QualificationAvailability + reference_ids: Annotated[list[ReferenceToken], Field(max_length=20)] + unavailable_reason: QualificationUnavailableReason | None + + @model_validator(mode="after") + def validate_shape(self) -> Self: + """Enforce the exact available/unavailable cross-field contract.""" + if any("://" in reference for reference in self.reference_ids): + raise ValueError("invalid qualification availability snapshot") + if self.availability is QualificationAvailability.AVAILABLE: + if not self.reference_ids or self.unavailable_reason is not None: + raise ValueError("invalid qualification availability snapshot") + elif self.reference_ids or self.unavailable_reason is None: + raise ValueError("invalid qualification availability snapshot") + return self + + +class ProjectRoleQualificationSnapshotInput(BaseModel): + """Typed immutable facts persisted for one exact project-role qualification.""" + + model_config = _MODEL_CONFIG + + project_id: UUID + actor_profile_id: UUID + requested_role: ProjectRole + skills_snapshot: QualificationAvailabilitySnapshot + reputation_snapshot: QualificationAvailabilitySnapshot + prior_project_work_refs: Annotated[list[UUID], Field(max_length=20)] + external_expertise_refs: Annotated[list[ReferenceToken], Field(max_length=20)] + + @model_validator(mode="after") + def reject_url_references(self) -> Self: + """Keep external references opaque and credential-free.""" + if any("://" in reference for reference in self.external_expertise_refs): + raise ValueError("invalid qualification reference") + return self Digest = Annotated[str, Field(pattern=r"^sha256:[0-9a-f]{64}$")] @@ -153,13 +217,12 @@ class AdminRoleGrantRevokeRequest(CanonicalAuthorityRequest): class ProjectRoleGrantIssueRequest(CanonicalAuthorityRequest): - """Canonical request facts for issuing or replacing one project role grant.""" + """Canonical request facts for issuing one independent project role grant.""" operation: Literal[AuthorityOperation.PROJECT_ROLE_GRANT_ISSUE] project_id: UUID target_actor_id: UUID role: ProjectRole - replaced_grant_id: UUID | None = None reason_digest: Digest diff --git a/backend/app/modules/authorization/service.py b/backend/app/modules/authorization/service.py index 3f0c0fd81..02e861a10 100644 --- a/backend/app/modules/authorization/service.py +++ b/backend/app/modules/authorization/service.py @@ -80,7 +80,6 @@ class _OperationEvidence: 201, ( AuthorityEventType.PROJECT_ROLE_GRANT_ISSUED, - AuthorityEventType.PROJECT_ROLE_GRANT_REPLACED, ), ), AuthorityOperation.PROJECT_ROLE_GRANT_REVOKE: _OperationEvidence( @@ -183,12 +182,6 @@ async def complete( spec = _EVIDENCE[claim.operation] expected_digest = canonical_json_hash(mutation.model_dump(mode="json", exclude_none=True)) allowed_events = spec.events - if isinstance(mutation, ProjectRoleGrantIssueRequest): - allowed_events = ( - AuthorityEventType.PROJECT_ROLE_GRANT_REPLACED - if mutation.replaced_grant_id - else AuthorityEventType.PROJECT_ROLE_GRANT_ISSUED, - ) valid = ( mutation.operation == claim.operation and expected_digest == claim.request_digest @@ -361,8 +354,7 @@ def _request_matches_success( success.target_actor_ref_kind == ActorReferenceKind.ACTOR_PROFILE and success.target_actor_ref == str(request.target_actor_id) and success.project_id == str(request.project_id) - and success.matched_grant_id - == (str(request.replaced_grant_id) if request.replaced_grant_id else None) + and success.matched_grant_id is None and facts.get("role") == request.role.value and facts.get("scope_type") == "project" and facts.get("scope_id") == str(request.project_id) diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 9c0202792..181b49b78 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -115,6 +115,31 @@ def test_alembic_upgrade_and_downgrade(isolated_database_env: str, migration_loc with migration_lock(): command.downgrade(config, "base") + + +def test_project_role_migration_constraints_and_immutable_history( + isolated_database_env: str, + migration_lock, +) -> None: + """Prove 0031 exact-role coexistence, evidence bounds, and lifecycle custody.""" + config = _alembic_config() + with migration_lock(): + try: + command.downgrade(config, "base") + command.upgrade(config, "head") + result = asyncio.run(_exercise_project_role_migration(isolated_database_env)) + assert result == { + "revision": "0031_project_role_grants", + "role_count": 3, + "invalid_availability": "23514", + "duplicate_role": "23505", + "snapshot_update": "55000", + "issuance_update": "23514", + "valid_revoke": ("revoked", 2), + "second_revoke": "23514", + } + finally: + command.downgrade(config, "base") command.upgrade(config, "head") command.downgrade(config, "base") @@ -133,7 +158,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": "0030_artifact_verification", + "revision": "0031_project_role_grants", "columns": { "aggregate_id", "aggregate_type", @@ -193,7 +218,7 @@ def test_outbox_migration_schema_and_downgrade_writer_guard( ) assert committed == "refused_after_commit" assert asyncio.run(_current_revision(isolated_database_env)) == ( - "0030_artifact_verification" + "0031_project_role_grants" ) asyncio.run(_remove_outbox_migration_row(isolated_database_env, committed_project_id)) command.downgrade(config, "0028_artifact_admission") @@ -7239,3 +7264,135 @@ async def _insert_authorization_action_event_for( return str(values["id"]) 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") + project_id, admin_grant_id = str(uuid4()), str(uuid4()) + snapshot_ids = [str(uuid4()) for _ in range(3)] + grant_ids = [str(uuid4()) for _ in range(3)] + results: dict[str, object] = {} + + async def rejected(connection, statement: str, values: dict[str, object]) -> str | None: + try: + async with connection.begin_nested(): + await connection.execute(text(statement), values) + except DBAPIError as error: + return _database_error_sqlstate(error) + return None + + try: + async with engine.connect() as connection: + transaction = await connection.begin() + try: + await _insert_canonical_actor(connection, actor_id, "auth10a-actor", "human") + await connection.execute( + text("insert into projects(id,name,slug,status) values (:id,'AUTH 10A',:slug,'active')"), + {"id": project_id, "slug": f"auth-10a-{project_id}"}, + ) + await connection.execute( + text( + "insert into admin_role_grants " + "(id,target_actor_profile_id,role,scope_type,status,version," + "granted_by_system_principal,grant_reason) values " + "(:id,:actor,'access_administrator','system','active',1," + "'workstream:system:bootstrap','AUTH 10A migration proof')" + ), + {"id": admin_grant_id, "actor": actor_id}, + ) + await connection.execute( + text( + "update authority_control set bootstrap_completed=true," + "bootstrap_grant_id=:grant,version=1 where id=1" + ), + {"grant": admin_grant_id}, + ) + snapshot_insert = text( + "insert into project_role_qualification_snapshots " + "(id,project_id,actor_profile_id,requested_role,skills_snapshot," + "reputation_snapshot,prior_project_work_refs,external_expertise_refs," + "captured_by_actor_profile_id,captured_by_admin_role_grant_id) values " + "(:id,:project,:actor,:role,cast(:skills as jsonb),cast(:reputation as jsonb)," + "cast(:prior as jsonb),cast(:external as jsonb),:actor,:admin)" + ) + available = json.dumps( + {"availability": "available", "reference_ids": ["opaque:1"], "unavailable_reason": None} + ) + unavailable = json.dumps( + {"availability": "unavailable", "reference_ids": [], "unavailable_reason": "no_record"} + ) + for role, snapshot_id in zip( + ("submitter", "reviewer", "adjudicator"), snapshot_ids, strict=True + ): + await connection.execute( + snapshot_insert, + {"id": snapshot_id, "project": project_id, "actor": actor_id, "role": role, + "skills": available, "reputation": unavailable, "prior": "[]", "external": "[]", + "admin": admin_grant_id}, + ) + results["invalid_availability"] = await rejected( + connection, + str(snapshot_insert), + {"id": str(uuid4()), "project": project_id, "actor": actor_id, "role": "submitter", + "skills": json.dumps({"availability": "available", "reference_ids": [], "unavailable_reason": None}), + "reputation": unavailable, "prior": "[]", "external": "[]", "admin": admin_grant_id}, + ) + grant_insert = text( + "insert into project_role_grants " + "(id,project_id,actor_profile_id,role,qualification_snapshot_id," + "granted_by_actor_profile_id,granted_by_admin_role_grant_id,grant_reason) " + "values (:id,:project,:actor,:role,:snapshot,:actor,:admin,'Qualified manually')" + ) + for role, snapshot_id, grant_id in zip( + ("submitter", "reviewer", "adjudicator"), snapshot_ids, grant_ids, strict=True + ): + await connection.execute( + grant_insert, + {"id": grant_id, "project": project_id, "actor": actor_id, "role": role, + "snapshot": snapshot_id, "admin": admin_grant_id}, + ) + results["revision"] = await connection.scalar(text("select version_num from alembic_version")) + results["role_count"] = await connection.scalar( + text("select count(*) from project_role_grants where project_id=:project"), + {"project": project_id}, + ) + results["duplicate_role"] = await rejected( + connection, str(grant_insert), + {"id": str(uuid4()), "project": project_id, "actor": actor_id, "role": "submitter", + "snapshot": snapshot_ids[0], "admin": admin_grant_id}, + ) + results["snapshot_update"] = await rejected( + connection, + "update project_role_qualification_snapshots set external_expertise_refs='[]'::jsonb where id=:id", + {"id": snapshot_ids[0]}, + ) + results["issuance_update"] = await rejected( + connection, + "update project_role_grants set grant_reason='Changed' where id=:id", + {"id": grant_ids[0]}, + ) + await connection.execute( + text( + "update project_role_grants set status='revoked',version=2," + "revoked_by_actor_profile_id=:actor,revoked_by_admin_role_grant_id=:admin," + "revoked_reason='No longer assigned',revoked_at=clock_timestamp() where id=:id" + ), + {"id": grant_ids[0], "actor": actor_id, "admin": admin_grant_id}, + ) + results["valid_revoke"] = tuple( + (await connection.execute( + text("select status,version from project_role_grants where id=:id"), + {"id": grant_ids[0]}, + )).one() + ) + results["second_revoke"] = await rejected( + connection, + "update project_role_grants set revoked_reason='Again' where id=:id", + {"id": grant_ids[0]}, + ) + finally: + await transaction.rollback() + return results + finally: + await engine.dispose() diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index de06ef20d..ee98e3d81 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -404,15 +404,6 @@ def row(event_type, entity_type, reason, before, after, invalid_after, **extra): }, project_id=project_id, ), - row( - AuthorityEventType.PROJECT_ROLE_GRANT_REPLACED, - "project_role_grant", - "authority_replacement", - project_active, - project_active | {"role": "reviewer"}, - project_active | {"scope_id": str(uuid4()), "role": "reviewer"}, - project_id=project_id, - ), row( AuthorityEventType.PROJECT_ROLE_GRANT_REVOKED, "project_role_grant", @@ -732,7 +723,7 @@ def test_authority_input_enforces_grant_scope_matrix() -> None: AuthorityAuditEventInput.model_validate(source | patch) replacement = source | { - "event_type": AuthorityEventType.PROJECT_ROLE_GRANT_REPLACED, + "event_type": "ProjectRoleGrantReplaced", "entity_type": "project_role_grant", "reason": "authority_replacement", "before_facts": { @@ -750,7 +741,7 @@ def test_authority_input_enforces_grant_scope_matrix() -> None: "effective": True, }, } - with pytest.raises(ValidationError, match="scope"): + with pytest.raises(TypeError, match="invalid authority audit input"): AuthorityAuditEventInput.model_validate(replacement) with pytest.raises(ValidationError, match="project resource"): AuthorityAuditEventInput.model_validate( @@ -789,7 +780,6 @@ async def test_authority_event_matrix_preserves_shapes_and_requires_idempotency_ AuthorityEventType.ADMIN_ROLE_GRANT_ISSUED, AuthorityEventType.ADMIN_ROLE_GRANT_REVOKED, AuthorityEventType.PROJECT_ROLE_GRANT_ISSUED, - AuthorityEventType.PROJECT_ROLE_GRANT_REPLACED, AuthorityEventType.PROJECT_ROLE_GRANT_REVOKED, AuthorityEventType.ACTOR_PROFILE_SUSPENDED, AuthorityEventType.ACTOR_PROFILE_REACTIVATED, diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 79d78116f..399648b06 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -94,8 +94,12 @@ MismatchedReservation, PendingAuthorityReservationError, ProjectRole, + ProjectRoleQualificationSnapshotInput, ProjectRoleGrantIssueRequest, ProjectRoleGrantRevokeRequest, + QualificationAvailabilitySnapshot, + QualificationAvailability, + QualificationUnavailableReason, ReplayedReservation, ServiceActorCreateRequest, derive_reason_digest, @@ -441,11 +445,19 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> "WS-AUTH-001-09D-B", ), "actor.service.provision": ("actor.service.provision", "WS-AUTH-001-09B"), + "project.contributor_candidate.list": ( + "project.role_grant.manage", + "WS-AUTH-001-10B", + ), + "project_role_grant.list": ("project.role_grant.read", "WS-AUTH-001-10B"), + "project_role_grant.read": ("project.role_grant.read", "WS-AUTH-001-10B"), + "project_role_grant.issue": ("project.role_grant.manage", "WS-AUTH-001-10C"), + "project_role_grant.revoke": ("project.role_grant.manage", "WS-AUTH-001-10C"), } 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) == 65 + assert len(ACTION_IDS) == len(ACTION_DEFINITIONS) == len(ACTION_BY_ID) == 70 assert set(ACTION_BY_ID) == ACTION_IDS assert {definition.owner for definition in ACTION_DEFINITIONS} == set(ActionOwner) assert { @@ -551,7 +563,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> assert sum( definition.availability is ActionAvailability.PLANNED for definition in ACTION_DEFINITIONS - ) == 48 + ) == 53 assert resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id is ( PermissionId.ACTOR_PROFILE_READ_SELF ) @@ -678,7 +690,7 @@ def test_art_custody_documentation_matches_the_independent_catalogue_fixture() - assert "The ART transfer adds no migration" in operations assert "does not grant Operator" in operations assert "verification retry remains independently gated" in operations - assert "74 PermissionIds, 65 ActionIds, 17 active actions, and\n48 planned actions" in operations + assert "74 PermissionIds, 70 ActionIds, 17 active actions, and\n53 planned actions" in operations def test_rev_custody_documentation_matches_the_independent_catalogue_fixture() -> None: @@ -6147,13 +6159,9 @@ def _operation_success( "authority_revocation", ), AuthorityOperation.PROJECT_ROLE_GRANT_ISSUE: ( - AuthorityEventType.PROJECT_ROLE_GRANT_REPLACED - if getattr(request, "replaced_grant_id", None) - else AuthorityEventType.PROJECT_ROLE_GRANT_ISSUED, + AuthorityEventType.PROJECT_ROLE_GRANT_ISSUED, "project.role_grant.manage", - "authority_replacement" - if getattr(request, "replaced_grant_id", None) - else "authority_assignment", + "authority_assignment", ), AuthorityOperation.PROJECT_ROLE_GRANT_REVOKE: ( AuthorityEventType.PROJECT_ROLE_GRANT_REVOKED, @@ -6228,7 +6236,6 @@ def _operation_success( elif isinstance(request, ProjectRoleGrantIssueRequest): project_id = request.project_id target_actor = request.target_actor_id - matched_grant = request.replaced_grant_id after_facts = { "status": "active", "role": request.role.value, @@ -6236,8 +6243,6 @@ def _operation_success( "scope_id": str(project_id), "effective": True, } - if matched_grant: - before_facts = after_facts | {"role": "reviewer"} elif isinstance(request, ProjectRoleGrantRevokeRequest): project_id = request.project_id before_facts = { @@ -7047,8 +7052,7 @@ def test_every_operation_has_one_strict_canonical_request_variant() -> None: operation=AuthorityOperation.PROJECT_ROLE_GRANT_ISSUE, project_id=project, target_actor_id=actor, - role=ProjectRole.BOTH, - replaced_grant_id=resource, + role=ProjectRole.ADJUDICATOR, reason_digest=DIGEST, ), ProjectRoleGrantRevokeRequest( @@ -7088,6 +7092,58 @@ def test_every_operation_has_one_strict_canonical_request_variant() -> None: ) +def test_project_role_contract_rejects_replacement_and_bounds_qualification_references() -> None: + project_id, actor_id = uuid4(), uuid4() + base = { + "operation": "project_role_grant.issue", + "project_id": project_id, + "target_actor_id": actor_id, + "role": "submitter", + "reason_digest": DIGEST, + } + for invalid in ( + base | {"role": "both"}, + base | {"replaced_grant_id": uuid4()}, + ): + with pytest.raises(TypeError, match="invalid authority mutation request"): + parse_authority_request(invalid) + + available = { + "availability": QualificationAvailability.AVAILABLE, + "reference_ids": ["work:opaque-1"], + "unavailable_reason": None, + } + unavailable = { + "availability": QualificationAvailability.UNAVAILABLE, + "reference_ids": [], + "unavailable_reason": QualificationUnavailableReason.NO_RECORD, + } + snapshot = ProjectRoleQualificationSnapshotInput.model_validate( + { + "project_id": project_id, + "actor_profile_id": actor_id, + "requested_role": ProjectRole.ADJUDICATOR, + "skills_snapshot": available, + "reputation_snapshot": unavailable, + "prior_project_work_refs": [uuid4()], + "external_expertise_refs": ["expertise:opaque-1"], + } + ) + assert snapshot.requested_role is ProjectRole.ADJUDICATOR + assert QualificationAvailabilitySnapshot.model_validate(available).reference_ids == [ + "work:opaque-1" + ] + for invalid in ( + available | {"reference_ids": []}, + available | {"unavailable_reason": "no_record"}, + unavailable | {"reference_ids": ["unexpected"]}, + available | {"reference_ids": ["https://credential.example/secret"]}, + available | {"reference_ids": ["x"] * 21}, + ): + with pytest.raises(ValidationError): + QualificationAvailabilitySnapshot.model_validate(invalid) + + def test_actor_profile_lifecycle_public_schemas_are_strict_bounded_and_typed() -> None: target = uuid4() assert ActorLifecycleBody(reason=" approved correction ").reason == "approved correction" @@ -7124,7 +7180,7 @@ def test_actor_profile_lifecycle_public_schemas_are_strict_bounded_and_typed() - @pytest.mark.asyncio -async def test_all_operation_and_replacement_mappings_commit_one_linked_pair( +async def test_project_role_and_all_operation_mappings_commit_one_linked_pair( authorization_factory, ) -> None: project, actor, resource, admin_revoke_target = uuid4(), uuid4(), uuid4(), uuid4() @@ -7159,14 +7215,6 @@ async def test_all_operation_and_replacement_mappings_commit_one_linked_pair( role=ProjectRole.SUBMITTER, reason_digest=DIGEST, ), - ProjectRoleGrantIssueRequest( - operation=AuthorityOperation.PROJECT_ROLE_GRANT_ISSUE, - project_id=project, - target_actor_id=actor, - role=ProjectRole.SUBMITTER, - replaced_grant_id=resource, - reason_digest=DIGEST, - ), ProjectRoleGrantRevokeRequest( operation=AuthorityOperation.PROJECT_ROLE_GRANT_REVOKE, project_id=project, diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index be42d9990..056be2bc1 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -607,11 +607,11 @@ resource loader, lifecycle guards, negative tests, and evidence path exist. ### Catalogue And Action-Evidence Staging -The catalogue contains exactly 74 PermissionIds and 65 ActionIds after -AUTH-09D-B. The two AUTH-07B actor-self actions, seven AUTH-08 administrative +The catalogue contains exactly 74 PermissionIds and 70 ActionIds after +AUTH-10A. 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 other 48 entries remain planned +identity-link lifecycle actions are active; the other 53 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 @@ -636,8 +636,9 @@ complete. Counts and mappings remain unchanged. The ART transfer adds no migrati 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 remain 74 PermissionIds, 65 ActionIds, 17 active actions, and -48 planned actions. Four later +Catalogue totals are 74 PermissionIds, 70 ActionIds, 17 active actions, and +53 planned actions after AUTH-10A registers five unavailable project-role rows. +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 @@ -724,7 +725,7 @@ exact-project grants and canonical project composition. AUTH-10 is a clean cut to independent `submitter`, `reviewer`, and `adjudicator` grants. Before rollout, scan current typed schemas, audit facts, idempotency records, and PostgreSQL validators for `both`, replacement fields, -replacement events, and replacement reasons. Migration `0027` must stop on any +replacement events, and replacement reasons. Migration `0031` must stop on any incompatible evidence; operators must remediate through a separately approved data decision, never an automatic conversion. A safe downgrade also refuses rather than deleting adjudicator or new exact-role evidence. diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 0de449885..e56e9144f 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -119,7 +119,7 @@ The active model has no `both`, replacement field, replacement event, or replacement reason. Qualification evidence is bound to the same actor, project, and exact requested role. One active row is permitted per actor/project/role. Issue idempotency includes the requested role; revoke derives -the role from the locked grant. Migration `0027` refuses upgrade when obsolete +the role from the locked grant. Migration `0031` refuses upgrade when obsolete combined or replacement evidence exists and never converts or deletes those rows. It replaces current typed and PostgreSQL validators without changing historical migrations. @@ -238,8 +238,9 @@ approved Operator recovery identifiers, 21 artifact identifiers, and `review.queue.override` are the exact 25 post-`0020` permissions. AUTH-07A adds their matching typed/SQL audit parity without making them executable. -The closed action registry contains 65 rows after AUTH-09D-B: 17 active actions -and 48 planned rows. AUTH-08 adds seven active administrative definition, +The closed action registry contains 70 rows after AUTH-10A: 17 active actions +and 53 planned rows. AUTH-10A adds five planned project-role read/manage rows, +owned by AUTH-10B and AUTH-10C. AUTH-08 adds seven active administrative definition, grant-history, issue, revoke, and local-bootstrap actions without adding a permission. AUTH-09A adds eight planned actor, identity-link, and service provisioning actions without activating a route; AUTH-09B activates only From c7d4c71c4d6370c3835a4ccba46775f34af3041d Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 21 Jul 2026 14:18:09 +0100 Subject: [PATCH 06/19] test(auth): close project role migration safety gaps --- .../versions/0031_project_role_grants.py | 201 ++++- backend/app/modules/authorization/models.py | 21 +- backend/tests/test_alembic.py | 843 +++++++++++++++--- docs/operations_authorization_service.md | 32 + 4 files changed, 919 insertions(+), 178 deletions(-) diff --git a/backend/alembic/versions/0031_project_role_grants.py b/backend/alembic/versions/0031_project_role_grants.py index 4d88805ed..83bb44686 100644 --- a/backend/alembic/versions/0031_project_role_grants.py +++ b/backend/alembic/versions/0031_project_role_grants.py @@ -38,13 +38,17 @@ def _constraint_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() + 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_constraint(name: str, definition: str) -> None: @@ -90,9 +94,7 @@ def _replace_authority_registries(*, add: bool) -> None: raise RuntimeError("unexpected authority denial registry definition") definition = definition.replace(marker, marker + ", " + additions) else: - additions = ", " + ", ".join( - f"('{value}'::character varying)::text" for value in _DENIALS - ) + additions = ", " + ", ".join(f"('{value}'::character varying)::text" for value in _DENIALS) if definition.count(additions) != 1: raise RuntimeError("unexpected authority denial registry definition") definition = definition.replace(additions, "") @@ -111,9 +113,11 @@ def _replace_authority_registries(*, add: bool) -> None: def _function_definition(name: str) -> str: - return op.get_bind().execute( - sa.text("select pg_get_functiondef(cast(:name as regproc))"), {"name": name} - ).scalar_one() + return ( + op.get_bind() + .execute(sa.text("select pg_get_functiondef(cast(:name as regproc))"), {"name": name}) + .scalar_one() + ) def _replace_audit_functions(*, add: bool) -> None: @@ -177,7 +181,7 @@ def _create_helpers() -> None: select 1 from jsonb_array_elements(value) item where jsonb_typeof(item)<>'string' or case when uuid_only then not (item #>> '{}') ~ - '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' else not project_role_reference_token_is_safe(item #>> '{}') end ) $$ @@ -201,7 +205,9 @@ def _create_helpers() -> None: language plpgsql immutable strict as $$ declare point integer; index integer; begin - if octet_length(value) not between 1 and 500 or value <> btrim(value, """ + _STRIP + r""") then return false; end if; + if octet_length(value) not between 1 and 500 or value <> btrim(value, """ + + _STRIP + + r""") then return false; end if; for index in 1..char_length(value) loop point := ascii(substr(value,index,1)); if point between 0 and 31 or point between 127 and 159 @@ -211,7 +217,7 @@ def _create_helpers() -> None: end loop; return true; end $$ - """ + """, ) for statement in statements: op.execute(statement) @@ -221,16 +227,20 @@ def _create_history_guards() -> None: statements = ( """ create function guard_project_role_snapshot_history() returns trigger language plpgsql as $$ - begin raise exception 'project-role qualification snapshots are immutable' using errcode='55000'; end $$ + begin + if tg_op='INSERT' then new.captured_at := clock_timestamp(); return new; end if; + raise exception 'project-role qualification snapshots are immutable' using errcode='55000'; + end $$ """, """ create trigger trg_project_role_qualification_snapshots_immutable - before update or delete on project_role_qualification_snapshots + before insert or update or delete on project_role_qualification_snapshots for each row execute function guard_project_role_snapshot_history() """, """ create function guard_project_role_grant_history() returns trigger language plpgsql as $$ begin + if tg_op='INSERT' then new.granted_at := clock_timestamp(); return new; end if; if tg_op='DELETE' then raise exception 'project-role grants are immutable history' using errcode='55000'; end if; if (new.id,new.project_id,new.actor_profile_id,new.role,new.grant_method, new.qualification_snapshot_id,new.granted_by_actor_profile_id, @@ -241,16 +251,29 @@ def _create_history_guards() -> None: old.granted_by_admin_role_grant_id,old.grant_reason,old.granted_at) or old.status<>'active' or old.version<>1 or new.status<>'revoked' or new.version<>2 or new.revoked_by_actor_profile_id is null or new.revoked_by_admin_role_grant_id is null - or new.revoked_reason is null or new.revoked_at is null then + or new.revoked_reason is null then raise exception 'invalid project-role grant history transition' using errcode='23514'; end if; + new.revoked_at := clock_timestamp(); return new; end $$ """, """ - create trigger trg_project_role_grants_history before update or delete on project_role_grants + create trigger trg_project_role_grants_history before insert or update or delete on project_role_grants for each row execute function guard_project_role_grant_history() + """, + """ + create function reject_project_role_history_truncate() returns trigger language plpgsql as $$ + begin raise exception 'project-role history cannot be truncated' using errcode='55000'; end $$ + """, """ + create trigger trg_project_role_snapshots_reject_truncate before truncate + on project_role_qualification_snapshots execute function reject_project_role_history_truncate() + """, + """ + create trigger trg_project_role_grants_reject_truncate before truncate + on project_role_grants execute function reject_project_role_history_truncate() + """, ) for statement in statements: op.execute(statement) @@ -259,15 +282,19 @@ def _create_history_guards() -> None: def upgrade() -> None: """Install exact-role history only after proving no replacement-era state exists.""" bind = op.get_bind() - bind.execute(sa.text("lock table audit_events, authority_idempotency_records in access exclusive mode")) - blocked = bind.execute(sa.text(""" + bind.execute( + sa.text("lock table audit_events, authority_idempotency_records in access exclusive mode") + ) + blocked = bind.execute( + sa.text(""" select exists(select 1 from audit_events where event_domain='authority' and ( before_facts->>'role'='both' or after_facts->>'role'='both' or before_facts::jsonb ? 'replaced_grant_id' or after_facts::jsonb ? 'replaced_grant_id' or event_type='ProjectRoleGrantReplaced' or reason='authority_replacement')) or exists(select 1 from authority_idempotency_records where operation in ('project_role_grant.issue','project_role_grant.revoke')) - """)).scalar_one() + """) + ).scalar_one() if blocked: raise RuntimeError("cannot safely upgrade replacement-era project-role evidence") _create_helpers() @@ -275,49 +302,124 @@ def upgrade() -> None: "project_role_qualification_snapshots", sa.Column("id", sa.Uuid(), primary_key=True), sa.Column("project_id", sa.String(36), sa.ForeignKey("projects.id"), nullable=False), - sa.Column("actor_profile_id", sa.String(36), sa.ForeignKey("actor_profiles.id"), nullable=False), + sa.Column( + "actor_profile_id", sa.String(36), sa.ForeignKey("actor_profiles.id"), nullable=False + ), sa.Column("requested_role", sa.String(24), nullable=False), sa.Column("skills_snapshot", postgresql.JSONB(), nullable=False), sa.Column("reputation_snapshot", postgresql.JSONB(), nullable=False), sa.Column("prior_project_work_refs", postgresql.JSONB(), nullable=False), sa.Column("external_expertise_refs", postgresql.JSONB(), nullable=False), - sa.Column("captured_by_actor_profile_id", sa.String(36), sa.ForeignKey("actor_profiles.id"), nullable=False), - sa.Column("captured_by_admin_role_grant_id", sa.Uuid(), sa.ForeignKey("admin_role_grants.id"), nullable=False), - sa.Column("captured_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column( + "captured_by_actor_profile_id", + sa.String(36), + sa.ForeignKey("actor_profiles.id"), + nullable=False, + ), + sa.Column( + "captured_by_admin_role_grant_id", + sa.Uuid(), + sa.ForeignKey("admin_role_grants.id"), + nullable=False, + ), + sa.Column( + "captured_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now() + ), sa.CheckConstraint("requested_role in ('submitter','reviewer','adjudicator')", name="role"), - sa.CheckConstraint("project_role_availability_is_safe(skills_snapshot) and project_role_availability_is_safe(reputation_snapshot)", name="availability"), - sa.CheckConstraint("project_role_reference_array_is_safe(prior_project_work_refs,true)", name="prior_work_refs"), - sa.CheckConstraint("project_role_reference_array_is_safe(external_expertise_refs,false)", name="external_expertise_refs"), - sa.UniqueConstraint("id","actor_profile_id","project_id","requested_role", name="grant_reference"), + sa.CheckConstraint( + "project_role_availability_is_safe(skills_snapshot) and project_role_availability_is_safe(reputation_snapshot)", + name="availability", + ), + sa.CheckConstraint( + "project_role_reference_array_is_safe(prior_project_work_refs,true)", + name="prior_work_refs", + ), + sa.CheckConstraint( + "project_role_reference_array_is_safe(external_expertise_refs,false)", + name="external_expertise_refs", + ), + sa.UniqueConstraint( + "id", "actor_profile_id", "project_id", "requested_role", name="grant_reference" + ), + ) + op.create_index( + "ix_project_role_qualification_snapshots_history", + "project_role_qualification_snapshots", + ["project_id", "actor_profile_id", "requested_role", "captured_at"], ) - op.create_index("ix_project_role_qualification_snapshots_history", "project_role_qualification_snapshots", ["project_id","actor_profile_id","requested_role","captured_at"]) op.create_table( "project_role_grants", sa.Column("id", sa.Uuid(), primary_key=True), sa.Column("project_id", sa.String(36), sa.ForeignKey("projects.id"), nullable=False), - sa.Column("actor_profile_id", sa.String(36), sa.ForeignKey("actor_profiles.id"), nullable=False), + sa.Column( + "actor_profile_id", sa.String(36), sa.ForeignKey("actor_profiles.id"), nullable=False + ), sa.Column("role", sa.String(24), nullable=False), sa.Column("status", sa.String(16), nullable=False, server_default="active"), sa.Column("version", sa.SmallInteger(), nullable=False, server_default="1"), sa.Column("grant_method", sa.String(16), nullable=False, server_default="manual"), sa.Column("qualification_snapshot_id", sa.Uuid(), nullable=False), - sa.Column("granted_by_actor_profile_id", sa.String(36), sa.ForeignKey("actor_profiles.id"), nullable=False), - sa.Column("granted_by_admin_role_grant_id", sa.Uuid(), sa.ForeignKey("admin_role_grants.id"), nullable=False), + sa.Column( + "granted_by_actor_profile_id", + sa.String(36), + sa.ForeignKey("actor_profiles.id"), + nullable=False, + ), + sa.Column( + "granted_by_admin_role_grant_id", + sa.Uuid(), + sa.ForeignKey("admin_role_grants.id"), + nullable=False, + ), sa.Column("grant_reason", sa.Text(), nullable=False), - sa.Column("granted_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column( + "granted_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now() + ), sa.Column("revoked_by_actor_profile_id", sa.String(36), sa.ForeignKey("actor_profiles.id")), - sa.Column("revoked_by_admin_role_grant_id", sa.Uuid(), sa.ForeignKey("admin_role_grants.id")), + sa.Column( + "revoked_by_admin_role_grant_id", sa.Uuid(), sa.ForeignKey("admin_role_grants.id") + ), sa.Column("revoked_reason", sa.Text()), sa.Column("revoked_at", sa.DateTime(timezone=True)), sa.CheckConstraint("role in ('submitter','reviewer','adjudicator')", name="role"), sa.CheckConstraint("grant_method='manual'", name="grant_method"), - sa.CheckConstraint("project_role_reason_is_safe(grant_reason) and (revoked_reason is null or project_role_reason_is_safe(revoked_reason))", name="reason"), - sa.CheckConstraint("(status='active' and version=1 and revoked_by_actor_profile_id is null and revoked_by_admin_role_grant_id is null and revoked_reason is null and revoked_at is null) or (status='revoked' and version=2 and revoked_by_actor_profile_id is not null and revoked_by_admin_role_grant_id is not null and revoked_reason is not null and revoked_at is not null)", name="lifecycle"), - sa.ForeignKeyConstraint(["qualification_snapshot_id","actor_profile_id","project_id","role"], ["project_role_qualification_snapshots.id","project_role_qualification_snapshots.actor_profile_id","project_role_qualification_snapshots.project_id","project_role_qualification_snapshots.requested_role"], name="qualification_ownership", ondelete="RESTRICT"), + sa.CheckConstraint( + "project_role_reason_is_safe(grant_reason) and (revoked_reason is null or project_role_reason_is_safe(revoked_reason))", + name="reason", + ), + sa.CheckConstraint( + "(status='active' and version=1 and revoked_by_actor_profile_id is null and revoked_by_admin_role_grant_id is null and revoked_reason is null and revoked_at is null) or (status='revoked' and version=2 and revoked_by_actor_profile_id is not null and revoked_by_admin_role_grant_id is not null and revoked_reason is not null and revoked_at is not null)", + name="lifecycle", + ), + sa.ForeignKeyConstraint( + ["qualification_snapshot_id", "actor_profile_id", "project_id", "role"], + [ + "project_role_qualification_snapshots.id", + "project_role_qualification_snapshots.actor_profile_id", + "project_role_qualification_snapshots.project_id", + "project_role_qualification_snapshots.requested_role", + ], + name="qualification_ownership", + ondelete="RESTRICT", + ), + ) + op.create_index( + "uq_project_role_grants_active_exact_role", + "project_role_grants", + ["project_id", "actor_profile_id", "role"], + unique=True, + postgresql_where=sa.text("status='active'"), + ) + op.create_index( + "ix_project_role_grants_project_actor_role_status", + "project_role_grants", + ["project_id", "actor_profile_id", "role", "status"], + ) + op.create_index( + "ix_project_role_grants_actor_role_status", + "project_role_grants", + ["actor_profile_id", "role", "status"], ) - op.create_index("uq_project_role_grants_active_exact_role", "project_role_grants", ["project_id","actor_profile_id","role"], unique=True, postgresql_where=sa.text("status='active'")) - op.create_index("ix_project_role_grants_project_actor_role_status", "project_role_grants", ["project_id","actor_profile_id","role","status"]) - op.create_index("ix_project_role_grants_actor_role_status", "project_role_grants", ["actor_profile_id","role","status"]) _create_history_guards() _replace_authority_registries(add=True) _replace_action_registry(add=True) @@ -327,8 +429,13 @@ def upgrade() -> None: def downgrade() -> None: """Restore 0030 only when no 10A-owned truth or evidence exists.""" bind = op.get_bind() - bind.execute(sa.text("lock table project_role_grants, project_role_qualification_snapshots, audit_events in access exclusive mode")) - blocked = bind.execute(sa.text(""" + bind.execute( + sa.text( + "lock table project_role_grants, project_role_qualification_snapshots, audit_events in access exclusive mode" + ) + ) + blocked = bind.execute( + sa.text(""" select exists(select 1 from project_role_grants) or exists(select 1 from project_role_qualification_snapshots) or exists(select 1 from audit_events where event_domain='authority' and ( @@ -336,7 +443,8 @@ def downgrade() -> None: action_id in ('project.contributor_candidate.list','project_role_grant.list', 'project_role_grant.read','project_role_grant.issue','project_role_grant.revoke') or denial_code in ('project_role_grant_already_revoked','project_role_grant_replay_state_changed'))) - """)).scalar_one() + """) + ).scalar_one() if blocked: raise RuntimeError("cannot downgrade project-role grant evidence") _replace_audit_functions(add=False) @@ -346,6 +454,7 @@ def downgrade() -> None: op.drop_table("project_role_qualification_snapshots") op.execute("drop function guard_project_role_grant_history()") op.execute("drop function guard_project_role_snapshot_history()") + op.execute("drop function reject_project_role_history_truncate()") op.execute("drop function project_role_availability_is_safe(jsonb)") op.execute("drop function project_role_reference_array_is_safe(jsonb,boolean)") op.execute("drop function project_role_reference_token_is_safe(text)") diff --git a/backend/app/modules/authorization/models.py b/backend/app/modules/authorization/models.py index b2bfe2265..5306ced4a 100644 --- a/backend/app/modules/authorization/models.py +++ b/backend/app/modules/authorization/models.py @@ -217,6 +217,19 @@ class ProjectRoleQualificationSnapshot(Base): __tablename__ = "project_role_qualification_snapshots" __table_args__ = ( CheckConstraint("requested_role in ('submitter','reviewer','adjudicator')", name="role"), + CheckConstraint( + "project_role_availability_is_safe(skills_snapshot) and " + "project_role_availability_is_safe(reputation_snapshot)", + name="availability", + ), + CheckConstraint( + "project_role_reference_array_is_safe(prior_project_work_refs,true)", + name="prior_work_refs", + ), + CheckConstraint( + "project_role_reference_array_is_safe(external_expertise_refs,false)", + name="external_expertise_refs", + ), UniqueConstraint( "id", "actor_profile_id", @@ -259,14 +272,18 @@ class ProjectRoleGrant(Base): __table_args__ = ( CheckConstraint("role in ('submitter','reviewer','adjudicator')", name="role"), CheckConstraint("grant_method='manual'", name="grant_method"), - CheckConstraint("octet_length(grant_reason) between 1 and 500", name="grant_reason"), + CheckConstraint( + "project_role_reason_is_safe(grant_reason) and " + "(revoked_reason is null or project_role_reason_is_safe(revoked_reason))", + name="reason", + ), CheckConstraint( "(status='active' and version=1 and revoked_by_actor_profile_id is null " "and revoked_by_admin_role_grant_id is null and revoked_reason is null " "and revoked_at is null) or (status='revoked' and version=2 and " "revoked_by_actor_profile_id is not null and " "revoked_by_admin_role_grant_id is not null and revoked_reason is not null " - "and octet_length(revoked_reason) between 1 and 500 and revoked_at is not null)", + "and revoked_at is not null)", name="lifecycle", ), ForeignKeyConstraint( diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 181b49b78..95003ae0c 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -2,6 +2,7 @@ import asyncio from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime import json import os from pathlib import Path @@ -60,12 +61,12 @@ def _alembic_config() -> Config: def test_service_identity_migration_contract_is_frozen_from_application_modules() -> None: backend_root = Path(__file__).resolve().parents[1] - revision_source = ( - backend_root / "alembic/versions/0023_service_actor_identity.py" - ).read_text(encoding="utf-8") - contract_source = ( - backend_root / "migration_contracts/service_identity_0023.py" - ).read_text(encoding="utf-8") + revision_source = (backend_root / "alembic/versions/0023_service_actor_identity.py").read_text( + encoding="utf-8" + ) + contract_source = (backend_root / "migration_contracts/service_identity_0023.py").read_text( + encoding="utf-8" + ) assert "from migration_contracts.service_identity_0023 import" in revision_source assert "app.modules" not in revision_source assert "app.modules" not in contract_source @@ -101,11 +102,14 @@ def test_frozen_mapping_path_custody_is_independent_of_install_location( private_root = tmp_path / "private" deployment_root.mkdir() private_root.mkdir() - assert validate_frozen_mapping_path( - private_root / "private-envelope.json", - repository_root=deployment_root, - output=True, - ) == private_root / "private-envelope.json" + assert ( + validate_frozen_mapping_path( + private_root / "private-envelope.json", + repository_root=deployment_root, + output=True, + ) + == private_root / "private-envelope.json" + ) def test_alembic_upgrade_and_downgrade(isolated_database_env: str, migration_lock) -> None: @@ -115,6 +119,8 @@ def test_alembic_upgrade_and_downgrade(isolated_database_env: str, migration_loc with migration_lock(): command.downgrade(config, "base") + command.upgrade(config, "head") + command.downgrade(config, "base") def test_project_role_migration_constraints_and_immutable_history( @@ -134,14 +140,172 @@ def test_project_role_migration_constraints_and_immutable_history( "invalid_availability": "23514", "duplicate_role": "23505", "snapshot_update": "55000", + "snapshot_delete": "55000", + "snapshot_truncate": "0A000", "issuance_update": "23514", + "grant_delete": "55000", + "grant_truncate": "55000", + "database_timestamps": True, + "snapshot_constraint_rejections": { + "extra_key": "23514", + "available_empty": "23514", + "unavailable_with_reference": "23514", + "url_reference": "23514", + "too_many_references": "23514", + "invalid_prior_uuid": "23514", + }, + "grant_constraint_rejections": { + "automated_method": "23514", + "combined_role": "23514", + "leading_space_reason": "23514", + "control_reason": "23514", + "oversize_reason": "23514", + "snapshot_mismatch": "23503", + "invalid_active_version": "23514", + }, "valid_revoke": ("revoked", 2), "second_revoke": "23514", } + project_definitions = tuple( + definition + for definition in ACTION_DEFINITIONS + if definition.owner in {ActionOwner.AUTH_10B, ActionOwner.AUTH_10C} + ) + assert len(project_definitions) == 5 + asyncio.run( + _assert_authorization_action_sql_pairs( + isolated_database_env, definitions=project_definitions + ) + ) + asyncio.run(_assert_project_role_denial_sql(isolated_database_env)) + finally: + command.downgrade(config, "base") + + +def test_project_role_upgrade_refuses_each_legacy_predicate_before_ddl( + isolated_database_env: str, + migration_lock, +) -> None: + """Every obsolete storage predicate leaves 0030 and its schema untouched.""" + config = _alembic_config() + cases = ( + {"before_facts": {"role": "both"}}, + {"after_facts": {"role": "both"}}, + {"before_facts": {"replaced_grant_id": str(uuid4())}}, + {"after_facts": {"replaced_grant_id": str(uuid4())}}, + {"event_type": "ProjectRoleGrantReplaced"}, + {"reason": "authority_replacement"}, + { + "before_facts": {"role": "both", "replaced_grant_id": str(uuid4())}, + "after_facts": {"role": "both", "replaced_grant_id": str(uuid4())}, + "event_type": "ProjectRoleGrantReplaced", + "reason": "authority_replacement", + }, + ) + with migration_lock(): + try: + command.downgrade(config, "base") + command.upgrade(config, "0030_artifact_verification") + for patch in cases: + event_id, constraints = asyncio.run( + _install_legacy_project_role_blocker(isolated_database_env, patch) + ) + with pytest.raises( + RuntimeError, + match="cannot safely upgrade replacement-era project-role evidence", + ): + command.upgrade(config, "head") + assert asyncio.run(_project_role_refusal_state(isolated_database_env)) == ( + "0030_artifact_verification", + False, + False, + 1, + ) + asyncio.run( + _remove_legacy_project_role_blocker( + isolated_database_env, event_id, constraints + ) + ) + + record_id = asyncio.run(_insert_project_role_idempotency_blocker(isolated_database_env)) + with pytest.raises( + RuntimeError, + match="cannot safely upgrade replacement-era project-role evidence", + ): + command.upgrade(config, "head") + assert asyncio.run(_project_role_refusal_state(isolated_database_env))[:3] == ( + "0030_artifact_verification", + False, + False, + ) + asyncio.run(_remove_project_role_idempotency_blocker(isolated_database_env, record_id)) + command.upgrade(config, "head") + finally: + command.downgrade(config, "base") + + +def test_project_role_downgrade_refuses_each_reserved_evidence_predicate( + isolated_database_env: str, + migration_lock, +) -> None: + """Every 10A audit predicate leaves the head schema and row untouched.""" + config = _alembic_config() + cases = ( + {"before_facts": {"role": "adjudicator"}}, + {"after_facts": {"role": "adjudicator"}}, + *( + {"action_id": action} + for action in ( + "project.contributor_candidate.list", + "project_role_grant.list", + "project_role_grant.read", + "project_role_grant.issue", + "project_role_grant.revoke", + ) + ), + {"denial_code": "project_role_grant_already_revoked"}, + {"denial_code": "project_role_grant_replay_state_changed"}, + { + "before_facts": {"role": "adjudicator"}, + "after_facts": {"role": "adjudicator"}, + "action_id": "project_role_grant.issue", + "denial_code": "project_role_grant_replay_state_changed", + }, + ) + with migration_lock(): + try: + command.downgrade(config, "base") + command.upgrade(config, "head") + for patch in cases: + event_id, constraints = asyncio.run( + _install_legacy_project_role_blocker(isolated_database_env, patch) + ) + with pytest.raises( + RuntimeError, match="cannot downgrade project-role grant evidence" + ): + command.downgrade(config, "0030_artifact_verification") + assert asyncio.run(_project_role_refusal_state(isolated_database_env))[:3] == ( + "0031_project_role_grants", + True, + True, + ) + asyncio.run( + _remove_legacy_project_role_blocker( + isolated_database_env, event_id, constraints + ) + ) + table_ids = asyncio.run(_install_project_role_table_blockers(isolated_database_env)) + with pytest.raises(RuntimeError, match="cannot downgrade project-role grant evidence"): + command.downgrade(config, "0030_artifact_verification") + assert asyncio.run(_project_role_refusal_state(isolated_database_env))[:3] == ( + "0031_project_role_grants", + True, + True, + ) + asyncio.run(_remove_project_role_table_blockers(isolated_database_env, table_ids)) + command.downgrade(config, "0030_artifact_verification") finally: command.downgrade(config, "base") - command.upgrade(config, "head") - command.downgrade(config, "base") def test_outbox_migration_schema_and_downgrade_writer_guard( @@ -870,9 +1034,7 @@ def test_fixed_service_identity_schema_mapping_and_guarded_downgrade( ) -> None: """Prove exact legacy mapping, static action parity, and destructive rollback guards.""" config = _alembic_config() - service_id = actor_id_from_external_identity( - "https://identity.test", "auth09-legacy-service" - ) + service_id = actor_id_from_external_identity("https://identity.test", "auth09-legacy-service") envelope_path = tmp_path / "service-identity-envelope.json" with migration_lock(): try: @@ -944,9 +1106,7 @@ def test_fixed_service_identity_schema_mapping_and_guarded_downgrade( "unknown_identity_rejected": True, "duplicate_identity_rejected": True, } - assert asyncio.run( - _service_identity_evidence_guards(isolated_database_env) - ) == { + assert asyncio.run(_service_identity_evidence_guards(isolated_database_env)) == { "update_rejected": True, "delete_rejected": True, "truncate_rejected": True, @@ -990,9 +1150,7 @@ def test_fixed_service_identity_schema_mapping_and_guarded_downgrade( match="^cannot downgrade fixed service identity authority$", ): command.downgrade(config, "0022_bootstrap_admin_grants") - asyncio.run( - _remove_authorization_action_events(isolated_database_env, [event_id]) - ) + asyncio.run(_remove_authorization_action_events(isolated_database_env, [event_id])) command.downgrade(config, "0022_bootstrap_admin_grants") command.upgrade(config, "0023_service_actor_identity") finally: @@ -1052,8 +1210,7 @@ async def schema_state() -> dict[str, object]: ) human_verified = await connection.scalar( text( - "select last_verified_at is not null from actor_identity_links " - "where id=:id" + "select last_verified_at is not null from actor_identity_links where id=:id" ), {"id": human_link_id}, ) @@ -1147,8 +1304,7 @@ async def cleanup() -> None: ) await connection.execute( text( - "delete from actor_profiles where id in " - "(:human,:rejected_human,:service)" + "delete from actor_profiles where id in (:human,:rejected_human,:service)" ), { "human": human_id, @@ -1385,9 +1541,7 @@ def test_artifact_store_v2_refuses_populated_v2_downgrade_before_ddl( command.downgrade(config, "0023_service_actor_identity") refused_revision = asyncio.run(_current_revision(isolated_database_env)) refused_columns = asyncio.run(_fetch_columns(isolated_database_env)) - refused_namespace_count = asyncio.run( - _artifact_namespace_count(isolated_database_env) - ) + refused_namespace_count = asyncio.run(_artifact_namespace_count(isolated_database_env)) asyncio.run(_truncate_v2_artifact_namespace(isolated_database_env)) command.downgrade(config, "0023_service_actor_identity") finally: @@ -1688,9 +1842,7 @@ async def prove_guards() -> None: 12288, ) assert python_strip_code_points == tuple( - code_point - for code_point in range(0x110000) - if chr(code_point).isspace() + code_point for code_point in range(0x110000) if chr(code_point).isspace() ) for code_point in python_strip_code_points: with pytest.raises(DBAPIError): @@ -1753,10 +1905,8 @@ def test_actor_profile_lifecycle_upgrade_refuses_dirty_rows( ( str(uuid4()), "partial-link-reactivation", - "update actor_identity_links set reactivated_by=:actor " - "where actor_profile_id=:actor", - "update actor_identity_links set reactivated_by=null " - "where actor_profile_id=:actor", + "update actor_identity_links set reactivated_by=:actor where actor_profile_id=:actor", + "update actor_identity_links set reactivated_by=null where actor_profile_id=:actor", ), ( str(uuid4()), @@ -1937,7 +2087,10 @@ def test_actor_profile_lifecycle_safe_downgrade_and_reupgrade( command.downgrade(config, "0025_artifact_store_v2") assert asyncio.run(_current_revision(isolated_database_env)) == "0025_artifact_store_v2" command.upgrade(config, "0026_actor_profile_lifecycle") - assert asyncio.run(_current_revision(isolated_database_env)) == "0026_actor_profile_lifecycle" + assert ( + asyncio.run(_current_revision(isolated_database_env)) + == "0026_actor_profile_lifecycle" + ) finally: command.downgrade(config, "base") @@ -1963,7 +2116,9 @@ async def write_profile_reactivation(*, clear: bool = False) -> None: try: async with engine.begin() as connection: if clear: - await connection.execute(text("alter table actor_profiles disable trigger user")) + await connection.execute( + text("alter table actor_profiles disable trigger user") + ) await connection.execute( text( "update actor_profiles set reactivated_by=null,reactivated_at=null," @@ -1997,9 +2152,7 @@ async def insert_audit_blocker(blocker: str) -> str: try: async with engine.begin() as connection: link_id = await connection.scalar( - text( - "select id from actor_identity_links where actor_profile_id=:actor" - ), + text("select id from actor_identity_links where actor_profile_id=:actor"), {"actor": actor_id}, ) if blocker in {"ActorProfileReactivated", "ActorIdentityLinkReactivated"}: @@ -2011,8 +2164,12 @@ async def insert_audit_blocker(blocker: str) -> str: if is_profile else "actor.identity_link.reactivate" ) - reason = "administrative_correction" if is_profile else "identity_lifecycle_change" - before_facts = '{"status":"suspended"}' if is_profile else '{"status":"revoked"}' + reason = ( + "administrative_correction" if is_profile else "identity_lifecycle_change" + ) + before_facts = ( + '{"status":"suspended"}' if is_profile else '{"status":"revoked"}' + ) await connection.execute( text( "alter table audit_events disable trigger " @@ -2033,7 +2190,7 @@ async def insert_audit_blocker(blocker: str) -> str: "'actor_profile',:request_id,:correlation_id,'actor_profile',:actor," ":permission_id,:resource_type,:resource_id," ":resource_type,:resource_id,:reason,cast(:before_facts as json)," - "'{\"status\":\"active\"}'::json)" + '\'{"status":"active"}\'::json)' ), { "id": event_id, @@ -2092,8 +2249,7 @@ async def remove_audit_blocker(event_id: str) -> None: async with engine.begin() as connection: await connection.execute( text( - "alter table audit_events disable trigger " - "audit_events_reject_update_delete" + "alter table audit_events disable trigger audit_events_reject_update_delete" ) ) await connection.execute( @@ -2101,8 +2257,7 @@ async def remove_audit_blocker(event_id: str) -> None: ) await connection.execute( text( - "alter table audit_events enable trigger " - "audit_events_reject_update_delete" + "alter table audit_events enable trigger audit_events_reject_update_delete" ) ) finally: @@ -2233,9 +2388,7 @@ def test_contributor_foundation_upgrade_guards_and_reversible_preservation( "unrelated_update_preserved": True, } assert asyncio.run( - _exercise_contributor_lineage_function_contract( - isolated_database_env - ) + _exercise_contributor_lineage_function_contract(isolated_database_env) ) == { "zero_arguments": "55000", "extra_arguments": "55000", @@ -2277,12 +2430,9 @@ def test_contributor_foundation_preflight_refuses_all_unsafe_classes_atomically( """Classify both source tables without guessing or partially changing schema.""" config = _alembic_config() missing_ids = (str(uuid4()), str(uuid4())) - assignment_malformed = tuple( - f"private.person.{index}@example.test" for index in range(22) - ) + assignment_malformed = tuple(f"private.person.{index}@example.test" for index in range(22)) submission_malformed = tuple( - f"eyJhbGciOiJSUzI1NiJ9.secret-token-material-{index}" - for index in range(22) + f"eyJhbGciOiJSUzI1NiJ9.secret-token-material-{index}" for index in range(22) ) with migration_lock(): @@ -2292,10 +2442,8 @@ def test_contributor_foundation_preflight_refuses_all_unsafe_classes_atomically( fixture = asyncio.run( _seed_contributor_prior_head( isolated_database_env, - assignment_values=assignment_malformed - + (missing_ids[0], "service"), - submission_values=submission_malformed - + (missing_ids[1], "service"), + assignment_values=assignment_malformed + (missing_ids[0], "service"), + submission_values=submission_malformed + (missing_ids[1], "service"), ) ) service_id = str(fixture["service_id"]) @@ -2318,19 +2466,14 @@ def test_contributor_foundation_preflight_refuses_all_unsafe_classes_atomically( assert [row[0] for row in malformed["rows"]] == sorted( row[0] for row in malformed["rows"] ) - assert all( - row[1] == "" - for row in malformed["rows"] - ) + assert all(row[1] == "" for row in malformed["rows"]) assert all( - value not in message - for value in assignment_malformed + submission_malformed + value not in message for value in assignment_malformed + submission_malformed ) assert all( row_id in message for row_id in ( - tuple(fixture["assignment_ids"])[-2:] - + tuple(fixture["submission_ids"])[-2:] + tuple(fixture["assignment_ids"])[-2:] + tuple(fixture["submission_ids"])[-2:] ) ) assert "issuer" not in message @@ -2696,9 +2839,7 @@ async def _outbox_schema(database_url: str) -> dict[str, object]: await connection.scalar(text("select version_num from alembic_version")) ), "columns": {row.column_name for row in column_rows}, - "nullable": { - row.column_name for row in column_rows if row.is_nullable == "YES" - }, + "nullable": {row.column_name for row in column_rows if row.is_nullable == "YES"}, "indexes": indexes, "triggers": triggers, } @@ -2754,7 +2895,9 @@ async def _outbox_downgrade_writer_race( assert not downgrade.done() if commit_writer: await transaction.commit() - with pytest.raises(RuntimeError, match="cannot downgrade with shared outbox events"): + with pytest.raises( + RuntimeError, match="cannot downgrade with shared outbox events" + ): await asyncio.wait_for(downgrade, timeout=5) return "refused_after_commit" await transaction.rollback() @@ -3094,9 +3237,7 @@ async def _reset_canonical_actor_guard_state( try: async with engine.begin() as connection: await connection.execute( - text( - "alter table actor_profiles disable trigger actor_profile_history_guard" - ) + text("alter table actor_profiles disable trigger actor_profile_history_guard") ) await connection.execute( text( @@ -4337,9 +4478,7 @@ async def _truncate_v2_artifact_namespace(database_url: str) -> None: text("select to_regclass('public.artifact_storage_namespaces') is not null") ) if exists: - await connection.execute( - text("truncate table artifact_storage_namespaces cascade") - ) + await connection.execute(text("truncate table artifact_storage_namespaces cascade")) finally: await engine.dispose() @@ -4376,9 +4515,7 @@ async def _artifact_v2_refusal_state(database_url: str) -> dict[str, object]: namespace_table = await connection.scalar( text("select to_regclass('public.artifact_storage_namespaces') is not null") ) - content_count = await connection.scalar( - text("select count(*) from artifact_contents") - ) + content_count = await connection.scalar(text("select count(*) from artifact_contents")) return { "revision": revision, "namespace_table_exists": namespace_table, @@ -6000,7 +6137,9 @@ async def _insert_orphan_admin_evidence(database_url: str, event_type: str) -> N "target": target_id, "request": str(uuid4()), "correlation": str(uuid4()), - "reason": "authorization_policy_denial" if denied else "initial_access_bootstrap", + "reason": "authorization_policy_denial" + if denied + else "initial_access_bootstrap", "denial_code": "permission_not_granted" if denied else None, "facts": None if denied @@ -6300,9 +6439,7 @@ async def _exercise_admin_authority_guards(database_url: str) -> dict[str, objec immutable_before = tuple( ( await connection.execute( - text( - f"select {immutable_columns} from admin_role_grants where id=:id" - ), + text(f"select {immutable_columns} from admin_role_grants where id=:id"), {"id": grant_id}, ) ).one() @@ -6370,9 +6507,7 @@ async def _exercise_admin_authority_guards(database_url: str) -> dict[str, objec try: async with engine.begin() as connection: await connection.execute( - text( - "update admin_role_grants set status='revoked',version=2 where id=:id" - ), + text("update admin_role_grants set status='revoked',version=2 where id=:id"), {"id": grant_id}, ) except DBAPIError: @@ -6539,8 +6674,7 @@ async def _service_identity_schema(database_url: str) -> dict[str, object]: } service_identity = await connection.scalar( text( - "select service_identity from actor_profiles " - "where actor_kind='service' limit 1" + "select service_identity from actor_profiles where actor_kind='service' limit 1" ) ) if service_identity is not None: @@ -6653,9 +6787,7 @@ async def _service_identity_evidence_guards(database_url: str) -> dict[str, bool try: async with engine.begin() as connection: await connection.execute( - text( - "alter table actor_profile_migration_state disable trigger user" - ) + text("alter table actor_profile_migration_state disable trigger user") ) await connection.execute(text(statement)) except DBAPIError: @@ -6771,8 +6903,7 @@ async def _seed_contributor_prior_head( }, ) resolved_assignment_values = tuple( - service_id if value == "service" else value - for value in assignment_values + service_id if value == "service" else value for value in assignment_values ) for index, value in enumerate(assignment_values): await connection.execute( @@ -6854,16 +6985,12 @@ async def _clear_contributor_migration_fixtures(database_url: str) -> None: engine = create_async_engine(database_url) try: async with engine.begin() as connection: - await connection.execute( - text("alter table actor_identity_links disable trigger user") - ) + await connection.execute(text("alter table actor_identity_links disable trigger user")) await connection.execute(text("alter table actor_profiles disable trigger user")) await connection.execute(text("delete from actor_identity_links")) await connection.execute(text("delete from actor_profiles")) await connection.execute(text("alter table actor_profiles enable trigger user")) - await connection.execute( - text("alter table actor_identity_links enable trigger user") - ) + await connection.execute(text("alter table actor_identity_links enable trigger user")) finally: await engine.dispose() @@ -6872,6 +6999,7 @@ async def _contributor_foundation_shape(database_url: str) -> dict[str, object]: engine = create_async_engine(database_url) try: async with engine.connect() as connection: + async def column(table: str) -> tuple[str, int]: row = ( await connection.execute( @@ -7214,8 +7342,7 @@ async def _exercise_contributor_lineage_function_contract( text("insert into lineage_nullable (contributor_id) values (null)") ) results["nullable_field_accepted"] = ( - await connection.scalar(text("select count(*) from lineage_nullable")) - == 1 + await connection.scalar(text("select count(*) from lineage_nullable")) == 1 ) return results finally: @@ -7242,10 +7369,7 @@ async def _drop_contributor_function_dependency(database_url: str) -> None: try: async with engine.begin() as connection: await connection.execute( - text( - "drop trigger task_assignments_contributor_dependency " - "on task_assignments" - ) + text("drop trigger task_assignments_contributor_dependency on task_assignments") ) finally: await engine.dispose() @@ -7273,6 +7397,7 @@ async def _exercise_project_role_migration(database_url: str) -> dict[str, objec snapshot_ids = [str(uuid4()) for _ in range(3)] grant_ids = [str(uuid4()) for _ in range(3)] results: dict[str, object] = {} + supplied_at = datetime(2000, 1, 1, tzinfo=UTC) async def rejected(connection, statement: str, values: dict[str, object]) -> str | None: try: @@ -7288,7 +7413,9 @@ async def rejected(connection, statement: str, values: dict[str, object]) -> str try: await _insert_canonical_actor(connection, actor_id, "auth10a-actor", "human") await connection.execute( - text("insert into projects(id,name,slug,status) values (:id,'AUTH 10A',:slug,'active')"), + text( + "insert into projects(id,name,slug,status) values (:id,'AUTH 10A',:slug,'active')" + ), {"id": project_id, "slug": f"auth-10a-{project_id}"}, ) await connection.execute( @@ -7312,79 +7439,259 @@ async def rejected(connection, statement: str, values: dict[str, object]) -> str "insert into project_role_qualification_snapshots " "(id,project_id,actor_profile_id,requested_role,skills_snapshot," "reputation_snapshot,prior_project_work_refs,external_expertise_refs," - "captured_by_actor_profile_id,captured_by_admin_role_grant_id) values " + "captured_by_actor_profile_id,captured_by_admin_role_grant_id,captured_at) values " "(:id,:project,:actor,:role,cast(:skills as jsonb),cast(:reputation as jsonb)," - "cast(:prior as jsonb),cast(:external as jsonb),:actor,:admin)" + "cast(:prior as jsonb),cast(:external as jsonb),:actor,:admin," + "cast(:supplied_at as timestamptz))" ) available = json.dumps( - {"availability": "available", "reference_ids": ["opaque:1"], "unavailable_reason": None} + { + "availability": "available", + "reference_ids": ["opaque:1"], + "unavailable_reason": None, + } ) unavailable = json.dumps( - {"availability": "unavailable", "reference_ids": [], "unavailable_reason": "no_record"} + { + "availability": "unavailable", + "reference_ids": [], + "unavailable_reason": "no_record", + } ) for role, snapshot_id in zip( ("submitter", "reviewer", "adjudicator"), snapshot_ids, strict=True ): await connection.execute( snapshot_insert, - {"id": snapshot_id, "project": project_id, "actor": actor_id, "role": role, - "skills": available, "reputation": unavailable, "prior": "[]", "external": "[]", - "admin": admin_grant_id}, + { + "id": snapshot_id, + "project": project_id, + "actor": actor_id, + "role": role, + "skills": available, + "reputation": unavailable, + "prior": "[]", + "external": "[]", + "admin": admin_grant_id, + "supplied_at": supplied_at, + }, ) results["invalid_availability"] = await rejected( connection, str(snapshot_insert), - {"id": str(uuid4()), "project": project_id, "actor": actor_id, "role": "submitter", - "skills": json.dumps({"availability": "available", "reference_ids": [], "unavailable_reason": None}), - "reputation": unavailable, "prior": "[]", "external": "[]", "admin": admin_grant_id}, + { + "id": str(uuid4()), + "project": project_id, + "actor": actor_id, + "role": "submitter", + "skills": json.dumps( + { + "availability": "available", + "reference_ids": [], + "unavailable_reason": None, + } + ), + "reputation": unavailable, + "prior": "[]", + "external": "[]", + "admin": admin_grant_id, + "supplied_at": supplied_at, + }, ) + snapshot_rejections: dict[str, str | None] = {} + snapshot_variants = { + "extra_key": { + "availability": "available", + "reference_ids": ["opaque:1"], + "unavailable_reason": None, + "extra": True, + }, + "available_empty": { + "availability": "available", + "reference_ids": [], + "unavailable_reason": None, + }, + "unavailable_with_reference": { + "availability": "unavailable", + "reference_ids": ["opaque:1"], + "unavailable_reason": "no_record", + }, + "url_reference": { + "availability": "available", + "reference_ids": ["https://unsafe.example"], + "unavailable_reason": None, + }, + "too_many_references": { + "availability": "available", + "reference_ids": [f"opaque:{index}" for index in range(21)], + "unavailable_reason": None, + }, + } + for name, skills in snapshot_variants.items(): + snapshot_rejections[name] = await rejected( + connection, + str(snapshot_insert), + { + "id": str(uuid4()), + "project": project_id, + "actor": actor_id, + "role": "submitter", + "skills": json.dumps(skills), + "reputation": unavailable, + "prior": "[]", + "external": "[]", + "admin": admin_grant_id, + "supplied_at": supplied_at, + }, + ) + snapshot_rejections["invalid_prior_uuid"] = await rejected( + connection, + str(snapshot_insert), + { + "id": str(uuid4()), + "project": project_id, + "actor": actor_id, + "role": "submitter", + "skills": available, + "reputation": unavailable, + "prior": json.dumps(["not-a-uuid"]), + "external": "[]", + "admin": admin_grant_id, + "supplied_at": supplied_at, + }, + ) + results["snapshot_constraint_rejections"] = snapshot_rejections + raw_grant_insert = ( + "insert into project_role_grants " + "(id,project_id,actor_profile_id,role,status,version,grant_method," + "qualification_snapshot_id,granted_by_actor_profile_id," + "granted_by_admin_role_grant_id,grant_reason) values " + "(:id,:project,:actor,:role,:status,:version,:method,:snapshot,:actor,:admin,:reason)" + ) + base_grant = { + "project": project_id, + "actor": actor_id, + "role": "submitter", + "status": "active", + "version": 1, + "method": "manual", + "snapshot": snapshot_ids[0], + "admin": admin_grant_id, + "reason": "Qualified", + } + grant_variants = { + "automated_method": {"method": "automated"}, + "combined_role": {"role": "both"}, + "leading_space_reason": {"reason": " Qualified"}, + "control_reason": {"reason": "bad\u200bcontrol"}, + "oversize_reason": {"reason": "é" * 251}, + "snapshot_mismatch": {"snapshot": str(uuid4())}, + "invalid_active_version": {"version": 2}, + } + results["grant_constraint_rejections"] = { + name: await rejected( + connection, raw_grant_insert, base_grant | patch | {"id": str(uuid4())} + ) + for name, patch in grant_variants.items() + } grant_insert = text( "insert into project_role_grants " "(id,project_id,actor_profile_id,role,qualification_snapshot_id," - "granted_by_actor_profile_id,granted_by_admin_role_grant_id,grant_reason) " - "values (:id,:project,:actor,:role,:snapshot,:actor,:admin,'Qualified manually')" + "granted_by_actor_profile_id,granted_by_admin_role_grant_id,grant_reason,granted_at) " + "values (:id,:project,:actor,:role,:snapshot,:actor,:admin," + "'Qualified manually',cast(:supplied_at as timestamptz))" ) for role, snapshot_id, grant_id in zip( ("submitter", "reviewer", "adjudicator"), snapshot_ids, grant_ids, strict=True ): await connection.execute( grant_insert, - {"id": grant_id, "project": project_id, "actor": actor_id, "role": role, - "snapshot": snapshot_id, "admin": admin_grant_id}, + { + "id": grant_id, + "project": project_id, + "actor": actor_id, + "role": role, + "snapshot": snapshot_id, + "admin": admin_grant_id, + "supplied_at": supplied_at, + }, ) - results["revision"] = await connection.scalar(text("select version_num from alembic_version")) + results["revision"] = await connection.scalar( + text("select version_num from alembic_version") + ) results["role_count"] = await connection.scalar( text("select count(*) from project_role_grants where project_id=:project"), {"project": project_id}, ) results["duplicate_role"] = await rejected( - connection, str(grant_insert), - {"id": str(uuid4()), "project": project_id, "actor": actor_id, "role": "submitter", - "snapshot": snapshot_ids[0], "admin": admin_grant_id}, + connection, + str(grant_insert), + { + "id": str(uuid4()), + "project": project_id, + "actor": actor_id, + "role": "submitter", + "snapshot": snapshot_ids[0], + "admin": admin_grant_id, + "supplied_at": supplied_at, + }, ) results["snapshot_update"] = await rejected( connection, "update project_role_qualification_snapshots set external_expertise_refs='[]'::jsonb where id=:id", {"id": snapshot_ids[0]}, ) + results["snapshot_delete"] = await rejected( + connection, + "delete from project_role_qualification_snapshots where id=:id", + {"id": snapshot_ids[0]}, + ) + results["snapshot_truncate"] = await rejected( + connection, "truncate project_role_qualification_snapshots", {} + ) results["issuance_update"] = await rejected( connection, "update project_role_grants set grant_reason='Changed' where id=:id", {"id": grant_ids[0]}, ) + results["grant_delete"] = await rejected( + connection, "delete from project_role_grants where id=:id", {"id": grant_ids[0]} + ) + results["grant_truncate"] = await rejected( + connection, "truncate project_role_grants", {} + ) await connection.execute( text( "update project_role_grants set status='revoked',version=2," "revoked_by_actor_profile_id=:actor,revoked_by_admin_role_grant_id=:admin," - "revoked_reason='No longer assigned',revoked_at=clock_timestamp() where id=:id" + "revoked_reason='No longer assigned',revoked_at='2000-01-01T00:00:00+00:00' where id=:id" ), {"id": grant_ids[0], "actor": actor_id, "admin": admin_grant_id}, ) results["valid_revoke"] = tuple( - (await connection.execute( - text("select status,version from project_role_grants where id=:id"), + ( + await connection.execute( + text("select status,version from project_role_grants where id=:id"), + {"id": grant_ids[0]}, + ) + ).one() + ) + results["database_timestamps"] = bool( + await connection.scalar( + text( + "select captured_at > '2026-01-01'::timestamptz from " + "project_role_qualification_snapshots where id=:id" + ), + {"id": snapshot_ids[0]}, + ) + ) and bool( + await connection.scalar( + text( + "select granted_at > '2026-01-01'::timestamptz and " + "revoked_at > '2026-01-01'::timestamptz from project_role_grants where id=:id" + ), {"id": grant_ids[0]}, - )).one() + ) ) results["second_revoke"] = await rejected( connection, @@ -7396,3 +7703,279 @@ async def rejected(connection, statement: str, values: dict[str, object]) -> str return results finally: await engine.dispose() + + +async def _install_legacy_project_role_blocker( + database_url: str, patch: dict[str, object] +) -> tuple[str, list[tuple[str, str]]]: + event_id = await _insert_authorization_action_event(database_url) + engine = create_async_engine(database_url) + try: + async with engine.begin() as connection: + constraints = [ + tuple(row) + for row in ( + await connection.execute( + text( + "select conname,pg_get_constraintdef(oid) from pg_constraint " + "where conrelid='audit_events'::regclass and contype='c' order by conname" + ) + ) + ).all() + ] + await connection.execute(text("alter table audit_events disable trigger user")) + for name, _definition in constraints: + await connection.execute(text(f'alter table audit_events drop constraint "{name}"')) + assignments = [] + values: dict[str, object] = {"id": event_id} + for key, value in patch.items(): + if key in {"before_facts", "after_facts"}: + assignments.append(f"{key}=cast(:{key} as json)") + values[key] = json.dumps(value) + else: + assignments.append(f"{key}=:{key}") + values[key] = value + await connection.execute( + text(f"update audit_events set {','.join(assignments)} where id=:id"), values + ) + return event_id, constraints + finally: + await engine.dispose() + + +async def _remove_legacy_project_role_blocker( + database_url: str, + event_id: str, + constraints: list[tuple[str, str]], +) -> None: + engine = create_async_engine(database_url) + try: + async with engine.begin() as connection: + await connection.execute( + text("delete from audit_events where id=:id"), {"id": event_id} + ) + for name, definition in constraints: + await connection.execute( + text(f'alter table audit_events add constraint "{name}" {definition}') + ) + await connection.execute(text("alter table audit_events enable trigger user")) + finally: + await engine.dispose() + + +async def _project_role_refusal_state(database_url: str) -> tuple[str, bool, bool, int]: + engine = create_async_engine(database_url) + try: + async with engine.connect() as connection: + return ( + str(await connection.scalar(text("select version_num from alembic_version"))), + bool( + await connection.scalar( + text("select to_regclass('project_role_grants') is not null") + ) + ), + bool( + await connection.scalar( + text( + "select to_regclass('project_role_qualification_snapshots') is not null" + ) + ) + ), + int( + await connection.scalar( + text( + "select count(*) from audit_events where event_domain='authority' and " + "(before_facts->>'role'='both' or after_facts->>'role'='both' or " + "before_facts::jsonb ? 'replaced_grant_id' or " + "after_facts::jsonb ? 'replaced_grant_id' or " + "event_type='ProjectRoleGrantReplaced' or reason='authority_replacement')" + ) + ) + ), + ) + finally: + await engine.dispose() + + +async def _insert_project_role_idempotency_blocker(database_url: str) -> str: + record_id = str(uuid4()) + engine = create_async_engine(database_url) + try: + async with engine.begin() as connection: + await connection.execute( + text("alter table authority_idempotency_records disable trigger user") + ) + await connection.execute( + text( + "insert into authority_idempotency_records " + "(id,idempotency_key,actor_ref_kind,actor_ref,operation,request_digest,status) " + "values (:id,:key,'system_principal','workstream:system:bootstrap'," + "'project_role_grant.issue',:digest,'pending')" + ), + { + "id": record_id, + "key": str(uuid4()), + "digest": "sha256:" + "0" * 64, + }, + ) + return record_id + finally: + await engine.dispose() + + +async def _remove_project_role_idempotency_blocker(database_url: str, record_id: str) -> None: + engine = create_async_engine(database_url) + try: + async with engine.begin() as connection: + await connection.execute( + text("delete from authority_idempotency_records where id=:id"), {"id": record_id} + ) + await connection.execute( + text("alter table authority_idempotency_records enable trigger user") + ) + finally: + await engine.dispose() + + +async def _assert_project_role_denial_sql(database_url: str) -> None: + engine = create_async_engine(database_url) + insert = text(str(_ACTION_EVIDENCE_INSERT).replace("'permission_not_granted'", ":denial_code")) + try: + for denial_code in ( + "project_role_grant_already_revoked", + "project_role_grant_replay_state_changed", + ): + values = _action_evidence_values("project_role_grant.read", "project.role_grant.read") + values["denial_code"] = denial_code + async with engine.connect() as connection: + transaction = await connection.begin() + await connection.execute(insert, values) + await transaction.rollback() + invalid = _action_evidence_values("project_role_grant.read", "project.role_grant.read") + invalid["denial_code"] = "project_role_grant_neighboring_unknown" + async with engine.connect() as connection: + transaction = await connection.begin() + with pytest.raises(IntegrityError): + await connection.execute(insert, invalid) + await transaction.rollback() + finally: + await engine.dispose() + + +async def _install_project_role_table_blockers(database_url: str) -> dict[str, str]: + ids = { + "actor": actor_id_from_external_identity("https://identity.test", "auth10a-blocker"), + "project": str(uuid4()), + "admin": str(uuid4()), + "snapshot": str(uuid4()), + "grant": str(uuid4()), + } + engine = create_async_engine(database_url) + try: + async with engine.begin() as connection: + await _insert_canonical_actor(connection, ids["actor"], "auth10a-blocker", "human") + await connection.execute( + text( + "insert into projects(id,name,slug,status) values (:id,'AUTH 10A blocker',:slug,'active')" + ), + {"id": ids["project"], "slug": f"auth-10a-blocker-{ids['project']}"}, + ) + await connection.execute( + text( + "insert into admin_role_grants " + "(id,target_actor_profile_id,role,scope_type,status,version," + "granted_by_system_principal,grant_reason) values " + "(:admin,:actor,'access_administrator','system','active',1," + "'workstream:system:bootstrap','AUTH 10A downgrade blocker')" + ), + ids, + ) + await connection.execute( + text( + "update authority_control set bootstrap_completed=true," + "bootstrap_grant_id=:admin,version=1 where id=1" + ), + ids, + ) + availability = json.dumps( + { + "availability": "available", + "reference_ids": ["opaque:1"], + "unavailable_reason": None, + } + ) + unavailable = json.dumps( + { + "availability": "unavailable", + "reference_ids": [], + "unavailable_reason": "no_record", + } + ) + await connection.execute( + text( + "insert into project_role_qualification_snapshots " + "(id,project_id,actor_profile_id,requested_role,skills_snapshot," + "reputation_snapshot,prior_project_work_refs,external_expertise_refs," + "captured_by_actor_profile_id,captured_by_admin_role_grant_id) values " + "(:snapshot,:project,:actor,'submitter',cast(:available as jsonb)," + "cast(:unavailable as jsonb),'[]'::jsonb,'[]'::jsonb,:actor,:admin)" + ), + ids | {"available": availability, "unavailable": unavailable}, + ) + await connection.execute( + text( + "insert into project_role_grants " + "(id,project_id,actor_profile_id,role,qualification_snapshot_id," + "granted_by_actor_profile_id,granted_by_admin_role_grant_id,grant_reason) " + "values (:grant,:project,:actor,'submitter',:snapshot,:actor,:admin,'Qualified')" + ), + ids, + ) + return ids + finally: + await engine.dispose() + + +async def _remove_project_role_table_blockers(database_url: str, ids: dict[str, str]) -> None: + engine = create_async_engine(database_url) + try: + async with engine.begin() as connection: + for table in ( + "project_role_grants", + "project_role_qualification_snapshots", + "admin_role_grants", + "authority_control", + "actor_identity_links", + "actor_profiles", + ): + await connection.execute(text(f"alter table {table} disable trigger user")) + await connection.execute(text("delete from project_role_grants where id=:grant"), ids) + await connection.execute( + text("delete from project_role_qualification_snapshots where id=:snapshot"), + ids, + ) + await connection.execute( + text( + "update authority_control set bootstrap_completed=false," + "bootstrap_grant_id=null,version=0 where id=1" + ) + ) + await connection.execute(text("delete from admin_role_grants where id=:admin"), ids) + await connection.execute( + text("delete from actor_identity_links where actor_profile_id=:actor"), ids + ) + await connection.execute(text("delete from actor_profiles where id=:actor"), ids) + await connection.execute(text("delete from projects where id=:project"), ids) + for table in reversed( + ( + "project_role_grants", + "project_role_qualification_snapshots", + "admin_role_grants", + "authority_control", + "actor_identity_links", + "actor_profiles", + ) + ): + await connection.execute(text(f"alter table {table} enable trigger user")) + finally: + await engine.dispose() diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 056be2bc1..22033eb25 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -730,6 +730,38 @@ incompatible evidence; operators must remediate through a separately approved data decision, never an automatic conversion. A safe downgrade also refuses rather than deleting adjudicator or new exact-role evidence. +Before upgrading to `0031`, run the following read-only preflight against the +same database. Both counts must be zero: + +```sql +select count(*) from audit_events where event_domain='authority' and ( + before_facts->>'role'='both' or after_facts->>'role'='both' or + before_facts::jsonb ? 'replaced_grant_id' or + after_facts::jsonb ? 'replaced_grant_id' or + event_type='ProjectRoleGrantReplaced' or reason='authority_replacement'); +select count(*) from authority_idempotency_records where operation in + ('project_role_grant.issue','project_role_grant.revoke'); +``` + +Before downgrading from `0031`, both new tables must be empty and this count +must be zero: + +```sql +select count(*) from audit_events where event_domain='authority' and ( + before_facts->>'role'='adjudicator' or after_facts->>'role'='adjudicator' or + action_id in ('project.contributor_candidate.list','project_role_grant.list', + 'project_role_grant.read','project_role_grant.issue','project_role_grant.revoke') or + denial_code in ('project_role_grant_already_revoked', + 'project_role_grant_replay_state_changed')); +``` + +The migration repeats these checks while holding `ACCESS EXCLUSIVE` locks on +the affected authority tables, so schedule a maintenance window that prevents +authority writes. A refusal occurs before schema mutation. Keep the database at +its current revision, investigate the exact nonzero predicate, and recover +forward through a separately reviewed data decision; never delete or convert +authority evidence merely to make the migration proceed. + Project-role revocation is routed by exact role. Submitter invalidation may reach task assignment; reviewer invalidation reaches only REV; adjudicator invalidation remains dormant until that lifecycle exists. Verify grant ID, From e8d9c37e6fd552439ff9f0db8b2972337a3b019f Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 21 Jul 2026 14:30:30 +0100 Subject: [PATCH 07/19] test(auth): prove migration refusal preservation --- backend/tests/test_alembic.py | 218 +++++++++++++++++++---- docs/operations_authorization_service.md | 2 + 2 files changed, 189 insertions(+), 31 deletions(-) diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 95003ae0c..4a8c212ea 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -141,7 +141,7 @@ def test_project_role_migration_constraints_and_immutable_history( "duplicate_role": "23505", "snapshot_update": "55000", "snapshot_delete": "55000", - "snapshot_truncate": "0A000", + "snapshot_truncate": "55000", "issuance_update": "23514", "grant_delete": "55000", "grant_truncate": "55000", @@ -210,6 +210,7 @@ def test_project_role_upgrade_refuses_each_legacy_predicate_before_ddl( event_id, constraints = asyncio.run( _install_legacy_project_role_blocker(isolated_database_env, patch) ) + before_event = asyncio.run(_project_role_audit_row(isolated_database_env, event_id)) with pytest.raises( RuntimeError, match="cannot safely upgrade replacement-era project-role evidence", @@ -221,25 +222,81 @@ def test_project_role_upgrade_refuses_each_legacy_predicate_before_ddl( False, 1, ) + assert ( + asyncio.run(_project_role_audit_row(isolated_database_env, event_id)) + == before_event + ) asyncio.run( _remove_legacy_project_role_blocker( isolated_database_env, event_id, constraints ) ) - record_id = asyncio.run(_insert_project_role_idempotency_blocker(isolated_database_env)) + for operation in ("project_role_grant.issue", "project_role_grant.revoke"): + record_id = asyncio.run( + _insert_project_role_idempotency_blocker(isolated_database_env, operation) + ) + before_record = asyncio.run( + _project_role_idempotency_row(isolated_database_env, record_id) + ) + with pytest.raises( + RuntimeError, + match="cannot safely upgrade replacement-era project-role evidence", + ): + command.upgrade(config, "head") + assert asyncio.run(_project_role_refusal_state(isolated_database_env))[:3] == ( + "0030_artifact_verification", + False, + False, + ) + assert ( + asyncio.run(_project_role_idempotency_row(isolated_database_env, record_id)) + == before_record + ) + asyncio.run( + _remove_project_role_idempotency_blocker(isolated_database_env, record_id) + ) + + event_id, constraints = asyncio.run( + _install_legacy_project_role_blocker( + isolated_database_env, {"after_facts": {"role": "both"}} + ) + ) + record_id = asyncio.run( + _insert_project_role_idempotency_blocker( + isolated_database_env, "project_role_grant.revoke" + ) + ) + before_record = asyncio.run( + _project_role_idempotency_row(isolated_database_env, record_id) + ) with pytest.raises( RuntimeError, match="cannot safely upgrade replacement-era project-role evidence", ): command.upgrade(config, "head") - assert asyncio.run(_project_role_refusal_state(isolated_database_env))[:3] == ( - "0030_artifact_verification", - False, - False, + assert ( + asyncio.run(_project_role_idempotency_row(isolated_database_env, record_id)) + == before_record ) asyncio.run(_remove_project_role_idempotency_blocker(isolated_database_env, record_id)) + asyncio.run( + _remove_legacy_project_role_blocker(isolated_database_env, event_id, constraints) + ) + unrelated_event_id = asyncio.run( + _insert_authorization_action_event(isolated_database_env) + ) + unrelated_before = asyncio.run( + _project_role_audit_row(isolated_database_env, unrelated_event_id) + ) command.upgrade(config, "head") + assert ( + asyncio.run(_project_role_audit_row(isolated_database_env, unrelated_event_id)) + == unrelated_before + ) + asyncio.run( + _remove_authorization_action_events(isolated_database_env, [unrelated_event_id]) + ) finally: command.downgrade(config, "base") @@ -280,6 +337,7 @@ def test_project_role_downgrade_refuses_each_reserved_evidence_predicate( event_id, constraints = asyncio.run( _install_legacy_project_role_blocker(isolated_database_env, patch) ) + before_event = asyncio.run(_project_role_audit_row(isolated_database_env, event_id)) with pytest.raises( RuntimeError, match="cannot downgrade project-role grant evidence" ): @@ -289,20 +347,38 @@ def test_project_role_downgrade_refuses_each_reserved_evidence_predicate( True, True, ) + assert ( + asyncio.run(_project_role_audit_row(isolated_database_env, event_id)) + == before_event + ) asyncio.run( _remove_legacy_project_role_blocker( isolated_database_env, event_id, constraints ) ) - table_ids = asyncio.run(_install_project_role_table_blockers(isolated_database_env)) - with pytest.raises(RuntimeError, match="cannot downgrade project-role grant evidence"): - command.downgrade(config, "0030_artifact_verification") - assert asyncio.run(_project_role_refusal_state(isolated_database_env))[:3] == ( - "0031_project_role_grants", - True, - True, - ) - asyncio.run(_remove_project_role_table_blockers(isolated_database_env, table_ids)) + for include_grant in (False, True): + table_ids = asyncio.run( + _install_project_role_table_blockers( + isolated_database_env, include_grant=include_grant + ) + ) + before_tables = asyncio.run( + _project_role_table_rows(isolated_database_env, table_ids) + ) + with pytest.raises( + RuntimeError, match="cannot downgrade project-role grant evidence" + ): + command.downgrade(config, "0030_artifact_verification") + assert asyncio.run(_project_role_refusal_state(isolated_database_env))[:3] == ( + "0031_project_role_grants", + True, + True, + ) + assert ( + asyncio.run(_project_role_table_rows(isolated_database_env, table_ids)) + == before_tables + ) + asyncio.run(_remove_project_role_table_blockers(isolated_database_env, table_ids)) command.downgrade(config, "0030_artifact_verification") finally: command.downgrade(config, "base") @@ -7561,6 +7637,11 @@ async def rejected(connection, statement: str, values: dict[str, object]) -> str }, ) results["snapshot_constraint_rejections"] = snapshot_rejections + results["snapshot_truncate"] = await rejected( + connection, + "truncate project_role_qualification_snapshots, project_role_grants", + {}, + ) raw_grant_insert = ( "insert into project_role_grants " "(id,project_id,actor_profile_id,role,status,version,grant_method," @@ -7585,7 +7666,7 @@ async def rejected(connection, statement: str, values: dict[str, object]) -> str "leading_space_reason": {"reason": " Qualified"}, "control_reason": {"reason": "bad\u200bcontrol"}, "oversize_reason": {"reason": "é" * 251}, - "snapshot_mismatch": {"snapshot": str(uuid4())}, + "snapshot_mismatch": {"snapshot": snapshot_ids[1]}, "invalid_active_version": {"version": 2}, } results["grant_constraint_rejections"] = { @@ -7646,9 +7727,6 @@ async def rejected(connection, statement: str, values: dict[str, object]) -> str "delete from project_role_qualification_snapshots where id=:id", {"id": snapshot_ids[0]}, ) - results["snapshot_truncate"] = await rejected( - connection, "truncate project_role_qualification_snapshots", {} - ) results["issuance_update"] = await rejected( connection, "update project_role_grants set grant_reason='Changed' where id=:id", @@ -7797,7 +7875,26 @@ async def _project_role_refusal_state(database_url: str) -> tuple[str, bool, boo await engine.dispose() -async def _insert_project_role_idempotency_blocker(database_url: str) -> str: +async def _project_role_audit_row(database_url: str, event_id: str) -> tuple: + engine = create_async_engine(database_url) + try: + async with engine.connect() as connection: + return tuple( + ( + await connection.execute( + text( + "select id,event_type,reason,action_id,denial_code,before_facts," + "after_facts from audit_events where id=:id" + ), + {"id": event_id}, + ) + ).one() + ) + finally: + await engine.dispose() + + +async def _insert_project_role_idempotency_blocker(database_url: str, operation: str) -> str: record_id = str(uuid4()) engine = create_async_engine(database_url) try: @@ -7810,11 +7907,12 @@ async def _insert_project_role_idempotency_blocker(database_url: str) -> str: "insert into authority_idempotency_records " "(id,idempotency_key,actor_ref_kind,actor_ref,operation,request_digest,status) " "values (:id,:key,'system_principal','workstream:system:bootstrap'," - "'project_role_grant.issue',:digest,'pending')" + ":operation,:digest,'pending')" ), { "id": record_id, "key": str(uuid4()), + "operation": operation, "digest": "sha256:" + "0" * 64, }, ) @@ -7823,6 +7921,25 @@ async def _insert_project_role_idempotency_blocker(database_url: str) -> str: await engine.dispose() +async def _project_role_idempotency_row(database_url: str, record_id: str) -> tuple: + engine = create_async_engine(database_url) + try: + async with engine.connect() as connection: + return tuple( + ( + await connection.execute( + text( + "select id,idempotency_key,actor_ref_kind,actor_ref,operation," + "request_digest,status from authority_idempotency_records where id=:id" + ), + {"id": record_id}, + ) + ).one() + ) + finally: + await engine.dispose() + + async def _remove_project_role_idempotency_blocker(database_url: str, record_id: str) -> None: engine = create_async_engine(database_url) try: @@ -7862,7 +7979,9 @@ async def _assert_project_role_denial_sql(database_url: str) -> None: await engine.dispose() -async def _install_project_role_table_blockers(database_url: str) -> dict[str, str]: +async def _install_project_role_table_blockers( + database_url: str, *, include_grant: bool +) -> dict[str, str]: ids = { "actor": actor_id_from_external_identity("https://identity.test", "auth10a-blocker"), "project": str(uuid4()), @@ -7922,20 +8041,57 @@ async def _install_project_role_table_blockers(database_url: str) -> dict[str, s ), ids | {"available": availability, "unavailable": unavailable}, ) - await connection.execute( - text( - "insert into project_role_grants " - "(id,project_id,actor_profile_id,role,qualification_snapshot_id," - "granted_by_actor_profile_id,granted_by_admin_role_grant_id,grant_reason) " - "values (:grant,:project,:actor,'submitter',:snapshot,:actor,:admin,'Qualified')" - ), - ids, - ) + if include_grant: + await connection.execute( + text( + "insert into project_role_grants " + "(id,project_id,actor_profile_id,role,qualification_snapshot_id," + "granted_by_actor_profile_id,granted_by_admin_role_grant_id,grant_reason) " + "values (:grant,:project,:actor,'submitter',:snapshot,:actor,:admin,'Qualified')" + ), + ids, + ) return ids finally: await engine.dispose() +async def _project_role_table_rows( + database_url: str, ids: dict[str, str] +) -> tuple[tuple | None, tuple | None]: + engine = create_async_engine(database_url) + try: + async with engine.connect() as connection: + snapshot = ( + await connection.execute( + text( + "select id,project_id,actor_profile_id,requested_role,skills_snapshot," + "reputation_snapshot,prior_project_work_refs,external_expertise_refs," + "captured_by_actor_profile_id,captured_by_admin_role_grant_id,captured_at " + "from project_role_qualification_snapshots where id=:snapshot" + ), + ids, + ) + ).one_or_none() + grant = ( + await connection.execute( + text( + "select id,project_id,actor_profile_id,role,status,version,grant_method," + "qualification_snapshot_id,granted_by_actor_profile_id," + "granted_by_admin_role_grant_id,grant_reason,granted_at from " + "project_role_grants where id=:grant" + ), + ids, + ) + ).one_or_none() + return ( + tuple(snapshot) if snapshot is not None else None, + tuple(grant) if grant is not None else None, + ) + finally: + await engine.dispose() + + async def _remove_project_role_table_blockers(database_url: str, ids: dict[str, str]) -> None: engine = create_async_engine(database_url) try: diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 22033eb25..f6cd5703f 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -747,6 +747,8 @@ Before downgrading from `0031`, both new tables must be empty and this count must be zero: ```sql +select count(*) from project_role_grants; +select count(*) from project_role_qualification_snapshots; select count(*) from audit_events where event_domain='authority' and ( before_facts->>'role'='adjudicator' or after_facts->>'role'='adjudicator' or action_id in ('project.contributor_candidate.list','project_role_grant.list', From 184404a0c0744402b2582ce4a3a27c8207bc8cf8 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 21 Jul 2026 14:35:20 +0100 Subject: [PATCH 08/19] docs(auth): record AUTH-10A review evidence --- .agent-loop/REVIEW_LOG.md | 12 +++ ...S-AUTH-001-10A-internal-review-evidence.md | 69 +++++++++++++++++ .../WS-AUTH-001-10A-pr-trust-bundle.md | 74 +++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-internal-review-evidence.md create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-pr-trust-bundle.md diff --git a/.agent-loop/REVIEW_LOG.md b/.agent-loop/REVIEW_LOG.md index a935506df..90b9eb7bc 100644 --- a/.agent-loop/REVIEW_LOG.md +++ b/.agent-loop/REVIEW_LOG.md @@ -2752,3 +2752,15 @@ revoked-2 grant version, keeps child actions planned until their owning child activates them with routes, records the complete PREP/idempotency/advisory-lock issue order, and makes `/api/v1` the sole endpoint namespace. Exact-SHA internal review and fresh external checks remain. + +## 2026-07-21 - WS-AUTH-001-10A Internal Implementation Review + +AUTH-10A implementation SHA `e8d9c37e` establishes immutable qualification +snapshots, independent submitter/reviewer/adjudicator grant history, exact typed +and PostgreSQL evidence parity, and five planned future actions without exposing +runtime behavior. Two internal repair loops closed timestamp/truncate custody, +ORM and UUID parity, exhaustive migration refusal/no-mutation proof, composite +ownership proof, unrelated-history preservation, and operator preflight gaps. +Senior engineering, architecture, reuse/dedup, security/auth, QA/test, +test-delta, product/ops, CI integrity, and docs all pass the exact SHA. GitHub +full CI/coverage, CodeRabbit, and human review remain. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-internal-review-evidence.md new file mode 100644 index 000000000..82dbd053a --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-internal-review-evidence.md @@ -0,0 +1,69 @@ +# WS-AUTH-001-10A Internal Review Evidence + +Reviewed code SHA: `e8d9c37e6fd552439ff9f0db8b2972337a3b019f` + +Reviewed implementation SHA: `e8d9c37e6fd552439ff9f0db8b2972337a3b019f` + +Reviewed against trusted main: `70f9c7bcdb63680e545f661a956929379df138e4` + +Reviewed at: `2026-07-21T13:20:00Z` + +Reviewer run IDs: `auth10_plan_core`, `auth10_plan_security_qa`, +`auth10_plan_ops_ci_docs` + +Reviewer tracks: senior engineering, QA/test, security/auth, product/ops, +architecture, CI integrity, docs, reuse/dedup, and test delta + +## Scope + +AUTH-10A adds the project-role qualification and immutable grant data/evidence +foundation only. It adds no route, active action, kernel/PREP behavior, or +callable read/issue/revoke service. + +## Deterministic evidence + +- Ruff passed for authorization, audit, migration, and focused test files. +- Fresh install and full downgrade passed through migration `0031`. +- Focused PostgreSQL migration tests passed for exact-role coexistence, + constraints, timestamps, immutability, lifecycle, action/denial parity, every + upgrade/downgrade refusal predicate, combined blockers, and no-mutation row + preservation. +- `tests/test_audit.py`: 12 passed. +- Focused typed catalogue/schema tests passed. +- Stale authorization wording, Markdown links, and `git diff --check` passed. +- Full suite, shards, aggregate 78 percent coverage, authorization 90 percent + coverage, API E2E, and Agent Gates are delegated to GitHub Actions. + +One combined local selector was interrupted when the local Postgres container +process segfaulted and recovered. Its product assertions were rerun in isolated +groups successfully; this is not recorded as a passing test run. + +## Reviewer Results + +All tracks reviewed exact SHA `e8d9c37e` and passed after two repair loops: + +| Reviewer | Result | Blocking findings | Notes | +|---|---|---|---| +| senior engineering | PASS AFTER FIXES | none | Immutable history, timestamps, refusal preservation, and bounded scope are coherent. | +| QA/test | PASS AFTER FIXES | none | Focused proof reaches every declared migration, ownership, and lifecycle boundary. | +| security/auth | PASS AFTER FIXES | none | Privacy shapes, exact-role ownership, and fail-closed upgrade/downgrade behavior pass. | +| product/ops | PASS | none | Independent roles coexist and no product route or behavior activates. | +| architecture | PASS AFTER FIXES | none | 10A remains data/evidence-only; 10B/10C ownership is preserved. | +| CI integrity | PASS AFTER FIXES | none | Generic round-trip is restored; no workflow, threshold, shard, or script weakened. | +| docs | PASS AFTER FIXES | none | Exact preflight, lock impact, and forward recovery are documented. | +| reuse/dedup | PASS | none | Existing authority, catalogue, audit, and migration conventions are reused. | +| test delta | PASS AFTER FIXES | none | Tests are additive; no assertion, selector, skip, or threshold was weakened. | + +The repair loops added database-owned timestamps, truncate guards, ORM/schema +parity, general UUID parity, exhaustive refusal and row-preservation proof, +existing-row composite ownership proof, successful unrelated-history +preservation, and executable operator preflight queries. + +Valid findings addressed: yes + +Open sub-agent sessions: none + +## Remaining external gates + +GitHub full CI/coverage, CodeRabbit, and the human checkpoint remain. The user +retains merge ownership. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-pr-trust-bundle.md new file mode 100644 index 000000000..e6d0002a2 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-pr-trust-bundle.md @@ -0,0 +1,74 @@ +# WS-AUTH-001-10A PR Trust Bundle + +## Chunk + +`WS-AUTH-001-10A` — Project Role Grant Data And Evidence Foundation + +## Goal and approved intent + +Establish immutable, privacy-bounded qualification snapshots and independent +submitter/reviewer/adjudicator grant truth before shipping reads or mutations. +The user approved the sequential 10A/10B/10C design and explicitly started 10A. + +## What changed and why + +- Migration `0031` creates qualification-snapshot and exact-role grant history. +- PostgreSQL enforces bounded opaque evidence, composite ownership, manual-only + issuance, database timestamps, exact active-v1/revoked-v2 lifecycle, and + immutable history. +- Typed and SQL audit vocabularies remove `both` and replacement evidence while + retaining captured/issued/revoked events. +- Five future project-role actions and two denial codes are registered; every + action remains planned and non-executable under its 10B/10C owner. +- The dormant authority mutation evidence matcher now accepts issued-only + project-role success with no prior matched grant. +- Operator docs provide exact upgrade/downgrade preflight and lock/recovery + guidance. + +## Design and alternatives + +One immutable row represents one actor/project/exact-role grant. Multiple roles +coexist; revocation never replaces another role, and regrant creates a new row. +Automatic inference, combined roles, replacement conversion, free-form evidence, +and shipping routes together with schema were rejected. + +## Scope and product behavior + +All changed files are contract-allowed. No route/OpenAPI, active action, +candidate/read API, mutation service, kernel/PREP, or product lifecycle behavior +was added. Current runtime behavior is unchanged. + +## Acceptance proof and tests + +Focused PostgreSQL proof covers fresh/previous-head migration, clean downgrade, +each and combined refusal predicate, exact no-mutation preservation, unrelated +history preservation, evidence bounds, composite ownership, role coexistence, +uniqueness, timestamps, immutable update/delete/truncate, lifecycle transitions, +five action pairs, and two denial codes. Typed schema/catalogue and complete +audit-event tests pass. Ruff, stale wording, Markdown links, and diff integrity +pass. Full coverage and E2E run in GitHub Actions per the approved fast-CI policy. + +## Test delta and CI integrity + +The generic Alembic round-trip test remains intact. Tests are additive; none are +skipped, deleted, or weakened. No workflow, package script, dependency, shard, +or coverage threshold changed. + +## Reviewer and external results + +Senior, architecture, reuse, security, QA, test-delta, product/ops, CI, and docs +all pass reviewed SHA `e8d9c37e`. GitHub Actions, CodeRabbit, and human review +remain external gates. + +## Remaining risks and follow-up + +The migration takes ACCESS EXCLUSIVE authority-table locks and refuses ambiguous +legacy evidence; operators must use the documented maintenance preflight. +AUTH-10B owns reads, and AUTH-10C owns PREP-bound mutations. Neither starts from +this merge without its explicit protected start event. + +## Human review focus and merge ownership + +Review privacy shapes, composite snapshot ownership, independent-role +uniqueness, immutability, refusal predicates, and the absence of exposed +behavior. The user retains approval for this specific PR and merge. From 6a5ad7065dabb425526b01dc1731029e5191617e Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 21 Jul 2026 14:38:13 +0100 Subject: [PATCH 09/19] docs(auth): bind AUTH-10A reviewed descendant --- .../reviews/WS-AUTH-001-10A-internal-review-evidence.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-internal-review-evidence.md index 82dbd053a..194e42c75 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-internal-review-evidence.md @@ -1,12 +1,12 @@ # WS-AUTH-001-10A Internal Review Evidence -Reviewed code SHA: `e8d9c37e6fd552439ff9f0db8b2972337a3b019f` +Reviewed code SHA: `184404a0c0744402b2582ce4a3a27c8207bc8cf8` Reviewed implementation SHA: `e8d9c37e6fd552439ff9f0db8b2972337a3b019f` Reviewed against trusted main: `70f9c7bcdb63680e545f661a956929379df138e4` -Reviewed at: `2026-07-21T13:20:00Z` +Reviewed at: `2026-07-21T13:37:12Z` Reviewer run IDs: `auth10_plan_core`, `auth10_plan_security_qa`, `auth10_plan_ops_ci_docs` From 933f17f7c932e6faee46107ef300c1cf956c5dc9 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 21 Jul 2026 14:44:03 +0100 Subject: [PATCH 10/19] fix(loop): align AUTH-10B successor title --- .agent-loop/merge-intents/WS-AUTH-001-10A.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agent-loop/merge-intents/WS-AUTH-001-10A.json b/.agent-loop/merge-intents/WS-AUTH-001-10A.json index 7365c2eb0..4c6f1044e 100644 --- a/.agent-loop/merge-intents/WS-AUTH-001-10A.json +++ b/.agent-loop/merge-intents/WS-AUTH-001-10A.json @@ -3,7 +3,7 @@ "chunk_title": "Project Role Grant Data And Evidence Foundation", "initiative_id": "WS-AUTH-001", "next_chunk_id": "WS-AUTH-001-10B", - "next_chunk_title": "Project Role Grant Reads", + "next_chunk_title": "Project Role Grant Read And Candidate Surfaces", "next_requires_explicit_start": true, "schema_version": 2 } From 2b750d251fb67f34853af79fd2eeddfd349aeb4a Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 21 Jul 2026 14:47:45 +0100 Subject: [PATCH 11/19] docs(auth): bind AUTH-10A integration review --- .../WS-AUTH-001-10A-internal-review-evidence.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-internal-review-evidence.md index 194e42c75..2382cd3db 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-internal-review-evidence.md @@ -1,12 +1,12 @@ # WS-AUTH-001-10A Internal Review Evidence -Reviewed code SHA: `184404a0c0744402b2582ce4a3a27c8207bc8cf8` +Reviewed code SHA: `933f17f7c932e6faee46107ef300c1cf956c5dc9` Reviewed implementation SHA: `e8d9c37e6fd552439ff9f0db8b2972337a3b019f` -Reviewed against trusted main: `70f9c7bcdb63680e545f661a956929379df138e4` +Reviewed against trusted main: `dda60ed0cb97d9de4a375df4147f31172cb3839b` -Reviewed at: `2026-07-21T13:37:12Z` +Reviewed at: `2026-07-21T13:46:52Z` Reviewer run IDs: `auth10_plan_core`, `auth10_plan_security_qa`, `auth10_plan_ops_ci_docs` @@ -61,6 +61,13 @@ preservation, and executable operator preflight queries. Valid findings addressed: yes +All tracks then re-reviewed the exact integrated and repaired SHA `933f17f7` +after trusted main advanced. The integration preserved the reviewed AUTH-10A +implementation byte-for-byte. One new fail-closed validator finding was fixed: +the merge intent successor title now exactly matches the reviewed 10B contract +heading. Senior engineering, architecture, reuse/dedup, security/auth, QA/test, +test delta, product/ops, CI integrity, and docs all passed the repaired SHA. + Open sub-agent sessions: none ## Remaining external gates From 3e6ba3dbf265ce287b4f8bf114f3c3081f63f94e Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 21 Jul 2026 14:59:56 +0100 Subject: [PATCH 12/19] test(auth): integrate role grants with shared database proofs --- backend/tests/conftest.py | 16 ++++++++++++++++ backend/tests/test_alembic.py | 2 ++ 2 files changed, 18 insertions(+) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index ae22f1317..8df8f8a06 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -34,6 +34,12 @@ async def _reset_test_database_state( await connection.execute( "alter table authority_idempotency_records disable trigger user" ) + await connection.execute( + "alter table project_role_grants disable trigger user" + ) + await connection.execute( + "alter table project_role_qualification_snapshots disable trigger user" + ) await connection.execute("alter table admin_role_grants disable trigger user") await connection.execute("alter table authority_control disable trigger user") if include_canonical_actors: @@ -46,6 +52,10 @@ async def _reset_test_database_state( ) await connection.execute("truncate table audit_events cascade") await connection.execute("truncate table authority_idempotency_records cascade") + await connection.execute( + "truncate table project_role_grants, " + "project_role_qualification_snapshots" + ) await connection.execute( "truncate table authority_control, admin_role_grants cascade" ) @@ -74,6 +84,12 @@ async def _reset_test_database_state( await connection.execute( "alter table authority_idempotency_records enable trigger user" ) + await connection.execute( + "alter table project_role_qualification_snapshots enable trigger user" + ) + await connection.execute( + "alter table project_role_grants enable trigger user" + ) await connection.execute( "alter table audit_events enable trigger audit_events_reject_update_delete" ) diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 4a8c212ea..a517a0c9c 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -908,6 +908,8 @@ def test_authorization_action_evidence_constraints_and_guarded_downgrade( ActionOwner.AUTH_09C, ActionOwner.AUTH_09D_A, ActionOwner.AUTH_09D_B, + ActionOwner.AUTH_10B, + ActionOwner.AUTH_10C, } ), ) From fc0b494b1e4dd4b11b47435142c578328831f4de Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 21 Jul 2026 15:01:27 +0100 Subject: [PATCH 13/19] docs(auth): bind AUTH-10A CI repair review --- .../WS-AUTH-001-10A-internal-review-evidence.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-internal-review-evidence.md index 2382cd3db..b6679825e 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-internal-review-evidence.md @@ -1,12 +1,12 @@ # WS-AUTH-001-10A Internal Review Evidence -Reviewed code SHA: `933f17f7c932e6faee46107ef300c1cf956c5dc9` +Reviewed code SHA: `3e6ba3dbf265ce287b4f8bf114f3c3081f63f94e` Reviewed implementation SHA: `e8d9c37e6fd552439ff9f0db8b2972337a3b019f` Reviewed against trusted main: `dda60ed0cb97d9de4a375df4147f31172cb3839b` -Reviewed at: `2026-07-21T13:46:52Z` +Reviewed at: `2026-07-21T14:01:09Z` Reviewer run IDs: `auth10_plan_core`, `auth10_plan_security_qa`, `auth10_plan_ops_ci_docs` @@ -68,6 +68,13 @@ the merge intent successor title now exactly matches the reviewed 10B contract heading. Senior engineering, architecture, reuse/dedup, security/auth, QA/test, test delta, product/ops, CI integrity, and docs all passed the repaired SHA. +All tracks also reviewed CI repair SHA `3e6ba3db`. The repair keeps the +historical 0021 action-parity test scoped to actions present at that revision +and extends the transaction-scoped privileged test reset to clear the two new +immutable role-history tables without weakening their production guards. The +dedicated 0031 tests retain proof of all five new action pairs. All tracks +passed with no open finding. + Open sub-agent sessions: none ## Remaining external gates From 12fe910a8d7369456bb66caa7a213f43d8bd262c Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 21 Jul 2026 15:09:29 +0100 Subject: [PATCH 14/19] test(auth): scope bootstrap parity to revision actions --- backend/tests/test_alembic.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index a517a0c9c..4c9f50df3 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -1064,6 +1064,8 @@ def test_bootstrap_admin_grant_schema_is_immutable_and_guarded( ActionOwner.AUTH_09C, ActionOwner.AUTH_09D_A, ActionOwner.AUTH_09D_B, + ActionOwner.AUTH_10B, + ActionOwner.AUTH_10C, } ), ) From e2891d54e08f6cb159d1496dcea4ef2977067275 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 21 Jul 2026 15:10:49 +0100 Subject: [PATCH 15/19] docs(auth): bind bootstrap parity repair review --- .../WS-AUTH-001-10A-internal-review-evidence.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-internal-review-evidence.md index b6679825e..9cc492504 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-internal-review-evidence.md @@ -1,12 +1,12 @@ # WS-AUTH-001-10A Internal Review Evidence -Reviewed code SHA: `3e6ba3dbf265ce287b4f8bf114f3c3081f63f94e` +Reviewed code SHA: `12fe910a8d7369456bb66caa7a213f43d8bd262c` Reviewed implementation SHA: `e8d9c37e6fd552439ff9f0db8b2972337a3b019f` Reviewed against trusted main: `dda60ed0cb97d9de4a375df4147f31172cb3839b` -Reviewed at: `2026-07-21T14:01:09Z` +Reviewed at: `2026-07-21T14:10:33Z` Reviewer run IDs: `auth10_plan_core`, `auth10_plan_security_qa`, `auth10_plan_ops_ci_docs` @@ -75,6 +75,12 @@ immutable role-history tables without weakening their production guards. The dedicated 0031 tests retain proof of all five new action pairs. All tracks passed with no open finding. +All tracks reviewed follow-up CI repair SHA `12fe910a`. It applies the same +revision-ownership boundary to the historical 0022 parity test; the dedicated +0031 selector still asserts and exercises exactly five AUTH-10 action pairs. +The previously failing focused bootstrap migration test passed. All tracks +passed with no open finding. + Open sub-agent sessions: none ## Remaining external gates From 5152c336e3556bd616fb545a87cf369ba90dba47 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 21 Jul 2026 15:27:02 +0100 Subject: [PATCH 16/19] test(auth): assert legacy path leaves role facts empty --- backend/tests/test_actors.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_actors.py b/backend/tests/test_actors.py index 1db49c26a..714c8272d 100644 --- a/backend/tests/test_actors.py +++ b/backend/tests/test_actors.py @@ -1250,7 +1250,15 @@ async def test_legacy_activation_writes_only_compatibility_metadata( ) assert "admin_role_grants" in table_names assert await session.scalar(text("select count(*) from admin_role_grants")) == 0 - assert "project_role_grants" not in table_names + assert "project_role_grants" in table_names + assert "project_role_qualification_snapshots" in table_names + assert await session.scalar(text("select count(*) from project_role_grants")) == 0 + assert ( + await session.scalar( + text("select count(*) from project_role_qualification_snapshots") + ) + == 0 + ) async def test_repeated_legacy_activation_updates_one_row_and_audits_only_changes( From 07bb38a9b40a87eecdcd0518ec3122dad6553a59 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 21 Jul 2026 15:29:20 +0100 Subject: [PATCH 17/19] fix(auth): align role grant model and compatibility proof --- backend/app/modules/authorization/models.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/backend/app/modules/authorization/models.py b/backend/app/modules/authorization/models.py index 5306ced4a..c2226720e 100644 --- a/backend/app/modules/authorization/models.py +++ b/backend/app/modules/authorization/models.py @@ -295,6 +295,7 @@ class ProjectRoleGrant(Base): "project_role_qualification_snapshots.requested_role", ], name="qualification_ownership", + ondelete="RESTRICT", ), Index( "uq_project_role_grants_active_exact_role", @@ -320,12 +321,18 @@ class ProjectRoleGrant(Base): ) id: Mapped[UUID] = mapped_column(Uuid(), primary_key=True) - project_id: Mapped[str] = mapped_column(ForeignKey("projects.id"), nullable=False) - actor_profile_id: Mapped[str] = mapped_column(ForeignKey("actor_profiles.id"), nullable=False) + project_id: Mapped[str] = mapped_column( + String(36), ForeignKey("projects.id"), nullable=False + ) + actor_profile_id: Mapped[str] = mapped_column( + String(36), ForeignKey("actor_profiles.id"), nullable=False + ) role: Mapped[str] = mapped_column(String(24), nullable=False) - status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") - version: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=1) - grant_method: Mapped[str] = mapped_column(String(16), nullable=False, default="manual") + status: Mapped[str] = mapped_column(String(16), nullable=False, server_default="active") + version: Mapped[int] = mapped_column(SmallInteger, nullable=False, server_default="1") + grant_method: Mapped[str] = mapped_column( + String(16), nullable=False, server_default="manual" + ) qualification_snapshot_id: Mapped[UUID] = mapped_column(Uuid(), nullable=False) granted_by_actor_profile_id: Mapped[str] = mapped_column( ForeignKey("actor_profiles.id"), nullable=False From 622b389cf00683cbaef3b019f6c2cf7b784f30b8 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 21 Jul 2026 15:31:41 +0100 Subject: [PATCH 18/19] docs(auth): bind external review repair evidence --- .../WS-AUTH-001-10A-internal-review-evidence.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-internal-review-evidence.md index 9cc492504..621344741 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-internal-review-evidence.md @@ -1,12 +1,12 @@ # WS-AUTH-001-10A Internal Review Evidence -Reviewed code SHA: `12fe910a8d7369456bb66caa7a213f43d8bd262c` +Reviewed code SHA: `07bb38a9b40a87eecdcd0518ec3122dad6553a59` Reviewed implementation SHA: `e8d9c37e6fd552439ff9f0db8b2972337a3b019f` Reviewed against trusted main: `dda60ed0cb97d9de4a375df4147f31172cb3839b` -Reviewed at: `2026-07-21T14:10:33Z` +Reviewed at: `2026-07-21T14:31:09Z` Reviewer run IDs: `auth10_plan_core`, `auth10_plan_security_qa`, `auth10_plan_ops_ci_docs` @@ -81,6 +81,15 @@ revision-ownership boundary to the historical 0022 parity test; the dedicated The previously failing focused bootstrap migration test passed. All tracks passed with no open finding. +All tracks reviewed CI compatibility repair SHA `5152c336`; the actor legacy +activation test now recognizes the 0031 tables while proving it writes zero +grant and qualification facts. The exact focused test passed. + +All tracks then reviewed CodeRabbit repair SHA `07bb38a9`. The valid finding +was resolved by matching the ORM to migration 0031 for composite `RESTRICT`, +identifier widths, and database-owned status/version/method defaults. Direct +metadata verification and Ruff passed. All tracks passed with no open finding. + Open sub-agent sessions: none ## Remaining external gates From 7d3031f53181e82da41a703cfb3ef3aed9193aba Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 21 Jul 2026 15:36:00 +0100 Subject: [PATCH 19/19] docs(auth): bind final main integration review --- .../WS-AUTH-001-10A-internal-review-evidence.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-internal-review-evidence.md index 621344741..e5747b760 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10A-internal-review-evidence.md @@ -1,12 +1,12 @@ # WS-AUTH-001-10A Internal Review Evidence -Reviewed code SHA: `07bb38a9b40a87eecdcd0518ec3122dad6553a59` +Reviewed code SHA: `90a2f98667ab6bd0419665f185e05b7acd669688` Reviewed implementation SHA: `e8d9c37e6fd552439ff9f0db8b2972337a3b019f` -Reviewed against trusted main: `dda60ed0cb97d9de4a375df4147f31172cb3839b` +Reviewed against trusted main: `dd4a454b9ab8735a3c4aa8e85e7e64b1e7222b0a` -Reviewed at: `2026-07-21T14:31:09Z` +Reviewed at: `2026-07-21T14:34:49Z` Reviewer run IDs: `auth10_plan_core`, `auth10_plan_security_qa`, `auth10_plan_ops_ci_docs` @@ -90,6 +90,13 @@ was resolved by matching the ORM to migration 0031 for composite `RESTRICT`, identifier widths, and database-owned status/version/method defaults. Direct metadata verification and Ruff passed. All tracks passed with no open finding. +All tracks re-reviewed final integration merge SHA `90a2f986` after trusted +main advanced through PR #171. AUTH-10A remained byte-identical to its reviewed +parent, both review histories were preserved, the updated merge-intent +validator passed, and the incoming loop-recovery tooling did not alter AUTH +behavior, tests, migration, thresholds, or successor semantics. All tracks +passed with no open finding. + Open sub-agent sessions: none ## Remaining external gates