diff --git a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/ACTIVE_DOC_INVENTORY.md b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/ACTIVE_DOC_INVENTORY.md index b2fedc000..0eb0303e7 100644 --- a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/ACTIVE_DOC_INVENTORY.md +++ b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/ACTIVE_DOC_INVENTORY.md @@ -1,22 +1,22 @@ # Active Documentation Inventory: WS-CON-001 -## Current 03A implementation scope +## Current 02C implementation scope Live implementation state is carried by `STATUS.md`, `SOURCE_MANIFEST.md`, the -`WS-CON-001-03A` chunk contract, `RUNTIME_VERIFICATION.md`, and the canonical -schema sections in `docs/spec_contribution_compensation.md` and -`docs/architecture_data_model.md`. +`WS-CON-001-02C` chunk contract, `RUNTIME_VERIFICATION.md`, the 02C internal +review evidence and PR trust bundle, and the canonical schema sections in +`docs/spec_contribution_compensation.md` and `docs/architecture_data_model.md`. `INTENT.md`, `DISCOVERY.md`, `PLAN.md`, `JOINT_RELEASE_HANDOFF.md`, and the PLAN4 review artifacts are frozen historical planning evidence. Their recorded -PLAN4 SHA/head and planning-only stop statement are not live 03A status. +PLAN4 SHA/head and planning-only stop statement are not live 02C status. -## Direct reconciliation scope +## Direct implementation scope -This planning refresh updates the WS-CON-001 initiative package plus the stale -Required Implementation Order in the canonical contribution specification. It -does not change runtime code, migrations, other specification sections, -roadmaps, exports, workflows, or another initiative's files. +02C changes only the shared audit participant modules/tests, the exact +shared-audit note in `docs/architecture_data_model.md`, and WS-CON initiative +evidence. It adds runtime code but no migration, route, background executor, +product lifecycle behavior, roadmap/export, workflow, or other initiative change. The PLAN4 package was: @@ -46,6 +46,7 @@ rewritten merely to restate this plan. Historical chunk evidence remains histori initiatives retain ownership of their own plans and runtime contracts. Local roadmap XLSX/CSV exports are not changed because the roadmap is not changed. -The pre-existing deletion of +The working-tree deletion of `docs/reference_specs/WS-CON-001-contribution-record-and-compensation-boundary-specification.pdf` -is user-owned and remains untouched and unstaged. +is user-owned, outside 02C scope, and remains untouched and unstaged. It is not +part of the 02C publication diff or its reference-spec integrity result. diff --git a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/CHUNK_MAP.md b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/CHUNK_MAP.md index 4fec59e94..58e2a4b52 100644 --- a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/CHUNK_MAP.md +++ b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/CHUNK_MAP.md @@ -12,6 +12,7 @@ signed-loop records do not make behavior live. | `01` | Canonical specification and ADR 0016 | Merged PR #144 | | `02A` | Shared transactional outbox persistence/append | Merged PR #155; migration 0029 | | `03A` | Adapter-binding persistence | Merged PR #267; migration 0053 | +| `03B` | Contribution-policy persistence | Merged PR #274; migration 0055 | | `PLAN5` | Complete-context human needs-revision rebase reconciliation | Merged PR #270 | ## Current reconciliation @@ -25,8 +26,8 @@ signed-loop records do not make behavior live. | Chunk | Goal | Entry gate | Status | |---|---|---|---| -| `03B` | Contribution-policy persistence | 03A | Implemented and internally reviewed; PR checkpoint; unblocks REV-03A2 FK after merge | -| `02C` | Shared lifecycle-audit participant | PLAN4; current AuditEvent contract | Proposed; independent of dispatcher; required before REV-04B | +| `03B` | Contribution-policy persistence | 03A | Merged PR #274; REV-03A2 FK is unblocked | +| `02C` | Shared lifecycle-audit participant | PLAN4; current AuditEvent contract | Implementation, deterministic proof, and required internal review complete; PR/external review pending; independent of dispatcher; required before REV-04B | | `04A` | Hidden adapter-binding service | 03A + exact AUTH registration/PREP contract | Blocked on AUTH registration | | `04B` | Hidden contribution-policy service | 03B + 04A + exact AUTH registration/PREP contract | Blocked on AUTH registration | | `05A` | Legacy semantic cutover + initial TaskAssignment policy freeze and guarded human-revision rebase support | 04B + task/assignment/revision authority contract + row-classification decision | Proposed | @@ -80,7 +81,7 @@ distributed-lane changes. ## Stop -CON-03B stops at this PR checkpoint. No later chunk starts until 03B passes its -external checks and receives human merge approval. Binding creation/lifecycle -behavior remains deferred and existing ART/REV identities do not substitute -for the future AUTH-approved compensation adapter contract. +CON-02C stops at its PR checkpoint. Do not begin another CON chunk +automatically. Binding creation/lifecycle behavior remains deferred and +existing ART/REV identities do not substitute for the future AUTH-approved +compensation adapter contract. diff --git a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/SOURCE_MANIFEST.md b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/SOURCE_MANIFEST.md index 9971ed1d0..36ba99ae0 100644 --- a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/SOURCE_MANIFEST.md +++ b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/SOURCE_MANIFEST.md @@ -2,9 +2,9 @@ ## Reconciliation baseline -- Reconciled `main`: `9550b3cdb8ac12dea72931277cf46ac1b8ffda7f`. -- Current migration head on `main`: `0054_guide_sufficiency_authority`; - this branch advances it to `0055_contribution_policy`. +- Reconciled `main`: `9865456b3fb1f6048f4c7b7aef4dac71fbf3323e`. +- Current migration head on `main`: `0056_review_lease_preference`; 02C adds no + migration. - Current capability ledger: `docs/roadmap_status.md`. - Current contribution process: `AGENTS.md`, `CONTRIBUTING.md`, and `.agent-loop/README.md`. Historical signed-start and merge-intent records are @@ -36,8 +36,9 @@ guide sufficiency, and guide read/binding foundations. - `backend/app/modules/outbox/**` and migration `0029`: shared outbox persistence/append only; no dispatcher exists. -- `backend/app/modules/audit/**`: existing shared audit foundation; no typed - lifecycle participant yet. +- `backend/app/modules/audit/**`: existing shared audit foundation plus the 02C + branch's typed, flush-only REV/CON lifecycle participant. This branch state + remains unpublished until PR review and human merge. - `backend/app/modules/compensation/**` and merged migration `0053` add only the adapter-binding schema; no creation/lifecycle service exists. - Review queue persistence now exists; ReviewLease, Review, and FinalAcceptance diff --git a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/STATUS.md b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/STATUS.md index e4c587ac6..7a2c2ac31 100644 --- a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/STATUS.md +++ b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/STATUS.md @@ -2,19 +2,18 @@ ## Current baseline -- Reconciled main: `9550b3cdb8ac12dea72931277cf46ac1b8ffda7f`. -- Alembic head on main: `0054_guide_sufficiency_authority`; 03B advances the branch - to `0055_contribution_policy` after AUTH migration - `0054_guide_sufficiency_authority`. +- Reconciled main: `9865456b3fb1f6048f4c7b7aef4dac71fbf3323e`. +- Alembic head on main: `0056_review_lease_preference`. - CON-01, CON-02A, and CON-03A are merged; 03A merged in PR #267. - PLAN5 is merged in PR #270 and preserves the human-confirmed complete-context `needs_revision` rebase rule. -- Runtime on main contains shared outbox persistence and the schema-only - compensation binding foundation. 03B is complete on its bounded branch and - adds contribution-policy persistence; contribution-record, +- Runtime on main contains shared outbox persistence, the schema-only + compensation binding foundation, and contribution-policy persistence. 02C + implementation, deterministic proof, internal review, and external checks + are complete; human approval and merge remain pending. Contribution-record, dispatcher, fulfillment, operations, and CON API behavior remain absent. - The pre-existing local deletion of the archival reference PDF is user-owned - and excluded from this planning change. + and excluded from this runtime change. ## Current external work inspected @@ -38,9 +37,9 @@ of main; their contracts remain ART-owned inputs rather than CON behavior. ### REV -REV PR #258 is merged planning-only end-to-end evidence. It correctly -preserves CON ownership and identifies CON-03B as -the policy FK prerequisite for REV-03A2. +REV planning evidence remains aligned with CON ownership. REV-03A2 lease and +preference persistence is merged in PR #280 on top of the merged CON-03B policy +FK prerequisite; Review and FinalAcceptance behavior remain future REV work. ## Corrected CON priority @@ -66,15 +65,16 @@ schema work. Current dependency analysis yields: registered. - CON-05A/05B: deterministic legacy-row classification remains a human data decision. -- CON-03C: REV Review/ReviewLease/FinalAcceptance tables are not implemented. +- CON-03C: REV Review and FinalAcceptance tables are not implemented. - CON-06/07: corresponding REV lease/decision caller contracts are future. - CON-03A creation behavior: AUTH has not approved a compensation-adapter service identity/capability; existing ART/REV identities cannot substitute. ## Immediate next action -Publish 03B for external CI, CodeRabbit, and human review, then stop at its PR -checkpoint. Do not begin another chunk automatically. After 03B merges it -unblocks the stable policy-version FK required by REV-03A2; binding creation -remains deferred to 04A after AUTH approves the exact adapter +Move CON-02C through external review and human approval now that implementation, +deterministic proof, and required internal review are complete. REV-03A2 may +proceed against the merged contribution-policy version FK, and REV-04B may +proceed in parallel against the 02C contract but must merge after 02C. +Binding creation remains deferred to 04A after AUTH approves the exact adapter identity/capability contract. diff --git a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/chunks/WS-CON-001-02C-shared-lifecycle-audit-participant.md b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/chunks/WS-CON-001-02C-shared-lifecycle-audit-participant.md index 22b1057ca..8baa2907a 100644 --- a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/chunks/WS-CON-001-02C-shared-lifecycle-audit-participant.md +++ b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/chunks/WS-CON-001-02C-shared-lifecycle-audit-participant.md @@ -25,7 +25,7 @@ docs/architecture_data_model.md only exact shared-audit ownership note ```text AuditEvent schema/migration rewrite -review, contribution, compensation, task, AUTH or outbox event semantics +review, contribution, compensation, task, AUTH or outbox product behavior or persistence commit ownership, route, background executor, dependency or CI weakening ``` @@ -38,7 +38,9 @@ commit ownership, route, background executor, dependency or CI weakening - [ ] Caller rollback removes audit with all other transaction effects; exact replay/idempotency ownership is explicit and no second audit ledger appears. - [ ] REV-04 and CON-07 may depend on the merged interface without importing - feature services; this chunk implements no feature-specific event. + feature services. The shared interface owns only the adopted closed REV/CON + audit-event vocabulary, primary-entity pairings, and required immutable + source references; it implements no feature lifecycle behavior. ## Verification and reviewers diff --git a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/reviews/WS-CON-001-02C-internal-review-evidence.md b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/reviews/WS-CON-001-02C-internal-review-evidence.md new file mode 100644 index 000000000..dd684c5a7 --- /dev/null +++ b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/reviews/WS-CON-001-02C-internal-review-evidence.md @@ -0,0 +1,46 @@ +# Internal Review Evidence: WS-CON-001-02C + +## Scope + +02C adds a typed, feature-neutral lifecycle-audit participant over the existing +append-only `audit_events` ledger. It uses the caller's `AsyncSession`, flushes +without committing, and adds no schema, migration, route, worker, AUTH, REV, +CON product behavior, or outbox behavior. + +Current reconciled main: `9865456b3fb1f6048f4c7b7aef4dac71fbf3323e`. + +## Deterministic evidence + +```text +39 isolated PostgreSQL audit tests passed before final main reconciliation +26 focused lifecycle tests passed again after final main reconciliation +11 schema-only lifecycle input tests passed +audit subsystem coverage: 94.64% (required at least 90%) +Ruff: passed +git diff --check: passed +Markdown links: passed +stale Workstream wording: passed +``` + +The proof covers caller rollback, exact persisted replay, changed replay +conflict, deterministic concurrent replay through an observed PostgreSQL +advisory-lock waiter, fixed internal provenance, complete canonical event/entity +mapping, exact event-specific UUID references, reason/status consistency, +generic repository bypass denial, and forged-input secret non-retention. + +## Review state + +Preimplementation architecture review passed with low-risk conditions. Those +conditions were implemented: the participant uses the existing +lifecycle-compatible `audit_events` representation, fixes internal provenance +itself, has a dedicated +repository replay path with an explicit immutable comparison set, raises a +non-leaking typed conflict, and prevents the generic repository from +impersonating it. + +Required internal postimplementation review is complete. Senior engineering, QA, +security, product/ops, architecture, docs, reuse/dedup, and test-delta all +passed after their valid findings were repaired. Repairs included canonical +PascalCase REV/CON event ownership, exact source-lineage reference sets, +removal of ambiguous authority/generic contribution tokens, complete state +endpoints, deterministic concurrent replay proof, and current initiative docs. diff --git a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/reviews/WS-CON-001-02C-pr-trust-bundle.md b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/reviews/WS-CON-001-02C-pr-trust-bundle.md new file mode 100644 index 000000000..a6d945a93 --- /dev/null +++ b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/reviews/WS-CON-001-02C-pr-trust-bundle.md @@ -0,0 +1,166 @@ +# WS-CON-001-02C PR Trust Bundle + +## Chunk + +`WS-CON-001-02C` — Shared Lifecycle Audit Participant. + +## Goal + +Provide the flush-only, caller-transaction audit participant required by future +REV and CON lifecycle transactions without creating a second ledger or taking +commit ownership. + +## Human-approved intent + +Workstream must record bounded canonical lifecycle evidence in the same +transaction as the product facts it describes. This chunk supplies that shared +boundary only; it does not implement review, contribution, compensation, or +outbox lifecycle behavior. + +## What changed + +- Added closed canonical REV/CON lifecycle event, entity, reason, and reference + types. +- Enforced exact event-to-entity and event-to-reference contracts, including + FinalAcceptance, reviewer/submitter contribution, and award lineage. +- Added `LifecycleAuditParticipant` over the existing `audit_events` ledger and + caller-owned `AsyncSession`. +- Added exact replay handling and changed-replay conflict behavior serialized by + a transaction-scoped PostgreSQL advisory lock. +- Reserved fixed internal provenance and blocked generic-repository bypass. +- Added rollback, privacy, replay, concurrency, vocabulary-completeness, and + lineage tests. +- Reconciled 02C status and evidence with current main, REV-03A2, and migration + head `0056_review_lease_preference`. + +## Why it changed + +Future REV decision and CON contribution/award transactions need audit evidence +to succeed or roll back atomically with their domain rows. The previous generic +repository path accepted unbounded compatibility shapes and did not define +deterministic concurrent replay behavior. + +## Design chosen + +The participant reuses the existing lifecycle-compatible `audit_events` +representation. Its persisted discriminator remains `legacy_lifecycle`, but +that compatibility token is not exposed as the interface name or product +boundary. Callers can supply only typed canonical facts. It flushes through the +caller's session and never commits or creates a session. Exact event-ID replay +returns the existing immutable row; changed reuse raises a non-leaking +`LifecycleAuditConflict`. A transaction-scoped advisory lock serializes the +read/insert decision for that event ID. + +Nested `project_id` is provenance evidence only. Authorization and filtering +must reload the canonical entity chain rather than trusting audit payload data. + +## Alternatives rejected + +- A second lifecycle audit table or event domain: duplicate ledger authority. +- Participant-owned sessions or commits: breaks atomic REV/CON transactions. +- Arbitrary event names or metadata: permits semantic and privacy drift. +- Unique-constraint failure as replay behavior: leaks a storage concern and is + nondeterministic for callers. +- Authorization-link events in this participant: canonical authority decisions + remain owned by the typed authority audit boundary. + +## Scope control + +Changed runtime scope is limited to: + +- `backend/app/modules/audit/{schemas,repository,service}.py` +- `backend/tests/test_audit.py` +- the exact shared-audit architecture note and WS-CON-001 loop evidence + +No model, migration, route, background executor, feature service, dependency, +workflow, CI threshold, outbox dispatcher, review command, contribution +persistence, compensation fulfillment, or reputation behavior changed. + +## Product behavior + +No user-facing lifecycle is activated. The shared contract distinguishes +`accept`, `needs_revision`, and `reject`; only acceptance evidence may reference +FinalAcceptance. Reviewer and submitter contribution evidence use distinct exact +source shapes. Generic or ambiguous contribution events are not admitted. + +## Acceptance criteria proof + +- Caller rollback removes staged lifecycle evidence. +- The participant flushes without commit and opens no independent session. +- Event vocabulary and primary-entity mappings are complete and closed. +- Event references are exact; unrelated lineage is rejected. +- Exact replay succeeds and changed replay fails closed. +- Concurrent replay waits on the exact event advisory lock. +- Generic repository bypass and forged secret-bearing input are rejected. +- The existing append-only ledger remains the only audit store. + +## Tests and checks run + +- 39 isolated PostgreSQL audit tests passed before final main reconciliation. +- 26 focused lifecycle tests passed again after reconciliation with current + main and migration `0056`. +- 11 schema-only lifecycle input tests passed. +- Audit subsystem coverage: 94.64 percent; required minimum: 90 percent. +- Four focused replay/concurrency tests passed after lifecycle fixture cleanup. +- Ruff, Markdown links, stale Workstream wording, stale authorization docs, and + diff integrity passed. + +## Test delta + +Lifecycle audit tests were added; no existing test was removed, skipped, +weakened, or rewritten to accept broken behavior. Test cleanup now removes all +audit rows created by the fixture, including lifecycle compatibility rows. + +## CI integrity + +No workflow, lane, runner, dependency, package script, coverage threshold, or +branch-protection behavior changed. Final hosted results pass: + +- Agent Gates +- Backend `shared_foundations` +- Backend `schema_contracts_a` +- Backend `schema_contracts_b` +- Backend `project_lifecycle` +- Backend `task_lifecycle` +- merged Backend `test` + +## Reviewer results + +Senior engineering, QA, security, product/ops, architecture, docs, +reuse/dedup, and test-delta tracks passed after valid findings were fixed. See +`WS-CON-001-02C-internal-review-evidence.md`. + +## External review + +CodeRabbit passes. Actionable findings were addressed: concurrent replay is +atomic, committed lifecycle fixtures are cleaned up, the waiter assertion is +bound to the exact event lock, coverage evidence is precise, and initiative +status wording is current. + +## Remaining risks + +- The participant uses fixed internal values in compatibility provenance columns; + future audit-schema cleanup may replace that compatibility representation. +- New lifecycle event tokens require an adopted feature contract, exact primary + entity, exact source references, and contract tests. +- REV/CON readers must not use nested audit `project_id` as authorization truth. + +## Follow-up work + +After human merge, stop. REV-04B may consume this interface and merge after 02C. +CON-03C contribution/award persistence begins only under a separate explicit +instruction and after its REV-owned FK prerequisites exist. + +## Human review focus + +- Is caller transaction ownership preserved with no hidden commit path? +- Are accepted, revision, contribution, and award lineage shapes exact? +- Does concurrent event-ID replay deterministically return or conflict? +- Is the shared audit module free of REV/CON feature-service coupling? + +## Human merge ownership + +- [x] Required internal reviewers passed. +- [x] Hosted CI and CodeRabbit passed. +- [x] Valid external findings were addressed. +- [ ] The user explicitly approves PR #277 for merge. diff --git a/backend/app/modules/audit/repository.py b/backend/app/modules/audit/repository.py index 1d9a12f2e..0a0d22b4d 100644 --- a/backend/app/modules/audit/repository.py +++ b/backend/app/modules/audit/repository.py @@ -4,12 +4,38 @@ from collections.abc import Sequence -from sqlalchemy import select +from sqlalchemy import select, text from sqlalchemy.ext.asyncio import AsyncSession from app.modules.tasks.models import AuditEvent +LIFECYCLE_AUTH_SOURCE = "local_lifecycle" +_LIFECYCLE_REPLAY_FIELDS = ( + "entity_type", + "entity_id", + "event_type", + "from_status", + "to_status", + "actor_id", + "external_subject", + "external_issuer", + "actor_roles", + "claim_snapshot", + "auth_source", + "is_dev_auth", + "reason", + "event_payload", + "event_domain", + "event_version", + "occurred_at", +) + + +class LifecycleAuditConflict(ValueError): + """Signal changed reuse of an immutable lifecycle audit identity.""" + + class AuditRepository: """Wraps persistence for audit events independent of domain services.""" @@ -32,6 +58,26 @@ async def add_audit_event(self, event: AuditEvent) -> AuditEvent: """ if event.event_domain == "authority": raise ValueError("authority events require the typed audit service") + if event.auth_source == LIFECYCLE_AUTH_SOURCE: + raise ValueError("lifecycle events require the typed audit participant") + return await self._persist(event) + + async def _add_validated_lifecycle_event(self, event: AuditEvent) -> AuditEvent: + """Return exact replay or flush one participant-validated lifecycle event.""" + if event.event_domain != "legacy_lifecycle" or event.auth_source != LIFECYCLE_AUTH_SOURCE: + raise ValueError("expected validated lifecycle audit event") + await self._session.execute( + text("select pg_advisory_xact_lock(hashtextextended(:event_id, 0))"), + {"event_id": event.id}, + ) + existing = await self._session.get(AuditEvent, event.id) + if existing is not None: + if existing.event_domain != "legacy_lifecycle" or any( + getattr(existing, field) != getattr(event, field) + for field in _LIFECYCLE_REPLAY_FIELDS + ): + raise LifecycleAuditConflict("lifecycle audit identity conflict") + return existing return await self._persist(event) async def _add_validated_authority_event(self, event: AuditEvent) -> AuditEvent: diff --git a/backend/app/modules/audit/schemas.py b/backend/app/modules/audit/schemas.py index e1aed5a72..42876580e 100644 --- a/backend/app/modules/audit/schemas.py +++ b/backend/app/modules/audit/schemas.py @@ -68,11 +68,232 @@ "provisioning_method": frozenset({"automatic_first_access", "manual_service_provisioning"}), "role": _ADMIN_ROLES | _PROJECT_ROLES, "scope_type": frozenset({"system", "project"}), - "future_obligation": frozenset( - {"auth13_assignment", "rev_reviewer_obligation", "none"} + "future_obligation": frozenset({"auth13_assignment", "rev_reviewer_obligation", "none"}), +} + + +class LifecycleAuditEntityType(StrEnum): + """Closed product-fact namespaces admitted by the shared participant.""" + + REVIEW_QUEUE_ENTRY = "review_queue_entry" + REVIEW_LEASE = "review_lease" + REVIEW = "review" + REVIEW_FINDING = "review_finding" + FINDING_RESOLUTION = "finding_resolution" + SUBMISSION_FINDING_RESPONSE = "submission_finding_response" + CONTRIBUTION = "contribution" + COMPENSATION_AWARD = "compensation_award" + + +class LifecycleAuditEventType(StrEnum): + """Canonical REV/CON lifecycle facts admitted by the shared participant.""" + + REVIEW_QUEUE_ENTRY_CREATED = "ReviewQueueEntryCreated" + REVIEW_ROUTED_TO_PREFERRED_REVIEWER = "ReviewRoutedToPreferredReviewer" + REVIEWER_PREFERENCE_EXPIRED = "ReviewerPreferenceExpired" + REVIEWER_PREFERENCE_INVALIDATED = "ReviewerPreferenceInvalidated" + REVIEWER_DECLINED_PREFERENCE = "ReviewerDeclinedPreference" + REVIEW_QUEUE_ENTRY_OPENED = "ReviewQueueEntryOpened" + REVIEW_QUEUE_ENTRY_CLOSED = "ReviewQueueEntryClosed" + REVIEWER_CLAIMED_TASK = "ReviewerClaimedTask" + REVIEWER_RELEASED_TASK = "ReviewerReleasedTask" + REVIEWER_LEASE_EXPIRED = "ReviewerLeaseExpired" + REVIEWER_LEASE_REVOKED = "ReviewerLeaseRevoked" + REVIEWER_LEASE_CONSUMED = "ReviewerLeaseConsumed" + REVIEWER_LEASE_FORCE_RELEASED = "ReviewerLeaseForceReleased" + REVIEW_RECORDED = "ReviewRecorded" + REVIEW_ACCEPTED = "ReviewAccepted" + REVIEW_NEEDS_REVISION = "ReviewNeedsRevision" + REVIEW_REJECTED = "ReviewRejected" + REVIEW_FINDING_CREATED = "ReviewFindingCreated" + FINDING_RESOLUTION_RECORDED = "FindingResolutionRecorded" + SUBMISSION_FINDING_RESPONSE_CREATED = "SubmissionFindingResponseCreated" + REVIEW_EVIDENCE_ACCESSED = "ReviewEvidenceAccessed" + REVIEW_EVIDENCE_UNAVAILABLE = "ReviewEvidenceUnavailable" + REVIEW_EVIDENCE_INTEGRITY_MISMATCH = "ReviewEvidenceIntegrityMismatch" + REVIEW_FINDING_EVIDENCE_BOUND = "ReviewFindingEvidenceBound" + SUBMISSION_FINDING_RESPONSE_EVIDENCE_BOUND = "SubmissionFindingResponseEvidenceBound" + REVIEW_SNAPSHOT_PROJECTION_REQUESTED = "ReviewSnapshotProjectionRequested" + REVIEWER_CONTRIBUTION_RECORDED = "ReviewerContributionRecorded" + SUBMITTER_CONTRIBUTION_RECORDED = "SubmitterContributionRecorded" + COMPENSATION_AWARD_CREATED = "CompensationAwardCreated" + + +class LifecycleAuditReason(StrEnum): + """Feature-neutral reasons for durable lifecycle evidence.""" + + STATE_CHANGED = "lifecycle_state_changed" + FACT_RECORDED = "lifecycle_fact_recorded" + + +class LifecycleAuditReferenceKind(StrEnum): + """Closed UUID reference keys allowed in lifecycle audit payloads.""" + + PROJECT = "project_id" + TASK = "task_id" + ASSIGNMENT = "assignment_id" + SUBMISSION = "submission_id" + REVIEW = "review_id" + REVIEW_QUEUE_ENTRY = "review_queue_entry_id" + REVIEW_LEASE = "review_lease_id" + REVIEW_FINDING = "review_finding_id" + FINDING_RESOLUTION = "finding_resolution_id" + SUBMISSION_FINDING_RESPONSE = "submission_finding_response_id" + FINAL_ACCEPTANCE = "final_acceptance_id" + CONTRIBUTION_RECORD = "contribution_record_id" + COMPENSATION_AWARD = "compensation_award_id" + + +_LIFECYCLE_EVENT_ENTITY = { + **dict.fromkeys( + ( + LifecycleAuditEventType.REVIEW_QUEUE_ENTRY_CREATED, + LifecycleAuditEventType.REVIEW_ROUTED_TO_PREFERRED_REVIEWER, + LifecycleAuditEventType.REVIEWER_PREFERENCE_EXPIRED, + LifecycleAuditEventType.REVIEWER_PREFERENCE_INVALIDATED, + LifecycleAuditEventType.REVIEWER_DECLINED_PREFERENCE, + LifecycleAuditEventType.REVIEW_QUEUE_ENTRY_OPENED, + LifecycleAuditEventType.REVIEW_QUEUE_ENTRY_CLOSED, + ), + LifecycleAuditEntityType.REVIEW_QUEUE_ENTRY, + ), + **dict.fromkeys( + ( + LifecycleAuditEventType.REVIEWER_CLAIMED_TASK, + LifecycleAuditEventType.REVIEWER_RELEASED_TASK, + LifecycleAuditEventType.REVIEWER_LEASE_EXPIRED, + LifecycleAuditEventType.REVIEWER_LEASE_REVOKED, + LifecycleAuditEventType.REVIEWER_LEASE_CONSUMED, + LifecycleAuditEventType.REVIEWER_LEASE_FORCE_RELEASED, + ), + LifecycleAuditEntityType.REVIEW_LEASE, + ), + **dict.fromkeys( + ( + LifecycleAuditEventType.REVIEW_RECORDED, + LifecycleAuditEventType.REVIEW_ACCEPTED, + LifecycleAuditEventType.REVIEW_NEEDS_REVISION, + LifecycleAuditEventType.REVIEW_REJECTED, + LifecycleAuditEventType.REVIEW_EVIDENCE_ACCESSED, + LifecycleAuditEventType.REVIEW_EVIDENCE_UNAVAILABLE, + LifecycleAuditEventType.REVIEW_EVIDENCE_INTEGRITY_MISMATCH, + LifecycleAuditEventType.REVIEW_SNAPSHOT_PROJECTION_REQUESTED, + ), + LifecycleAuditEntityType.REVIEW, ), + LifecycleAuditEventType.REVIEW_FINDING_CREATED: LifecycleAuditEntityType.REVIEW_FINDING, + LifecycleAuditEventType.REVIEW_FINDING_EVIDENCE_BOUND: LifecycleAuditEntityType.REVIEW_FINDING, + LifecycleAuditEventType.FINDING_RESOLUTION_RECORDED: LifecycleAuditEntityType.FINDING_RESOLUTION, + LifecycleAuditEventType.SUBMISSION_FINDING_RESPONSE_CREATED: LifecycleAuditEntityType.SUBMISSION_FINDING_RESPONSE, + LifecycleAuditEventType.SUBMISSION_FINDING_RESPONSE_EVIDENCE_BOUND: LifecycleAuditEntityType.SUBMISSION_FINDING_RESPONSE, + LifecycleAuditEventType.REVIEWER_CONTRIBUTION_RECORDED: LifecycleAuditEntityType.CONTRIBUTION, + LifecycleAuditEventType.SUBMITTER_CONTRIBUTION_RECORDED: LifecycleAuditEntityType.CONTRIBUTION, + LifecycleAuditEventType.COMPENSATION_AWARD_CREATED: LifecycleAuditEntityType.COMPENSATION_AWARD, } +_LIFECYCLE_EVENT_REQUIRED_REFERENCES = { + LifecycleAuditEventType.REVIEW_ACCEPTED: frozenset( + {LifecycleAuditReferenceKind.FINAL_ACCEPTANCE} + ), + LifecycleAuditEventType.REVIEWER_CONTRIBUTION_RECORDED: frozenset( + { + LifecycleAuditReferenceKind.TASK, + LifecycleAuditReferenceKind.SUBMISSION, + LifecycleAuditReferenceKind.REVIEW, + LifecycleAuditReferenceKind.REVIEW_LEASE, + } + ), + LifecycleAuditEventType.SUBMITTER_CONTRIBUTION_RECORDED: frozenset( + { + LifecycleAuditReferenceKind.TASK, + LifecycleAuditReferenceKind.ASSIGNMENT, + LifecycleAuditReferenceKind.SUBMISSION, + LifecycleAuditReferenceKind.FINAL_ACCEPTANCE, + } + ), + LifecycleAuditEventType.COMPENSATION_AWARD_CREATED: frozenset( + {LifecycleAuditReferenceKind.CONTRIBUTION_RECORD} + ), +} + + +class LifecycleAuditEventInput(BaseModel): + """Admit one bounded lifecycle fact without claims or arbitrary metadata.""" + + model_config = ConfigDict(extra="forbid", strict=True) + + event_id: UUID + entity_type: LifecycleAuditEntityType + entity_id: UUID + event_type: LifecycleAuditEventType + actor_id: UUID + reason: LifecycleAuditReason + from_status: Annotated[str, Field(pattern=r"^[a-z][a-z0-9_]{0,29}$")] | None = None + to_status: Annotated[str, Field(pattern=r"^[a-z][a-z0-9_]{0,29}$")] | None = None + references: dict[LifecycleAuditReferenceKind, UUID] = Field(default_factory=dict) + + @model_validator(mode="before") + @classmethod + def admit_closed_input(cls, value: object) -> object: + """Copy only the closed input shape before Pydantic retains values.""" + try: + if not isinstance(value, Mapping): + raise TypeError + data = dict(value) + if set(data) - cls.model_fields.keys(): + raise TypeError + references = data.get("references", {}) + if not isinstance(references, Mapping) or len(references) > len( + LifecycleAuditReferenceKind + ): + raise TypeError + data["references"] = dict(references) + return data + except Exception as exc: # noqa: BLE001 - hostile Mapping methods are untrusted + raise TypeError("invalid lifecycle audit input") from exc + + @model_validator(mode="after") + def validate_lifecycle_shape(self) -> Self: + """Keep state transitions distinct from immutable fact creation.""" + if self.reason is LifecycleAuditReason.STATE_CHANGED: + if ( + self.from_status is None + or self.to_status is None + or self.from_status == self.to_status + ): + raise ValueError("state change requires distinct lifecycle states") + elif self.from_status is not None or self.to_status is not None: + raise ValueError("fact recording cannot carry lifecycle states") + entity_reference = { + LifecycleAuditEntityType.REVIEW_QUEUE_ENTRY: LifecycleAuditReferenceKind.REVIEW_QUEUE_ENTRY, + LifecycleAuditEntityType.REVIEW_LEASE: LifecycleAuditReferenceKind.REVIEW_LEASE, + LifecycleAuditEntityType.REVIEW: LifecycleAuditReferenceKind.REVIEW, + LifecycleAuditEntityType.REVIEW_FINDING: LifecycleAuditReferenceKind.REVIEW_FINDING, + LifecycleAuditEntityType.FINDING_RESOLUTION: LifecycleAuditReferenceKind.FINDING_RESOLUTION, + LifecycleAuditEntityType.SUBMISSION_FINDING_RESPONSE: LifecycleAuditReferenceKind.SUBMISSION_FINDING_RESPONSE, + LifecycleAuditEntityType.CONTRIBUTION: ( + LifecycleAuditReferenceKind.CONTRIBUTION_RECORD + ), + LifecycleAuditEntityType.COMPENSATION_AWARD: ( + LifecycleAuditReferenceKind.COMPENSATION_AWARD + ), + }[self.entity_type] + if LifecycleAuditReferenceKind.PROJECT not in self.references: + raise ValueError("lifecycle audit requires project reference") + if self.references.get(entity_reference) != self.entity_id: + raise ValueError("entity reference must match lifecycle entity") + if _LIFECYCLE_EVENT_ENTITY[self.event_type] is not self.entity_type: + raise ValueError("event type must match lifecycle entity") + required_references = _LIFECYCLE_EVENT_REQUIRED_REFERENCES.get(self.event_type, frozenset()) + allowed_references = { + LifecycleAuditReferenceKind.PROJECT, + entity_reference, + *required_references, + } + if set(self.references) != allowed_references: + raise ValueError("lifecycle event requires exact canonical references") + return self + class AuthorityEventType(StrEnum): """Closed authority event tokens from the adopted specification.""" @@ -119,7 +340,10 @@ class ActorReferenceKind(StrEnum): ), AuthorityEventType.ACTOR_PROFILE_SUSPENDED: {"security_response", "administrative_correction"}, AuthorityEventType.ACTOR_PROFILE_REACTIVATED: {"administrative_correction"}, - AuthorityEventType.ACTOR_PROFILE_DEACTIVATED: {"security_response", "administrative_correction"}, + AuthorityEventType.ACTOR_PROFILE_DEACTIVATED: { + "security_response", + "administrative_correction", + }, AuthorityEventType.INITIAL_ACCESS_ADMIN_BOOTSTRAPPED: {"initial_access_bootstrap"}, AuthorityEventType.ADMIN_ROLE_GRANT_ISSUED: {"authority_assignment"}, AuthorityEventType.ADMIN_ROLE_GRANT_REVOKED: {"authority_revocation"}, @@ -165,8 +389,7 @@ def _facts(value: object) -> dict[str, object] | None: return None data = dict(value) if len(data) > 8 or not set(data).issubset( - frozenset(_FACT_VALUES) - | {"effective", "allowed", "resource_context_digest", "scope_id"} + frozenset(_FACT_VALUES) | {"effective", "allowed", "resource_context_digest", "scope_id"} ): return None for key, item in data.items(): @@ -175,8 +398,7 @@ def _facts(value: object) -> dict[str, object] | None: if key == "scope_id" and _uuid(item) is None: return None if key == "resource_context_digest" and ( - not isinstance(item, str) - or re.fullmatch(r"sha256:[0-9a-f]{64}", item) is None + not isinstance(item, str) or re.fullmatch(r"sha256:[0-9a-f]{64}", item) is None ): return None if key in _FACT_VALUES and (not isinstance(item, str) or item not in _FACT_VALUES[key]): @@ -186,11 +408,15 @@ def _facts(value: object) -> dict[str, object] | None: return data -def _grant_facts(facts: dict[str, object] | None, roles: frozenset[str], status: str, effective: bool) -> bool: +def _grant_facts( + facts: dict[str, object] | None, roles: frozenset[str], status: str, effective: bool +) -> bool: if facts is None or facts.get("role") not in roles: return False scope = facts.get("scope_type") - expected = {"status", "role", "scope_type", "effective"} | ({"scope_id"} if scope == "project" else set()) + expected = {"status", "role", "scope_type", "effective"} | ( + {"scope_id"} if scope == "project" else set() + ) return ( set(facts) == expected and facts["status"] == status @@ -201,7 +427,9 @@ def _grant_facts(facts: dict[str, object] | None, roles: frozenset[str], status: ) -def _event_facts_valid(event: AuthorityEventType, before: dict[str, object] | None, after: dict[str, object] | None) -> bool: +def _event_facts_valid( + event: AuthorityEventType, before: dict[str, object] | None, after: dict[str, object] | None +) -> bool: if event in { AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED, AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED, @@ -211,18 +439,42 @@ def _event_facts_valid(event: AuthorityEventType, before: dict[str, object] | No before is None and after is not None and after.get("allowed") is expected_allowed - and set(after) in ( + and set(after) + in ( {"allowed"}, {"allowed", "resource_context_digest"}, ) ) exact = { - AuthorityEventType.ACTOR_PROFILE_PROVISIONED: (None, {"status": "active", "subject_kind": "human", "provisioning_method": "automatic_first_access"}), - AuthorityEventType.SERVICE_ACTOR_PROVISIONED: (None, {"status": "active", "subject_kind": "service", "provisioning_method": "manual_service_provisioning"}), - AuthorityEventType.ACTOR_IDENTITY_LINK_REVOKED: ({"status": "active"}, {"status": "revoked"}), - AuthorityEventType.ACTOR_IDENTITY_LINK_REACTIVATED: ({"status": "revoked"}, {"status": "active"}), + AuthorityEventType.ACTOR_PROFILE_PROVISIONED: ( + None, + { + "status": "active", + "subject_kind": "human", + "provisioning_method": "automatic_first_access", + }, + ), + AuthorityEventType.SERVICE_ACTOR_PROVISIONED: ( + None, + { + "status": "active", + "subject_kind": "service", + "provisioning_method": "manual_service_provisioning", + }, + ), + AuthorityEventType.ACTOR_IDENTITY_LINK_REVOKED: ( + {"status": "active"}, + {"status": "revoked"}, + ), + AuthorityEventType.ACTOR_IDENTITY_LINK_REACTIVATED: ( + {"status": "revoked"}, + {"status": "active"}, + ), AuthorityEventType.ACTOR_PROFILE_SUSPENDED: ({"status": "active"}, {"status": "suspended"}), - AuthorityEventType.ACTOR_PROFILE_REACTIVATED: ({"status": "suspended"}, {"status": "active"}), + AuthorityEventType.ACTOR_PROFILE_REACTIVATED: ( + {"status": "suspended"}, + {"status": "active"}, + ), AuthorityEventType.PROJECT_ROLE_QUALIFICATION_CAPTURED: (None, {"status": "captured"}), AuthorityEventType.ADMIN_ROLE_GRANT_ISSUE_DENIED: (None, None), AuthorityEventType.LAST_ACCESS_ADMIN_OPERATION_DENIED: (None, None), @@ -251,16 +503,23 @@ def _event_facts_valid(event: AuthorityEventType, before: dict[str, object] | No and before["scope_type"] == after["scope_type"] == "project" and before["role"] == after["role"] and before["scope_id"] == after["scope_id"] - and before["future_obligation"] - == after["future_obligation"] - == expected_obligation + and before["future_obligation"] == after["future_obligation"] == expected_obligation ) if event == AuthorityEventType.ACTOR_IDENTITY_LINKED: - return before is None and after in ({"status": "active", "subject_kind": "human"}, {"status": "active", "subject_kind": "service"}) + return before is None and after in ( + {"status": "active", "subject_kind": "human"}, + {"status": "active", "subject_kind": "service"}, + ) if event == AuthorityEventType.ACTOR_PROFILE_DEACTIVATED: - return before in ({"status": "active"}, {"status": "suspended"}) and after == {"status": "deactivated"} + return before in ({"status": "active"}, {"status": "suspended"}) and after == { + "status": "deactivated" + } if event == AuthorityEventType.INITIAL_ACCESS_ADMIN_BOOTSTRAPPED: - return before is None and _grant_facts(after, _ADMIN_ROLES, "active", True) and after["role"] == "access_administrator" + return ( + before is None + and _grant_facts(after, _ADMIN_ROLES, "active", True) + and after["role"] == "access_administrator" + ) roles, action = { AuthorityEventType.ADMIN_ROLE_GRANT_ISSUED: (_ADMIN_ROLES, "issued"), AuthorityEventType.ADMIN_ROLE_GRANT_REVOKED: (_ADMIN_ROLES, "revoked"), @@ -276,7 +535,9 @@ def _event_facts_valid(event: AuthorityEventType, before: dict[str, object] | No and (before["role"], before["scope_type"], before.get("scope_id")) == (after["role"], after["scope_type"], after.get("scope_id")) ) - return _grant_facts(before, roles, "active", True) and _grant_facts(after, roles, "active", True) + return _grant_facts(before, roles, "active", True) and _grant_facts( + after, roles, "active", True + ) class AuthorityAuditEventInput(BaseModel): @@ -337,8 +598,15 @@ def _inspect_privacy_safe_input(cls, value: object) -> dict | None: before_facts = _facts(data.get("before_facts")) after_facts = _facts(data.get("after_facts")) uuid_fields = ( - "event_id", "entity_id", "request_id", "correlation_id", "matched_grant_id", - "project_id", "resource_id", "idempotency_reference", "invalidation_cause_event_id", + "event_id", + "entity_id", + "request_id", + "correlation_id", + "matched_grant_id", + "project_id", + "resource_id", + "idempotency_reference", + "invalidation_cause_event_id", ) invalid = ( event is None @@ -347,17 +615,28 @@ def _inspect_privacy_safe_input(cls, value: object) -> dict | None: or kind is None or (kind == "system_principal" and actor_ref != "workstream:system:bootstrap") or (kind != "system_principal" and _uuid(actor_ref) is None) - or data.get("permission_id") is not None and not _registered(data["permission_id"], PERMISSION_IDS) - or data.get("action_id") is not None and not _registered(data["action_id"], ACTION_IDS) - or data.get("denial_code") is not None and not _registered(data["denial_code"], _DENIAL_CODES) - or data.get("resource_type") is not None and not _registered(data["resource_type"], _RESOURCE_TYPES) + or data.get("permission_id") is not None + and not _registered(data["permission_id"], PERMISSION_IDS) + or data.get("action_id") is not None + and not _registered(data["action_id"], ACTION_IDS) + or data.get("denial_code") is not None + and not _registered(data["denial_code"], _DENIAL_CODES) + or data.get("resource_type") is not None + and not _registered(data["resource_type"], _RESOURCE_TYPES) or data.get("target_ref_kind") is not None - and not _registered(data["target_ref_kind"], _TARGET_REF_KINDS | {"permission_registry"}) + and not _registered( + data["target_ref_kind"], _TARGET_REF_KINDS | {"permission_registry"} + ) or data.get("invalidation_target_kind") is not None - and not _registered(data["invalidation_target_kind"], _UUID_TARGET_KINDS | {"permission_registry"}) - or event is not None and not _registered(data.get("reason"), _REASONS[event]) - or before_facts is None and data.get("before_facts") is not None - or after_facts is None and data.get("after_facts") is not None + and not _registered( + data["invalidation_target_kind"], _UUID_TARGET_KINDS | {"permission_registry"} + ) + or event is not None + and not _registered(data.get("reason"), _REASONS[event]) + or before_facts is None + and data.get("before_facts") is not None + or after_facts is None + and data.get("after_facts") is not None ) for prefix in ("target_ref", "invalidation_target"): ref_kind = data.get(f"{prefix}_kind") @@ -366,12 +645,15 @@ def _inspect_privacy_safe_input(cls, value: object) -> dict | None: valid_uuid_kinds = ( _UUID_TARGET_KINDS if prefix == "invalidation_target" else _TARGET_REF_KINDS ) - invalid |= _registered(ref_kind, valid_uuid_kinds) and ref is not None and _uuid(ref) is None + invalid |= ( + _registered(ref_kind, valid_uuid_kinds) and ref is not None and _uuid(ref) is None + ) invalid |= ref_kind == "permission_registry" and not _registered(ref, PERMISSION_IDS) target_kind, target_ref = data.get("target_actor_ref_kind"), data.get("target_actor_ref") invalid |= (target_kind is None) != (target_ref is None) invalid |= target_kind is not None and ( - _enum_value(target_kind, ActorReferenceKind) != "actor_profile" or _uuid(target_ref) is None + _enum_value(target_kind, ActorReferenceKind) != "actor_profile" + or _uuid(target_ref) is None ) if invalid: return None @@ -404,9 +686,16 @@ def validate_shape(self) -> Self: """Enforce event, reference, fact, and project-scope integrity.""" if self.resource_id is not None and self.resource_type is None: raise ValueError("resource ID requires resource type") - if self.entity_type in {"authorization_decision", "authority_invalidation"} and self.entity_id != str(self.event_id): + if self.entity_type in { + "authorization_decision", + "authority_invalidation", + } and self.entity_id != str(self.event_id): raise ValueError("decision entity ID must equal event ID") - if self.resource_type == "project" and self.resource_id is not None and self.resource_id != self.project_id: + if ( + self.resource_type == "project" + and self.resource_id is not None + and self.resource_id != self.project_id + ): raise ValueError("project resource must match project scope") before, after = _facts(self.before_facts), _facts(self.after_facts) if not _event_facts_valid(self.event_type, before, after): @@ -417,7 +706,12 @@ def validate_shape(self) -> Self: raise ValueError("system grant cannot carry project scope") if facts["scope_type"] == "project" and facts.get("scope_id") != self.project_id: raise ValueError("grant facts must match project scope") - if before and after and "scope_id" in before and before.get("scope_id") != after.get("scope_id"): + if ( + before + and after + and "scope_id" in before + and before.get("scope_id") != after.get("scope_id") + ): raise ValueError("replacement cannot change project scope") invalidation = self.invalidation_cause_event_id is not None or self.invalidation_target_kind action = ACTION_BY_ID.get(self.action_id) if self.action_id is not None else None @@ -436,7 +730,12 @@ def validate_shape(self) -> Self: if action is not None and action.availability is ActionAvailability.PLANNED: raise ValueError("planned action cannot produce allowed evidence") elif self.event_type == AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED: - if self.permission_id is None or self.denial_code is None or invalidation or self.idempotency_reference: + if ( + self.permission_id is None + or self.denial_code is None + or invalidation + or self.idempotency_reference + ): raise ValueError("invalid denied authorization evidence") elif self.event_type in { AuthorityEventType.ADMIN_ROLE_GRANT_ISSUE_DENIED, @@ -445,7 +744,11 @@ def validate_shape(self) -> Self: if self.denial_code is None: raise ValueError("denied authority operation requires denial code") elif self.event_type == AuthorityEventType.AUTHORITY_INVALIDATION_REQUESTED: - if self.invalidation_cause_event_id is None or self.invalidation_target_kind is None or self.denial_code: + if ( + self.invalidation_cause_event_id is None + or self.invalidation_target_kind is None + or self.denial_code + ): raise ValueError("invalid authority invalidation evidence") restoration = self.permission_id in { PermissionId.ADMIN_ROLE_GRANT, @@ -465,8 +768,7 @@ def validate_shape(self) -> Self: and after.get("effective") is False and before.get("role") in _PROJECT_ROLES and before.get("role") == after.get("role") - and before.get("future_obligation") - == after.get("future_obligation") + and before.get("future_obligation") == after.get("future_obligation") ) if (before, after) != expected_direction and not projected_project_role: raise ValueError("invalid authority invalidation direction") diff --git a/backend/app/modules/audit/service.py b/backend/app/modules/audit/service.py index 95b28db55..d1996bf03 100644 --- a/backend/app/modules/audit/service.py +++ b/backend/app/modules/audit/service.py @@ -2,8 +2,8 @@ from sqlalchemy.ext.asyncio import AsyncSession -from app.modules.audit.repository import AuditRepository -from app.modules.audit.schemas import AuthorityAuditEventInput +from app.modules.audit.repository import AuditRepository, LIFECYCLE_AUTH_SOURCE +from app.modules.audit.schemas import AuthorityAuditEventInput, LifecycleAuditEventInput from app.modules.tasks.models import AuditEvent @@ -24,7 +24,10 @@ async def add_authority_event(self, value: AuthorityAuditEventInput) -> AuditEve raise TypeError("invalid authority audit input") value = AuthorityAuditEventInput.model_validate(fields) cause_id = value.invalidation_cause_event_id - if cause_id is not None and await self._repository.get_authority_event(str(cause_id)) is None: + if ( + cause_id is not None + and await self._repository.get_authority_event(str(cause_id)) is None + ): raise ValueError("invalidation cause must be an existing authority event") fields = value.model_dump(mode="json") fields["id"] = fields.pop("event_id") @@ -44,3 +47,49 @@ async def add_authority_event(self, value: AuthorityAuditEventInput) -> AuditEve event_version=1, ) return await self._repository._add_validated_authority_event(event) + + +class LifecycleAuditParticipant: + """Stage privacy-bounded lifecycle evidence in the caller transaction.""" + + def __init__(self, session: AsyncSession) -> None: + """Bind the participant to the caller-owned unit of work.""" + self._repository = AuditRepository(session) + + async def add_event(self, value: LifecycleAuditEventInput) -> AuditEvent: + """Flush one lifecycle event without committing the caller transaction.""" + try: + fields = dict(object.__getattribute__(value, "__dict__")) + except Exception: # noqa: BLE001 - caller diagnostics are never retained + fields = None + if fields is None: + raise TypeError("invalid lifecycle audit input") + try: + value = LifecycleAuditEventInput.model_validate(fields) + except Exception: + raise TypeError("invalid lifecycle audit input") from None + references = { + key.value: str(reference) + for key, reference in sorted(value.references.items(), key=lambda item: item[0].value) + } + event = AuditEvent( + id=str(value.event_id), + entity_type=value.entity_type.value, + entity_id=str(value.entity_id), + event_type=value.event_type.value, + from_status=value.from_status, + to_status=value.to_status, + actor_id=str(value.actor_id), + external_subject="workstream:lifecycle-participant", + external_issuer="workstream:internal", + actor_roles=[], + claim_snapshot={}, + auth_source=LIFECYCLE_AUTH_SOURCE, + is_dev_auth=False, + reason=value.reason.value, + event_payload={"references": references}, + event_domain="legacy_lifecycle", + event_version=None, + occurred_at=None, + ) + return await self._repository._add_validated_lifecycle_event(event) diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index bfe2b5d3d..02a5757f3 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -1,6 +1,7 @@ # pyright: reportOptionalSubscript=false from __future__ import annotations +import asyncio from collections import UserDict from collections.abc import Mapping import json @@ -18,13 +19,18 @@ create_async_engine, ) -from app.modules.audit.repository import AuditRepository +from app.modules.audit.repository import AuditRepository, LifecycleAuditConflict from app.modules.audit.schemas import ( ActorReferenceKind, AuthorityAuditEventInput, AuthorityEventType, + LifecycleAuditEntityType, + LifecycleAuditEventInput, + LifecycleAuditEventType, + LifecycleAuditReason, + LifecycleAuditReferenceKind, ) -from app.modules.audit.service import AuditService +from app.modules.audit.service import AuditService, LifecycleAuditParticipant from app.modules.authorization.catalogue import ( ACTION_DEFINITIONS, ActionAvailability, @@ -48,9 +54,7 @@ async def audit_factory(audit_database_env: str): async with engine.connect() as connection: existing = set( ( - await connection.execute( - text("select id from audit_events where event_domain = 'authority'") - ) + await connection.execute(text("select id from audit_events")) ).scalars() ) try: @@ -61,7 +65,7 @@ async def audit_factory(audit_database_env: str): set( ( await connection.execute( - text("select id from audit_events where event_domain = 'authority'") + text("select id from audit_events") ) ).scalars() ) @@ -1133,6 +1137,7 @@ async def test_database_rejects_malformed_and_mutated_audit_rows(audit_factory) text("delete from audit_events where id = :id"), {"id": event_id}, ) + await session.rollback() with pytest.raises(DBAPIError, match="append-only"): await session.execute(text("truncate table audit_events cascade")) @@ -1306,3 +1311,493 @@ def grant_values(facts: str, *, scope_project_id: str | None = project_id) -> di }, ) await session.rollback() + + +def _lifecycle_input(**overrides) -> LifecycleAuditEventInput: + entity_id = uuid4() + values = { + "event_id": uuid4(), + "entity_type": LifecycleAuditEntityType.REVIEW, + "entity_id": entity_id, + "event_type": LifecycleAuditEventType.REVIEW_ACCEPTED, + "actor_id": uuid4(), + "reason": LifecycleAuditReason.FACT_RECORDED, + "references": { + LifecycleAuditReferenceKind.PROJECT: uuid4(), + LifecycleAuditReferenceKind.REVIEW: entity_id, + LifecycleAuditReferenceKind.FINAL_ACCEPTANCE: uuid4(), + }, + } + values.update(overrides) + return LifecycleAuditEventInput(**values) + + +def test_lifecycle_input_covers_every_canonical_event_entity_pair() -> None: + event_groups = { + LifecycleAuditEntityType.REVIEW_QUEUE_ENTRY: { + LifecycleAuditEventType.REVIEW_QUEUE_ENTRY_CREATED, + LifecycleAuditEventType.REVIEW_ROUTED_TO_PREFERRED_REVIEWER, + LifecycleAuditEventType.REVIEWER_PREFERENCE_EXPIRED, + LifecycleAuditEventType.REVIEWER_PREFERENCE_INVALIDATED, + LifecycleAuditEventType.REVIEWER_DECLINED_PREFERENCE, + LifecycleAuditEventType.REVIEW_QUEUE_ENTRY_OPENED, + LifecycleAuditEventType.REVIEW_QUEUE_ENTRY_CLOSED, + }, + LifecycleAuditEntityType.REVIEW_LEASE: { + LifecycleAuditEventType.REVIEWER_CLAIMED_TASK, + LifecycleAuditEventType.REVIEWER_RELEASED_TASK, + LifecycleAuditEventType.REVIEWER_LEASE_EXPIRED, + LifecycleAuditEventType.REVIEWER_LEASE_REVOKED, + LifecycleAuditEventType.REVIEWER_LEASE_CONSUMED, + LifecycleAuditEventType.REVIEWER_LEASE_FORCE_RELEASED, + }, + LifecycleAuditEntityType.REVIEW: { + LifecycleAuditEventType.REVIEW_RECORDED, + LifecycleAuditEventType.REVIEW_ACCEPTED, + LifecycleAuditEventType.REVIEW_NEEDS_REVISION, + LifecycleAuditEventType.REVIEW_REJECTED, + LifecycleAuditEventType.REVIEW_EVIDENCE_ACCESSED, + LifecycleAuditEventType.REVIEW_EVIDENCE_UNAVAILABLE, + LifecycleAuditEventType.REVIEW_EVIDENCE_INTEGRITY_MISMATCH, + LifecycleAuditEventType.REVIEW_SNAPSHOT_PROJECTION_REQUESTED, + }, + LifecycleAuditEntityType.REVIEW_FINDING: { + LifecycleAuditEventType.REVIEW_FINDING_CREATED, + LifecycleAuditEventType.REVIEW_FINDING_EVIDENCE_BOUND, + }, + LifecycleAuditEntityType.FINDING_RESOLUTION: { + LifecycleAuditEventType.FINDING_RESOLUTION_RECORDED, + }, + LifecycleAuditEntityType.SUBMISSION_FINDING_RESPONSE: { + LifecycleAuditEventType.SUBMISSION_FINDING_RESPONSE_CREATED, + LifecycleAuditEventType.SUBMISSION_FINDING_RESPONSE_EVIDENCE_BOUND, + }, + LifecycleAuditEntityType.CONTRIBUTION: { + LifecycleAuditEventType.REVIEWER_CONTRIBUTION_RECORDED, + LifecycleAuditEventType.SUBMITTER_CONTRIBUTION_RECORDED, + }, + LifecycleAuditEntityType.COMPENSATION_AWARD: { + LifecycleAuditEventType.COMPENSATION_AWARD_CREATED, + }, + } + assert set().union(*event_groups.values()) == set(LifecycleAuditEventType) + entity_references = { + LifecycleAuditEntityType.REVIEW_QUEUE_ENTRY: LifecycleAuditReferenceKind.REVIEW_QUEUE_ENTRY, + LifecycleAuditEntityType.REVIEW_LEASE: LifecycleAuditReferenceKind.REVIEW_LEASE, + LifecycleAuditEntityType.REVIEW: LifecycleAuditReferenceKind.REVIEW, + LifecycleAuditEntityType.REVIEW_FINDING: LifecycleAuditReferenceKind.REVIEW_FINDING, + LifecycleAuditEntityType.FINDING_RESOLUTION: LifecycleAuditReferenceKind.FINDING_RESOLUTION, + LifecycleAuditEntityType.SUBMISSION_FINDING_RESPONSE: LifecycleAuditReferenceKind.SUBMISSION_FINDING_RESPONSE, + LifecycleAuditEntityType.CONTRIBUTION: LifecycleAuditReferenceKind.CONTRIBUTION_RECORD, + LifecycleAuditEntityType.COMPENSATION_AWARD: LifecycleAuditReferenceKind.COMPENSATION_AWARD, + } + for entity_type, event_types in event_groups.items(): + for event_type in event_types: + entity_id = uuid4() + references = { + LifecycleAuditReferenceKind.PROJECT: uuid4(), + entity_references[entity_type]: entity_id, + } + if event_type is LifecycleAuditEventType.REVIEW_ACCEPTED: + references[LifecycleAuditReferenceKind.FINAL_ACCEPTANCE] = uuid4() + elif event_type is LifecycleAuditEventType.REVIEWER_CONTRIBUTION_RECORDED: + references.update( + { + LifecycleAuditReferenceKind.TASK: uuid4(), + LifecycleAuditReferenceKind.SUBMISSION: uuid4(), + LifecycleAuditReferenceKind.REVIEW: uuid4(), + LifecycleAuditReferenceKind.REVIEW_LEASE: uuid4(), + } + ) + elif event_type is LifecycleAuditEventType.SUBMITTER_CONTRIBUTION_RECORDED: + references.update( + { + LifecycleAuditReferenceKind.TASK: uuid4(), + LifecycleAuditReferenceKind.ASSIGNMENT: uuid4(), + LifecycleAuditReferenceKind.SUBMISSION: uuid4(), + LifecycleAuditReferenceKind.FINAL_ACCEPTANCE: uuid4(), + } + ) + elif event_type is LifecycleAuditEventType.COMPENSATION_AWARD_CREATED: + references[LifecycleAuditReferenceKind.CONTRIBUTION_RECORD] = uuid4() + value = _lifecycle_input( + entity_type=entity_type, + entity_id=entity_id, + event_type=event_type, + references=references, + ) + assert value.event_type is event_type + + +def test_lifecycle_input_requires_acceptance_and_contribution_source_lineage() -> None: + review_id = uuid4() + with pytest.raises(ValidationError, match="exact canonical references"): + _lifecycle_input( + references={ + LifecycleAuditReferenceKind.PROJECT: uuid4(), + LifecycleAuditReferenceKind.REVIEW: review_id, + }, + entity_id=review_id, + ) + + contribution_id = uuid4() + with pytest.raises(ValidationError, match="exact canonical references"): + _lifecycle_input( + entity_type=LifecycleAuditEntityType.CONTRIBUTION, + entity_id=contribution_id, + event_type=LifecycleAuditEventType.SUBMITTER_CONTRIBUTION_RECORDED, + references={ + LifecycleAuditReferenceKind.PROJECT: uuid4(), + LifecycleAuditReferenceKind.CONTRIBUTION_RECORD: contribution_id, + LifecycleAuditReferenceKind.FINAL_ACCEPTANCE: uuid4(), + }, + ) + + +@pytest.mark.parametrize( + ("event_type", "entity_type", "entity_reference", "reason", "states"), + [ + ( + LifecycleAuditEventType.REVIEW_QUEUE_ENTRY_CLOSED, + LifecycleAuditEntityType.REVIEW_QUEUE_ENTRY, + LifecycleAuditReferenceKind.REVIEW_QUEUE_ENTRY, + LifecycleAuditReason.STATE_CHANGED, + ("open", "closed"), + ), + ( + LifecycleAuditEventType.REVIEWER_LEASE_CONSUMED, + LifecycleAuditEntityType.REVIEW_LEASE, + LifecycleAuditReferenceKind.REVIEW_LEASE, + LifecycleAuditReason.STATE_CHANGED, + ("active", "consumed"), + ), + ( + LifecycleAuditEventType.REVIEW_FINDING_CREATED, + LifecycleAuditEntityType.REVIEW_FINDING, + LifecycleAuditReferenceKind.REVIEW_FINDING, + LifecycleAuditReason.FACT_RECORDED, + (None, None), + ), + ( + LifecycleAuditEventType.FINDING_RESOLUTION_RECORDED, + LifecycleAuditEntityType.FINDING_RESOLUTION, + LifecycleAuditReferenceKind.FINDING_RESOLUTION, + LifecycleAuditReason.FACT_RECORDED, + (None, None), + ), + ( + LifecycleAuditEventType.SUBMISSION_FINDING_RESPONSE_CREATED, + LifecycleAuditEntityType.SUBMISSION_FINDING_RESPONSE, + LifecycleAuditReferenceKind.SUBMISSION_FINDING_RESPONSE, + LifecycleAuditReason.FACT_RECORDED, + (None, None), + ), + ( + LifecycleAuditEventType.REVIEWER_CONTRIBUTION_RECORDED, + LifecycleAuditEntityType.CONTRIBUTION, + LifecycleAuditReferenceKind.CONTRIBUTION_RECORD, + LifecycleAuditReason.FACT_RECORDED, + (None, None), + ), + ( + LifecycleAuditEventType.COMPENSATION_AWARD_CREATED, + LifecycleAuditEntityType.COMPENSATION_AWARD, + LifecycleAuditReferenceKind.COMPENSATION_AWARD, + LifecycleAuditReason.FACT_RECORDED, + (None, None), + ), + ], +) +async def test_lifecycle_participant_persists_canonical_rev_con_event_vocabulary( + audit_factory, + event_type: LifecycleAuditEventType, + entity_type: LifecycleAuditEntityType, + entity_reference: LifecycleAuditReferenceKind, + reason: LifecycleAuditReason, + states: tuple[str | None, str | None], +) -> None: + entity_id = uuid4() + value = _lifecycle_input( + event_type=event_type, + entity_type=entity_type, + entity_id=entity_id, + reason=reason, + from_status=states[0], + to_status=states[1], + references={ + LifecycleAuditReferenceKind.PROJECT: uuid4(), + entity_reference: entity_id, + **( + {LifecycleAuditReferenceKind.CONTRIBUTION_RECORD: uuid4()} + if event_type is LifecycleAuditEventType.COMPENSATION_AWARD_CREATED + else {} + ), + **( + { + LifecycleAuditReferenceKind.TASK: uuid4(), + LifecycleAuditReferenceKind.SUBMISSION: uuid4(), + LifecycleAuditReferenceKind.REVIEW: uuid4(), + LifecycleAuditReferenceKind.REVIEW_LEASE: uuid4(), + } + if event_type is LifecycleAuditEventType.REVIEWER_CONTRIBUTION_RECORDED + else {} + ), + }, + ) + async with audit_factory() as session: + stored = await LifecycleAuditParticipant(session).add_event(value) + assert stored.event_type == event_type.value + assert stored.entity_type == entity_type.value + assert stored.event_payload["references"][entity_reference.value] == str(entity_id) + await session.rollback() + + +async def test_lifecycle_participant_boundary_flushes_exact_closed_shape_without_commit( + audit_factory, +) -> None: + value = _lifecycle_input() + async with audit_factory() as session: + stored = await LifecycleAuditParticipant(session).add_event(value) + assert await session.get(AuditEvent, str(value.event_id)) is stored + assert stored.event_domain == "legacy_lifecycle" + assert stored.event_version is None + assert stored.occurred_at is None + assert stored.auth_source == "local_lifecycle" + assert stored.external_subject == "workstream:lifecycle-participant" + assert stored.external_issuer == "workstream:internal" + assert stored.actor_roles == [] + assert stored.claim_snapshot == {} + assert stored.event_payload == { + "references": { + "project_id": str(value.references[LifecycleAuditReferenceKind.PROJECT]), + "review_id": str(value.references[LifecycleAuditReferenceKind.REVIEW]), + "final_acceptance_id": str( + value.references[LifecycleAuditReferenceKind.FINAL_ACCEPTANCE] + ), + } + } + assert stored.project_id is None + assert stored.action_id is None + assert stored.before_facts is None + assert stored.after_facts is None + await session.rollback() + + +async def test_lifecycle_participant_rollback_removes_staged_event(audit_factory) -> None: + value = _lifecycle_input() + async with audit_factory() as session: + await LifecycleAuditParticipant(session).add_event(value) + await session.rollback() + async with audit_factory() as session: + assert await session.get(AuditEvent, str(value.event_id)) is None + + +async def test_lifecycle_participant_exact_replay_returns_existing_event( + audit_factory, +) -> None: + value = _lifecycle_input() + async with audit_factory() as session: + first = await LifecycleAuditParticipant(session).add_event(value) + await session.commit() + async with audit_factory() as session: + replay = await LifecycleAuditParticipant(session).add_event(value.model_copy(deep=True)) + assert replay.id == first.id + assert replay.event_payload == first.event_payload + + +async def test_lifecycle_participant_changed_replay_conflicts_without_payload_leak( + audit_factory, +) -> None: + value = _lifecycle_input() + async with audit_factory() as session: + await LifecycleAuditParticipant(session).add_event(value) + await session.commit() + async with audit_factory() as session: + participant = LifecycleAuditParticipant(session) + changed_references = dict(value.references) + changed_references[LifecycleAuditReferenceKind.FINAL_ACCEPTANCE] = uuid4() + changed = value.model_copy(update={"references": changed_references}) + with pytest.raises(LifecycleAuditConflict, match="identity conflict") as caught: + await participant.add_event(changed) + assert str(changed_references[LifecycleAuditReferenceKind.FINAL_ACCEPTANCE]) not in str( + caught.value + ) + + +async def test_lifecycle_participant_concurrent_exact_replay_is_deterministic( + audit_factory, +) -> None: + value = _lifecycle_input() + + async def persist(candidate: LifecycleAuditEventInput) -> str: + async with audit_factory() as session: + stored = await LifecycleAuditParticipant(session).add_event(candidate) + await session.commit() + return stored.id + + async with audit_factory() as first_session: + first = await LifecycleAuditParticipant(first_session).add_event(value) + replay_task = asyncio.create_task(persist(value.model_copy(deep=True))) + await _wait_for_advisory_lock_waiter(audit_factory, value.event_id) + await first_session.commit() + assert first.id == await replay_task == str(value.event_id) + + +async def test_lifecycle_participant_concurrent_changed_replay_conflicts( + audit_factory, +) -> None: + value = _lifecycle_input() + changed_references = dict(value.references) + changed_references[LifecycleAuditReferenceKind.FINAL_ACCEPTANCE] = uuid4() + changed = value.model_copy(update={"references": changed_references}) + + async def persist(candidate: LifecycleAuditEventInput) -> str: + async with audit_factory() as session: + try: + stored = await LifecycleAuditParticipant(session).add_event(candidate) + await session.commit() + return stored.id + except Exception: + await session.rollback() + raise + + async with audit_factory() as first_session: + first = await LifecycleAuditParticipant(first_session).add_event(value) + changed_task = asyncio.create_task(persist(changed)) + await _wait_for_advisory_lock_waiter(audit_factory, value.event_id) + await first_session.commit() + assert first.id == str(value.event_id) + with pytest.raises(LifecycleAuditConflict, match="identity conflict"): + await changed_task + + +async def _wait_for_advisory_lock_waiter(audit_factory, event_id: UUID) -> None: + """Prove the replay transaction overlaps while the first lock is held.""" + for _ in range(100): + async with audit_factory() as probe: + waiting = await probe.scalar( + text( + "with key as (select hashtextextended(:event_id, 0) as value) " + "select exists (select 1 from pg_locks, key " + "where locktype = 'advisory' and not granted " + "and classid = (((key.value >> 32) & 4294967295)::oid) " + "and objid = ((key.value & 4294967295)::oid) " + "and objsubid = 1)" + ), + {"event_id": str(event_id)}, + ) + if waiting: + return + await asyncio.sleep(0.01) + raise AssertionError("concurrent replay did not wait on lifecycle event lock") + + +async def test_lifecycle_participant_boundary_rejects_generic_repository_bypass( + audit_factory, +) -> None: + raw = AuditEvent( + id=str(uuid4()), + entity_type="review", + entity_id=str(uuid4()), + event_type="review_state_changed", + actor_id=str(uuid4()), + external_subject="workstream:lifecycle-participant", + external_issuer="workstream:internal", + actor_roles=[], + claim_snapshot={}, + auth_source="local_lifecycle", + is_dev_auth=False, + reason="lifecycle_state_changed", + event_payload={"references": {}}, + ) + async with audit_factory() as session: + with pytest.raises(ValueError, match="typed audit participant"): + await AuditRepository(session).add_audit_event(raw) + assert raw not in session + + +async def test_lifecycle_participant_payload_revalidates_forged_input_without_secret_leak( + audit_factory, +) -> None: + secret = "provider-token-must-not-survive" + value = _lifecycle_input() + value.__dict__["access_token"] = secret + async with audit_factory() as session: + with pytest.raises(TypeError, match="invalid lifecycle audit input") as caught: + await LifecycleAuditParticipant(session).add_event(value) + assert_secret_not_retained(caught.value, secret) + assert await session.get(AuditEvent, str(value.event_id)) is None + + +@pytest.mark.parametrize( + ("overrides", "message"), + [ + ( + { + "reason": LifecycleAuditReason.FACT_RECORDED, + "from_status": "pending", + "to_status": "accepted", + }, + "fact recording cannot carry lifecycle states", + ), + ( + { + "reason": LifecycleAuditReason.STATE_CHANGED, + "from_status": "pending", + "to_status": "pending", + }, + "state change requires distinct lifecycle states", + ), + ( + { + "reason": LifecycleAuditReason.STATE_CHANGED, + "from_status": None, + "to_status": "accepted", + }, + "state change requires distinct lifecycle states", + ), + ( + { + "reason": LifecycleAuditReason.STATE_CHANGED, + "from_status": "pending", + "to_status": None, + }, + "state change requires distinct lifecycle states", + ), + ( + { + "references": { + LifecycleAuditReferenceKind.PROJECT: uuid4(), + LifecycleAuditReferenceKind.REVIEW: uuid4(), + } + }, + "entity reference must match lifecycle entity", + ), + ( + {"event_type": LifecycleAuditEventType.REVIEW_QUEUE_ENTRY_CLOSED}, + "event type must match lifecycle entity", + ), + ( + {"event_type": LifecycleAuditEventType.REVIEW_NEEDS_REVISION}, + "lifecycle event requires exact canonical references", + ), + ( + {"references": {LifecycleAuditReferenceKind.PROJECT: uuid4()}}, + "entity reference must match lifecycle entity", + ), + ( + { + "references": { + LifecycleAuditReferenceKind.REVIEW: uuid4(), + } + }, + "lifecycle audit requires project reference", + ), + ], +) +def test_lifecycle_input_rejects_invalid_reason_state_shapes( + overrides: dict, + message: str, +) -> None: + with pytest.raises(ValidationError, match=message): + _lifecycle_input(**overrides) diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index 201b3bc39..42cbd7888 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -1989,6 +1989,25 @@ before/after facts without raw claims or unnecessary profile data. The shared repository participates in its caller's transaction and does not commit or open an independent session. +The typed `LifecycleAuditParticipant` is the feature-neutral writer for new +REV/CON lifecycle evidence. It accepts only closed entity/reason/reference +types and UUID references, flushes through the caller's `AsyncSession`, and +uses the existing lifecycle-compatible `audit_events` representation without +adding another domain or ledger. Its persisted compatibility discriminator is +`legacy_lifecycle`; that storage token is not the name or ownership boundary of +the new typed interface. The participant supplies fixed internal provenance +markers required by the existing representation; callers cannot provide +external subjects, issuers, +roles, claims, authorization facts, credentials, provider references, or +arbitrary payload metadata. Exact event-ID replay returns only an identical +immutable row, while changed reuse fails closed. Caller rollback removes the +audit row with the rest of the product transaction. The nested `project_id` +reference is provenance evidence only: it must never be used as an +authorization scope or query filter. Every reader must reload the canonical +entity relationships to establish the event's project context. +Lifecycle event tokens are added only when an adopted feature contract defines +them, together with their closed primary-entity pairing and contract tests. + ## Required Invariants - a task must belong to a project