From e46e720050f76e00915013cf0cf1269f890d679b Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Thu, 8 Oct 2026 16:42:05 +0100 Subject: [PATCH 01/17] plan(reviews): scope lifecycle control to first contribution path --- .../WS-REV-001/WS-REV-001-12A4A.md | 130 ++++++++++++++++++ docs/spec_contribution_compensation.md | 13 +- docs/spec_review_lifecycle.md | 74 ++++++---- 3 files changed, 186 insertions(+), 31 deletions(-) create mode 100644 .commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md diff --git a/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md b/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md new file mode 100644 index 000000000..ac252ae6b --- /dev/null +++ b/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md @@ -0,0 +1,130 @@ +# REV-12A4A — Scoped first-contribution lifecycle control + +- Initiative: `WS-REV-001` +- Durable disposition: `Planned` +- Risk: L1 — Operator authority, lifecycle concurrency and immutable evidence. +- Intended merge outcome: the existing lifecycle controller has an authorized, + generation-bound transition operation for the first contribution path. This + does not enable production routing, human review or payment delivery. + +## Intent and human decision + +The first contributor milestone needs one usable lifecycle generation before +false-policy acceptance can be composed. The canonical lifecycle specification +previously made fulfillment-obligation roots, ordinals and shutdown cutoffs a +prerequisite even while the milestone deferred fulfillment. The user explicitly +selected: keep payment delivery deferred and scope the controller to the first +contribution path. Conditional award facts remain atomic with contributions; +paid projects are not silently excluded. Obligation admission, dispatch and +callbacks remain unavailable until their own successor manifest and proofs. + +This extends REV-12A1's controller and the existing +`review.lifecycle.activation.manage` Operator action. It introduces no second +availability switch, bootstrap SQL, provider, generic drain framework or +compatibility path. Exact routing AUTH receipts and complete atomic consequences +remain the subsequent ARCH-04E2-B prerequisite before a production consumer. + +## Plan + +1. Reconcile canonical lifecycle/compensation specifications and the current + first-layer sequence with the selected scope. Preserve the four phases and + the future obligation writer/cutoff requirements before fulfillment activation. +2. Extend the singleton with immutable transition history and exact operation + replay. REV owns legal edges: disabled -> shadow -> live -> draining -> + disabled, plus shadow -> disabled. Each successful transition increments the + generation. Database time governs deadlines. No transition seeds an allow. +3. Use existing AUTH PREP with live system Operator authority before the REV + advisory/controller lock. Bind the exact singleton, operation, expected + generation, source/target phases, request, manifest and server observations. + AUTH consumes after locked validation; history and controller change commit + with its immutable decision in the caller's root transaction. Commit-time + closure requires exactly one N/P -> N+1/Q update, history row and matching + AUTH action/system target/resource digest/actor/grant receipt. Replay acquires + the same lock and looks up operation history before applying original + generation/deadline as new-effect guards. It requires the unchanged request + and receipt plus fresh same-actor live system Operator authority, with no + controller/history/audit mutation. Changed collisions deny. Bigint overflow + denies rather than wrapping the generation. +4. A fixed reviewed manifest describes the actual bounded participants. The + initial LIVE readiness check refuses any retained pre-authority FinalAcceptance + rows: mechanical storage cannot establish the missing originating receipt. + This is a real persisted observation, not an invented completeness claim. + No data is deleted or repaired. ARCH-04E2-B must add exact source custody and + update this evaluator before consuming retained facts in production. + Composition/inventory tests prove absent routing, human and fulfillment + registrations, rather than presenting their absence as runtime zero counts. + Atomic acceptance writers retain the existing advisory lock through commit, + so obtaining it fences earlier writers. No acceptance lease or asynchronous + acceptance queue is invented to drain. +5. New effects through the existing REV/TASK/CON participants require a live, + nonzero current generation. Consumer fence ports expose typed phase/generation + facts; owners derive new/replay from locked persisted state, then gate only + new effects. Keep exact verified terminal replay read-only + after shutdown using the current controller generation as its fence precondition, + with unchanged stored source/effect identities. Reuse existing select-only + participant replay branches; do not add a parallel replay implementation. A + stale generation denies. Award-free replay cannot attest a correlation value + that was never persisted. A future manifest + enabling asynchronous routing or fulfillment must add its actual drain proof + before activation. + +## Allowed files + +- REV `backend/app/modules/reviews/api/lifecycle.py`, + `lifecycle/{models,fence,service}.py`, and `acceptance/participant.py`. +- Existing TASK accepted-effects and CON participation owner modules and their + consumer-owned typed fence ports; no private cross-owner SQL/imports. +- AUTH `review_contracts.py`, `catalogue.py`, `kernel.py`, `prepared.py`, runtime + resource/digest/audit contracts, and focused lifecycle resource/PREP adapter + modules. Existing composition roots only for explicit controller construction. +- One successor Alembic migration, model registration, exact schema inventory; + focused REV lifecycle and AUTH tests plus affected acceptance/participation + tests and shared fixtures. Existing production inventory tests prove absent + surfaces; do not create a parallel registry. +- Exact behavior ownership and lane inventory files/tests; no CI gate changes. +- This record; REV/AUTH/CON/ARCH/POL current overviews, ARCH/AUTH/POL current + plans/maps, Commitrail index; canonical review/compensation specifications, + relevant authorization custody docs, README and roadmap. Local roadmap exports + only if present. Historical completed records remain historical. + +## Prohibited + +No fulfillment obligation/root/counter/ordinal/cutoff storage; payment admission, +dispatch or callback; production routing handler registration; public acceptance, +human queue/decision/revision runtime; new role/action/controller; invented Review; +retained-data deletion or rewriting; default permissive generation; generic +service locator; timeout/completeness/boundary weakening; unrelated cleanup. + +## Acceptance and verification + +- Real PostgreSQL and real AUTH prove the allowed edges, exact grant provenance, + revocation, wrong actor/scope, stale generation, expired deadline, request and + manifest substitution, operation collision and response-loss replay. +- Direct SQL cannot mutate/delete history, skip a generation, change phases + without the exact authorized history, substitute receipts or shadow protected + controller/history tables. This is not a claim of database LIVE guards on + mechanical acceptance storage. Retained controller identity/genesis and + existing records survive. +- Independent sessions prove writer-first and transition-first ordering through + actual participant writes; rollback removes state/history/AUTH effects together. + Managed and raw savepoints remain rejected by the canonical root fence. +- New participant effects fail in disabled/shadow/draining and at generation zero. + Mechanically composed live paid and unpaid controls preserve exact contribution and complete award + facts. Verified replay during draining/disabled performs no inserts or updates. +- The initial manifest refuses pre-authority retained acceptance facts before + LIVE (including reactivation) and unsupported enabled surfaces. + Fulfillment and routing registration remain unavailable. Shutdown does not + claim to cancel jobs or drain owners absent from this bounded manifest. +- Guard-removal probes must fail at the relevant phase/authority/history assertion, + not an unrelated fixture or digest guard. Retain existing meaningful proofs. +- Run focused PostgreSQL suites, lint, module boundaries, ownership/inventory, + links, stale wording, Commitrail and diff checks, then exact-head hosted CI. + +## Review and human focus + +Plan review before implementation; architecture/reuse, security, QA/test delta, +documentation/product operations and CI integrity review the clean candidate. +Review whether this stays the same controller/action, keeps contribution/award +facts atomic, and cannot enable payment or human runtime. Controller permission +is not routing or acceptance authority. Scope and phase history must be verifiable +without relying on receipt-shaped caller values or misleading drain counts. diff --git a/docs/spec_contribution_compensation.md b/docs/spec_contribution_compensation.md index c9fcf3421..8cf813eb4 100644 --- a/docs/spec_contribution_compensation.md +++ b/docs/spec_contribution_compensation.md @@ -1192,11 +1192,16 @@ decision-event receipt mandatory on the same strict input with no optional/default path; add database-enforced FinalAcceptance/TASK/CON complete-set closure, shared audit/outbox and exact activation to prove the first genuine allow with source, FinalAcceptance, TASK effects, CON rows and audit/outbox in one -transaction. No standalone allow or fabricated authority fixture is permitted. Actual root ordinal custody and authorized lifecycle -transition/drain proof precede live AUTH routing composition. +transaction. No standalone allow or fabricated authority fixture is permitted. +Authorized scoped lifecycle transition/drain proof precedes live AUTH routing +composition. Conditional award facts remain atomic for paid and unpaid policies. +The first-contribution manifest keeps fulfillment admission, dispatch and callbacks +unavailable; obligation/root/ordinal/cutoff storage is required before a reviewed +successor manifest enables fulfillment, not before this contribution path. False guide activation follows joint proof. A stable Review FK target is -not live ReviewLease/queue/decision behavior. The shared lifecycle/obligation -fence is required for either trigger; human runtime and fulfillment endpoints +not live ReviewLease/queue/decision behavior. The shared lifecycle +fence is required for either acceptance trigger; obligation writers join that same +fence before fulfillment admission is enabled. Human runtime and fulfillment endpoints are not prerequisites for accepting without a reviewer. The older interleaving below describes the human branch, not a second acceptance implementation. diff --git a/docs/spec_review_lifecycle.md b/docs/spec_review_lifecycle.md index 63d233d09..d6c1077a8 100644 --- a/docs/spec_review_lifecycle.md +++ b/docs/spec_review_lifecycle.md @@ -708,10 +708,12 @@ Extract foundations from existing owner work, not a new initiative: accepted/completed effects for either source. These isolated controls prove mechanical transaction behavior without claiming acceptance authority or fabricating allows. - Actual CON fulfillment roots own immutable ordinal allocation; no award or - outbox row substitutes for a root. Authorized transition/drain composition - and real root/cutoff proof extend this fence before activation, independently - of live human queues or decisions. + Authorized scoped transition/drain composition extends this fence before + the first contribution path. Applicable award facts remain atomic. Actual + CON fulfillment roots own immutable ordinal allocation before fulfillment + admission is enabled; no award or outbox row substitutes for a root. Payment + obligation storage and its root/cutoff proof are not prerequisites of a + manifest that keeps fulfillment admission, dispatch and callbacks unavailable. 4. ARCH-04E1B-B's hidden routing handler is next and invokes the delivered participant for false/pass. It owns TASK-before-CHECKERS currentness and both successor-generation race orders. True routing does not @@ -1142,8 +1144,10 @@ shutdown stages in historical plans are not additional current persisted phases. No transition, history, ordinal, cutoff, consumer or usable generation is implemented by this foundation. The later authorized transition operation owns legal adjacency and extends the same controller. Actual CON root storage and -real authorized writer-versus-cutoff proof are required before activation; lock -mechanics alone do not prove drain correctness. Both acceptance callers require +real authorized writer-versus-cutoff proof are required before a successor +manifest activates fulfillment admission, dispatch or callbacks. They do not +block the first contribution manifest with fulfillment disabled. Lock mechanics +alone do not prove asynchronous drain correctness. Both acceptance callers require a valid authorized lifecycle generation, without a bootstrap bypass or second availability flag. @@ -1158,27 +1162,43 @@ The resource still binds singleton, expected generation, phases, operation, reviewed manifest and observation digests, deadline and reason. No SQL bootstrap, new action, permissive default generation or second availability flag is allowed. -This scoped manifest covers every enabled TASK admission/routing and shared -acceptance/CON obligation writer, including legacy-reachability removal. Its -real observation ports, ordinal fencing, crash recovery and safe drain/stop -proof must pass before it can admit work. Unlisted writers, fabricated zero -counts and unsupported transitions deny. Human queues/leases/decisions and -unreleased CON product/fulfillment surfaces must be proven unreachable; their -absence is a checked fact, not a fake observation adapter. Required shared -storage/history observations still run even when a surface is unavailable. - -The same authorized Operator command is exposed as shared infrastructure for -this bounded manifest, not as a reviewer endpoint. Later human/fulfillment -release adds its observation/custody proofs and a successor manifest/evaluator -under the same controller and action. This is product deployment control, -not a new contributor approval step or permission to activate human review. - -Activation and shutdown are generation-bound and crash resumable. Shutdown -fences new admission, drains admitted commands and leases, captures the -immutable fulfillment-obligation cutoff after prior writers drain, permits only -same-generation pre-cutoff completion work, then disables. Timeout leaves the -phase unchanged for forward retry. No background job replays human Operator -authority or advances a phase. Reactivation requires a newly reviewed manifest. +The first-contribution manifest covers its enabled TASK admission/routing and +shared acceptance/CON contribution and conditional award writers. Payment +obligation admission, dispatch and callbacks remain unavailable; their absence +must be checked against actual composition, not represented by fake zero drain +counts. Paid and unpaid contribution policies retain their full atomic award +semantics. Neither payment delivery nor its obligation/root/ordinal substrate +is a prerequisite of this bounded manifest. + +The initial controller implementation is +[REV-12A4A](../.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md). +It remains planned. It extends the same Operator action, singleton and fence; +it does not grant routing or acceptance authority. Every enabled writer must +appear in its reviewed manifest. Server-derived observations are checked under +the canonical lock. Unsupported surfaces and unlisted writers deny. Human +queues/leases/decisions and fulfillment remain unavailable. Retained mechanical +acceptance facts cannot establish missing originating AUTH receipts. + +For atomic participants, the shared transaction fence waits for earlier writers; +a phase change prevents later new effects. Exact terminal replay compares retained +facts without new writes, using the current generation as the fence precondition. +When asynchronous routing is enabled, its successor manifest must prove actual +admitted-work drain and crash recovery before activation. No generic drain system +or fabricated observation is required for absent asynchronous participants. + +The same authorized Operator command is shared infrastructure, not a reviewer +endpoint or contributor approval step. Later human/fulfillment release adds its +actual observation/custody proofs under the same controller and action. +Before fulfillment admission is enabled, CON must own real immutable roots and +ordinals. Shutdown then drains prior writers and captures the server-derived +cutoff, permits only same-generation pre-cutoff completion, and disables only +after the governed drain. None of those future operations is implied by storing +an award fact. + +Activation and shutdown are generation-bound and crash resumable. Timeout leaves +the phase unchanged for forward retry. No background job replays human Operator +authority or advances a phase. Reactivation verifies the reviewed manifest and +current observations; an expanded scope requires a reviewed successor manifest. This controller is product release state, not AUTH action availability. The full human 12A1 through 12A4 implementation expands the shared foundation; From 5550190c7d93bfd4a2a85123cbe3646f773356cf Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Thu, 8 Oct 2026 17:51:34 +0100 Subject: [PATCH 02/17] feat(reviews): authorize scoped lifecycle transitions and participant gates --- .ci/behavior-ownership/partition.v1.json | 18 +- .commitrail/INDEX.md | 8 +- .../initiatives/WS-ARCH-001/OVERVIEW.md | 4 +- .../WS-ARCH-001/planning/CHUNK_MAP.md | 5 +- .../initiatives/WS-ARCH-001/planning/PLAN.md | 17 +- .../initiatives/WS-AUTH-001/OVERVIEW.md | 4 +- .../WS-AUTH-001/planning/CHUNK_MAP.md | 5 +- .../initiatives/WS-AUTH-001/planning/PLAN.md | 3 +- .../initiatives/WS-CON-001/OVERVIEW.md | 12 +- .../initiatives/WS-POL-003/OVERVIEW.md | 4 +- .../WS-POL-003/planning/CHUNK_MAP.md | 3 +- .../initiatives/WS-POL-003/planning/PLAN.md | 3 +- .../initiatives/WS-REV-001/OVERVIEW.md | 12 +- .../WS-REV-001/WS-REV-001-12A4A.md | 9 +- README.md | 10 +- .../versions/0027_lifecycle_transitions.py | 217 ++++++++++++++++++ backend/app/adapters/auth/__init__.py | 7 + backend/app/modules/audit/schemas.py | 3 +- .../app/modules/authorization/catalogue.py | 3 +- .../app/modules/authorization/domain/audit.py | 2 + .../authorization/domain/audit_targets.py | 3 + .../modules/authorization/domain/lifecycle.py | 55 +++++ backend/app/modules/authorization/kernel.py | 8 +- .../authorization/lifecycle_authorization.py | 62 +++++ backend/app/modules/authorization/prepared.py | 9 +- .../prepared_lifecycle_replay.py | 43 ++++ .../app/modules/authorization/repository.py | 4 +- .../modules/authorization/review_contracts.py | 27 +-- backend/app/modules/authorization/runtime.py | 2 + .../contributions/api/participation.py | 14 +- .../contributions/records/participant.py | 6 +- .../modules/reviews/acceptance/participant.py | 8 +- backend/app/modules/reviews/api/lifecycle.py | 79 +++++++ .../app/modules/reviews/lifecycle/fence.py | 13 +- .../app/modules/reviews/lifecycle/models.py | 38 ++- .../app/modules/reviews/lifecycle/service.py | 107 +++++++++ backend/app/modules/tasks/accepted_effects.py | 4 +- .../app/modules/tasks/api/accepted_effects.py | 14 +- backend/scripts/behavior_ownership.py | 7 + backend/scripts/test_lane_catalogue.py | 4 + .../tests/authorization/catalogue_fixtures.py | 2 +- backend/tests/conftest.py | 18 +- .../contributions/participation/conftest.py | 12 + .../contributions/participation/support.py | 2 +- .../participation/test_postgresql.py | 5 +- .../participation/test_transactions.py | 5 +- backend/tests/reviews/acceptance/conftest.py | 12 + .../acceptance/participation_support.py | 2 +- .../reviews/acceptance/test_participation.py | 3 + .../test_participation_transactions.py | 3 + backend/tests/reviews/lifecycle/conftest.py | 13 ++ backend/tests/reviews/lifecycle/fixtures.py | 14 ++ .../lifecycle/test_authority_binding.py | 40 ++++ .../tests/reviews/lifecycle/test_contracts.py | 2 +- backend/tests/reviews/lifecycle/test_fence.py | 2 +- .../tests/reviews/lifecycle/test_migration.py | 37 +++ .../lifecycle/test_participant_control.py | 193 ++++++++++++++++ .../tests/reviews/lifecycle/test_storage.py | 2 +- .../lifecycle/test_transition_storage.py | 87 +++++++ .../reviews/lifecycle/test_transitions.py | 136 +++++++++++ .../reviews/lifecycle/transition_support.py | 101 ++++++++ .../tests/tasks/accepted_effects/conftest.py | 12 + .../tasks/accepted_effects/test_postgresql.py | 29 +-- .../tasks/post_submit_routing/conftest.py | 12 + .../test_evaluation_currentness.py | 2 + backend/tests/test_behavior_ownership.py | 18 +- backend/tests/test_ci_lane_catalogue.py | 2 + backend/tests/test_database_reset.py | 10 +- .../test_review_authorization_contracts.py | 75 +++--- docs/architecture_data_model.md | 17 +- .../authorization_activation_custody.md | 43 ++-- .../review_authorization_action_custody.md | 39 ++-- docs/roadmap_status.md | 25 +- docs/spec_authorization_service.md | 9 + docs/spec_contribution_compensation.md | 9 +- docs/spec_review_lifecycle.md | 17 +- 76 files changed, 1652 insertions(+), 214 deletions(-) create mode 100644 backend/alembic/versions/0027_lifecycle_transitions.py create mode 100644 backend/app/modules/authorization/domain/lifecycle.py create mode 100644 backend/app/modules/authorization/lifecycle_authorization.py create mode 100644 backend/app/modules/authorization/prepared_lifecycle_replay.py create mode 100644 backend/app/modules/reviews/lifecycle/service.py create mode 100644 backend/tests/contributions/participation/conftest.py create mode 100644 backend/tests/reviews/acceptance/conftest.py create mode 100644 backend/tests/reviews/lifecycle/conftest.py create mode 100644 backend/tests/reviews/lifecycle/fixtures.py create mode 100644 backend/tests/reviews/lifecycle/test_authority_binding.py create mode 100644 backend/tests/reviews/lifecycle/test_participant_control.py create mode 100644 backend/tests/reviews/lifecycle/test_transition_storage.py create mode 100644 backend/tests/reviews/lifecycle/test_transitions.py create mode 100644 backend/tests/reviews/lifecycle/transition_support.py create mode 100644 backend/tests/tasks/accepted_effects/conftest.py create mode 100644 backend/tests/tasks/post_submit_routing/conftest.py diff --git a/.ci/behavior-ownership/partition.v1.json b/.ci/behavior-ownership/partition.v1.json index b1b153fef..78f2a0803 100644 --- a/.ci/behavior-ownership/partition.v1.json +++ b/.ci/behavior-ownership/partition.v1.json @@ -564,6 +564,10 @@ "group": "auth", "target": "backend/app/modules/authorization/domain/guide_proposals.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/domain/lifecycle.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/domain/outbox_dispatch.py" @@ -656,6 +660,10 @@ "group": "auth", "target": "backend/app/modules/authorization/kernel.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/lifecycle_authorization.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/lifecycle_schemas.py" @@ -700,6 +708,10 @@ "group": "auth", "target": "backend/app/modules/authorization/prepared.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/prepared_lifecycle_replay.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/prepared_post_submit_replay.py" @@ -1396,6 +1408,10 @@ "group": "lifecycle", "target": "backend/app/modules/reviews/lifecycle/models.py" }, + { + "group": "lifecycle", + "target": "backend/app/modules/reviews/lifecycle/service.py" + }, { "group": "lifecycle", "target": "backend/app/modules/reviews/models.py" @@ -1713,7 +1729,7 @@ "target": "backend/scripts/validate_test_lane_evidence.py" } ], - "authority_digest": "ef9821de8c9f801b8883d9d485e320cae5283e7ae5abd9bac3ad2ab31042731a", + "authority_digest": "5cd1d20885b35481f54dc89d4534ffa397fa02751448f7d7d93fb4d83ad103e6", "protected_base_commit": "7676ce4347db0c9694962a9b587a20765e16eac6", "schema": "workstream.behavior-ownership-partition.v1" } diff --git a/.commitrail/INDEX.md b/.commitrail/INDEX.md index bc9f779f8..26968b4f0 100644 --- a/.commitrail/INDEX.md +++ b/.commitrail/INDEX.md @@ -11,11 +11,11 @@ for current product capability. | [WS-CLI-001](initiatives/WS-CLI-001/OVERVIEW.md) | Planned | Public Go CLI self-service, inspection, task browsing/claim/start, governing context, project creation and guide declaration delivered through WS-CLI-001-09; document upload, further public journeys and binary distribution remain; hidden submission is not exposed | | [WS-ARCH-001](initiatives/WS-ARCH-001/OVERVIEW.md) | Planned | Source storage, inert AUTH contracts, TASK request reservation, hidden exact AUTH preparation and the REV-04C hidden FinalAcceptance/TASK/CON participant are delivered; TASK-before-CHECKERS reservation/current-read custody and ordered admission INSERTs are delivered by 04E1B-B1; 04E1B-B2 adds exact source preparation without publication; 04E1B-B3 retains inspected ZIP metadata and B4 binds Submission text to checked packet custody and B5 validates bounded evaluation content before durable admission; B6 commits initial Submission/dispatch and exact replay; B7 adds hidden request delivery; completion routing is next under the [first-layer sequence](initiatives/WS-ARCH-001/planning/PLAN.md#first-complete-contributor-milestone), ending in a public contributor drill before live human review/revision. True admission remains independent of shared acceptance; false activation still requires exact AUTH receipt custody, database complete-set enforcement, currentness race proof and atomic routing activation. | | [WS-ART-001](initiatives/WS-ART-001/OVERVIEW.md) | Planned | Exact checker input/output custody and packet foundations are delivered; routing integration, remediation and public intake remain. | -| [WS-AUTH-001](initiatives/WS-AUTH-001/OVERVIEW.md) | Planned | AUTH-19A commitments, TASK request reservation, ARCH-04E2-A hidden strict PREP matching and the REV-04C hidden FinalAcceptance/TASK/CON participant are delivered; the action remains unavailable, with mandatory exact AUTH receipt input, database closure, audit/outbox and scoped activation still required for the automated path. | -| [WS-CON-001](initiatives/WS-CON-001/OVERVIEW.md) | Planned | Contribution/award storage, CON-07 participation and REV-04C hidden FinalAcceptance/TASK/CON composition are delivered; production consumption still requires genuine authority, database complete-set enforcement, currentness race proof, shared audit/outbox, fulfillment-root ordinals and lifecycle activation. | -| [WS-AUTH-003](initiatives/WS-AUTH-003/OVERVIEW.md) | Planned | Canonical source facts and disabled lifecycle fencing are delivered; exact lifecycle activation remains tied to the governed consequence. | +| [WS-AUTH-001](initiatives/WS-AUTH-001/OVERVIEW.md) | Planned | AUTH-19A commitments, TASK request reservation, ARCH-04E2-A hidden strict PREP matching and the REV-04C hidden FinalAcceptance/TASK/CON participant are delivered; routing remains unavailable; REV-12A4A supplies internal Operator lifecycle control, while mandatory routing AUTH receipt input, database closure and audit/outbox remain required for the automated path. | +| [WS-CON-001](initiatives/WS-CON-001/OVERVIEW.md) | Planned | Contribution/award storage, CON-07 participation and REV-04C hidden FinalAcceptance/TASK/CON composition are delivered; production consumption still requires genuine authority, database complete-set enforcement, currentness race proof, shared audit/outbox and production manifest readiness on the delivered REV-12A4A controller. | +| [WS-AUTH-003](initiatives/WS-AUTH-003/OVERVIEW.md) | Planned | Canonical source facts and scoped Operator lifecycle control are delivered; exact acceptance activation remains tied to the governed consequence. | | [WS-POL-003](initiatives/WS-POL-003/OVERVIEW.md) | Planned | Locked policy configuration, inert source commitments and the REV-04C hidden false/pass acceptance participant are delivered; false-policy activation remains gated by exact AUTH receipt custody, database closure, currentness proof, remediation and scoped lifecycle authority. | -| [WS-REV-001](initiatives/WS-REV-001/OVERVIEW.md) | Planned | Packet and Review storage plus REV-04C hidden source-neutral FinalAcceptance/TASK/CON composition are delivered; ARCH-04E1B-B6 atomic Submission/dispatch is delivered; B7 adds hidden request delivery; completion routing remains next, while authority/evidence closure and human review/revision remain separately gated. | +| [WS-REV-001](initiatives/WS-REV-001/OVERVIEW.md) | Planned | Packet and Review storage plus REV-04C hidden source-neutral FinalAcceptance/TASK/CON composition are delivered; ARCH-04E1B-B6 atomic Submission/dispatch is delivered; B7 adds hidden request delivery; completion routing remains next, while REV-12A4A supplies scoped lifecycle control; acceptance authority/evidence closure and human review/revision remain separately gated. | | [WS-QUAL-002](initiatives/WS-QUAL-002/OVERVIEW.md) | Planned | Populate subsystem ownership before changed-line mutation work | | [WS-QUAL-003](initiatives/WS-QUAL-003/OVERVIEW.md) | Planned | Audit and prune test proof, add missing safety cases, decompose oversized test modules | | [WS-XINT-002](initiatives/WS-XINT-002/OVERVIEW.md) | Planned | Remaining ART/AUTH activation edges only | diff --git a/.commitrail/initiatives/WS-ARCH-001/OVERVIEW.md b/.commitrail/initiatives/WS-ARCH-001/OVERVIEW.md index 35804dead..c933f5910 100644 --- a/.commitrail/initiatives/WS-ARCH-001/OVERVIEW.md +++ b/.commitrail/initiatives/WS-ARCH-001/OVERVIEW.md @@ -1,5 +1,7 @@ # WS-ARCH-001 — Modular monolith boundaries +[REV-12A4A](../WS-REV-001/WS-REV-001-12A4A.md) adds internal Operator-controlled lifecycle transitions and current-generation terminal replay. New acceptance effects require LIVE; source AUTH custody and production routing remain unavailable. Conditional award facts stay atomic, while fulfillment admission, payment delivery and their root/cutoff machinery remain deferred until a successor manifest enables them. + [ARCH-04E1B-B5](WS-ARCH-001-04E1BB5.md) delivers shared bounded evaluation content and rejects unrepresentable ZIP input before durable admission. ARCH-04E1B-B6 reuses this projection in atomic Submission/dispatch creation with exact AUTH receipts and select-only replay. B7 adds hidden request delivery with exact invocation fencing; completion routing is next. Production registration remains unavailable. Delivery priority: [first complete contributor milestone](planning/PLAN.md#first-complete-contributor-milestone). Use its nine-step order and end-to-end exit proof when selecting the next bounded change; live human review/revision and external integration are later work. @@ -26,7 +28,7 @@ CON-07 hidden submitter participation and complete frozen award-set staging/repl [REV-04C](../WS-REV-001/WS-REV-001-04C.md) now composes FinalAcceptance, TASK terminal effects and CON participation inside one hidden caller-owned transaction. Mandatory exact AUTH receipt input, database complete-set enforcement, currentness race proof, shared audit/outbox, -fulfillment-root ordinal custody and scoped lifecycle activation remain +the reviewed scoped lifecycle manifest remain required before production consumption. Neither phase may commit a standalone allow. The first durable receipt must commit with its full governed consequence. Hidden handlers and activation follow as 04E1B-B/04E2-B, diff --git a/.commitrail/initiatives/WS-ARCH-001/planning/CHUNK_MAP.md b/.commitrail/initiatives/WS-ARCH-001/planning/CHUNK_MAP.md index 991eea995..5818d02c6 100644 --- a/.commitrail/initiatives/WS-ARCH-001/planning/CHUNK_MAP.md +++ b/.commitrail/initiatives/WS-ARCH-001/planning/CHUNK_MAP.md @@ -24,8 +24,7 @@ PREP while the action remains planned/unavailable. CON-07 hidden submitter participation and complete frozen award-set staging/replay are delivered. REV-04C adds the hidden FinalAcceptance/TASK/CON participant. Remaining 04E1B-B work begins with atomic initial Submission/dispatch and B7 hidden request delivery before completion routing. Mandatory exact same-input AUTH receipt, database complete-set enforcement, -TASK-before-CHECKERS race proof, shared audit/outbox, fulfillment-root ordinal -custody and scoped lifecycle activation remain required +TASK-before-CHECKERS race proof, shared audit/outbox, the reviewed scoped lifecycle manifest remain required before production consumption. Neither phase may commit a standalone allow. The first durable receipt must commit with its full governed consequence. Hidden handlers and activation follow as 04E1B-B/04E2-B, @@ -76,7 +75,7 @@ currentness proof stay within 04E1B-B, before activation and live composition. | [WS-ARCH-001-04D1](../WS-ARCH-001-04D1.md) | Canonical terminal ART material custody | L1 | Complete; valid retained history preserved; invalid upgrades refused | | [WS-ARCH-001-04D2](../WS-ARCH-001-04D2.md) | AUTH exact fixed-service post-submit activation (replaces XINT-06B) | L1 | Complete: exact input, execute and finalize authority; output write/bind remains unavailable | | [WS-ARCH-001-04E1A](../WS-ARCH-001-04E1A.md) | Route-neutral immutable source schema and shared accepted-effects types | L1 | Complete; REV-04C uses a bounded exact-source verifier, with no general routing publication writer/reader, routing authority or current pointer; its hidden effects participant does not make false routing available | -| [WS-ARCH-001-04E](chunks/WS-ARCH-001-04E-canonical-allow-review.md) | TASK current routing: true to canonical `allow_review`, false/pass to shared acceptance | L1 | Both branches: delivered 04E1A/04D2/OUTBOX-02, request reservation 04E1B-A and AUTH preparation 04E2-A; exact source preparation [04E1B-B2](../WS-ARCH-001-04E1BB2.md) -> verified ZIP metadata [04E1B-B3](../WS-ARCH-001-04E1BB3.md) -> checked packet custody [04E1B-B4](../WS-ARCH-001-04E1BB4.md) -> bounded evaluation content [04E1B-B5](../WS-ARCH-001-04E1BB5.md) -> atomic Submission/dispatch [04E1B-B6](../WS-ARCH-001-04E1BB6.md) -> hidden request delivery [04E1B-B7](../WS-ARCH-001-04E1BB7.md) -> completion/currentness proof 04E1B-B -> authority/evidence closure and activation 04E2-B -> live 04E3. False uses delivered REV-04C participation and additionally needs mandatory exact receipt/database/audit closure, fulfillment-root ordinal custody and scoped lifecycle activation; true does not depend on CON. False guide activation also requires 04F remediation | +| [WS-ARCH-001-04E](chunks/WS-ARCH-001-04E-canonical-allow-review.md) | TASK current routing: true to canonical `allow_review`, false/pass to shared acceptance | L1 | Both branches: delivered 04E1A/04D2/OUTBOX-02, request reservation 04E1B-A and AUTH preparation 04E2-A; exact source preparation [04E1B-B2](../WS-ARCH-001-04E1BB2.md) -> verified ZIP metadata [04E1B-B3](../WS-ARCH-001-04E1BB3.md) -> checked packet custody [04E1B-B4](../WS-ARCH-001-04E1BB4.md) -> bounded evaluation content [04E1B-B5](../WS-ARCH-001-04E1BB5.md) -> atomic Submission/dispatch [04E1B-B6](../WS-ARCH-001-04E1BB6.md) -> hidden request delivery [04E1B-B7](../WS-ARCH-001-04E1BB7.md) -> completion/currentness proof 04E1B-B -> authority/evidence closure and activation 04E2-B -> live 04E3. False uses delivered REV-04C participation and additionally needs mandatory exact receipt/database/audit closure, the reviewed scoped lifecycle manifest; true does not depend on CON. False guide activation also requires 04F remediation | | [WS-ARCH-001-03D](../WS-ARCH-001-03D.md) | Exact activated historical guide through hidden durable intake; obsolete lookup removed | L1 | Complete; hidden exact post-submit materialization, ARCH-04B2 output custody, ARCH-04C execution, ARCH-04D1/04D2 custody/authority and ARCH-04E1A source-only facts/types delivered; public cutover remains deferred | | [WS-ARCH-001-04F](chunks/WS-ARCH-001-04F-checker-remediation.md) | Contributor-correctable checker failures and same-lineage admission-backed replacement Submission | L1 | Planned from required hidden 04E result/handler contracts; before false-policy activation and public 02I, without requiring live human review | diff --git a/.commitrail/initiatives/WS-ARCH-001/planning/PLAN.md b/.commitrail/initiatives/WS-ARCH-001/planning/PLAN.md index 3738319ea..5030934f6 100644 --- a/.commitrail/initiatives/WS-ARCH-001/planning/PLAN.md +++ b/.commitrail/initiatives/WS-ARCH-001/planning/PLAN.md @@ -2,6 +2,8 @@ ## First complete contributor milestone +[REV-12A4A](../../WS-REV-001/WS-REV-001-12A4A.md) supplies the scoped controller prerequisite for step 5. It keeps fulfillment disabled and does not activate routing. Completion delivery must follow the exact authority/effect operation in steps 4–5: preparation alone must never acknowledge a completion event. + This is the governing delivery order for the first usable backend journey, not nine promised PRs. Review each bounded change against merged owners and name the step it closes, its concrete prerequisite and its observable proof. Existing @@ -16,7 +18,7 @@ outcomes. | 2 | [Bounded evaluation content, ARCH-04E1B-B5](../WS-ARCH-001-04E1BB5.md) | Complete: preparation validates the shared content contract before durable admission; later dispatch must reuse it with real record identities. No truncation or stranded successful preparation. | | 3 | [Atomic Submission/dispatch, ARCH-04E1B-B6](../WS-ARCH-001-04E1BB6.md) | Complete: Submission, ART binding, exact creation/binding receipts, initial evaluation reservation and shared outbox event commit together. Fresh-authorized replay returns original identities after a lost response. | | 4 | Hidden request/completion handlers within ARCH-04E1B-B | [B7](../WS-ARCH-001-04E1BB7.md) supplies the unregistered request handler; completion routing remains. Reuse shared outbox and CHECKERS execution. Verify exact bytes, locked policies and current generation; duplicate/stale delivery has no additional effect. | -| 5 | Authorized governed outcomes, ARCH-04E2-B and existing shared participants | Genuine authority commits with its full consequence. False/pass atomically creates FinalAcceptance, accepted TASK state, submitter ContributionRecord and applicable awards; true preserves its independent human-review handoff. Include exact receipt, database closure, audit/outbox, lock-race and required fulfillment-root custody. Exercise the existing scoped controller action with a valid generation in the isolated hidden-composition proof; this does not enable the path in production. | +| 5 | Authorized governed outcomes, ARCH-04E2-B and existing shared participants | Genuine authority commits with its full consequence. False/pass atomically creates FinalAcceptance, accepted TASK state, submitter ContributionRecord and applicable awards; true preserves its independent human-review handoff. Include exact receipt, database closure, audit/outbox, lock-race and the reviewed scoped lifecycle manifest. Use the REV-12A4A Operator-controlled generation in isolated composition; its initial manifest refuses retained pre-authority acceptance rows. Extend its manifest/readiness with exact source custody before production consumption. | | 6 | [Checker remediation and recovery, ARCH-04F](chunks/WS-ARCH-001-04F-checker-remediation.md) | Contributor-correctable failure has bounded findings and same-policy replacement ZIP intake. Infrastructure recovery and project/setup faults remain separate, with correct authorized retry and immutable prior evidence. | | 7 | Live composition, ARCH-04E3, and false-policy readiness | Register proven handlers; enable the false-policy path only after success, remediation and the existing scoped lifecycle activation/drain controls are proven. Preserve default-true policy and deny unsupported paths. | | 8 | Public intake, ARCH-02I | Expose initial upload/preparation, verification progress, feedback, admission-backed creation and checker-remediation resubmission through exact authorized APIs and outcome reads. Remove superseded touched paths; no provider coordinates or compatibility route. | @@ -40,8 +42,10 @@ a requirement to activate human review queues for this first milestone. Live human review/decisions and reviewer contributions, controlled human revision, contributor claim expiry/skip, external integrations, frontend expansion, runtime reputation and unrelated cleanup follow later unless the user changes priority. -Applicable award facts stay atomic in step 5; broader external payment fulfillment -is not required to prove an unpaid project's first usable path. Supported registered +Applicable award facts stay atomic in step 5 for paid and unpaid policies. +Fulfillment admission and payment delivery remain unavailable; their obligation +roots, ordinals and cutoff/drain machinery are required before a successor +manifest enables fulfillment, not before the first contribution path. Supported registered checks must genuinely meet the selected project's requirements: structural checks must not be advertised as substantive judges, and an unsupported required evaluator blocks that project rather than being silently omitted. @@ -69,8 +73,7 @@ source write or product effect is reachable. CON-07 hidden submitter participation and complete frozen award-set staging/replay are delivered. REV-04C adds the hidden FinalAcceptance/TASK/CON participant. Hidden handlers 04E1B-B are next. Mandatory exact same-input AUTH receipt, database complete-set enforcement, -TASK-before-CHECKERS race proof, shared audit/outbox, fulfillment-root ordinal -custody and scoped lifecycle activation remain required +TASK-before-CHECKERS race proof, shared audit/outbox, the reviewed scoped lifecycle manifest remain required before production consumption. Neither phase may commit a standalone allow. The first durable receipt must commit with its full governed consequence. Hidden handlers and activation follow as 04E1B-B/04E2-B, @@ -150,8 +153,8 @@ checker-remediation boundary before public Submission cutover. | [ARCH-04E1B-B6](../WS-ARCH-001-04E1BB6.md) | B3/B4/B5 plus existing TASK/ART/AUTH/CHECKERS/outbox owners | Complete: atomic new Submission, exact creation/binding receipts, generation-one request and shared event; fresh-authorized select-only replay; handlers remain unregistered | | [ARCH-04E1B-B7](../WS-ARCH-001-04E1BB7.md) | B6 plus existing execution/authorization/outbox owners | Complete: hidden request handler, exact committed invocation and per-phase fencing; UNKNOWN never automatically repeats execution; production registration remains unavailable | | ARCH-04E1B-B | ARCH-04E2-A and CON-02B; false additionally uses delivered REV-04C shared acceptance participation | Hidden TASK handlers plus TASK-before-CHECKERS currentness and both successor-generation race orders; durable receipt proof follows at exact activation | -| Scoped XINT-003-08B controller activation | Existing REV-12A foundation, delivered REV-04C hidden participant and remaining real-writer observation proof | Existing Operator lifecycle-control action for the bounded shared manifest: isolated authorized proof before step 5, production enablement at step 7; not human runtime | -| ARCH-04E2-B | ARCH-04E1B-B; valid scoped XINT-003-08B controller generation in hidden false-branch proof | Require the exact AUTH receipt on the same strict input; add database complete-set and audit/outbox closure; genuine allow commits with all governed effects | +| [REV-12A4A scoped controller](../../WS-REV-001/WS-REV-001-12A4A.md) | Same REV-12A1 fence and REV-04C participants | Complete: internal Operator transitions, immutable AUTH/history custody and current-generation participant gates. The initial manifest refuses pre-authority acceptance rows; exact source custody and production readiness remain step 5/7 work. | +| ARCH-04E2-B | Delivered exact source preparation and REV-12A4A generation; complete atomic operation precedes completion-handler wiring | Require the exact AUTH receipt on the same strict input; add database complete-set and audit/outbox closure; genuine allow commits with all governed effects | | ARCH-04E3 | ARCH-04E2, ARCH-04D2, AUTH-OUTBOX-02; shared acceptance proof for false | TASK live dispatch/routing composition: true to allow_review, false/pass to shared acceptance when proven | | ARCH-04E | ARCH-04E3 | Completed coordination boundary consumed by downstream REV | | ARCH-04F (before false activation and public intake) | Required hidden 04E result/handler contracts; no live false-branch prerequisite | CHECKER failure facts and TASK/ART remediation resubmission, not REV | diff --git a/.commitrail/initiatives/WS-AUTH-001/OVERVIEW.md b/.commitrail/initiatives/WS-AUTH-001/OVERVIEW.md index c4fcdb399..337a12949 100644 --- a/.commitrail/initiatives/WS-AUTH-001/OVERVIEW.md +++ b/.commitrail/initiatives/WS-AUTH-001/OVERVIEW.md @@ -1,5 +1,7 @@ # WS-AUTH-001 — Workstream authorization service +[REV-12A4A](../WS-REV-001/WS-REV-001-12A4A.md) adds internal Operator-controlled lifecycle transitions and current-generation terminal replay. New acceptance effects require LIVE; source AUTH custody and production routing remain unavailable. Conditional award facts stay atomic, while fulfillment admission, payment delivery and their root/cutoff machinery remain deferred until a successor manifest enables them. + Delivery priority follows the [first complete contributor milestone](../WS-ARCH-001/planning/PLAN.md#first-complete-contributor-milestone): contribute only prerequisites of that public backend path; live human review/revision and external integrations remain later work. [AUTH-19A](../WS-AUTH-001/WS-AUTH-001-19A.md) delivers inert exact source/receipt contracts and the @@ -12,7 +14,7 @@ CON-07 hidden submitter participation and complete frozen award-set staging/repl [REV-04C](../WS-REV-001/WS-REV-001-04C.md) now composes FinalAcceptance, TASK terminal effects and CON participation inside one hidden caller-owned transaction. Mandatory exact AUTH receipt input, database complete-set enforcement, currentness race proof, shared audit/outbox, -fulfillment-root ordinal custody and scoped lifecycle activation remain +the reviewed scoped lifecycle manifest remain required before production consumption. Neither phase may commit a standalone allow. The first durable receipt must commit with its full governed consequence. Hidden handlers and activation follow as 04E1B-B/04E2-B, diff --git a/.commitrail/initiatives/WS-AUTH-001/planning/CHUNK_MAP.md b/.commitrail/initiatives/WS-AUTH-001/planning/CHUNK_MAP.md index c0d7b60cf..1f48341f6 100644 --- a/.commitrail/initiatives/WS-AUTH-001/planning/CHUNK_MAP.md +++ b/.commitrail/initiatives/WS-AUTH-001/planning/CHUNK_MAP.md @@ -8,8 +8,7 @@ PREP while the action remains planned/unavailable. CON-07 hidden submitter participation and complete frozen award-set staging/replay are delivered. REV-04C adds the hidden FinalAcceptance/TASK/CON participant. Hidden handlers 04E1B-B are next. Mandatory exact same-input AUTH receipt, database complete-set enforcement, -TASK-before-CHECKERS race proof, shared audit/outbox, fulfillment-root ordinal -custody and scoped lifecycle activation remain required +TASK-before-CHECKERS race proof, shared audit/outbox, the reviewed scoped lifecycle manifest remain required before production consumption. Neither phase may commit a standalone allow. The first durable receipt must commit with its full governed consequence. Hidden handlers and activation follow as 04E1B-B/04E2-B, @@ -40,7 +39,7 @@ work and historical proposals. | [ARCH-04D2](../../WS-ARCH-001/WS-ARCH-001-04D2.md) | Complete: exact materialization and execute/finalize authority; output write/bind unavailable; replaces AUTH-14/XINT-06B | | [AUTH-OUTBOX-01](PLAN.md#ws-auth-001-outbox-01--unavailable-dispatcher-contract) | Complete: unavailable exact dispatcher identity/action/phase contract; CON-02B and AUTH-OUTBOX-02 mechanics complete; feature authority/registration remain separate | | [AUTH-OUTBOX-02](PLAN.md#ws-auth-001-outbox-02--exact-dispatcher-activation) | Complete: exact dispatcher mechanics activation, phase audit custody and bounded prefork delivery; ARCH-03C2 subsequently registers assignment invalidation, while future handlers require their own exact authority | -| [ARCH-04E2](../../WS-ARCH-001/planning/chunks/WS-ARCH-001-04E-canonical-allow-review.md#current-bounded-sequence) | Both branches: delivered source-only 04E1A -> request reservation 04E1B-A -> hidden AUTH preparation 04E2-A -> handlers/currentness proof 04E1B-B -> authority/evidence closure and consequence activation 04E2-B -> live 04E3. True uses its own prerequisites and does not depend on CON; false uses delivered REV-04C participation and additionally requires exact AUTH receipt/database/audit closure, fulfillment-root ordinal custody and scoped lifecycle activation | +| [ARCH-04E2](../../WS-ARCH-001/planning/chunks/WS-ARCH-001-04E-canonical-allow-review.md#current-bounded-sequence) | Both branches: delivered source-only 04E1A -> request reservation 04E1B-A -> hidden AUTH preparation 04E2-A -> handlers/currentness proof 04E1B-B -> authority/evidence closure and consequence activation 04E2-B -> live 04E3. True uses its own prerequisites and does not depend on CON; false uses delivered REV-04C participation and additionally requires exact AUTH receipt/database/audit closure, the reviewed scoped lifecycle manifest | Guide activation needs CP05 -> CP06 -> hidden CP07 and POL-07, which also requires independent ARCH-04A registered-capability proof. It does not need diff --git a/.commitrail/initiatives/WS-AUTH-001/planning/PLAN.md b/.commitrail/initiatives/WS-AUTH-001/planning/PLAN.md index 708314339..11682d7fc 100644 --- a/.commitrail/initiatives/WS-AUTH-001/planning/PLAN.md +++ b/.commitrail/initiatives/WS-AUTH-001/planning/PLAN.md @@ -8,8 +8,7 @@ PREP while the action remains planned/unavailable. CON-07 hidden submitter participation and complete frozen award-set staging/replay are delivered. REV-04C adds the hidden FinalAcceptance/TASK/CON participant. Hidden handlers 04E1B-B are next. Mandatory exact same-input AUTH receipt, database complete-set enforcement, -TASK-before-CHECKERS race proof, shared audit/outbox, fulfillment-root ordinal -custody and scoped lifecycle activation remain required +TASK-before-CHECKERS race proof, shared audit/outbox, the reviewed scoped lifecycle manifest remain required before production consumption. Neither phase may commit a standalone allow. The first durable receipt must commit with its full governed consequence. Hidden handlers and activation follow as 04E1B-B/04E2-B, diff --git a/.commitrail/initiatives/WS-CON-001/OVERVIEW.md b/.commitrail/initiatives/WS-CON-001/OVERVIEW.md index 43f1f52b7..03c0674d2 100644 --- a/.commitrail/initiatives/WS-CON-001/OVERVIEW.md +++ b/.commitrail/initiatives/WS-CON-001/OVERVIEW.md @@ -1,5 +1,7 @@ # WS-CON-001 — Contribution and conditional compensation +[REV-12A4A](../WS-REV-001/WS-REV-001-12A4A.md) adds internal Operator-controlled lifecycle transitions and current-generation terminal replay. New acceptance effects require LIVE; source AUTH custody and production routing remain unavailable. Conditional award facts stay atomic, while fulfillment admission, payment delivery and their root/cutoff machinery remain deferred until a successor manifest enables them. + Delivery priority follows the [first complete contributor milestone](../WS-ARCH-001/planning/PLAN.md#first-complete-contributor-milestone): contribute only prerequisites of that public backend path; live human review/revision and external integrations remain later work. [AUTH-19A](../WS-AUTH-001/WS-AUTH-001-19A.md) delivers inert exact source/receipt contracts and the @@ -11,8 +13,7 @@ CON-07 delivers hidden source-neutral submitter participation and complete frozen award-set staging/replay. [REV-04C](../WS-REV-001/WS-REV-001-04C.md) now composes it with FinalAcceptance and TASK terminal effects in one hidden caller-owned transaction. Mandatory exact AUTH receipt input, database complete-set enforcement, shared audit/outbox, -currentness race proof, fulfillment-root ordinal custody and -scoped lifecycle activation remain required before production consumption. Neither phase may +currentness race proof, the reviewed scoped lifecycle manifest remain required before production consumption. Neither phase may commit a standalone allow. The first durable receipt must commit with its full governed consequence. Hidden handlers and activation follow as 04E1B-B/04E2-B, then live 04E3; true admission does not depend on CON/shared acceptance. @@ -134,9 +135,10 @@ the internal policy operations; CP05A exposes their public Finance routes. `human_review_required` defaults true; false permits authorized automated acceptance without a Review or reviewer contribution. Both use the same FinalAcceptance/submitter-contribution participant and applicable awards. - The delivered REV-12A1 fence precedes this operation. Actual fulfillment-root - ordinal custody and authorized transition/drain proof remain required before activation; live - fulfillment endpoints do not block hidden composition. + REV-12A4A extends the delivered fence with scoped Operator transitions and + phase-gated participants. Fulfillment-root/ordinal custody and cutoff/drain + proof are required when fulfillment is enabled, not for first contribution + acceptance with payment delivery disabled. Automated acceptance must not require live human-review infrastructure; see the [canonical source, authority and transaction contract](../../../docs/spec_review_lifecycle.md#finalacceptance). 3. Shared dispatcher CON-02B is pulled forward before canonical task authority diff --git a/.commitrail/initiatives/WS-POL-003/OVERVIEW.md b/.commitrail/initiatives/WS-POL-003/OVERVIEW.md index cd18aaba7..dc855a3a2 100644 --- a/.commitrail/initiatives/WS-POL-003/OVERVIEW.md +++ b/.commitrail/initiatives/WS-POL-003/OVERVIEW.md @@ -1,5 +1,7 @@ # WS-POL-003 — Unified project-guide compilation +[REV-12A4A](../WS-REV-001/WS-REV-001-12A4A.md) adds internal Operator-controlled lifecycle transitions and current-generation terminal replay. New acceptance effects require LIVE; source AUTH custody and production routing remain unavailable. Conditional award facts stay atomic, while fulfillment admission, payment delivery and their root/cutoff machinery remain deferred until a successor manifest enables them. + Delivery priority follows the [first complete contributor milestone](../WS-ARCH-001/planning/PLAN.md#first-complete-contributor-milestone): contribute only prerequisites of that public backend path; live human review/revision and external integrations remain later work. [AUTH-19A](../WS-AUTH-001/WS-AUTH-001-19A.md) delivers inert exact source/receipt contracts and the @@ -12,7 +14,7 @@ CON-07 hidden submitter participation and complete frozen award-set staging/repl [REV-04C](../WS-REV-001/WS-REV-001-04C.md) now composes FinalAcceptance, TASK terminal effects and CON participation inside one hidden caller-owned transaction. Mandatory exact AUTH receipt input, database complete-set enforcement, currentness race proof, shared audit/outbox, -fulfillment-root ordinal custody and scoped lifecycle activation remain +the reviewed scoped lifecycle manifest remain required before production consumption. Neither phase may commit a standalone allow. The first durable receipt must commit with its full governed consequence. Hidden handlers and activation follow as 04E1B-B/04E2-B, diff --git a/.commitrail/initiatives/WS-POL-003/planning/CHUNK_MAP.md b/.commitrail/initiatives/WS-POL-003/planning/CHUNK_MAP.md index 06f358ea9..18ed8a42c 100644 --- a/.commitrail/initiatives/WS-POL-003/planning/CHUNK_MAP.md +++ b/.commitrail/initiatives/WS-POL-003/planning/CHUNK_MAP.md @@ -8,8 +8,7 @@ PREP while the action remains planned/unavailable. CON-07 hidden submitter participation and complete frozen award-set staging/replay are delivered. REV-04C adds the hidden FinalAcceptance/TASK/CON participant. Hidden handlers 04E1B-B are next. Mandatory exact same-input AUTH receipt, database complete-set enforcement, -TASK-before-CHECKERS race proof, shared audit/outbox, fulfillment-root ordinal -custody and scoped lifecycle activation remain required +TASK-before-CHECKERS race proof, shared audit/outbox, the reviewed scoped lifecycle manifest remain required before production consumption. Neither phase may commit a standalone allow. The first durable receipt must commit with its full governed consequence. Hidden handlers and activation follow as 04E1B-B/04E2-B, diff --git a/.commitrail/initiatives/WS-POL-003/planning/PLAN.md b/.commitrail/initiatives/WS-POL-003/planning/PLAN.md index ddf8643cc..b518a5684 100644 --- a/.commitrail/initiatives/WS-POL-003/planning/PLAN.md +++ b/.commitrail/initiatives/WS-POL-003/planning/PLAN.md @@ -8,8 +8,7 @@ PREP while the action remains planned/unavailable. CON-07 hidden submitter participation and complete frozen award-set staging/replay are delivered. REV-04C adds the hidden FinalAcceptance/TASK/CON participant. Hidden handlers 04E1B-B are next. Mandatory exact same-input AUTH receipt, database complete-set enforcement, -TASK-before-CHECKERS race proof, shared audit/outbox, fulfillment-root ordinal -custody and scoped lifecycle activation remain required +TASK-before-CHECKERS race proof, shared audit/outbox, the reviewed scoped lifecycle manifest remain required before production consumption. Neither phase may commit a standalone allow. The first durable receipt must commit with its full governed consequence. Hidden handlers and activation follow as 04E1B-B/04E2-B, diff --git a/.commitrail/initiatives/WS-REV-001/OVERVIEW.md b/.commitrail/initiatives/WS-REV-001/OVERVIEW.md index dd802f41d..2497f45d4 100644 --- a/.commitrail/initiatives/WS-REV-001/OVERVIEW.md +++ b/.commitrail/initiatives/WS-REV-001/OVERVIEW.md @@ -1,5 +1,7 @@ # WS-REV-001 — Review and revision lifecycle +[REV-12A4A](WS-REV-001-12A4A.md) adds internal Operator-controlled lifecycle transitions and current-generation terminal replay. New acceptance effects require LIVE; source AUTH custody and production routing remain unavailable. Conditional award facts stay atomic, while fulfillment admission, payment delivery and their root/cutoff machinery remain deferred until a successor manifest enables them. + Delivery priority follows the [first complete contributor milestone](../WS-ARCH-001/planning/PLAN.md#first-complete-contributor-milestone): contribute only prerequisites of that public backend path; live human review/revision and external integrations remain later work. [AUTH-19A](../WS-AUTH-001/WS-AUTH-001-19A.md) delivers inert exact source/receipt contracts and the @@ -12,7 +14,7 @@ are delivered. [REV-04C](WS-REV-001-04C.md) now composes FinalAcceptance, TASK t that CON participant in one hidden caller-owned transaction for either source. It is not the complete authorized operation. Mandatory exact AUTH receipt input, database complete-set enforcement across FinalAcceptance/TASK/CON, shared -audit/outbox, fulfillment-root ordinal custody and scoped lifecycle activation +audit/outbox, the reviewed scoped lifecycle manifest remain required before production consumption. Neither phase may commit a standalone allow. The first durable receipt must commit with its full governed consequence. Hidden handlers and activation follow as 04E1B-B/04E2-B, @@ -122,11 +124,11 @@ proof. No adjudication setting or behavior is included. strict participant input to require the exact AUTH decision-event receipt, with no optional/default path, before production consumption. 04E2-B also owns database-enforced complete-set closure, shared audit/outbox, - fulfillment-root ordinal custody and scoped activation. This foundation + the reviewed scoped lifecycle manifest. This foundation can precede human runtime: ARCH-04E uses it for false/pass acceptance without - live queues, leases or decisions. The delivered REV-12A1 disabled fence serves both triggers; actual root ordinal - custody and authorized drain/operator work extend the same controller before - activation, not another fence. + live queues, leases or decisions. REV-12A4A extends the same REV-12A1 + controller with scoped Operator transitions. Root/ordinal/cutoff custody is + required before fulfillment admission, not before contribution acceptance. Human decision composition later adds Review/reviewer participation and invokes that same acceptance sequence on accept, not a second implementation. 4. Activate public claim/decision behavior only after exact AUTH/ART/CON gates. diff --git a/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md b/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md index ac252ae6b..bb5f52b3c 100644 --- a/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md +++ b/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md @@ -36,6 +36,9 @@ remain the subsequent ARCH-04E2-B prerequisite before a production consumer. 3. Use existing AUTH PREP with live system Operator authority before the REV advisory/controller lock. Bind the exact singleton, operation, expected generation, source/target phases, request, manifest and server observations. + For this action only, AUTH locks the actor profile FOR NO KEY UPDATE, retaining + link/grant FOR UPDATE: actor foreign-key reads may complete under a prior REV + writer, while actor status changes/revocation still serialize. AUTH consumes after locked validation; history and controller change commit with its immutable decision in the caller's root transaction. Commit-time closure requires exactly one N/P -> N+1/Q update, history row and matching @@ -75,7 +78,9 @@ remain the subsequent ARCH-04E2-B prerequisite before a production consumer. - Existing TASK accepted-effects and CON participation owner modules and their consumer-owned typed fence ports; no private cross-owner SQL/imports. - AUTH `review_contracts.py`, `catalogue.py`, `kernel.py`, `prepared.py`, runtime - resource/digest/audit contracts, and focused lifecycle resource/PREP adapter + `repository.py` for lifecycle-only actor NO KEY UPDATE custody, + resource/digest/audit contracts, `backend/app/modules/audit/schemas.py` for the + exact UUID lifecycle target kind, and focused lifecycle resource/PREP adapter modules. Existing composition roots only for explicit controller construction. - One successor Alembic migration, model registration, exact schema inventory; focused REV lifecycle and AUTH tests plus affected acceptance/participation @@ -84,7 +89,7 @@ remain the subsequent ARCH-04E2-B prerequisite before a production consumer. - Exact behavior ownership and lane inventory files/tests; no CI gate changes. - This record; REV/AUTH/CON/ARCH/POL current overviews, ARCH/AUTH/POL current plans/maps, Commitrail index; canonical review/compensation specifications, - relevant authorization custody docs, README and roadmap. Local roadmap exports + relevant authorization custody docs, canonical authorization/data-model specifications, README and roadmap. Local roadmap exports only if present. Historical completed records remain historical. ## Prohibited diff --git a/README.md b/README.md index f196207f4..4d27430f0 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,11 @@ hidden source-neutral participant that stages FinalAcceptance, TASK accepted/completed effects and the complete CON submitter outcome in the caller's transaction. It is not a complete authorized acceptance operation: its input has no exact AUTH decision-event receipt, and database complete-set enforcement, -currentness race proof, shared audit/outbox and lifecycle activation remain. +currentness race proof and shared audit/outbox remain. REV-12A4A supplies internal +Operator-authorized transitions on the existing lifecycle controller. New +participant effects require a live, nonzero generation; exact terminal replay +remains read-only in stopped phases. This scope keeps payment delivery disabled +and does not grant routing or acceptance authority. ARCH-04E1B-B1 requires TASK locking before checker reservation, current-result reads and review admission INSERTs, preserving exact read-only reservation replay after acceptance. ARCH-04E1B-B3 retains the inspected ZIP file metadata with immutable @@ -794,7 +798,9 @@ the committed request events are not registered for delivery. The mandatory exact AUTH receipt must become required on the same strict input, with no optional/default path, before production consumption; database FinalAcceptance/TASK/CON closure, TASK-before-CHECKERS race proof, shared -audit/outbox, fulfillment-root ordinals and activation remain later gates. +audit/outbox and production manifest readiness remain later gates. Conditional +award facts stay atomic with contribution records. Fulfillment-root ordinals and +payment delivery are deferred until fulfillment is enabled. ## v0.1 Success Standard diff --git a/backend/alembic/versions/0027_lifecycle_transitions.py b/backend/alembic/versions/0027_lifecycle_transitions.py new file mode 100644 index 000000000..236989368 --- /dev/null +++ b/backend/alembic/versions/0027_lifecycle_transitions.py @@ -0,0 +1,217 @@ +"""Extend the shared singleton with exact Operator-authorized transition custody.""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + +revision = "0027_lifecycle_transitions" +down_revision = "0026_task_guide_read" +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute("SET LOCAL search_path=pg_catalog,public,pg_temp") + _audit_contract() + op.create_table( + "joint_lifecycle_transitions", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column("operation_id", sa.Uuid(), nullable=False), + sa.Column("singleton_id", sa.Uuid(), sa.ForeignKey("public.joint_lifecycle_release_control.id"), nullable=False), + sa.Column("generation", sa.BigInteger(), nullable=False), + sa.Column("previous_phase", sa.String(16), nullable=False), + sa.Column("phase", sa.String(16), nullable=False), + sa.Column("facts_json", JSONB(), nullable=False), + sa.Column("resource_context_digest", sa.String(71), nullable=False), + sa.Column("authorization_decision_event_id", sa.Uuid(), sa.ForeignKey("public.audit_events.id"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("clock_timestamp()"), nullable=False), + sa.UniqueConstraint("operation_id"), + sa.UniqueConstraint("singleton_id", "generation"), + sa.UniqueConstraint("authorization_decision_event_id"), + sa.CheckConstraint("generation > 0", name="generation_positive"), + schema="public", + ) + op.add_column("joint_lifecycle_release_control", sa.Column("transition_id", sa.Uuid()), schema="public") + op.create_foreign_key( + "fk_joint_lifecycle_release_control_transition_id", "joint_lifecycle_release_control", + "joint_lifecycle_transitions", ["transition_id"], ["id"], deferrable=True, initially="DEFERRED", + source_schema="public", referent_schema="public", + ) + op.execute("DROP TRIGGER joint_lifecycle_genesis_immutable ON public.joint_lifecycle_release_control") + op.execute("DROP TRIGGER joint_lifecycle_genesis_no_truncate ON public.joint_lifecycle_release_control") + op.execute("DROP FUNCTION public.guard_joint_lifecycle_genesis()") + _functions() + op.execute(""" +CREATE TRIGGER joint_lifecycle_control_change BEFORE INSERT OR UPDATE OR DELETE +ON public.joint_lifecycle_release_control FOR EACH ROW +EXECUTE FUNCTION public.guard_joint_lifecycle_control_change() +""") + op.execute(""" +CREATE TRIGGER joint_lifecycle_history_immutable BEFORE UPDATE OR DELETE +ON public.joint_lifecycle_transitions FOR EACH ROW +EXECUTE FUNCTION public.deny_joint_lifecycle_mutation() +""") + for table in ("joint_lifecycle_release_control", "joint_lifecycle_transitions"): + op.execute(f"CREATE TRIGGER {table}_no_truncate BEFORE TRUNCATE ON public.{table} " + "FOR EACH STATEMENT EXECUTE FUNCTION public.deny_joint_lifecycle_mutation()") + op.execute(""" +CREATE CONSTRAINT TRIGGER joint_lifecycle_history_closure AFTER INSERT +ON public.joint_lifecycle_transitions DEFERRABLE INITIALLY DEFERRED FOR EACH ROW +EXECUTE FUNCTION public.guard_joint_lifecycle_history_closure() +""") + op.execute(""" +CREATE CONSTRAINT TRIGGER joint_lifecycle_control_closure AFTER UPDATE +ON public.joint_lifecycle_release_control DEFERRABLE INITIALLY DEFERRED FOR EACH ROW +EXECUTE FUNCTION public.guard_joint_lifecycle_control_closure() +""") + op.execute(""" +CREATE CONSTRAINT TRIGGER joint_lifecycle_authority_closure AFTER INSERT +ON public.audit_events DEFERRABLE INITIALLY DEFERRED FOR EACH ROW +WHEN (NEW.action_id='review.lifecycle.activation.manage' AND NEW.event_type='SensitiveAuthorizationAllowed') +EXECUTE FUNCTION public.guard_joint_lifecycle_authority_closure() +""") + + +def _audit_contract(): + op.execute("LOCK TABLE public.audit_events IN ACCESS EXCLUSIVE MODE") + name = "ck_audit_events_authorization_action_evidence" + definition = _definition(name) + anchor = "(((action_id)::text = 'project.task.create'::text) AND ((permission_id)::text = 'project.task.manage'::text))" + addition = "(((action_id)::text = 'review.lifecycle.activation.manage'::text) AND ((permission_id)::text = 'operations.reconcile.run'::text))" + if definition.count(anchor) != 2: + raise RuntimeError("lifecycle audit action anchor changed") + _replace(name, definition.replace(anchor, anchor + " OR " + addition)) + name = "ck_audit_events_authority_privacy_bounds" + definition = _definition(name) + # Add only one UUID resource/target kind; retain every existing privacy bound. + resource_start = definition.index("resource_type IS NULL") + target_start = definition.index("target_ref_kind IS NULL") + for start in sorted((resource_start, target_start), reverse=True): + index = definition.index("ARRAY[", start) + len("ARRAY[") + definition = definition[:index] + "('joint_lifecycle_control'::character varying)::text, " + definition[index:] + _replace(name, definition) + + +def _definition(name): + return op.get_bind().execute(sa.text( + "SELECT pg_catalog.pg_get_constraintdef(oid) FROM pg_catalog.pg_constraint " + "WHERE conrelid='public.audit_events'::regclass AND conname=:name" + ), {"name": name}).scalar_one() + + +def _replace(name, definition): + op.execute(f"ALTER TABLE public.audit_events DROP CONSTRAINT {name}") + op.execute(f"ALTER TABLE public.audit_events ADD CONSTRAINT {name} {definition}") + + +def _functions(): + op.execute(""" +CREATE FUNCTION public.deny_joint_lifecycle_mutation() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +BEGIN RAISE EXCEPTION 'joint lifecycle custody is immutable' USING ERRCODE='23514'; END; +$$ +""") + op.execute(""" +CREATE FUNCTION public.joint_lifecycle_transition_valid(t public.joint_lifecycle_transitions) +RETURNS boolean LANGUAGE sql STABLE SET search_path=pg_catalog,public,pg_temp AS $$ + SELECT coalesce( + t.generation>0 AND t.generation=(t.facts_json->'command'->>'expected_generation')::bigint+1 + AND t.operation_id::text=t.facts_json->'command'->>'operation_id' + AND t.singleton_id::text=t.facts_json->'command'->>'singleton_id' + AND t.previous_phase=t.facts_json->'command'->>'current_phase' + AND t.phase=t.facts_json->'command'->>'target_phase' + AND (t.previous_phase,t.phase) IN (('disabled','shadow'),('shadow','live'),('shadow','disabled'),('live','draining'),('draining','disabled')) + AND t.created_at < (t.facts_json->'command'->>'deadline')::timestamptz + AND t.facts_json->'command'->>'reviewed_manifest_digest'='sha256:f7385c3930e0a3fc60a55e4a35c67b00c1bd3056ffa89a1e09ecae4b736c3ab4' + AND (t.phase<>'live' OR NOT EXISTS (SELECT 1 FROM public.final_acceptances)) + AND t.facts_json->>'observations_digest'='sha256:' || pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to( + public.project_guide_projection_canonical_json(pg_catalog.jsonb_build_object( + 'singleton_id',t.singleton_id::text,'generation',t.generation-1,'phase',t.previous_phase, + 'retained_pre_authority_acceptance',EXISTS(SELECT 1 FROM public.final_acceptances), + 'manifest','sha256:f7385c3930e0a3fc60a55e4a35c67b00c1bd3056ffa89a1e09ecae4b736c3ab4')), 'UTF8')), 'hex') + AND t.resource_context_digest='sha256:' || pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to( + public.project_guide_projection_canonical_json(pg_catalog.jsonb_build_object('resource_context', + pg_catalog.jsonb_build_object('action_id','review.lifecycle.activation.manage', + 'resource_type','joint_lifecycle_control','resource_id',t.singleton_id::text,'facts',t.facts_json))), 'UTF8')), 'hex') + AND EXISTS ( + SELECT 1 FROM public.audit_events a + JOIN public.admin_role_grants g ON g.id::text=a.matched_grant_id + JOIN public.actor_profiles p ON p.id::text=a.actor_id + JOIN public.actor_identity_links l ON l.actor_profile_id=p.id + WHERE a.id=t.authorization_decision_event_id + AND a.event_domain='authority' AND a.event_type='SensitiveAuthorizationAllowed' + AND a.actor_ref_kind='actor_profile' AND p.actor_kind='human' + AND p.id::text=t.facts_json->'command'->>'actor_profile_id' + AND l.id::text=t.facts_json->'command'->>'identity_link_id' + AND g.target_actor_profile_id=p.id AND g.role='operator' + AND g.scope_type='system' AND g.scope_project_id IS NULL + AND a.action_id='review.lifecycle.activation.manage' AND a.permission_id='operations.reconcile.run' + AND a.request_id IS NOT NULL AND a.correlation_id=t.operation_id + AND a.project_id IS NULL AND a.resource_type='joint_lifecycle_control' AND a.resource_id=t.singleton_id::text + AND a.target_ref_kind='joint_lifecycle_control' AND a.target_ref_id=t.singleton_id::text + AND a.denial_code IS NULL AND a.after_facts::jsonb=pg_catalog.jsonb_build_object( + 'allowed',true,'resource_context_digest',t.resource_context_digest) + ),false) +$$ +""") + op.execute(""" +CREATE FUNCTION public.guard_joint_lifecycle_authority_closure() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM public.joint_lifecycle_transitions t WHERE t.authorization_decision_event_id=NEW.id + AND public.joint_lifecycle_transition_valid(t) + ) THEN RAISE EXCEPTION 'joint lifecycle authority closure invalid' USING ERRCODE='23514'; END IF; + RETURN NEW; +END; +$$ +""") + op.execute(""" +CREATE FUNCTION public.guard_joint_lifecycle_control_change() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +BEGIN + IF TG_OP<>'UPDATE' THEN + RAISE EXCEPTION 'joint lifecycle custody is immutable' USING ERRCODE='23514'; + END IF; + IF NEW.id IS DISTINCT FROM OLD.id OR NEW.singleton IS DISTINCT FROM OLD.singleton + OR NEW.created_at IS DISTINCT FROM OLD.created_at OR NEW.generation<>OLD.generation+1 + OR NEW.transition_id IS NULL OR NOT EXISTS ( + SELECT 1 FROM public.joint_lifecycle_transitions t WHERE t.id=NEW.transition_id + AND t.singleton_id=OLD.id AND t.generation=NEW.generation + AND t.phase=NEW.phase AND t.previous_phase=OLD.phase + AND public.joint_lifecycle_transition_valid(t) + ) THEN RAISE EXCEPTION 'joint lifecycle transition custody invalid' USING ERRCODE='23514'; END IF; + RETURN NEW; +END; +$$ +""") + op.execute(""" +CREATE FUNCTION public.guard_joint_lifecycle_history_closure() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +BEGIN + IF NOT public.joint_lifecycle_transition_valid(NEW) OR NOT EXISTS ( + SELECT 1 FROM public.joint_lifecycle_release_control c + WHERE c.id=NEW.singleton_id AND c.transition_id=NEW.id AND c.generation=NEW.generation AND c.phase=NEW.phase + ) THEN RAISE EXCEPTION 'joint lifecycle history closure invalid' USING ERRCODE='23514'; END IF; + RETURN NEW; +END; +$$ +""") + op.execute(""" +CREATE FUNCTION public.guard_joint_lifecycle_control_closure() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM public.joint_lifecycle_transitions t JOIN public.joint_lifecycle_release_control c ON c.transition_id=t.id + WHERE c.id=NEW.id AND c.generation=NEW.generation AND c.phase=NEW.phase + AND t.singleton_id=c.id AND t.generation=c.generation AND t.phase=c.phase + AND public.joint_lifecycle_transition_valid(t) + ) THEN RAISE EXCEPTION 'joint lifecycle controller closure invalid' USING ERRCODE='23514'; END IF; + RETURN NEW; +END; +$$ +""") + + +def downgrade(): + raise RuntimeError("Workstream v0.1 migrations cannot be downgraded; recreate the database") diff --git a/backend/app/adapters/auth/__init__.py b/backend/app/adapters/auth/__init__.py index 789ab3b3d..38a2af3cb 100644 --- a/backend/app/adapters/auth/__init__.py +++ b/backend/app/adapters/auth/__init__.py @@ -246,3 +246,10 @@ def post_submit_materialization_authority(session): """Compose the separate fixed ART materializer principal.""" from app.modules.authorization.post_submit_authorization import PostSubmitMaterializationAuthorization return PostSubmitMaterializationAuthorization(session) + + +def lifecycle_transition_authorization(session: AsyncSession, context): + """Compose the existing system Operator action for the REV controller.""" + from app.modules.authorization.lifecycle_authorization import LifecycleAuthorizationAdapter + + return LifecycleAuthorizationAdapter(session, context) diff --git a/backend/app/modules/audit/schemas.py b/backend/app/modules/audit/schemas.py index 41da43de2..3dcecfec7 100644 --- a/backend/app/modules/audit/schemas.py +++ b/backend/app/modules/audit/schemas.py @@ -34,7 +34,7 @@ } ) _RESOURCE_TYPES = frozenset( - """checker_run outbox_event actor_profile actor_identity_link admin_role_grant project qualification_snapshot project_role_grant task + """joint_lifecycle_control checker_run outbox_event actor_profile actor_identity_link admin_role_grant project qualification_snapshot project_role_grant task submission submission_creation submission_binding review contribution compensation_award compensation_delivery compensation_adapter_binding contribution_policy operations audit_event project_create_operation project_submission_artifact_policy_mutation project_guide_activation pre_submit_checker_input project_guide_compilation_request @@ -45,6 +45,7 @@ ) _UUID_TARGET_KINDS = frozenset( { + "joint_lifecycle_control", "actor_profile", "actor_identity_link", "admin_role_grant", diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index 144f0f83d..f21037cbf 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -601,7 +601,7 @@ def _active( PermissionId.OPERATIONS_RECONCILE_RUN, ActionOwner.XINT_003_08A, ), - _planned( + _active( ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, PermissionId.OPERATIONS_RECONCILE_RUN, ActionOwner.XINT_003_08B, @@ -815,6 +815,7 @@ def _index_actions( ActionId.OUTBOX_DISPATCH, ActionId.PROJECT_GUIDE_ACTIVATE, *_CONTRIBUTION_POLICY_ACTION_IDS, + ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, *GUIDE_PROPOSAL_ACTION_IDS, *POST_POLICY_MUTATION_ACTION_IDS, ActionId.ACTOR_PROFILE_READ_SELF, diff --git a/backend/app/modules/authorization/domain/audit.py b/backend/app/modules/authorization/domain/audit.py index 115d69df5..021d60fee 100644 --- a/backend/app/modules/authorization/domain/audit.py +++ b/backend/app/modules/authorization/domain/audit.py @@ -12,6 +12,7 @@ from app.modules.authorization.catalogue import ActionId, PermissionId CONTEXT_DIGEST_RESOURCE_TYPES = ( + "joint_lifecycle_control", "submission_creation", "submission_binding", "outbox_event", @@ -44,6 +45,7 @@ AuthorizationDecisionResourceType = Literal[ + "joint_lifecycle_control", "outbox_event", "checker_run", "task_post_submit_routing_manifest", diff --git a/backend/app/modules/authorization/domain/audit_targets.py b/backend/app/modules/authorization/domain/audit_targets.py index 357096234..7be908bb3 100644 --- a/backend/app/modules/authorization/domain/audit_targets.py +++ b/backend/app/modules/authorization/domain/audit_targets.py @@ -1,6 +1,7 @@ """Exact project resource audit selectors, without raw product facts.""" from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.domain.lifecycle import ReviewLifecycleActivationContract from app.modules.authorization.domain.post_submit_routing import PostSubmitRoutingResourceContext from app.modules.authorization.domain.post_submit import PostSubmitResourceContext from app.modules.authorization.domain.assignment_invalidation import AssignmentInvalidationResourceContext @@ -44,6 +45,8 @@ def project_authority_audit_target( ProjectGuideActivationResourceContext, ) + if type(resource) is ReviewLifecycleActivationContract: + return None, resource.resource_type, str(resource.resource_id), "joint_lifecycle_control", str(resource.resource_id) if type(resource) is ContributionPolicyMutationScopeDenialResourceContext: project_id = str(resource.scope_project_id) if resource.project_exists: diff --git a/backend/app/modules/authorization/domain/lifecycle.py b/backend/app/modules/authorization/domain/lifecycle.py new file mode 100644 index 000000000..ff050cd60 --- /dev/null +++ b/backend/app/modules/authorization/domain/lifecycle.py @@ -0,0 +1,55 @@ +"""Exact system Operator resource and PREP binding for REV lifecycle transitions.""" + +import json + +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, model_validator + +from app.modules.authorization.catalogue import ActionId +from app.modules.reviews.api.lifecycle import LifecycleTransitionCommand, LifecycleTransitionFacts + +ACTION = ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE + + +class ReviewLifecycleActivationContract(BaseModel): + """Closed REV facts; arbitrary request values cannot become an allow.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True, revalidate_instances="always") + action_id: Literal[ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE] = ACTION + resource_type: Literal["joint_lifecycle_control"] = "joint_lifecycle_control" + resource_id: UUID + facts: LifecycleTransitionFacts + + @model_validator(mode="after") + def exact_identity(self): + if self.resource_id != self.facts.command.singleton_id: + raise ValueError("lifecycle resource mismatch") + return self + + +def parse_lifecycle_prepare(action, caller_input, scope, context): + """Close all request selectors over the authenticated human and system scope.""" + if action != ACTION: + return None + command = LifecycleTransitionCommand.model_validate_json(json.dumps(dict(caller_input.request_value))) + if ( + scope.kind.value != "system" or scope.project_id is not None + or command.operation_id != caller_input.idempotency_key + or command.operation_id != context.correlation_id + or command.actor_profile_id != context.actor_profile_id + or command.identity_link_id != context.identity_link_id + ): + raise ValueError("lifecycle preparation mismatch") + return command + + +def lifecycle_matches(binding, resource): + exact = type(resource) is ReviewLifecycleActivationContract + if exact != (binding is not None): + return False + if not exact: + return True + checked = ReviewLifecycleActivationContract.model_validate(resource.model_dump(), strict=True) + return binding == checked.facts.command diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index 3306733de..f7b50cfbb 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -32,6 +32,7 @@ ) from app.modules.authorization.domain.audit import CONTEXT_DIGEST_RESOURCE_TYPES from app.modules.authorization.domain.audit_targets import project_authority_audit_target +from app.modules.authorization.domain.lifecycle import ReviewLifecycleActivationContract from app.modules.authorization.domain.prepared_service import ( fixed_service_scope_project, fixed_service_resource_matches, ) @@ -120,6 +121,7 @@ _ADMIN_ACTIONS = frozenset( { + ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, ActionId.AUTHORIZATION_PERMISSION_CATALOGUE_READ, ActionId.AUTHORIZATION_ADMIN_ROLE_DEFINITIONS_READ, ActionId.ADMIN_ROLE_GRANT_LIST, @@ -166,6 +168,7 @@ ) | adapter_bindings.ADAPTER_BINDING_READ_ACTIONS | contribution_policies.CONTRIBUTION_POLICY_READ_ACTIONS _ADMIN_MUTATIONS = frozenset( { + ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, ActionId.ADMIN_ROLE_GRANT_ISSUE, ActionId.ADMIN_ROLE_GRANT_REVOKE, ActionId.ACTOR_SERVICE_PROVISION, @@ -205,6 +208,7 @@ _ADMIN_EXPECTED_RESOURCES = MappingProxyType( { + ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE: ReviewLifecycleActivationContract, ActionId.AUTHORIZATION_PERMISSION_CATALOGUE_READ: PermissionCatalogueResourceContext, ActionId.AUTHORIZATION_ADMIN_ROLE_DEFINITIONS_READ: AdminRoleDefinitionsResourceContext, ActionId.ADMIN_ROLE_GRANT_LIST: AdminRoleGrantCollectionResourceContext, @@ -445,7 +449,8 @@ async def _prepare_prelocked( ) else: locked = await self._admin.lock_request_actor( - context.identity_link_id, context.actor_profile_id + context.identity_link_id, context.actor_profile_id, + preserve_foreign_key_reads=(action_id is ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE), ) context = self._locked_human_context(locked, context) if action_id is ActionId.PROJECT_ROLE_GRANT_REVOKE and scope.grant_id is None: @@ -458,6 +463,7 @@ async def _prepare_prelocked( scope_project_id=scope.project_id, system_scope_only=scope.project_id is None, for_update=True, + **({"allowed_roles": (AdminRole.OPERATOR,)} if action_id is ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE else {}), **adapter_bindings.finance_authority_grant_filters(action_id), **contribution_policies.policy_finance_grant_filters(action_id), ) diff --git a/backend/app/modules/authorization/lifecycle_authorization.py b/backend/app/modules/authorization/lifecycle_authorization.py new file mode 100644 index 000000000..c8d60d5a7 --- /dev/null +++ b/backend/app/modules/authorization/lifecycle_authorization.py @@ -0,0 +1,62 @@ +"""Live system Operator preparation for the sole REV lifecycle controller.""" + +from contextlib import asynccontextmanager + +from app.modules.authorization.api import AuthorizationDenied as BoundaryDenied +from app.modules.authorization.domain.lifecycle import ACTION, ReviewLifecycleActivationContract +from app.modules.authorization.kernel import AuthorizationService +from app.modules.authorization.prepared import PreparedAuthorizationService +from app.modules.authorization.repository import AdminAuthorizationRepository +from app.modules.authorization.runtime import ( + HumanAuthorizationContext, PreparedAuthorizationInput, + PreparedAuthorityScope, PreparedAuthorityScopeKind, +) +from app.modules.reviews.api.lifecycle import LifecycleAuthorityReceipt + + +class _PreparedLifecycle: + def __init__(self, service, handle, caller_input): + self._service, self._handle, self._input = service, handle, caller_input + + async def consume_new(self, facts): + resource = ReviewLifecycleActivationContract(resource_id=facts.command.singleton_id, facts=facts) + decision = await self._service.consume(self._handle, ACTION, self._input, resource) + return LifecycleAuthorityReceipt( + decision_event_id=decision.decision_id, + resource_context_digest=decision.resource_context_digest, + ) + + async def validate_replay(self, facts, decision_id): + resource = ReviewLifecycleActivationContract(resource_id=facts.command.singleton_id, facts=facts) + await self._service.validate_replay(self._handle, ACTION, self._input, resource, decision_id) + + +class LifecycleAuthorizationAdapter: + """Explicit request-local AUTH composition; command actor selectors are untrusted.""" + + def __init__(self, session, context): + self._session, self._context = session, context + + @asynccontextmanager + async def lock_scope(self, command): + context = self._context + if ( + type(context) is not HumanAuthorizationContext + or context.actor_profile_id != command.actor_profile_id + or context.identity_link_id != command.identity_link_id + ): + raise BoundaryDenied("lifecycle authority denied") + context = context.model_copy(update={"correlation_id": command.operation_id}) + repository = AdminAuthorizationRepository(self._session) + kernel = AuthorizationService(self._session, context, admin_repository=repository) + service = PreparedAuthorizationService(self._session, context, kernel, repository) + caller_input = PreparedAuthorizationInput( + idempotency_key=command.operation_id, request_value=command.model_dump(mode="json"), + ) + try: + handle = await service.prepare( + ACTION, caller_input, PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM), + ) + yield _PreparedLifecycle(service, handle, caller_input) + finally: + service.close() diff --git a/backend/app/modules/authorization/prepared.py b/backend/app/modules/authorization/prepared.py index 8b3e48e84..228760b15 100644 --- a/backend/app/modules/authorization/prepared.py +++ b/backend/app/modules/authorization/prepared.py @@ -2,6 +2,9 @@ from __future__ import annotations from app.modules.authorization.catalogue import GUIDE_PROPOSAL_ACTION_IDS, POST_POLICY_ACTION_IDS +from app.modules.authorization.domain.lifecycle import parse_lifecycle_prepare, lifecycle_matches +from app.modules.authorization.prepared_lifecycle_replay import validate_lifecycle_replay +from app.modules.reviews.api.lifecycle import LifecycleTransitionCommand from copy import Error as CopyError from contextlib import asynccontextmanager @@ -193,6 +196,7 @@ class _PreparedAuthorizationBinding: post_submit_prepare_context: dict | None = None routing_request: TaskRoutingRequestFacts | None = None task_authority_context: TaskAuthorityResourceContext | None = None + lifecycle_command: LifecycleTransitionCommand | None = None project_create_operation_id: UUID | None = None project_create_project_id: UUID | None = None project_create_generation: int | None = None @@ -605,6 +609,8 @@ def _validate_consumption(self, issuance, expected_action_id, caller_input, fina or issuance.binding.assignment_invalidation_context != final_resource_context ): raise PreparedAuthorizationHandleInvalid("invalid assignment reconciliation authority") + if not lifecycle_matches(issuance.binding.lifecycle_command, final_resource_context): + raise PreparedAuthorizationHandleInvalid("invalid lifecycle authority") if expected_action_id is ROUTE and not post_submit_routing_prepare_matches( issuance.binding.routing_request, final_resource_context, ): @@ -721,7 +727,7 @@ async def validate_replay( issuance = self._live_issuance(handle) self._issued[handle] = _CONSUMED try: - replay = validate_submission_replay if expected_action_id in {ActionId.SUBMISSION_CREATE, ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE} else validate_post_submit_replay if expected_action_id in POST_SUBMIT_ACTIONS else validate_review_replay if expected_action_id in GUIDE_PROPOSAL_ACTION_IDS | POST_POLICY_ACTION_IDS | {ActionId.PROJECT_GUIDE_ACTIVATE} else validate_projection_replay + replay = validate_lifecycle_replay if expected_action_id is ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE else validate_submission_replay if expected_action_id in {ActionId.SUBMISSION_CREATE, ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE} else validate_post_submit_replay if expected_action_id in POST_SUBMIT_ACTIONS else validate_review_replay if expected_action_id in GUIDE_PROPOSAL_ACTION_IDS | POST_POLICY_ACTION_IDS | {ActionId.PROJECT_GUIDE_ACTIVATE} else validate_projection_replay await replay( self, issuance, @@ -924,6 +930,7 @@ def _binding( ), submission_policy_context=submission_policy_context, submission_policy_resource_digest=submission_policy_resource_digest, + lifecycle_command=parse_lifecycle_prepare(action_id, caller_input, scope, self._context), task_authority_context=parse_task_authority_binding( action_id, caller_input.request_value, PreparedAuthorizationHandleInvalid, caller_input.idempotency_key, diff --git a/backend/app/modules/authorization/prepared_lifecycle_replay.py b/backend/app/modules/authorization/prepared_lifecycle_replay.py new file mode 100644 index 000000000..fd3dca558 --- /dev/null +++ b/backend/app/modules/authorization/prepared_lifecycle_replay.py @@ -0,0 +1,43 @@ +"""Read-only validation of an original lifecycle allow under fresh Operator custody.""" + +from uuid import UUID + +from app.modules.authorization.catalogue import ACTION_BY_ID +from app.modules.authorization.domain.lifecycle import ACTION +from app.modules.authorization.runtime import ( + PreparedAuthorizationHandleInvalid, authorization_resource_digest, +) + + +async def validate_lifecycle_replay(owner, issuance, action, caller_input, resource, decision_id): + invalid = PreparedAuthorizationHandleInvalid("invalid lifecycle replay") + if action is not ACTION: + raise invalid + owner._validate_consumption(issuance, action, caller_input, resource) + authority = issuance.authority + if ( + authority.scope_project_id is not None + or authority.matched_grant_id is None + or authority.matched_grant_status != "active" + ): + raise invalid + command = resource.facts.command + event = await owner._authorization._audit.get_authority_event(decision_id) + expected = { + "event_domain": "authority", "event_type": "SensitiveAuthorizationAllowed", + "actor_ref_kind": "actor_profile", "actor_id": str(command.actor_profile_id), + "action_id": action.value, "permission_id": ACTION_BY_ID[action].permission_id.value, + "project_id": None, "resource_type": resource.resource_type, + "resource_id": str(command.singleton_id), "correlation_id": str(command.operation_id), + "target_ref_kind": "joint_lifecycle_control", "target_ref_id": str(command.singleton_id), "denial_code": None, + "after_facts": {"allowed": True, "resource_context_digest": authorization_resource_digest(resource)}, + } + if event is None or any(getattr(event, key, object()) != value for key, value in expected.items()): + raise invalid + try: + if UUID(str(event.id)) != decision_id: + raise ValueError("wrong event") + UUID(str(event.request_id)) + UUID(str(event.matched_grant_id)) + except (ValueError, TypeError, AttributeError) as exc: + raise invalid from exc diff --git a/backend/app/modules/authorization/repository.py b/backend/app/modules/authorization/repository.py index fae7a0f87..7f1d8d8f5 100644 --- a/backend/app/modules/authorization/repository.py +++ b/backend/app/modules/authorization/repository.py @@ -260,12 +260,14 @@ async def lock_request_actor( self, identity_link_id: UUID, actor_profile_id: UUID, + *, + preserve_foreign_key_reads: bool = False, ) -> tuple[ActorIdentityLink, ActorProfile] | None: """Lock and validate the request actor profile before its exact link.""" profile = await self._session.scalar( select(ActorProfile) .where(ActorProfile.id == str(actor_profile_id)) - .with_for_update() + .with_for_update(key_share=preserve_foreign_key_reads) .execution_options(populate_existing=True) ) if profile is None: diff --git a/backend/app/modules/authorization/review_contracts.py b/backend/app/modules/authorization/review_contracts.py index af9968a92..759f432a9 100644 --- a/backend/app/modules/authorization/review_contracts.py +++ b/backend/app/modules/authorization/review_contracts.py @@ -12,6 +12,7 @@ from app.modules.actors.api import ServiceIdentity from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.domain.lifecycle import ReviewLifecycleActivationContract from app.modules.authorization.api.acceptance_source import HumanReviewSourceCommitment _STRICT_FROZEN = ConfigDict(extra="forbid", frozen=True, strict=True) @@ -585,32 +586,6 @@ class ReviewRevisionContextLegacyCloseContract(_ProjectContract): reason: Literal["legacy_revision_context_unrecoverable"] -class ReviewLifecycleActivationContract(_ReviewContract): - """Generation-bound adjacent lifecycle-control transition facts.""" - - action_id: Literal[ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE] - singleton_id: UUID - operation_id: UUID - expected_generation: int = Field(ge=0, le=9_223_372_036_854_775_807) - current_phase: ReviewLifecyclePhase - target_phase: ReviewLifecyclePhase - adjacent_transition_confirmed: Literal[True] - reviewed_manifest_digest: str = Field(pattern=_DIGEST) - drain_observations_digest: str = Field(pattern=_DIGEST) - batch_limit: int = Field(ge=1, le=10_000) - deadline: AwareDatetime - reason: str = Field(min_length=1, max_length=512) - - @model_validator(mode="after") - def require_phase_change(self): - """Reject same-phase requests; legal adjacency remains REV-owned.""" - if self.lifecycle_phase is not self.current_phase: - raise ValueError("lifecycle phase must match current phase") - if self.expected_generation == 0 and self.current_phase is not ReviewLifecyclePhase.DISABLED: - raise ValueError("lifecycle generation zero must be disabled") - if self.current_phase is self.target_phase: - raise ValueError("lifecycle activation must change phase") - return self ReviewAuthorizationResourceContract = ( diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index 9a48fc1a0..c4b93e3fc 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -35,6 +35,7 @@ from app.modules.authorization.domain.post_submit_routing import PostSubmitRoutingResourceContext from app.modules.authorization.domain.post_submit import PostSubmitResourceContext from app.modules.authorization.domain.guide_activation import ProjectGuideActivationResourceContext +from app.modules.authorization.domain.lifecycle import ReviewLifecycleActivationContract from app.modules.authorization.service_actor_schemas import ServiceActorProvisionResourceContext from app.modules.authorization.catalogue import ActionId from app.modules.authorization.schemas import AdminRole, AdminScope, ProjectRole @@ -1187,6 +1188,7 @@ class PreSubmitCheckerInputResourceContext(PreSubmitCheckerInputPreparationConte AuthorizationResourceContext = ( + ReviewLifecycleActivationContract | submission_history.HistoryReadResourceContext | task_queues.QueueReadResourceContext | task_authority.TaskAuthorityResourceContext | ActorSelfResourceContext | ProjectReadResourceContext | ProjectDiagnosticReadResourceContext | ProjectPolicyReadResourceContext diff --git a/backend/app/modules/contributions/api/participation.py b/backend/app/modules/contributions/api/participation.py index 4523ea3fe..7bdbc83d0 100644 --- a/backend/app/modules/contributions/api/participation.py +++ b/backend/app/modules/contributions/api/participation.py @@ -20,10 +20,22 @@ class ContributionParticipationConflict(RuntimeError): """A replay differs from the immutable contribution or award facts.""" +class ParticipationLifecycleFacts(Protocol): + """Read-only scalar phase facts from the externally owned canonical fence.""" + + @property + def phase(self) -> str: + ... + + @property + def generation(self) -> int: + ... + + class ParticipationLifecycleFence(Protocol): """Acquire only the canonical lifecycle fence owned by external composition.""" - async def acquire(self, expected_generation: int) -> object: + async def acquire(self, expected_generation: int) -> ParticipationLifecycleFacts: """Hold the expected generation through the caller's root transaction.""" ... diff --git a/backend/app/modules/contributions/records/participant.py b/backend/app/modules/contributions/records/participant.py index ca64b44fd..183a7f2d2 100644 --- a/backend/app/modules/contributions/records/participant.py +++ b/backend/app/modules/contributions/records/participant.py @@ -50,7 +50,11 @@ async def participate_submitter( "contribution_participation_unavailable" ) from exc - await self._fence.acquire(checked.expected_generation) + lifecycle = await self._fence.acquire(checked.expected_generation) + if checked.acceptance_disposition == "new" and ( + lifecycle.phase != "live" or lifecycle.generation <= 0 + ): + raise ContributionParticipationUnavailable("lifecycle is not live") rule = await self._repository.get_frozen_rule( checked.project_id, checked.contribution_policy_version_id ) diff --git a/backend/app/modules/reviews/acceptance/participant.py b/backend/app/modules/reviews/acceptance/participant.py index 271273638..8d11ea159 100644 --- a/backend/app/modules/reviews/acceptance/participant.py +++ b/backend/app/modules/reviews/acceptance/participant.py @@ -39,9 +39,9 @@ async def participate(self, request: FinalAcceptanceRequest) -> FinalAcceptanceR checked = FinalAcceptanceRequest.model_validate(request) except (TypeError, ValueError, ValidationError) as exc: raise FinalAcceptanceConflict("final_acceptance_conflict") from exc - await self._fence.acquire(checked.expected_generation) + lifecycle = await self._fence.acquire(checked.expected_generation) try: - return await self._participate(checked) + return await self._participate(checked, lifecycle) except ( TaskAcceptedEffectsUnavailable, ContributionParticipationConflict, ContributionParticipationUnavailable, @@ -49,13 +49,15 @@ async def participate(self, request: FinalAcceptanceRequest) -> FinalAcceptanceR ) as exc: raise FinalAcceptanceConflict("final_acceptance_conflict") from exc - async def _participate(self, request: FinalAcceptanceRequest) -> FinalAcceptanceResult: + async def _participate(self, request: FinalAcceptanceRequest, lifecycle) -> FinalAcceptanceResult: source, task = request.acceptance, request.task_effects prepared = TaskAcceptedPreparation.model_validate( await self._tasks.lock_accepted_effects( task, expected_generation=request.expected_generation, ) ) + if prepared.disposition == "new" and (lifecycle.phase != "live" or lifecycle.generation <= 0): + raise FinalAcceptanceConflict("lifecycle is not live") if prepared.locked_review_policy_id != source.policy_context_ref: raise FinalAcceptanceConflict("final_acceptance_conflict") if source.acceptance_source == "human_review": diff --git a/backend/app/modules/reviews/api/lifecycle.py b/backend/app/modules/reviews/api/lifecycle.py index fc2c82625..e1f0c6d96 100644 --- a/backend/app/modules/reviews/api/lifecycle.py +++ b/backend/app/modules/reviews/api/lifecycle.py @@ -1,6 +1,7 @@ """Shared lifecycle fence facts, never an authorization capability.""" from enum import StrEnum +from contextlib import AbstractAsyncContextManager from typing import Protocol from uuid import UUID @@ -54,3 +55,81 @@ class JointLifecycleMutationFence(Protocol): async def acquire(self, expected_generation: int) -> JointLifecycleControlFacts: """Lock the canonical controller or deny stale/malformed transaction state.""" ... + + +class LifecycleTransitionCommand(BaseModel): + """Exact Operator request; none of these selectors grants authority.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True, revalidate_instances="always") + + operation_id: UUID + singleton_id: UUID + actor_profile_id: UUID + identity_link_id: UUID + expected_generation: int = Field(ge=0, lt=9_223_372_036_854_775_807) + current_phase: JointLifecyclePhase + target_phase: JointLifecyclePhase + reviewed_manifest_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + deadline: AwareDatetime + reason: str = Field(min_length=1, max_length=512) + + @model_validator(mode="after") + def validate_transition_shape(self): + if self.current_phase == self.target_phase: + raise ValueError("lifecycle transition must change phase") + if self.expected_generation == 0 and self.current_phase != JointLifecyclePhase.DISABLED: + raise ValueError("generation zero must be disabled") + return self + + +class LifecycleTransitionFacts(BaseModel): + """Server-observed state bound to the exact prepared Operator request.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True, revalidate_instances="always") + + command: LifecycleTransitionCommand + observations_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + +class LifecycleTransitionReceipt(BaseModel): + """Stored transition identity; consuming this value cannot authorize work.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True, revalidate_instances="always") + + operation_id: UUID + singleton_id: UUID + generation: int = Field(gt=0) + phase: JointLifecyclePhase + authorization_decision_event_id: UUID + created_at: AwareDatetime + + +class LifecycleAuthorityReceipt(BaseModel): + """Exact AUTH event identity; database custody must verify its actual event.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + decision_event_id: UUID + resource_context_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + +class PreparedLifecycleTransition(Protocol): + """AUTH-owned live Operator custody held before acquiring REV locks.""" + + async def consume_new(self, facts: LifecycleTransitionFacts) -> LifecycleAuthorityReceipt: + """Append exact authorization evidence in the caller's transaction.""" + ... + + async def validate_replay(self, facts: LifecycleTransitionFacts, decision_id: UUID) -> None: + """Compare retained evidence using fresh authority without another allow.""" + ... + + +class LifecycleTransitionAuthorization(Protocol): + """Explicit same-transaction Operator authorization, never an ambient flag.""" + + def lock_scope( + self, command: LifecycleTransitionCommand, + ) -> AbstractAsyncContextManager[PreparedLifecycleTransition]: + """Retain exact live system Operator authority until the transaction ends.""" + ... diff --git a/backend/app/modules/reviews/lifecycle/fence.py b/backend/app/modules/reviews/lifecycle/fence.py index 36dc1b23b..dad1e5204 100644 --- a/backend/app/modules/reviews/lifecycle/fence.py +++ b/backend/app/modules/reviews/lifecycle/fence.py @@ -31,13 +31,20 @@ def __init__(self, session: AsyncSession) -> None: async def acquire(self, expected_generation: int) -> JointLifecycleControlFacts: """Return current locked scalar facts only for the expected generation.""" + if type(expected_generation) is not int or not 0 <= expected_generation <= 9_223_372_036_854_775_807: + raise JointLifecycleUnavailable("lifecycle requires an exact generation") + facts = await self.lock_controller() + if facts.generation != expected_generation: + raise JointLifecycleUnavailable("lifecycle controller missing or generation changed") + return facts + + async def lock_controller(self) -> JointLifecycleControlFacts: + """Lock before transition history lookup; this never admits a mutation.""" transaction = self._session.get_transaction() if ( transaction is None or not transaction.is_active or self._session.in_nested_transaction() - or type(expected_generation) is not int - or not 0 <= expected_generation <= 9_223_372_036_854_775_807 ): raise JointLifecycleUnavailable( "lifecycle requires a root transaction and exact generation" @@ -71,7 +78,7 @@ async def acquire(self, expected_generation: int) -> JointLifecycleControlFacts: .with_for_update() ) ).one_or_none() - if row is None or row.generation != expected_generation: + if row is None: raise JointLifecycleUnavailable("lifecycle controller missing or generation changed") try: return JointLifecycleControlFacts( diff --git a/backend/app/modules/reviews/lifecycle/models.py b/backend/app/modules/reviews/lifecycle/models.py index b1508ea86..0b35c83c2 100644 --- a/backend/app/modules/reviews/lifecycle/models.py +++ b/backend/app/modules/reviews/lifecycle/models.py @@ -1,4 +1,4 @@ -"""Disabled shared lifecycle genesis awaiting authorized transition ownership.""" +"""Shared lifecycle controller and immutable authorized transition history.""" from datetime import datetime from uuid import UUID @@ -8,18 +8,20 @@ Boolean, CheckConstraint, DateTime, + ForeignKey, String, UniqueConstraint, Uuid, text, ) from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import JSONB from app.db.base import Base class JointLifecycleReleaseControl(Base): - """Single immutable genesis; no usable lifecycle generation is seeded.""" + """One preserved singleton; each phase change requires exact authorized history.""" __tablename__ = "joint_lifecycle_release_control" __table_args__ = ( @@ -41,3 +43,35 @@ class JointLifecycleReleaseControl(Base): created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=text("clock_timestamp()") ) + transition_id: Mapped[UUID | None] = mapped_column( + Uuid(), ForeignKey("joint_lifecycle_transitions.id", name="fk_joint_lifecycle_release_control_transition_id", deferrable=True, initially="DEFERRED"), + ) + + +class JointLifecycleTransition(Base): + """One immutable operation and AUTH event for each controller generation.""" + + __tablename__ = "joint_lifecycle_transitions" + __table_args__ = ( + UniqueConstraint("operation_id"), + UniqueConstraint("singleton_id", "generation"), + UniqueConstraint("authorization_decision_event_id"), + CheckConstraint("generation > 0", name="generation_positive"), + ) + + id: Mapped[UUID] = mapped_column(Uuid(), primary_key=True) + operation_id: Mapped[UUID] = mapped_column(Uuid(), nullable=False) + singleton_id: Mapped[UUID] = mapped_column( + Uuid(), ForeignKey("joint_lifecycle_release_control.id"), nullable=False, + ) + generation: Mapped[int] = mapped_column(BigInteger, nullable=False) + previous_phase: Mapped[str] = mapped_column(String(16), nullable=False) + phase: Mapped[str] = mapped_column(String(16), nullable=False) + facts_json: Mapped[dict] = mapped_column(JSONB, nullable=False) + resource_context_digest: Mapped[str] = mapped_column(String(71), nullable=False) + authorization_decision_event_id: Mapped[UUID] = mapped_column( + Uuid(), ForeignKey("audit_events.id"), nullable=False, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=text("clock_timestamp()"), + ) diff --git a/backend/app/modules/reviews/lifecycle/service.py b/backend/app/modules/reviews/lifecycle/service.py new file mode 100644 index 000000000..fe2fc9b7f --- /dev/null +++ b/backend/app/modules/reviews/lifecycle/service.py @@ -0,0 +1,107 @@ +"""Authorized transitions for the existing atomic-participant lifecycle scope.""" + +import json + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id +from app.modules.reviews.acceptance.models import FinalAcceptance +from app.modules.reviews.api.lifecycle import ( + JointLifecyclePhase, JointLifecycleUnavailable, LifecycleTransitionAuthorization, + LifecycleTransitionCommand, LifecycleTransitionFacts, LifecycleTransitionReceipt, +) +from app.modules.reviews.lifecycle.fence import PostgresJointLifecycleMutationFence +from app.modules.reviews.lifecycle.models import JointLifecycleReleaseControl, JointLifecycleTransition + +# This reviewed scope is not production routing availability. Its composition +# inventory tests must change before adding any asynchronous acceptance writer. +FIRST_CONTRIBUTION_MANIFEST_DIGEST = canonical_json_hash({ + "participants": ["reviews.acceptance", "tasks.accepted_effects", "contributions.records"], + "new_effects": "live_root_transaction", + "replay": "current_generation_select_only", + "live_readiness": "no_pre_authority_final_acceptances", + "unavailable": ["production_routing", "human_review", "fulfillment_admission", "payment_delivery"], +}) +_EDGES = frozenset({ + ("disabled", "shadow"), ("shadow", "live"), ("shadow", "disabled"), + ("live", "draining"), ("draining", "disabled"), +}) + + +class JointLifecycleController: + """Flush-only REV owner; AUTH locks precede the shared REV mutation fence.""" + + def __init__(self, session: AsyncSession, *, authorization: LifecycleTransitionAuthorization): + self._session = session + self._authorization = authorization + self._fence = PostgresJointLifecycleMutationFence(session) + + async def transition(self, command: LifecycleTransitionCommand) -> LifecycleTransitionReceipt: + """Commit control, history and exact AUTH together, or let the caller roll back.""" + checked = LifecycleTransitionCommand.model_validate(command) + async with self._authorization.lock_scope(checked) as authority: + current = await self._fence.lock_controller() + history = await self._session.scalar(select(JointLifecycleTransition).where( + JointLifecycleTransition.operation_id == checked.operation_id, + )) + if history is not None: + retained = LifecycleTransitionFacts.model_validate_json( + json.dumps(history.facts_json), + ) + if retained.command != checked or current.singleton_id != history.singleton_id: + raise JointLifecycleUnavailable("lifecycle operation conflicts") + await authority.validate_replay(retained, history.authorization_decision_event_id) + return _receipt(history) + + now = await self._session.scalar(select(func.clock_timestamp())) + if ( + current.singleton_id != checked.singleton_id + or current.generation != checked.expected_generation + or current.phase != checked.current_phase + or (current.phase, checked.target_phase) not in _EDGES + or checked.reviewed_manifest_digest != FIRST_CONTRIBUTION_MANIFEST_DIGEST + or now >= checked.deadline + ): + raise JointLifecycleUnavailable("lifecycle transition unavailable") + # Existing acceptance storage has no mandatory source AUTH receipt. + # Refuse to certify those rows; never delete or repair retained data. + retained_acceptance = bool(await self._session.scalar( + select(select(FinalAcceptance.id).exists()), + )) + if checked.target_phase == JointLifecyclePhase.LIVE and retained_acceptance: + raise JointLifecycleUnavailable("pre-authority acceptance blocks lifecycle readiness") + facts = LifecycleTransitionFacts( + command=checked, + observations_digest=canonical_json_hash({ + "singleton_id": str(current.singleton_id), + "generation": current.generation, "phase": current.phase.value, + "retained_pre_authority_acceptance": retained_acceptance, + "manifest": FIRST_CONTRIBUTION_MANIFEST_DIGEST, + }), + ) + receipt = await authority.consume_new(facts) + row = JointLifecycleTransition( + id=new_record_id(), operation_id=checked.operation_id, + singleton_id=current.singleton_id, generation=current.generation + 1, + previous_phase=current.phase.value, phase=checked.target_phase.value, + facts_json=facts.model_dump(mode="json"), + resource_context_digest=receipt.resource_context_digest, + authorization_decision_event_id=receipt.decision_event_id, + ) + self._session.add(row) + await self._session.flush() + control = await self._session.get(JointLifecycleReleaseControl, current.singleton_id) + control.phase, control.generation, control.transition_id = row.phase, row.generation, row.id + await self._session.flush() + return _receipt(row) + + +def _receipt(row: JointLifecycleTransition) -> LifecycleTransitionReceipt: + return LifecycleTransitionReceipt( + operation_id=row.operation_id, singleton_id=row.singleton_id, + generation=row.generation, phase=JointLifecyclePhase(row.phase), + authorization_decision_event_id=row.authorization_decision_event_id, + created_at=row.created_at, + ) diff --git a/backend/app/modules/tasks/accepted_effects.py b/backend/app/modules/tasks/accepted_effects.py index 718fa91fc..5ddc40821 100644 --- a/backend/app/modules/tasks/accepted_effects.py +++ b/backend/app/modules/tasks/accepted_effects.py @@ -115,7 +115,7 @@ async def _lock_and_validate_rows( expected_generation: int, ): """Retain the canonical fence and Task -> Assignment -> Submission locks.""" - await self._fence.acquire(expected_generation) + lifecycle = await self._fence.acquire(expected_generation) task = await self._repository.lock_project_task( request.project_id, request.task_id ) @@ -164,6 +164,8 @@ async def _lock_and_validate_rows( disposition = "replay" else: raise TaskAcceptedEffectsUnavailable("task_accepted_effects_unavailable") + if disposition == "new" and (lifecycle.phase != "live" or lifecycle.generation <= 0): + raise TaskAcceptedEffectsUnavailable("lifecycle is not live") try: preparation = TaskAcceptedPreparation( disposition=disposition, diff --git a/backend/app/modules/tasks/api/accepted_effects.py b/backend/app/modules/tasks/api/accepted_effects.py index cb809e425..43eb507d5 100644 --- a/backend/app/modules/tasks/api/accepted_effects.py +++ b/backend/app/modules/tasks/api/accepted_effects.py @@ -54,10 +54,22 @@ class TaskAcceptedEffectsResult(BaseModel): assignment_status: Literal["completed"] +class TaskLifecycleFacts(Protocol): + """Read-only scalar phase facts from the externally owned canonical fence.""" + + @property + def phase(self) -> str: + ... + + @property + def generation(self) -> int: + ... + + class TaskAcceptedEffectsFence(Protocol): """Acquire the canonical externally owned lifecycle fence.""" - async def acquire(self, expected_generation: _Generation) -> object: + async def acquire(self, expected_generation: _Generation) -> TaskLifecycleFacts: """Retain the expected generation through the caller's transaction.""" ... diff --git a/backend/scripts/behavior_ownership.py b/backend/scripts/behavior_ownership.py index 925002c6f..4374190b2 100644 --- a/backend/scripts/behavior_ownership.py +++ b/backend/scripts/behavior_ownership.py @@ -170,6 +170,12 @@ "backend/app/modules/authorization/api/acceptance_source.py", "backend/app/modules/authorization/acceptance_source_contracts.py", }) +REV_12A4A_CONTROL_TARGETS = frozenset({ + "backend/app/modules/authorization/domain/lifecycle.py", + "backend/app/modules/authorization/lifecycle_authorization.py", + "backend/app/modules/authorization/prepared_lifecycle_replay.py", + "backend/app/modules/reviews/lifecycle/service.py", +}) REV_12A1_FENCE_TARGETS = frozenset({"backend/app/modules/reviews/api/lifecycle.py", "backend/app/modules/reviews/lifecycle/models.py", "backend/app/modules/reviews/lifecycle/fence.py"}) REV_04C_REMOVED_TARGETS = frozenset({"backend/app/modules/reviews/acceptance/schemas.py"}) REV_04C_PARTICIPATION_TARGETS = frozenset({ @@ -807,6 +813,7 @@ def _validate_additive_partition_transition( | CON_07_PARTICIPATION_TARGETS | AUTH_19A_SOURCE_CONTRACT_TARGETS | ARCH_04E2A_ROUTING_AUTH_TARGETS + | REV_12A4A_CONTROL_TARGETS | REV_12A1_FENCE_TARGETS | REV_04C_PARTICIPATION_TARGETS | REV_04B_ACCEPTANCE_TARGETS diff --git a/backend/scripts/test_lane_catalogue.py b/backend/scripts/test_lane_catalogue.py index 61df134d7..3a5d0ee44 100644 --- a/backend/scripts/test_lane_catalogue.py +++ b/backend/scripts/test_lane_catalogue.py @@ -23,6 +23,8 @@ class TestLane: SHARED_FOUNDATION_MODULES = ( + "tests/reviews/lifecycle/test_transitions.py", + "tests/reviews/lifecycle/test_authority_binding.py", "tests/artifacts/test_review_packet_contract.py", "tests/authorization/post_submit/test_atomicity.py", "tests/authorization/post_submit/test_concurrency.py", @@ -396,6 +398,7 @@ class TestLane: ) TASK_MODULES = ( + "tests/reviews/lifecycle/test_participant_control.py", "tests/reviews/decision/test_contracts.py", "tests/reviews/decision/test_storage.py", "tests/reviews/decision/test_migration.py", @@ -536,6 +539,7 @@ class TestLane: "tests/reviews/packet/test_storage.py", "tests/reviews/lifecycle/test_contracts.py", "tests/reviews/lifecycle/test_storage.py", + "tests/reviews/lifecycle/test_transition_storage.py", "tests/reviews/lifecycle/test_fence.py", "tests/reviews/lifecycle/test_migration.py", "tests/reviews/acceptance/test_contracts.py", diff --git a/backend/tests/authorization/catalogue_fixtures.py b/backend/tests/authorization/catalogue_fixtures.py index ad691be5c..eab7817f5 100644 --- a/backend/tests/authorization/catalogue_fixtures.py +++ b/backend/tests/authorization/catalogue_fixtures.py @@ -231,7 +231,7 @@ "review.lifecycle.activation.manage": ( "operations.reconcile.run", "WS-XINT-003-08B", - "planned", + "active", ), } diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 02dd7a90b..ff080a2af 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -23,14 +23,15 @@ DDL_LOCK_DIRECTORY = Path("/tmp") # Match the PostgreSQL 16 engine used by Backend CI. Catalog identity rendering # differs across major versions; regenerate only after comparing actual objects. -EXPECTED_PUBLIC_SCHEMA_SHA256 = "27ba211f2e2daded2c41e661163a73ed173f53612ee03a8d922a6845a81d0be8" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "3bb7c5924fc8061b49f8f1c131e7be842ae02831e943e72b8e56fb73a7483c2d" PROTECTED_TEST_TABLES = ( - "joint_lifecycle_release_control", "actor_profile_migration_state", "alembic_version", "iso_4217_currency_codes", ) RESETTABLE_TEST_TABLES = ( + "joint_lifecycle_release_control", + "joint_lifecycle_transitions", "actor_identity_links", "actor_profiles", "admin_role_grants", @@ -133,6 +134,8 @@ "workstream_tasks", ) TRUNCATE_GUARDED_TABLES = ( + "joint_lifecycle_release_control", + "joint_lifecycle_transitions", "artifact_bindings", "artifact_put_attempts", "checker_runs", @@ -411,16 +414,27 @@ async def _reset_test_database_state( f"alter table {tables[name]} disable trigger user" for name in TRUNCATE_GUARDED_TABLES )) + lifecycle_genesis = await connection.fetchrow( + "select id,created_at from public.joint_lifecycle_release_control" + ) if after_disable is not None: await after_disable() await connection.execute( f"truncate table {', '.join(tables.values())} restart identity cascade" ) + if lifecycle_genesis is None: + raise AssertionError("isolated lifecycle singleton missing") + await connection.execute( + "insert into public.joint_lifecycle_release_control" + "(id,singleton,phase,generation,created_at) values($1,true,'disabled',0,$2)", + lifecycle_genesis["id"], lifecycle_genesis["created_at"], + ) await connection.execute( "insert into authority_control" "(id, bootstrap_completed, bootstrap_grant_id, version) " "values (1, false, null, 0)" ) + await connection.execute("SET CONSTRAINTS ALL IMMEDIATE") await connection.execute("; ".join( f"alter table {tables[name]} enable trigger user" for name in TRUNCATE_GUARDED_TABLES diff --git a/backend/tests/contributions/participation/conftest.py b/backend/tests/contributions/participation/conftest.py new file mode 100644 index 000000000..fd97385e6 --- /dev/null +++ b/backend/tests/contributions/participation/conftest.py @@ -0,0 +1,12 @@ +"""Reuse signed authority fixtures only for explicitly marked participant proofs.""" + +from tests.authentication.fixtures import ( + auth_database_env as auth_database_env, + clear_settings_cache as clear_settings_cache, + rsa_signing_material as rsa_signing_material, +) +from tests.authorization.admin_access.fixtures import ( + signed_access as signed_access, + admin_access as admin_access, +) +from tests.reviews.lifecycle.fixtures import live_acceptance_lifecycle as live_acceptance_lifecycle diff --git a/backend/tests/contributions/participation/support.py b/backend/tests/contributions/participation/support.py index 7cd38e89c..4d1604951 100644 --- a/backend/tests/contributions/participation/support.py +++ b/backend/tests/contributions/participation/support.py @@ -18,7 +18,7 @@ def request_for(h, *, acceptance_disposition, **changes): "contribution_policy_version_id": (h.submitter_record.contribution_policy_version_id), "artifact_hash": h.submitter_record.artifact_hash, "correlation_id": h.submitter_record.id, - "expected_generation": 0, + "expected_generation": 2, } values.update(changes) return SubmitterParticipationRequest(**values) diff --git a/backend/tests/contributions/participation/test_postgresql.py b/backend/tests/contributions/participation/test_postgresql.py index b73165114..3ac1dc9e3 100644 --- a/backend/tests/contributions/participation/test_postgresql.py +++ b/backend/tests/contributions/participation/test_postgresql.py @@ -1,6 +1,7 @@ """Real PostgreSQL proof for hidden source-neutral submitter participation.""" import pytest + from sqlalchemy import text from app.core.identifiers import new_record_id @@ -29,6 +30,8 @@ from .support import participant, request_for +pytestmark = pytest.mark.usefixtures("live_acceptance_lifecycle") + @pytest.mark.parametrize( "instruments", @@ -316,7 +319,7 @@ async def test_partial_same_transaction_replay_rejects_without_backfill( request = request_for(h, acceptance_disposition="replay", correlation_id=new_record_id()) async with h.factory() as session: await session.begin() - await PostgresJointLifecycleMutationFence(session).acquire(0) + await PostgresJointLifecycleMutationFence(session).acquire(2) await insert_record(session, h.submitter_record) expected_awards = await award_values(session, h.submitter_record) await insert_award( diff --git a/backend/tests/contributions/participation/test_transactions.py b/backend/tests/contributions/participation/test_transactions.py index a2abc145e..5f6facf78 100644 --- a/backend/tests/contributions/participation/test_transactions.py +++ b/backend/tests/contributions/participation/test_transactions.py @@ -1,6 +1,7 @@ """Caller transaction, rollback, and canonical fence proof.""" import pytest + from sqlalchemy import text from sqlalchemy.exc import DBAPIError @@ -12,6 +13,8 @@ from .support import participant, request_for +pytestmark = pytest.mark.usefixtures("live_acceptance_lifecycle") + async def test_later_sql_failure_rolls_back_acceptance_contribution_and_awards( tmp_path, isolated_database_env @@ -23,7 +26,7 @@ async def test_later_sql_failure_rolls_back_acceptance_contribution_and_awards( async with h.factory() as session: with pytest.raises(DBAPIError, match="division by zero"): async with session.begin(): - await PostgresJointLifecycleMutationFence(session).acquire(0) + await PostgresJointLifecycleMutationFence(session).acquire(2) await insert_acceptance(session, h.acceptance) result = await participant(session).participate_submitter(request) assert len(result.awards) == 2 diff --git a/backend/tests/reviews/acceptance/conftest.py b/backend/tests/reviews/acceptance/conftest.py new file mode 100644 index 000000000..fd97385e6 --- /dev/null +++ b/backend/tests/reviews/acceptance/conftest.py @@ -0,0 +1,12 @@ +"""Reuse signed authority fixtures only for explicitly marked participant proofs.""" + +from tests.authentication.fixtures import ( + auth_database_env as auth_database_env, + clear_settings_cache as clear_settings_cache, + rsa_signing_material as rsa_signing_material, +) +from tests.authorization.admin_access.fixtures import ( + signed_access as signed_access, + admin_access as admin_access, +) +from tests.reviews.lifecycle.fixtures import live_acceptance_lifecycle as live_acceptance_lifecycle diff --git a/backend/tests/reviews/acceptance/participation_support.py b/backend/tests/reviews/acceptance/participation_support.py index 3aa2a3e38..04e0a8c27 100644 --- a/backend/tests/reviews/acceptance/participation_support.py +++ b/backend/tests/reviews/acceptance/participation_support.py @@ -85,7 +85,7 @@ async def request_for(h, **changes) -> FinalAcceptanceRequest: expected_task_status="review_pending", ), "correlation_id": new_record_id(), - "expected_generation": 0, + "expected_generation": 2, } values.update(changes) return FinalAcceptanceRequest(**values) diff --git a/backend/tests/reviews/acceptance/test_participation.py b/backend/tests/reviews/acceptance/test_participation.py index c8443fd1c..e48a409cb 100644 --- a/backend/tests/reviews/acceptance/test_participation.py +++ b/backend/tests/reviews/acceptance/test_participation.py @@ -3,6 +3,7 @@ from uuid import UUID import pytest + from sqlalchemy import text from app.core.identifiers import new_record_id @@ -16,6 +17,8 @@ stored_effects, ) +pytestmark = pytest.mark.usefixtures("live_acceptance_lifecycle") + @pytest.mark.parametrize( "instruments", diff --git a/backend/tests/reviews/acceptance/test_participation_transactions.py b/backend/tests/reviews/acceptance/test_participation_transactions.py index 78f8bb73a..d05999d58 100644 --- a/backend/tests/reviews/acceptance/test_participation_transactions.py +++ b/backend/tests/reviews/acceptance/test_participation_transactions.py @@ -4,6 +4,7 @@ from contextlib import suppress import pytest + from sqlalchemy import text from sqlalchemy.exc import DBAPIError @@ -28,6 +29,8 @@ stored_effects, ) +pytestmark = pytest.mark.usefixtures("live_acceptance_lifecycle") + async def test_caller_rollback_and_late_sql_failure_discard_every_new_effect( tmp_path, isolated_database_env diff --git a/backend/tests/reviews/lifecycle/conftest.py b/backend/tests/reviews/lifecycle/conftest.py new file mode 100644 index 000000000..c096c74dd --- /dev/null +++ b/backend/tests/reviews/lifecycle/conftest.py @@ -0,0 +1,13 @@ +"""Canonical signed authentication and real Operator grant fixtures.""" + +from tests.authentication.fixtures import ( + auth_database_env as auth_database_env, + clear_settings_cache as clear_settings_cache, + rsa_signing_material as rsa_signing_material, +) +from tests.authorization.admin_access.fixtures import ( + signed_access as signed_access, + admin_access as admin_access, +) + +from tests.reviews.lifecycle.fixtures import live_acceptance_lifecycle as live_acceptance_lifecycle diff --git a/backend/tests/reviews/lifecycle/fixtures.py b/backend/tests/reviews/lifecycle/fixtures.py new file mode 100644 index 000000000..0509ab160 --- /dev/null +++ b/backend/tests/reviews/lifecycle/fixtures.py @@ -0,0 +1,14 @@ +"""Use real Operator transitions for acceptance participant integration controls.""" + +import pytest + +from tests.reviews.lifecycle.transition_support import command_for, transition + + +@pytest.fixture +async def live_acceptance_lifecycle(admin_access): + await admin_access.signed.grant(admin_access.admin, admin_access.target) + await transition(await command_for(admin_access.target.id, "shadow")) + live = await transition(await command_for(admin_access.target.id, "live")) + assert live.generation == 2 + return admin_access diff --git a/backend/tests/reviews/lifecycle/test_authority_binding.py b/backend/tests/reviews/lifecycle/test_authority_binding.py new file mode 100644 index 000000000..95392c92d --- /dev/null +++ b/backend/tests/reviews/lifecycle/test_authority_binding.py @@ -0,0 +1,40 @@ +"""Actual PREP consumption rejects independent changes to the selected command.""" + +import pytest + +from app.core.identifiers import new_record_id +from app.db.session import get_session_factory +from app.modules.authorization.domain.lifecycle import ReviewLifecycleActivationContract +from app.modules.authorization.runtime import PreparedAuthorizationHandleInvalid +from app.modules.reviews.api.lifecycle import JointLifecyclePhase +from tests.reviews.lifecycle.transition_support import command_for, controller, facts_for, snapshot, transition + + +@pytest.mark.parametrize("field", [ + "operation_id", "singleton_id", "actor_profile_id", "identity_link_id", + "expected_generation", "target_phase", "reviewed_manifest_digest", "reason", +]) +async def test_prepared_command_substitution_rejects_before_allow(admin_access, field): + await admin_access.signed.grant(admin_access.admin, admin_access.target) + command = await command_for(admin_access.target.id, "shadow") + value = { + "expected_generation": 1, + "target_phase": JointLifecyclePhase.LIVE, + "reviewed_manifest_digest": "sha256:" + "1" * 64, + "reason": "different selected operation", + }.get(field, new_record_id()) + changed = command.model_copy(update={field: value}) + facts = facts_for(changed) + # Recompute the outer resource identity so its own shape guard cannot mask + # the actual prepared-command comparison. + ReviewLifecycleActivationContract(resource_id=changed.singleton_id, facts=facts) + before = await snapshot() + async with get_session_factory()() as session: + await session.begin() + owner = controller(session, command) + async with owner._authorization.lock_scope(command) as prepared: + with pytest.raises(PreparedAuthorizationHandleInvalid, match="lifecycle"): + await prepared.consume_new(facts) + await session.rollback() + assert await snapshot() == before + assert (await transition(command)).generation == 1 diff --git a/backend/tests/reviews/lifecycle/test_contracts.py b/backend/tests/reviews/lifecycle/test_contracts.py index 42ad73f52..7ccde3d1b 100644 --- a/backend/tests/reviews/lifecycle/test_contracts.py +++ b/backend/tests/reviews/lifecycle/test_contracts.py @@ -47,5 +47,5 @@ def test_phase_projection_agrees(): ) assert ( ACTION_BY_ID[ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE].availability - is ActionAvailability.PLANNED + is ActionAvailability.ACTIVE ) diff --git a/backend/tests/reviews/lifecycle/test_fence.py b/backend/tests/reviews/lifecycle/test_fence.py index 422b5c6f0..fbaf9359f 100644 --- a/backend/tests/reviews/lifecycle/test_fence.py +++ b/backend/tests/reviews/lifecycle/test_fence.py @@ -164,7 +164,7 @@ async def test_missing_controller_denies_and_stale_identity_map_is_not_used( # Isolated corruption fixture is transactional and never committed. await session.execute( text( - "ALTER TABLE public.joint_lifecycle_release_control DISABLE TRIGGER joint_lifecycle_genesis_immutable" + "ALTER TABLE public.joint_lifecycle_release_control DISABLE TRIGGER joint_lifecycle_control_change" ) ) await session.execute(text("DELETE FROM public.joint_lifecycle_release_control")) diff --git a/backend/tests/reviews/lifecycle/test_migration.py b/backend/tests/reviews/lifecycle/test_migration.py index debd3cc6c..ba96b5c4a 100644 --- a/backend/tests/reviews/lifecycle/test_migration.py +++ b/backend/tests/reviews/lifecycle/test_migration.py @@ -105,3 +105,40 @@ async def test_lifecycle_upgrade_preserves_sources(tmp_path, isolated_database_e ) finally: await connection.close() + + +async def test_transition_upgrade_preserves_genesis_and_retained_sources( + tmp_path, isolated_database_env, migration_lock, +): + with migration_lock(): + await db_session.dispose_engine() + url = isolated_database_env.replace("+asyncpg", "") + connection = await asyncpg.connect(url) + try: + await connection.execute("DROP SCHEMA public CASCADE; CREATE SCHEMA public") + finally: + await connection.close() + await asyncio.to_thread(command.upgrade, _config(), "0026_task_guide_read") + async with contribution_source( + tmp_path, isolated_database_env, paid=True, material_source=historical_material_fixture, + ): + connection = await asyncpg.connect(url) + try: + before = await snapshot(connection) + genesis = dict(await connection.fetchrow( + "SELECT * FROM public.joint_lifecycle_release_control" + )) + await asyncio.to_thread(command.upgrade, _config(), "0027_lifecycle_transitions") + assert await snapshot(connection) == before + assert dict(await connection.fetchrow( + "SELECT * FROM public.joint_lifecycle_release_control" + )) == genesis | {"transition_id": None} + assert await connection.fetchval( + "SELECT count(*) FROM public.joint_lifecycle_transitions" + ) == 0 + with pytest.raises(asyncpg.CheckViolationError, match="transition custody invalid"): + await connection.execute( + "UPDATE public.joint_lifecycle_release_control SET phase='live',generation=1" + ) + finally: + await connection.close() diff --git a/backend/tests/reviews/lifecycle/test_participant_control.py b/backend/tests/reviews/lifecycle/test_participant_control.py new file mode 100644 index 000000000..796c8f40a --- /dev/null +++ b/backend/tests/reviews/lifecycle/test_participant_control.py @@ -0,0 +1,193 @@ +"""Actual acceptance effects against authorized controller changes and shutdown.""" + +import asyncio +from types import SimpleNamespace + +import pytest +from sqlalchemy import event, select, text + +from app.db.session import get_session_factory +from app.modules.reviews.acceptance.repository import FinalAcceptanceRepository +from app.modules.reviews.api.acceptance import FinalAcceptanceConflict +from app.modules.reviews.api.lifecycle import JointLifecycleUnavailable +from app.modules.reviews.lifecycle.models import JointLifecycleReleaseControl +from tests.contributions.records.support import contribution_source +from tests.reviews.acceptance.participation_support import ( + participant, prepare_review_pending, request_for, stored_effects, +) +from tests.reviews.lifecycle.test_fence import observe_advisory_wait +from tests.reviews.lifecycle.transition_support import command_for, controller, transition + + +@pytest.mark.parametrize("instruments", [(), ("money",), ("money", "project_points")]) +async def test_stopped_terminal_replay_is_select_only( + tmp_path, isolated_database_env, live_acceptance_lifecycle, instruments, +): + access = live_acceptance_lifecycle + async with contribution_source( + tmp_path, isolated_database_env, contribution_awards=instruments, persist_acceptance=False, + ) as h: + await prepare_review_pending(h) + request = await request_for(h) + async with h.factory() as session, session.begin(): + original = await participant(session).participate(request) + for phase in ("draining", "disabled"): + receipt = await transition(await command_for(access.target.id, phase)) + engine = h.factory.kw["bind"].sync_engine + writes = [] + + def observe(_connection, _cursor, statement, _parameters, _context, _many): + if statement.lstrip().split(None, 1)[0].upper() in {"INSERT", "UPDATE", "DELETE"}: + writes.append(statement) + + event.listen(engine, "before_cursor_execute", observe) + try: + async with h.factory() as session, session.begin(): + replay = await participant(session).participate( + request.model_copy(update={"expected_generation": receipt.generation}), + ) + finally: + event.remove(engine, "before_cursor_execute", observe) + assert replay == original + assert writes == [] + # Source receipts are not implemented by this manifest. Preserve its + # mechanically retained rows, and refuse to certify them for reactivation. + await transition(await command_for(access.target.id, "shadow")) + with pytest.raises(JointLifecycleUnavailable, match="pre-authority acceptance"): + await transition(await command_for(access.target.id, "live")) + async with h.factory() as session: + facts = await stored_effects(session, h.acceptance.task_id) + assert facts["acceptances"] == facts["contributions"] == 1 + assert facts["awards"] == len(instruments) + + +@pytest.mark.parametrize("phase", ["draining", "disabled", "shadow"]) +async def test_current_generation_cannot_admit_new_effects_when_stopped( + tmp_path, isolated_database_env, live_acceptance_lifecycle, phase, +): + access = live_acceptance_lifecycle + async with contribution_source(tmp_path, isolated_database_env, persist_acceptance=False) as h: + await prepare_review_pending(h) + for target in {"draining": ("draining",), "disabled": ("draining", "disabled"), + "shadow": ("draining", "disabled", "shadow")}[phase]: + receipt = await transition(await command_for(access.target.id, target)) + request = await request_for(h, expected_generation=receipt.generation) + async with h.factory() as session: + before = await stored_effects(session, h.acceptance.task_id) + async with h.factory() as session: + with pytest.raises(FinalAcceptanceConflict): + async with session.begin(): + await participant(session).participate(request) + async with h.factory() as session: + assert await stored_effects(session, h.acceptance.task_id) == before + + +async def test_operator_who_is_submitter_does_not_deadlock_prior_acceptance( + tmp_path, isolated_database_env, live_acceptance_lifecycle, monkeypatch, +): + access = live_acceptance_lifecycle + async with contribution_source(tmp_path, isolated_database_env, persist_acceptance=False) as h: + await prepare_review_pending(h) + await access.signed.grant( + access.admin, SimpleNamespace(id=h.acceptance.accepted_submitter_id), + ) + command = await command_for(h.acceptance.accepted_submitter_id, "draining") + request = await request_for(h) + reached, proceed = asyncio.Event(), asyncio.Event() + original = FinalAcceptanceRepository.persist + + async def pause(owner, source, **kwargs): + reached.set() + await proceed.wait() + return await original(owner, source, **kwargs) + + monkeypatch.setattr(FinalAcceptanceRepository, "persist", pause) + + async def accept(): + async with h.factory() as session, session.begin(): + return await participant(session).participate(request) + + pids = asyncio.Queue() + + async def stop(): + async with h.factory() as session, session.begin(): + await pids.put(await session.scalar(text("SELECT pg_catalog.pg_backend_pid()"))) + return await controller(session, command).transition(command) + + writer = asyncio.create_task(accept()) + stopper = None + try: + await asyncio.wait_for(reached.wait(), 10) + stopper = asyncio.create_task(stop()) + pid = await asyncio.wait_for(pids.get(), 10) + await observe_advisory_wait(h.factory, pid, stopper) + proceed.set() + accepted, stopped = await asyncio.wait_for(asyncio.gather(writer, stopper), 15) + assert accepted.acceptance.id == h.acceptance.id + assert stopped.phase == "draining" + finally: + proceed.set() + pending = [task for task in (writer, stopper) if task is not None] + for task in pending: + if not task.done(): + task.cancel() + await asyncio.gather(*pending, return_exceptions=True) + async with get_session_factory()() as session: + control = await session.scalar(select(JointLifecycleReleaseControl)) + assert control.phase == "draining" and control.generation == 3 + facts = await stored_effects(session, h.acceptance.task_id) + assert facts["acceptances"] == facts["contributions"] == 1 + + +async def test_committed_transition_fences_waiting_acceptance( + tmp_path, isolated_database_env, live_acceptance_lifecycle, +): + access = live_acceptance_lifecycle + async with contribution_source(tmp_path, isolated_database_env, persist_acceptance=False) as h: + await prepare_review_pending(h) + request = await request_for(h) + command = await command_for(access.target.id, "draining") + async with h.factory() as session: + before = await stored_effects(session, h.acceptance.task_id) + async with h.factory() as stopper: + await stopper.begin() + stopped = await controller(stopper, command).transition(command) + pids = asyncio.Queue() + + async def accept(): + async with h.factory() as session, session.begin(): + await pids.put(await session.scalar(text("SELECT pg_catalog.pg_backend_pid()"))) + return await participant(session).participate(request) + + writer = asyncio.create_task(accept()) + try: + await observe_advisory_wait(h.factory, await pids.get(), writer) + await stopper.commit() + with pytest.raises(JointLifecycleUnavailable, match="generation changed"): + await asyncio.wait_for(writer, 10) + # A fresh generation is still not permission for stopped effects. + async with h.factory() as session, session.begin(): + with pytest.raises(FinalAcceptanceConflict): + await participant(session).participate(request.model_copy(update={ + "expected_generation": stopped.generation, + })) + finally: + await stopper.rollback() + if not writer.done(): + writer.cancel() + await asyncio.gather(writer, return_exceptions=True) + async with h.factory() as session: + assert await stored_effects(session, h.acceptance.task_id) == before + + +async def test_generation_zero_cannot_create_acceptance(tmp_path, isolated_database_env): + async with contribution_source(tmp_path, isolated_database_env, persist_acceptance=False) as h: + await prepare_review_pending(h) + request = await request_for(h, expected_generation=0) + async with h.factory() as session: + before = await stored_effects(session, h.acceptance.task_id) + async with h.factory() as session, session.begin(): + with pytest.raises(FinalAcceptanceConflict): + await participant(session).participate(request) + async with h.factory() as session: + assert await stored_effects(session, h.acceptance.task_id) == before diff --git a/backend/tests/reviews/lifecycle/test_storage.py b/backend/tests/reviews/lifecycle/test_storage.py index 857848d22..1d215a52d 100644 --- a/backend/tests/reviews/lifecycle/test_storage.py +++ b/backend/tests/reviews/lifecycle/test_storage.py @@ -40,7 +40,7 @@ async def test_genesis_and_sql_immutability(clean_postgres_database): "TRUNCATE public.joint_lifecycle_release_control CASCADE", ): async with factory() as session: - with pytest.raises(DBAPIError, match="joint lifecycle genesis is immutable"): + with pytest.raises(DBAPIError, match="joint lifecycle (custody is immutable|transition custody invalid)"): await session.execute(text(statement)) await session.commit() async with factory() as session: diff --git a/backend/tests/reviews/lifecycle/test_transition_storage.py b/backend/tests/reviews/lifecycle/test_transition_storage.py new file mode 100644 index 000000000..67e4a4088 --- /dev/null +++ b/backend/tests/reviews/lifecycle/test_transition_storage.py @@ -0,0 +1,87 @@ +"""Direct PostgreSQL custody of controller, history and its actual AUTH event.""" + +import pytest +from sqlalchemy import text +from sqlalchemy.exc import DBAPIError + +from app.db.session import get_session_factory +from tests.reviews.lifecycle.transition_support import ( + command_for, facts_for, history_row, issue_authority, publish_history, snapshot, transition, +) + + +async def test_retained_history_and_controller_cannot_be_rewritten(admin_access): + await admin_access.signed.grant(admin_access.admin, admin_access.target) + await transition(await command_for(admin_access.target.id, "shadow")) + before = await snapshot() + for statement, error in ( + ("UPDATE public.joint_lifecycle_transitions SET phase='live'", "custody is immutable"), + ("DELETE FROM public.joint_lifecycle_transitions", "custody is immutable"), + ("TRUNCATE public.joint_lifecycle_transitions CASCADE", "custody is immutable"), + ("UPDATE public.joint_lifecycle_release_control SET phase='live',generation=2", "transition custody invalid"), + ("UPDATE public.joint_lifecycle_release_control SET generation=3", "transition custody invalid"), + ("DELETE FROM public.joint_lifecycle_release_control", "custody is immutable"), + ): + async with get_session_factory()() as session: + with pytest.raises(DBAPIError, match=error): + await session.execute(text(statement)) + await session.commit() + assert await snapshot() == before + + +async def test_transition_authority_and_history_cannot_commit_separately(admin_access): + await admin_access.signed.grant(admin_access.admin, admin_access.target) + command = await command_for(admin_access.target.id, "shadow") + before = await snapshot() + async with get_session_factory()() as session: + with pytest.raises(DBAPIError, match="authority closure invalid"): + await session.begin() + await issue_authority(session, command) + await session.commit() + assert await snapshot() == before + async with get_session_factory()() as session: + with pytest.raises(DBAPIError, match="history closure invalid"): + await session.begin() + receipt = await issue_authority(session, command) + session.add(history_row(command, receipt)) + await session.commit() + assert await snapshot() == before + # The same operation succeeds when all three actual owners commit together. + async with get_session_factory()() as session, session.begin(): + receipt = await issue_authority(session, command) + await publish_history(session, history_row(command, receipt)) + assert len((await snapshot())["history"]) == 1 + + +async def test_shadow_audit_table_cannot_substitute_a_real_receipt(admin_access): + import json + from app.core.identifiers import new_record_id + from app.modules.authorization.domain.lifecycle import ReviewLifecycleActivationContract + from app.modules.authorization.runtime import authorization_resource_digest + + await admin_access.signed.grant(admin_access.admin, admin_access.target) + authorized = await command_for(admin_access.target.id, "shadow") + substituted = authorized.model_copy(update={"operation_id": new_record_id()}) + facts = facts_for(substituted) + digest = authorization_resource_digest(ReviewLifecycleActivationContract( + resource_id=substituted.singleton_id, facts=facts, + )) + before = await snapshot() + async with get_session_factory()() as session: + with pytest.raises(DBAPIError, match="transition custody invalid"): + await session.begin() + receipt = await issue_authority(session, authorized) + await session.execute(text("CREATE TEMP TABLE audit_events (LIKE public.audit_events)")) + await session.execute(text( + "INSERT INTO pg_temp.audit_events SELECT * FROM public.audit_events WHERE id=:id" + ), {"id": receipt.decision_event_id}) + await session.execute(text( + "UPDATE pg_temp.audit_events SET correlation_id=:operation,after_facts=CAST(:facts AS json)" + ), {"operation": substituted.operation_id, + "facts": json.dumps({"allowed": True, "resource_context_digest": digest})}) + # Both digests and every FK are valid; only the canonical event says + # this receipt belongs to a different operation. No stale-hash guard + # can mask the protected-table lookup being tested here. + await publish_history(session, history_row(substituted, receipt, facts=facts, digest=digest)) + await session.commit() + assert await snapshot() == before diff --git a/backend/tests/reviews/lifecycle/test_transitions.py b/backend/tests/reviews/lifecycle/test_transitions.py new file mode 100644 index 000000000..5e520a8c0 --- /dev/null +++ b/backend/tests/reviews/lifecycle/test_transitions.py @@ -0,0 +1,136 @@ +"""Real Operator authority, immutable phase history and rollback.""" + +import asyncio +from datetime import timedelta + +import pytest +from sqlalchemy import select, text + +from sqlalchemy.exc import DBAPIError + +from app.db.session import get_session_factory +from app.modules.reviews.api.lifecycle import JointLifecycleUnavailable +from app.modules.reviews.lifecycle.models import JointLifecycleTransition +from app.modules.tasks.models import AuditEvent +from tests.reviews.lifecycle.transition_support import command_for, controller, snapshot, transition + + +async def test_operator_transitions_and_response_loss_replay(admin_access): + access = admin_access + grant = await access.signed.grant(access.admin, access.target) + receipts = [] + commands = [] + for phase in ("shadow", "live", "draining", "disabled", "shadow", "disabled"): + command = await command_for(access.target.id, phase) + commands.append(command) + receipt = await transition(command) + receipts.append(receipt) + assert receipt.generation == len(receipts) + assert receipt.phase == phase + before = await snapshot() + # Replay the original generation-zero request after several transitions. + assert await transition(commands[0]) == receipts[0] + assert await snapshot() == before + async with get_session_factory()() as session: + rows = list(await session.scalars(select(JointLifecycleTransition).order_by(JointLifecycleTransition.generation))) + assert len(rows) == 6 + for row, command in zip(rows, commands, strict=True): + event = await session.get(AuditEvent, row.authorization_decision_event_id) + assert event.matched_grant_id == grant + assert event.actor_id == str(access.target.id) + assert event.resource_id == str(command.singleton_id) + assert event.correlation_id == str(command.operation_id) + assert event.after_facts == {"allowed": True, "resource_context_digest": row.resource_context_digest} + + +async def test_invalid_edge_stale_generation_deadline_and_manifest_rollback(admin_access): + await admin_access.signed.grant(admin_access.admin, admin_access.target) + original = await command_for(admin_access.target.id, "shadow") + before = await snapshot() + for changes in ( + {"target_phase": "live"}, {"expected_generation": 1}, + {"deadline": original.deadline - timedelta(days=1)}, + {"reviewed_manifest_digest": "sha256:" + "0" * 64}, + ): + from app.modules.reviews.api.lifecycle import JointLifecyclePhase + if "target_phase" in changes: + changes["target_phase"] = JointLifecyclePhase(changes["target_phase"]) + command = original.model_copy(update=changes) + with pytest.raises(JointLifecycleUnavailable): + await transition(command) + assert await snapshot() == before + assert (await transition(original)).generation == 1 + + +async def test_caller_rollback_removes_authority_history_and_phase(admin_access): + grant = await admin_access.signed.grant(admin_access.admin, admin_access.target) + command = await command_for(admin_access.target.id, "shadow") + before = await snapshot() + async with get_session_factory()() as session: + await session.begin() + await controller(session, command).transition(command) + # The lifecycle-only NO KEY UPDATE lock still excludes suspension, + # identity unlinking and grant revocation until the caller finishes. + for table, identifier in ( + ("actor_profiles", command.actor_profile_id), + ("actor_identity_links", command.identity_link_id), + ("admin_role_grants", grant), + ): + async with get_session_factory()() as contender: + with pytest.raises(DBAPIError, match="could not obtain lock"): + await contender.execute(text( + f"SELECT id FROM public.{table} WHERE id=:id FOR UPDATE NOWAIT" + ), {"id": identifier}) + await session.rollback() + assert await snapshot() == before + assert (await transition(command)).generation == 1 + + +async def test_no_operator_revoked_operator_and_operation_collision(admin_access): + from app.modules.authorization.runtime import PreparedAuthorizationUnsupported + + command = await command_for(admin_access.target.id, "shadow") + before = await snapshot() + with pytest.raises(PreparedAuthorizationUnsupported): + await transition(command) + assert await snapshot() == before + grant = await admin_access.signed.grant(admin_access.admin, admin_access.target) + receipt = await transition(command) + before = await snapshot() + with pytest.raises(JointLifecycleUnavailable, match="conflicts"): + await transition(command.model_copy(update={"reason": "different request"})) + assert await snapshot() == before + assert await transition(command) == receipt + response = await admin_access.signed.revoke(admin_access.admin, grant) + assert response.status_code == 200, response.text + before = await snapshot() + with pytest.raises(PreparedAuthorizationUnsupported): + await transition(command) + assert await snapshot() == before + + +async def test_transition_replay_does_not_issue_sql_writes(admin_access): + from sqlalchemy import event + + await admin_access.signed.grant(admin_access.admin, admin_access.target) + command = await command_for(admin_access.target.id, "shadow") + async with get_session_factory()() as clock: + now = await clock.scalar(text("SELECT pg_catalog.clock_timestamp()")) + command = command.model_copy(update={"deadline": now + timedelta(seconds=5)}) + original = await transition(command) + async with get_session_factory()() as clock: + now = await clock.scalar(text("SELECT pg_catalog.clock_timestamp()")) + await asyncio.sleep(max(0, (command.deadline - now).total_seconds()) + 0.05) + engine = get_session_factory().kw["bind"].sync_engine + writes = [] + + def observe(_connection, _cursor, statement, _parameters, _context, _many): + if statement.lstrip().split(None, 1)[0].upper() in {"INSERT", "UPDATE", "DELETE"}: + writes.append(statement) + + event.listen(engine, "before_cursor_execute", observe) + try: + assert await transition(command) == original + finally: + event.remove(engine, "before_cursor_execute", observe) + assert writes == [] diff --git a/backend/tests/reviews/lifecycle/transition_support.py b/backend/tests/reviews/lifecycle/transition_support.py new file mode 100644 index 000000000..ba9aaea85 --- /dev/null +++ b/backend/tests/reviews/lifecycle/transition_support.py @@ -0,0 +1,101 @@ +"""Real AUTH/controller composition; no fake allow or SQL activation.""" + +from datetime import timedelta +from uuid import UUID + +from sqlalchemy import select, text + +from app.core.identifiers import new_record_id +from app.core.hashing import canonical_json_hash +from app.db.session import get_session_factory +from app.adapters.auth import lifecycle_transition_authorization +from app.modules.authorization.runtime import ( + ActorKind, ActorStatus, HumanAuthorizationContext, IdentityLinkStatus, +) +from app.modules.reviews.api.lifecycle import JointLifecyclePhase, LifecycleTransitionCommand +from app.modules.reviews.lifecycle.models import JointLifecycleReleaseControl +from app.modules.reviews.lifecycle.service import JointLifecycleController, FIRST_CONTRIBUTION_MANIFEST_DIGEST + + +async def command_for(actor_id, phase, **changes): + async with get_session_factory()() as session: + control = await session.scalar(select(JointLifecycleReleaseControl)) + identity = await session.scalar(text( + "SELECT id FROM public.actor_identity_links WHERE actor_profile_id=:actor" + ), {"actor": actor_id}) + now = await session.scalar(text("SELECT pg_catalog.clock_timestamp()")) + values = dict( + operation_id=new_record_id(), singleton_id=control.id, + actor_profile_id=actor_id, identity_link_id=UUID(str(identity)), + expected_generation=control.generation, current_phase=JointLifecyclePhase(control.phase), + target_phase=JointLifecyclePhase(phase), reviewed_manifest_digest=FIRST_CONTRIBUTION_MANIFEST_DIGEST, + deadline=now + timedelta(minutes=5), reason="Exercise scoped contribution control", + ) + values.update(changes) + return LifecycleTransitionCommand(**values) + + +def controller(session, command): + context = HumanAuthorizationContext( + actor_profile_id=command.actor_profile_id, identity_link_id=command.identity_link_id, + actor_kind=ActorKind.HUMAN, actor_status=ActorStatus.ACTIVE, + identity_link_status=IdentityLinkStatus.ACTIVE, + request_id=new_record_id(), correlation_id=command.operation_id, + ) + return JointLifecycleController(session, authorization=lifecycle_transition_authorization(session, context)) + + +async def transition(command): + async with get_session_factory()() as session, session.begin(): + return await controller(session, command).transition(command) + + +async def snapshot(): + async with get_session_factory()() as session: + return { + "control": (await session.execute(text("SELECT * FROM public.joint_lifecycle_release_control"))).all(), + "history": (await session.execute(text("SELECT * FROM public.joint_lifecycle_transitions ORDER BY generation"))).all(), + "events": (await session.execute(text( + "SELECT * FROM public.audit_events WHERE action_id='review.lifecycle.activation.manage' ORDER BY id" + ))).all(), + } + + +def facts_for(command): + """Complete canonical empty-store facts for direct-SQL substitution proofs.""" + from app.modules.reviews.api.lifecycle import LifecycleTransitionFacts + + return LifecycleTransitionFacts(command=command, observations_digest=canonical_json_hash({ + "singleton_id": str(command.singleton_id), "generation": command.expected_generation, + "phase": command.current_phase.value, "retained_pre_authority_acceptance": False, + "manifest": FIRST_CONTRIBUTION_MANIFEST_DIGEST, + })) + + +async def issue_authority(session, command): + """Real PREP and root locking; caller must attach the atomic transition.""" + owner = controller(session, command) + async with owner._authorization.lock_scope(command) as prepared: + await owner._fence.lock_controller() + return await prepared.consume_new(facts_for(command)) + + +def history_row(command, receipt, *, facts=None, digest=None): + from app.modules.reviews.lifecycle.models import JointLifecycleTransition + + return JointLifecycleTransition( + id=new_record_id(), operation_id=command.operation_id, singleton_id=command.singleton_id, + generation=command.expected_generation + 1, previous_phase=command.current_phase.value, + phase=command.target_phase.value, facts_json=(facts or facts_for(command)).model_dump(mode="json"), + resource_context_digest=digest or receipt.resource_context_digest, + authorization_decision_event_id=receipt.decision_event_id, + ) + + +async def publish_history(session, row): + session.add(row) + await session.flush() + await session.execute(text( + "UPDATE public.joint_lifecycle_release_control " + "SET phase=:phase,generation=:generation,transition_id=:transition WHERE id=:id" + ), {"phase": row.phase, "generation": row.generation, "transition": row.id, "id": row.singleton_id}) diff --git a/backend/tests/tasks/accepted_effects/conftest.py b/backend/tests/tasks/accepted_effects/conftest.py new file mode 100644 index 000000000..fd97385e6 --- /dev/null +++ b/backend/tests/tasks/accepted_effects/conftest.py @@ -0,0 +1,12 @@ +"""Reuse signed authority fixtures only for explicitly marked participant proofs.""" + +from tests.authentication.fixtures import ( + auth_database_env as auth_database_env, + clear_settings_cache as clear_settings_cache, + rsa_signing_material as rsa_signing_material, +) +from tests.authorization.admin_access.fixtures import ( + signed_access as signed_access, + admin_access as admin_access, +) +from tests.reviews.lifecycle.fixtures import live_acceptance_lifecycle as live_acceptance_lifecycle diff --git a/backend/tests/tasks/accepted_effects/test_postgresql.py b/backend/tests/tasks/accepted_effects/test_postgresql.py index 64e74e267..90e019dbe 100644 --- a/backend/tests/tasks/accepted_effects/test_postgresql.py +++ b/backend/tests/tasks/accepted_effects/test_postgresql.py @@ -1,6 +1,7 @@ """Real PostgreSQL TASK state, source, and lock-boundary proof.""" import pytest + from sqlalchemy import text from sqlalchemy.exc import DBAPIError @@ -11,6 +12,8 @@ from .support import accepted_effects_source, participant +pytestmark = pytest.mark.usefixtures("live_acceptance_lifecycle") + @pytest.mark.parametrize("prestate", ("review_pending", "evaluation_pending")) async def test_new_then_exact_replay_preserves_nonterminal_task_facts( @@ -46,12 +49,12 @@ async def test_new_then_exact_replay_preserves_nonterminal_task_facts( async with h.factory() as session, session.begin(): owner = participant(session) prepared = await owner.lock_accepted_effects( - h.effects_request, expected_generation=0 + h.effects_request, expected_generation=2 ) assert prepared.disposition == "new" assert prepared.locked_review_policy_id == h.locked_review_policy_id result = await owner.apply_accepted_effects( - h.effects_request, disposition="new", expected_generation=0 + h.effects_request, disposition="new", expected_generation=2 ) assert result.task_status == "accepted" assert result.assignment_status == "completed" @@ -59,11 +62,11 @@ async def test_new_then_exact_replay_preserves_nonterminal_task_facts( async with h.factory() as session, session.begin(): owner = participant(session) prepared = await owner.lock_accepted_effects( - h.effects_request, expected_generation=0 + h.effects_request, expected_generation=2 ) assert prepared.disposition == "replay" assert await owner.apply_accepted_effects( - h.effects_request, disposition="replay", expected_generation=0 + h.effects_request, disposition="replay", expected_generation=2 ) == result async with h.factory() as session: @@ -108,7 +111,7 @@ async def test_exact_lineage_gates_reject_without_mutation( async with h.factory() as session, session.begin(): with pytest.raises(TaskAcceptedEffectsUnavailable): await participant(session).lock_accepted_effects( - changed, expected_generation=0 + changed, expected_generation=2 ) async with h.factory() as session: states = ( @@ -144,7 +147,7 @@ async def test_mixed_terminal_states_are_neither_new_nor_replay( async with h.factory() as session, session.begin(): with pytest.raises(TaskAcceptedEffectsUnavailable): await participant(session).lock_accepted_effects( - h.effects_request, expected_generation=0 + h.effects_request, expected_generation=2 ) @@ -168,7 +171,7 @@ async def test_assignment_timestamp_preconditions_are_required( async with h.factory() as session, session.begin(): with pytest.raises(TaskAcceptedEffectsUnavailable): await participant(session).lock_accepted_effects( - h.effects_request, expected_generation=0 + h.effects_request, expected_generation=2 ) @@ -180,14 +183,14 @@ async def test_apply_rechecks_acceptance_disposition( owner = participant(session) assert ( await owner.lock_accepted_effects( - h.effects_request, expected_generation=0 + h.effects_request, expected_generation=2 ) ).disposition == "new" with pytest.raises(TaskAcceptedEffectsUnavailable): await owner.apply_accepted_effects( h.effects_request, disposition="replay", - expected_generation=0, + expected_generation=2, ) @@ -200,7 +203,7 @@ async def test_foreign_project_is_rejected_before_the_actual_task_row_is_locked( await first.begin() with pytest.raises(TaskAcceptedEffectsUnavailable): await participant(first).lock_accepted_effects( - foreign, expected_generation=0 + foreign, expected_generation=2 ) async with h.factory() as second, second.begin(): locked = await second.scalar( @@ -229,7 +232,7 @@ async def test_preparation_retains_each_owner_lock_until_caller_rollback( async with h.factory() as first: await first.begin() await participant(first).lock_accepted_effects( - h.effects_request, expected_generation=0 + h.effects_request, expected_generation=2 ) async with h.factory() as second: await second.begin() @@ -266,7 +269,7 @@ async def test_canonical_fence_rejects_missing_nested_and_stale_generation( async with h.factory() as session: with pytest.raises(JointLifecycleUnavailable, match="root transaction"): await participant(session).lock_accepted_effects( - h.effects_request, expected_generation=0 + h.effects_request, expected_generation=2 ) assert not session.in_transaction() @@ -274,7 +277,7 @@ async def test_canonical_fence_rejects_missing_nested_and_stale_generation( async with session.begin_nested(): with pytest.raises(JointLifecycleUnavailable, match="root transaction"): await participant(session).lock_accepted_effects( - h.effects_request, expected_generation=0 + h.effects_request, expected_generation=2 ) async with h.factory() as session, session.begin(): diff --git a/backend/tests/tasks/post_submit_routing/conftest.py b/backend/tests/tasks/post_submit_routing/conftest.py new file mode 100644 index 000000000..fd97385e6 --- /dev/null +++ b/backend/tests/tasks/post_submit_routing/conftest.py @@ -0,0 +1,12 @@ +"""Reuse signed authority fixtures only for explicitly marked participant proofs.""" + +from tests.authentication.fixtures import ( + auth_database_env as auth_database_env, + clear_settings_cache as clear_settings_cache, + rsa_signing_material as rsa_signing_material, +) +from tests.authorization.admin_access.fixtures import ( + signed_access as signed_access, + admin_access as admin_access, +) +from tests.reviews.lifecycle.fixtures import live_acceptance_lifecycle as live_acceptance_lifecycle diff --git a/backend/tests/tasks/post_submit_routing/test_evaluation_currentness.py b/backend/tests/tasks/post_submit_routing/test_evaluation_currentness.py index 35d3e07af..c70922a5e 100644 --- a/backend/tests/tasks/post_submit_routing/test_evaluation_currentness.py +++ b/backend/tests/tasks/post_submit_routing/test_evaluation_currentness.py @@ -91,10 +91,12 @@ async def compete(): assert await _counts(session) == before +@pytest.mark.usefixtures("live_acceptance_lifecycle") async def test_acceptance_blocks_successor_and_retains_exact_replay(tmp_path, isolated_database_env): await _acceptance_wins(tmp_path, isolated_database_env) +@pytest.mark.usefixtures("live_acceptance_lifecycle") async def test_terminal_guard_removal_is_detected(tmp_path, isolated_database_env, monkeypatch): original = TaskEvaluationGuard.lock_evaluation_scope diff --git a/backend/tests/test_behavior_ownership.py b/backend/tests/test_behavior_ownership.py index b3b9483b5..817772e79 100644 --- a/backend/tests/test_behavior_ownership.py +++ b/backend/tests/test_behavior_ownership.py @@ -2303,7 +2303,7 @@ def test_lifecycle_fence_has_exact_ownership() -> None: retained = "backend/app/core/config.py" trusted = _partition([retained]) ownership._validate_additive_partition_transition(_partition(sorted({retained, *expected})), trusted) - for neighbor in ("backend/app/modules/reviews/lifecycle/service.py", "backend/app/modules/reviews/lifecycle/authority.py"): + for neighbor in ("backend/app/modules/reviews/lifecycle/dispatcher.py", "backend/app/modules/reviews/lifecycle/authority.py"): with pytest.raises(ownership.BehaviorOwnershipError, match="untrusted_partition_change"): ownership._validate_additive_partition_transition(_partition(sorted({retained, *expected, neighbor})), trusted) @@ -2432,3 +2432,19 @@ def test_assigned_guide_read_ownership_accepts_only_exact_new_modules(): ownership._validate_additive_partition_transition( _partition(sorted({retained, *expected, neighbor})), trusted ) + + +def test_scoped_lifecycle_controller_has_exact_ownership() -> None: + expected = { + "backend/app/modules/authorization/domain/lifecycle.py", + "backend/app/modules/authorization/lifecycle_authorization.py", + "backend/app/modules/authorization/prepared_lifecycle_replay.py", + "backend/app/modules/reviews/lifecycle/service.py", + } + assert ownership.REV_12A4A_CONTROL_TARGETS == expected + retained = "backend/app/core/config.py" + trusted = _partition([retained]) + ownership._validate_additive_partition_transition(_partition(sorted({retained, *expected})), trusted) + for neighbor in ("backend/app/modules/reviews/lifecycle/fulfillment.py", "backend/app/modules/reviews/lifecycle/dispatcher.py"): + with pytest.raises(ownership.BehaviorOwnershipError, match="untrusted_partition_change"): + ownership._validate_additive_partition_transition(_partition(sorted({retained, *expected, neighbor})), trusted) diff --git a/backend/tests/test_ci_lane_catalogue.py b/backend/tests/test_ci_lane_catalogue.py index 97703dae8..15daa26da 100644 --- a/backend/tests/test_ci_lane_catalogue.py +++ b/backend/tests/test_ci_lane_catalogue.py @@ -202,6 +202,7 @@ def test_measured_hotspots_have_explicit_semantic_owners() -> None: "tests/authorization/submission_history/test_migration.py", "tests/authorization/submission_history/test_absence.py", "tests/authorization/submission_history/test_failures.py", + "tests/reviews/lifecycle/test_participant_control.py", "tests/reviews/decision/test_contracts.py", "tests/reviews/decision/test_storage.py", "tests/reviews/decision/test_migration.py", @@ -340,6 +341,7 @@ def test_measured_hotspots_have_explicit_semantic_owners() -> None: "tests/reviews/packet/test_storage.py", "tests/reviews/lifecycle/test_contracts.py", "tests/reviews/lifecycle/test_storage.py", + "tests/reviews/lifecycle/test_transition_storage.py", "tests/reviews/lifecycle/test_fence.py", "tests/reviews/lifecycle/test_migration.py", "tests/reviews/acceptance/test_contracts.py", diff --git a/backend/tests/test_database_reset.py b/backend/tests/test_database_reset.py index 50f934d48..e6897c2c1 100644 --- a/backend/tests/test_database_reset.py +++ b/backend/tests/test_database_reset.py @@ -29,6 +29,7 @@ def test_reset_batches_all_trigger_commands_in_one_transaction( ) -> None: """Batching changes round trips, not statements or transaction ordering.""" connection = AsyncMock() + connection.fetchrow.return_value = {"id": "retained-singleton", "created_at": "retained-time"} transaction = AsyncMock() connection.transaction = Mock(return_value=transaction) tables = {name: f'"public"."{name}"' for name in RESETTABLE_TEST_TABLES} @@ -47,7 +48,7 @@ async def after_disable() -> None: custody.assert_awaited_once_with(connection, "test-url") schema.assert_awaited_once_with(connection) statements = [call.args[0] for call in connection.execute.await_args_list] - assert len(statements) == 4 + assert len(statements) == 6 assert statements[0].split("; ") == [ f"alter table {tables[name]} disable trigger user" for name in TRUNCATE_GUARDED_TABLES @@ -56,11 +57,16 @@ async def after_disable() -> None: f"truncate table {', '.join(tables.values())} restart identity cascade" ) assert statements[2] == ( + "insert into public.joint_lifecycle_release_control" + "(id,singleton,phase,generation,created_at) values($1,true,'disabled',0,$2)" + ) + assert statements[3] == ( "insert into authority_control" "(id, bootstrap_completed, bootstrap_grant_id, version) " "values (1, false, null, 0)" ) - assert statements[3].split("; ") == [ + assert statements[4] == "SET CONSTRAINTS ALL IMMEDIATE" + assert statements[5].split("; ") == [ f"alter table {tables[name]} enable trigger user" for name in TRUNCATE_GUARDED_TABLES ] diff --git a/backend/tests/test_review_authorization_contracts.py b/backend/tests/test_review_authorization_contracts.py index bd9bd2ca4..633f30f30 100644 --- a/backend/tests/test_review_authorization_contracts.py +++ b/backend/tests/test_review_authorization_contracts.py @@ -114,12 +114,13 @@ def _obligation_values() -> dict[str, object]: } -def test_manifest_exactly_covers_registered_review_actions_and_stays_unavailable(): +def test_manifest_covers_review_actions_with_only_scoped_controller_available(): expected = frozenset(action for action in ActionId if action.value.startswith("review.")) assert frozenset(REVIEW_AUTHORIZATION_CONTRACT_BY_ACTION) == expected assert all( - ACTION_BY_ID[action].availability is ActionAvailability.PLANNED for action in expected + ACTION_BY_ID[action].availability is ActionAvailability.PLANNED + for action in expected - {ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE} ) assert { action @@ -157,7 +158,7 @@ def test_contract_models_exclude_handles_callbacks_bytes_and_unbounded_maps(): def _assert_inert_contract_model(model): - """Allow only bounded scalar, enum, literal, and optional field annotations.""" + """Recursively require closed scalar models, enums and optional fields.""" def assert_allowed(annotation, field_name): origin = get_origin(annotation) @@ -167,6 +168,12 @@ def assert_allowed(annotation, field_name): for member in get_args(annotation): assert_allowed(member, field_name) return + from pydantic import BaseModel + if isinstance(annotation, type) and issubclass(annotation, BaseModel): + assert annotation.model_config.get("extra") == "forbid" + assert annotation.model_config.get("frozen") is True + _assert_inert_contract_model(annotation) + return assert annotation is not PreparedAuthorizationHandle, field_name assert annotation not in (bytes, bytearray), field_name assert annotation in (str, int, bool, datetime, AwareDatetime, UUID, NoneType) or ( @@ -474,46 +481,32 @@ def test_revision_repair_uses_canonical_outcome_direction_and_repairability(): assert rebased.preparation_head_direction is RevisionPreparationDirection.BACKWARD -def test_lifecycle_activation_rejects_same_phase_and_stays_scalar_serializable(): - values = { - "action_id": ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, - "lifecycle_phase": ReviewLifecyclePhase.SHADOW, - "lifecycle_digest": SHA, - "singleton_id": new_record_id(), - "operation_id": new_record_id(), - "expected_generation": 1, - "current_phase": ReviewLifecyclePhase.SHADOW, - "target_phase": ReviewLifecyclePhase.DRAINING, - "adjacent_transition_confirmed": True, - "reviewed_manifest_digest": SHA, - "drain_observations_digest": SHA, - "batch_limit": 100, - "deadline": NOW, - "reason": "reviewed activation transition", - } - ReviewLifecycleActivationContract.model_validate(values) - _assert_inert_contract_model(ReviewLifecycleActivationContract) - genesis = values | { - "expected_generation": 0, - "current_phase": ReviewLifecyclePhase.DISABLED, - "lifecycle_phase": ReviewLifecyclePhase.DISABLED, - "target_phase": ReviewLifecyclePhase.SHADOW, - } - assert ReviewLifecycleActivationContract.model_validate(genesis).expected_generation == 0 - for invalid in ( - values | {"expected_generation": 0}, - genesis | {"lifecycle_phase": ReviewLifecyclePhase.SHADOW}, - values | {"lifecycle_phase": ReviewLifecyclePhase.LIVE}, - genesis | {"expected_generation": True}, - genesis | {"expected_generation": -1}, - genesis | {"expected_generation": 2**63}, +def test_lifecycle_activation_binds_canonical_command_and_observed_facts(): + from app.modules.reviews.api.lifecycle import ( + JointLifecyclePhase, LifecycleTransitionCommand, LifecycleTransitionFacts, + ) + values = dict( + operation_id=new_record_id(), singleton_id=new_record_id(), + actor_profile_id=new_record_id(), identity_link_id=new_record_id(), + expected_generation=0, current_phase=JointLifecyclePhase.DISABLED, + target_phase=JointLifecyclePhase.SHADOW, reviewed_manifest_digest=SHA, + deadline=NOW, reason="reviewed transition", + ) + command = LifecycleTransitionCommand(**values) + facts = LifecycleTransitionFacts(command=command, observations_digest=SHA) + contract = ReviewLifecycleActivationContract(resource_id=command.singleton_id, facts=facts) + assert ReviewLifecycleActivationContract.model_validate_json(contract.model_dump_json()) == contract + with pytest.raises(ValidationError): + ReviewLifecycleActivationContract(resource_id=new_record_id(), facts=facts) + for changes in ( + {"expected_generation": True}, {"expected_generation": -1}, + {"expected_generation": 2**63 - 1}, + {"current_phase": JointLifecyclePhase.LIVE}, + {"target_phase": JointLifecyclePhase.DISABLED}, + {"adjacent_transition_confirmed": True}, ): with pytest.raises(ValidationError): - ReviewLifecycleActivationContract.model_validate(invalid) - with pytest.raises(ValidationError): - ReviewLifecycleActivationContract.model_validate( - values | {"target_phase": ReviewLifecyclePhase.SHADOW} - ) + LifecycleTransitionCommand(**(values | changes)) def test_contract_module_has_no_rev_import_and_workers_carry_no_prepared_handle(): diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index afd0710cd..e52da4396 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -2308,13 +2308,16 @@ them, together with their closed primary-entity pairing and contract tests. REV-12A1 adds `joint_lifecycle_release_control`: one UUIDv7 identity, true singleton key, closed phase, nonnegative generation and database-owned creation time. The -seed is disabled at generation zero. INSERT, UPDATE, DELETE and TRUNCATE are -blocked after seeding; no usable generation or transition authority exists yet. -The REV public fence reads detached facts under a caller-owned root transaction, -acquiring its fixed advisory lock before the singleton row lock. It neither -commits nor grants business authority. Later authorized transitions extend this -same controller. Fulfillment roots, ordinals, cutoffs and phase history remain -unimplemented; they are not fields on an award or a substitute outbox record. +seed is disabled at generation zero. REV-12A4A extends this same row with an +exact transition reference. Each generation increment requires one immutable +`joint_lifecycle_transitions` row and its exact Operator AUTH event in the same +transaction. Database closure rejects missing or substituted custody; controller +identity and creation time remain unchanged. The root fence takes its advisory +lock before the controller row. The transition operation takes AUTH custody +first; the fence itself grants no business authority. New participant effects +require live/nonzero, while terminal replay stays read-only under the current +generation. Fulfillment roots, ordinals and cutoffs remain deferred until +fulfillment activation; conditional awards are separate atomic facts. ### TASK routing request reservation diff --git a/docs/engineering/authorization_activation_custody.md b/docs/engineering/authorization_activation_custody.md index 6220754ac..1c5ea72aa 100644 --- a/docs/engineering/authorization_activation_custody.md +++ b/docs/engineering/authorization_activation_custody.md @@ -72,7 +72,7 @@ packet storage, REV-04A immutable Review source storage, REV-04B FinalAcceptance storage, CON-03C persistence, CON-07 participation and REV-04C hidden shared acceptance/TASK/CON composition are delivered. Hidden routing handlers ARCH-04E1B-B are next. Mandatory exact AUTH receipts, database complete-effect -custody, shared audit/outbox and fulfillment-root custody, and both TASK-before- +custody, shared audit/outbox and the scoped lifecycle manifest, and both TASK-before- CHECKERS currentness races remain prerequisites of activation at ARCH-04E2-B. Runtime owner `WS-XINT-002-07` retains catalogue custody. The only approved @@ -138,10 +138,11 @@ unchanged until each exact XINT-003 activation wave. cardinalities `2/5/3/1/1/5/2` in the table order above. The seven historical REV runtime owner values remain registered for those actions until their exact activation waves replace them. It changes no mapping or availability and -adds no migration. All 19 actions remain planned and unavailable; these AUTH -custodian labels grant no reviewer, Operator, or service authority. The four -approved lifecycle actions remain planned and unavailable. The shared PREP -foundation is already complete; its existence grants no lifecycle authority. +adds no migration. These custodian labels alone grant no authority. Subsequent +REV-12A4A activates only the internal `review.lifecycle.activation.manage` slice +for exact system Operator transitions; the other 18 transferred actions remain +planned and unavailable. Of the four 02C lifecycle registrations, only that +controller action is active. PREP alone grants no lifecycle authority. The completed front-loaded readiness waves are: @@ -305,20 +306,24 @@ behavior stays unavailable until its exact activation is implemented and verified. Each PR uses the normal evidence, review and human merge workflow; planning records do not introduce an additional administrator checkpoint. -### Disabled shared lifecycle foundation - -[REV-12A1](../../.commitrail/initiatives/WS-REV-001/WS-REV-001-12A1.md) provides -immutable disabled generation-zero storage and caller-root transaction locking. -It does not activate `review.lifecycle.activation.manage` or an acceptance writer. -AUTH's scalar activation contract binds its inherited phase to current_phase; -generation zero requires disabled. Those facts do not prove adjacency or grant -authority. Hidden AUTH preparation and nominal caller-session receipt projection -are delivered before CON-07 -flush-only participant proof. Mandatory persisted source/FinalAcceptance custody -accompanies the first genuine allowed consequence at 04E2-B, before production -composition or consumption; no standalone allow is committed. Authorized -REV transitions and real obligation/cutoff proof remain required before live -shared acceptance, without requiring live human-review queues first. +### Scoped shared lifecycle controller + +[REV-12A4A](../../.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md) +extends REV-12A1's same singleton and root-transaction fence. The internal +`review.lifecycle.activation.manage` action requires live system Operator +authority and binds the exact command, reviewed manifest and persisted +observations. Controller, immutable transition history and actual AUTH evidence +commit together. Unchanged operation replay requires fresh authority and does +not write another event. New participant effects require a live nonzero +generation; verified terminal replay uses the current generation in stopped +phases without rewriting contribution or award facts. + +This is not acceptance authority. The initial manifest refuses retained +pre-authority FinalAcceptance rows before entering live; ARCH-04E2-B must add +mandatory source receipts and complete atomic consequences before production +consumption. Routing, human review and payment delivery remain unavailable. +Obligation roots, ordinals and cutoff/drain machinery are deferred until +fulfillment activation; conditional awards remain atomic in the first path. ARCH-04E1B-A adds immutable TASK routing-request and future source-ID reservation, with caller-owned rollback and current-completion replay checks. It does not diff --git a/docs/engineering/review_authorization_action_custody.md b/docs/engineering/review_authorization_action_custody.md index 851299689..83550c5b2 100644 --- a/docs/engineering/review_authorization_action_custody.md +++ b/docs/engineering/review_authorization_action_custody.md @@ -113,7 +113,7 @@ actions remain reference-only external handoffs. | `review.revision_context.repair` | 02C unavailable registration plus merged REV-11B covered-project repair command | | `review.revision_obligation.close` | 02C unavailable registration plus merged REV-11B obligation-close command | | `review.revision_context.legacy_close` | 02C unavailable registration plus merged REV-11D legacy-close command | -| `review.lifecycle.activation.manage` | Existing 02C/02D registration/resource; scoped shared-acceptance controller transition/recovery and real writer/drain proof for the early manifest; REV-12A4/13C later expand the same controller/action for human lifecycle | +| `review.lifecycle.activation.manage` | Active internally through REV-12A4A: exact Operator transition/history custody for the bounded atomic-participant manifest; production acceptance and later human/fulfillment scopes require successor proofs | | `review.reconcile.run` | merged REV-11C invalidation and general reconciliation commands | | `review.artifact_reference.reconcile` | merged REV-12P2 artifact-reference command and typed ART repair port | | `review.projection.rebuild` | merged REV-12P2 derived-projection command | @@ -129,22 +129,27 @@ pulls the existing XINT-003-08B controller-action slice forward after the early REV-12A foundation and exact shared writer/observation manifest. It does not activate the other 08B actions or human review. Reuse the registered Operator permission and `ReviewLifecycleActivationContract`; prove exact generation, -legal phase adjacency, manifest/observation binding, real cutoff races and -safe stop before admitting work. Unsupported resources/transitions deny. +legal phase adjacency, manifest/observation binding and atomic writer/stop races. +The scoped implementation is REV-12A4A; fulfillment cutoff machinery remains +deferred until fulfillment is enabled. Unsupported resources/transitions deny. Later REV-12A4/13C work extends the same action only after successor parity and activation evidence, not by handler-only widening or an extra controller. -### Disabled shared lifecycle foundation - -[REV-12A1](../../.commitrail/initiatives/WS-REV-001/WS-REV-001-12A1.md) provides -immutable disabled generation-zero storage and caller-root transaction locking. -It does not activate `review.lifecycle.activation.manage` or an acceptance writer. -AUTH's scalar activation contract binds its inherited phase to current_phase; -generation zero requires disabled. Those facts do not prove adjacency or grant -authority. Hidden AUTH preparation and nominal caller-session receipt projection -are delivered before CON-07 -flush-only participant proof. Mandatory persisted source/FinalAcceptance custody -accompanies the first genuine allowed consequence at 04E2-B, before production -composition or consumption; no standalone allow is committed. Authorized -REV transitions and real obligation/cutoff proof remain required before live -shared acceptance, without requiring live human-review queues first. +### Scoped shared lifecycle controller + +[REV-12A4A](../../.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md) +extends REV-12A1's same singleton and root-transaction fence. The internal +`review.lifecycle.activation.manage` action requires live system Operator +authority and binds the exact command, reviewed manifest and persisted +observations. Controller, immutable transition history and actual AUTH evidence +commit together. Unchanged operation replay requires fresh authority and does +not write another event. New participant effects require a live nonzero +generation; verified terminal replay uses the current generation in stopped +phases without rewriting contribution or award facts. + +This is not acceptance authority. The initial manifest refuses retained +pre-authority FinalAcceptance rows before entering live; ARCH-04E2-B must add +mandatory source receipts and complete atomic consequences before production +consumption. Routing, human review and payment delivery remain unavailable. +Obligation roots, ordinals and cutoff/drain machinery are deferred until +fulfillment activation; conditional awards remain atomic in the first path. diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index 9d67c7825..831336887 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -60,7 +60,12 @@ defines one FinalAcceptance/submitter-contribution operation with two triggers, not a separate automated acceptance system. The human branch retains canonical `allow_review`. Shared REV source persistence and hidden CON submitter participation are delivered before ARCH-04E false/pass integration; human -queues/leases are not its prerequisites. +queues/leases are not its prerequisites. REV-12A4A supplies the existing +controller’s internal Operator-authorized phase transitions and current-generation +participant gates. The initial manifest refuses retained pre-authority acceptance +facts before entering live. It enables neither routing nor payment delivery; +conditional award facts remain atomic, while obligation/root/cutoff machinery +belongs to later fulfillment activation. The setting is configurable through the existing authorized policy writer; creation defaults true and omitted replacements inherit the predecessor. @@ -222,10 +227,10 @@ cannot be reused as post-submission review-gate evidence. See the | Contributor artifact preparation | **Hidden and proven** | One outer ZIP; bounded scratch inspection; canonical manifest; shared evaluation-content capacity and exact locked-policy checks before attempts or durable intent; platform and project prechecks; unchanged-work rejection; durable put intent; verification; capacity-charged ready admission; hidden final handoff validates the exact activated historical guide through owner ports | Complete the later public admission-only cutover | | Pre-submission intake checking | **Hidden with approved-guide lineage** | Separate versioned pre-submission catalogue, locked effective-plan compilation, platform/project checks during continuous preparation, blocking feedback before Submission creation, and one internal phase command covering execution/replay with the JSON precheck removed; ARCH-03D connects approved-guide lineage through the final durable handoff | Complete the canonical public cutover after evaluation/remediation prerequisites; passing intake must never substitute for post-submit evaluation | | Immutable Submission creation | **Hidden foundation; public packet creation retired** | Contributor preparation authority; durable pre-submit reservation and exact completed-evidence recovery without rerunning checks; atomic admission consumption; TASK-owned admission-backed creation with exact assignment ContributionPolicyVersion, locked policy lineage and service/database checked-packet custody; fixed-service artifact binding; replay/concurrency/rollback proof | Finish downstream evaluation and the canonical public integration. The retained submission-list GET is not a usable creation POST | -| Post-submission evaluation and `allow_review` | **Hidden exact preparation; routing planned** | One canonical CHECKER post-submit catalogue/compiler used by existing consumers, internal phase service with exact fixed-service post-submit authority, hidden value contracts and structural-handler conformance; ARCH-04B/04B2 input and output custody; ARCH-04C durable execution and current-result custody; ARCH-04D1 canonical ART material custody; ARCH-04D2 exact phase authority and receipts; ARCH-04E1A immutable route-neutral source table and detached facts; AUTH-19A inert source/receipt commitments and planned router identity; ARCH-04E1B-A immutable request/source-identity reservation and exact replay; ARCH-04E2-A strict preparation; REV-04C hidden FinalAcceptance/TASK/CON participant; ARCH-04E1B-B1 required TASK reservation/current-read guard, ordered admission INSERTs and mechanical race controls; ARCH-04E1B-B2 exact source preparation without publication; 04E1B-B3 retains inspected ZIP metadata and B4 binds checked packet text for consumption; B5 rejects unrepresentable evaluation content before durable admission; B6 commits atomic Submission/dispatch with exact receipts and select-only replay | B7 adds hidden request delivery; completion routing 04E1B-B is next. True routing remains independent of shared acceptance. False activation at 04E2-B requires the same strict participant input to carry the mandatory exact AUTH decision-event receipt, database-enforced FinalAcceptance/TASK/CON completeness, shared audit/outbox and scoped lifecycle/fulfillment custody. 04E1B-B1 supplies reservation/current-read and admission INSERT lock ordering; the remaining handlers must prove complete authorized routing/currentness race orders. No handler, current pointer, route, currentness guarantee or acceptance effect is live; 04F remediation still precedes false guide activation | +| Post-submission evaluation and `allow_review` | **Hidden exact preparation; routing planned** | One canonical CHECKER post-submit catalogue/compiler used by existing consumers, internal phase service with exact fixed-service post-submit authority, hidden value contracts and structural-handler conformance; ARCH-04B/04B2 input and output custody; ARCH-04C durable execution and current-result custody; ARCH-04D1 canonical ART material custody; ARCH-04D2 exact phase authority and receipts; ARCH-04E1A immutable route-neutral source table and detached facts; AUTH-19A inert source/receipt commitments and planned router identity; ARCH-04E1B-A immutable request/source-identity reservation and exact replay; ARCH-04E2-A strict preparation; REV-04C hidden FinalAcceptance/TASK/CON participant; ARCH-04E1B-B1 required TASK reservation/current-read guard, ordered admission INSERTs and mechanical race controls; ARCH-04E1B-B2 exact source preparation without publication; 04E1B-B3 retains inspected ZIP metadata and B4 binds checked packet text for consumption; B5 rejects unrepresentable evaluation content before durable admission; B6 commits atomic Submission/dispatch with exact receipts and select-only replay | B7 adds hidden request delivery; completion routing 04E1B-B is next. True routing remains independent of shared acceptance. False activation at 04E2-B requires the same strict participant input to carry the mandatory exact AUTH decision-event receipt, database-enforced FinalAcceptance/TASK/CON completeness, shared audit/outbox and the reviewed scoped lifecycle manifest. 04E1B-B1 supplies reservation/current-read and admission INSERT lock ordering; the remaining handlers must prove complete authorized routing/currentness race orders. No handler, current pointer, route, currentness guarantee or acceptance effect is live; 04F remediation still precedes false guide activation | | Review queue and lease | **Hidden persistence foundation** | Queue/admission idempotency and ReviewLease/preference persistence; complete unavailable REV action/principal catalogue and typed AUTH contracts; ART-07A1 metadata-only packet contract and REV-03B immutable normalized packet persistence with live ingest custody; REV-04A Review/finding/resolution and completed request storage | Future resolver proof; canonical admission from `allow_review`; claim/lease/packet authority; lease copies the Submission-stamped policy version with no CON lookup | | Review decision and revision | **Hidden acceptance core; runtime planned** | Review/revision policy identities and mutation authority, with `requires_second_review` fixed false through typed and database boundaries; REV-04A immutable Review storage; REV-04B source storage; REV-04C hidden source-neutral FinalAcceptance/TASK/CON participant; approved same-task revision-rebase semantics | Add the mandatory exact AUTH receipt to the same participant input and complete database/audit/outbox closure before either trigger consumes it; then authorize atomic human decisions, revision preparation, replay and recovery | -| Contribution and compensation truth | **Hidden shared participant plus public policy administration** | ContributionPolicyVersion persistence; lifecycle-audit participant; adapter bindings; public Finance policy administration; REV-04B shared acceptance source storage; CON-03C immutable ContributionRecord/CompensationAward storage; obsolete guide-keyed payment storage and task-local payment fields removed; CON-07 source-neutral submitter participant and complete award sets; REV-04C composes it with FinalAcceptance/TASK effects in the caller transaction | Add authority/evidence and complete-set database closure before production consumption. Only actual Reviews create reviewer records. Add audit/outbox and fulfillment-root/ordinal custody before activation; no public recognition or fulfillment route is live | +| Contribution and compensation truth | **Hidden shared participant plus public policy administration** | ContributionPolicyVersion persistence; lifecycle-audit participant; adapter bindings; public Finance policy administration; REV-04B shared acceptance source storage; CON-03C immutable ContributionRecord/CompensationAward storage; obsolete guide-keyed payment storage and task-local payment fields removed; CON-07 source-neutral submitter participant and complete award sets; REV-04C composes it with FinalAcceptance/TASK effects in the caller transaction | Add authority/evidence and complete-set database closure before production consumption. Only actual Reviews create reviewer records. REV-12A4A adds scoped lifecycle control; add audit/outbox and exact acceptance authority before production consumption. Obligation/root/ordinal custody remains deferred until fulfillment activation; no public recognition or fulfillment route is live | | Fulfillment, reconciliation, and audit | **Planned** | Shared audit foundations, provider-neutral adapter convention, AUTH-OUTBOX-02 live dispatcher authority, retained phase audit decisions, Celery delivery/recovery scans and CON-02B custody | Feature-specific handlers and authority, conditional award fulfillment, callbacks, idempotent recovery, reconciliation, bounded operational reads, and release controls | | Runtime diagnostics | **Implemented foundation** | Closed structured logging, explicit API/Celery tracing, bounded metrics, safe correlation and optional typed OTLP export | Restrict broker publishers; configure and secure collector/log access, egress, encryption and finite deletion; prove diagnostics during the release drill; no deployed monitoring claim | | Frontend and pilot | **Local runtime foundation; product pilot planned** | Checkout-isolated Docker Compose API, prefork Celery process, beat, PostgreSQL, Redis and MinIO stack with local Flow-HMAC identities, existing authority bootstrap/grants, configurable loopback ports and project-scoped state; React + Vite + TypeScript stack decision | Complete provider-backed guide compilation and activated-guide scoped task-denial proof on the base stack, implement only stable backed frontend surfaces, run the real internal pilot, repair findings, and complete release drills; Docker Desktop/macOS runtime proof remains | @@ -747,7 +752,8 @@ Remaining integration selected false path first: delivered shared participant -> handler -> mandatory same-input AUTH receipt + DB complete-set closure -> TASK-before-CHECKERS race proof + shared audit/outbox - -> fulfillment-root ordinal custody and authorized lifecycle activation + -> exact authorized production manifest on delivered REV-12A4A controller + (conditional awards atomic; payment delivery and obligation roots deferred) -> 04F hidden remediation + authorized recovery proof -> 04E3 live composition and false-policy readiness -> public initial/checker-remediation intake and immutable admitted Submission cutover @@ -801,10 +807,13 @@ v0.1 is not ready until all of the following are true: - Before either route is published, the existing TASK source table gains mandatory exact routing and owner-receipt custody and rejects retained pre-authority rows; no parallel manifest or permissive backfill is introduced. -- Before live shared-acceptance route activation, prove actual fulfillment-root - and immutable ordinal custody plus authorized lifecycle transitions and drain - against the real writer/cutoff. Disabled REV-12A1 locking alone does not meet - this gate; no fabricated observation or permissive generation can replace it. +- REV-12A4A supplies authorized lifecycle transitions and phase-gated atomic + participants. Before production shared acceptance, extend its reviewed manifest + with genuine source-receipt custody and complete authorized consequences. + Conditional awards remain atomic. Fulfillment obligation/root/ordinal storage + and real cutoff/drain proof are required when payment fulfillment is enabled, + not before the first contribution path. No fabricated observation or + permissive generation can replace a supported manifest. - A reviewer can claim only that admitted version, access only its bounded packet, and record one immutable final decision. - `needs_revision` safely continues or rebases the same assignment while diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index f72358c04..c8de5d4d1 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -438,6 +438,15 @@ evaluator. Reads continue through request-scoped authorization. Mutations and service commands later use the existing opaque, process-local, transaction- bound `PreparedAuthorizationHandle`; REV locks and composes canonical facts, while the exact activation wave installs the corresponding AUTH evaluator. + +REV-12A4A now activates the internal `review.lifecycle.activation.manage` slice +with a strict nested command/observations resource owned by AUTH's lifecycle +contract. It replaces the earlier inert scalar shape. Live system Operator +PREP precedes the REV root fence; consumption binds the singleton, operation, +actor/link, generation, phases, deadline, manifest and observations. Controller, +immutable history and exact AUTH evidence commit together. Same-command replay +requires fresh same-actor authority and writes nothing. Other review actions +remain unavailable; this action grants neither acceptance nor payment authority. XINT-002 packet, evidence-binding, and revision-submission actions are external handoff references only and are not redefined by this manifest. diff --git a/docs/spec_contribution_compensation.md b/docs/spec_contribution_compensation.md index 8cf813eb4..7e92e7402 100644 --- a/docs/spec_contribution_compensation.md +++ b/docs/spec_contribution_compensation.md @@ -284,9 +284,10 @@ accepted/completed effects in one caller-owned transaction for either source. No production recognition route, authorized acceptance operation, reviewer participant or fulfillment consumer is registered. Before activation, originating Review/FinalAcceptance authority, mandatory source receipts and -fulfillment-root ordinal custody must be installed; retained pre-authority +complete atomic consequences must be installed; retained pre-authority sources and dependent contribution/award rows must cause refusal unchanged, -never receipt backfill or deletion. +never receipt backfill or deletion. REV-12A4A supplies scoped lifecycle control; +fulfillment-root ordinal custody is required only before fulfillment activation. Canonical fields: @@ -1057,8 +1058,8 @@ hidden FinalAcceptance/TASK/CON composition, while mandatory persisted receipt custody remains required before production composition or consumption. Actual CON root storage and ordinal allocation remain required before either trigger creates -fulfillment obligations; neither awards nor generic outbox rows substitute. Later REV-12A drain/operator -work extends this same controller; it is not a prerequisite on live human +fulfillment obligations; neither awards nor generic outbox rows substitute. REV-12A4A adds internal Operator transitions for the atomic-participant manifest. +Later fulfillment drain work extends this same controller; it is not a prerequisite on live human review for the false branch. Every creation, requeue, successor, retry-root, and repair path that can admit a diff --git a/docs/spec_review_lifecycle.md b/docs/spec_review_lifecycle.md index d6c1077a8..8090b9a52 100644 --- a/docs/spec_review_lifecycle.md +++ b/docs/spec_review_lifecycle.md @@ -708,8 +708,8 @@ Extract foundations from existing owner work, not a new initiative: accepted/completed effects for either source. These isolated controls prove mechanical transaction behavior without claiming acceptance authority or fabricating allows. - Authorized scoped transition/drain composition extends this fence before - the first contribution path. Applicable award facts remain atomic. Actual + REV-12A4A supplies authorized transitions and atomic writer/stop composition + on this fence before the first contribution path. Applicable award facts remain atomic. Actual CON fulfillment roots own immutable ordinal allocation before fulfillment admission is enabled; no award or outbox row substitutes for a root. Payment obligation storage and its root/cutoff proof are not prerequisites of a @@ -1136,14 +1136,15 @@ as product data; PostgreSQL holds the temporary snapshot (including xmin) until transaction end. Callers keep transactions short, retain commit/rollback ownership and roll back after acquisition failure. PostgreSQL two-phase prepare is unsupported. Savepoints established after acquisition cannot release the earlier root locks. Its -public scalar facts are not authority. AUTH retains an independent scalar phase -projection; lifecycle_phase must equal current_phase, and generation zero must -be disabled. The four phases are disabled, shadow, live and draining. Detailed +public scalar facts are not authority. The current AUTH activation resource +binds a strict transition command and observations; generation zero must be +disabled. Other inert REV resources retain their own phase projections. The four phases are disabled, shadow, live and draining. Detailed shutdown stages in historical plans are not additional current persisted phases. -No transition, history, ordinal, cutoff, consumer or usable generation is -implemented by this foundation. The later authorized transition operation owns -legal adjacency and extends the same controller. Actual CON root storage and +REV-12A1 alone implemented no transition or usable generation. REV-12A4A now +extends it with Operator-authorized legal adjacency, immutable history, exact +AUTH/controller closure and current-generation read-only replay. It does not +activate an acceptance consumer or fulfillment. Actual CON root storage and real authorized writer-versus-cutoff proof are required before a successor manifest activates fulfillment admission, dispatch or callbacks. They do not block the first contribution manifest with fulfillment disabled. Lock mechanics From 5852c79236f3838f7f154d22d73ee7fee9a7600b Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Thu, 8 Oct 2026 17:52:28 +0100 Subject: [PATCH 03/17] docs(reviews): record controller scope and retained proof --- .../WS-REV-001/WS-REV-001-12A4A.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md b/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md index bb5f52b3c..928f11599 100644 --- a/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md +++ b/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md @@ -7,7 +7,7 @@ generation-bound transition operation for the first contribution path. This does not enable production routing, human review or payment delivery. -## Intent and human decision +## Intent The first contributor milestone needs one usable lifecycle generation before false-policy acceptance can be composed. The canonical lifecycle specification @@ -133,3 +133,20 @@ Review whether this stays the same controller/action, keeps contribution/award facts atomic, and cannot enable payment or human runtime. Controller permission is not routing or acceptance authority. Scope and phase history must be verifiable without relying on receipt-shaped caller values or misleading drain counts. + +## Scope and retained proof + +This L1 change exceeds the preferred 500-line guideline because the same +transition must bind AUTH, REV storage and all three existing participant gates +atomically. Splitting those gates would temporarily preserve permissive +acceptance writers. Most additional paths are existing consumer fixtures, +required ownership/lane inventories and current documentation; no independent +product feature is included. + +The existing `test_production_registry_claims_only_registered_invalidation` +(`tests/outbox/test_worker_postgresql.py`) remains the exact production-registry +proof; `test_observed_task_inventory_matches_every_registered_workstream_task` +retains worker inventory coverage. Existing review catalogue tests now allow +only the scoped controller action and keep other review actions planned. These +composition checks are not represented as runtime drain observations. Full-suite +execution remains required; partial local batches are not completeness evidence. From 792f5292d37b37846d191024bd59acbb57641b88 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Thu, 8 Oct 2026 18:24:52 +0100 Subject: [PATCH 04/17] fix(reviews): close lifecycle authority and regression proof gaps --- .ci/auth-boundaries/TEST_STRUCTURE_DEBT.json | 32 ++++----- .../WS-REV-001/WS-REV-001-12A4A.md | 23 ++++--- backend/alembic/env.py | 4 +- .../versions/0027_lifecycle_transitions.py | 10 ++- backend/app/modules/authorization/kernel.py | 3 +- .../app/modules/reviews/lifecycle/models.py | 8 ++- .../app/modules/reviews/lifecycle/service.py | 7 +- .../setup_finalization/test_catalogue.py | 1 + backend/tests/authorization/test_catalogue.py | 4 +- backend/tests/conftest.py | 2 +- .../lifecycle/test_authority_binding.py | 12 +++- .../tests/reviews/lifecycle/test_migration.py | 2 +- .../lifecycle/test_participant_control.py | 55 +++++++++++++++ .../lifecycle/test_transition_storage.py | 69 +++++++++++++++++++ .../reviews/lifecycle/test_transitions.py | 33 +++++++++ .../reviews/lifecycle/transition_support.py | 2 +- backend/tests/test_alembic.py | 2 +- .../review_authorization_action_custody.md | 4 +- docs/roadmap_status.md | 3 + docs/roles_permissions.md | 6 +- 20 files changed, 234 insertions(+), 48 deletions(-) diff --git a/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json b/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json index c78659678..de1c5e0e1 100644 --- a/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json +++ b/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json @@ -2,11 +2,11 @@ "entries": [ { "capability": "oversized_authorization_structure", - "content_sha256": "67cde79d416c7999c859e183516f4a4e9ed17201bcd47ff3767f8ee0997a1835", - "end_line": 1347, + "content_sha256": "9af1e5ce3199398267d171f4b0dcb7760af7ca5e170a6ac53adfaf9b69d0a9a0", + "end_line": 1354, "hard_limit": 1200, "kind": "production_file", - "observed_lines": 1347, + "observed_lines": 1354, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -26,11 +26,11 @@ }, { "capability": "oversized_authorization_structure", - "content_sha256": "2f9f25aad00519e3715ac3a12144aff6d1d08da1ab1ff7f6da20700286adca02", - "end_line": 1243, + "content_sha256": "5f0cc6950755629f053c545cfafcc803ec80e9ee9f74ac48d6c87f644756f5ab", + "end_line": 1245, "hard_limit": 1200, "kind": "production_file", - "observed_lines": 1243, + "observed_lines": 1245, "path": "backend/app/modules/authorization/runtime.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -38,39 +38,39 @@ }, { "capability": "oversized_authorization_structure", - "content_sha256": "3a02a5b9ede4304b1fd126a51794324d78028448a0f7d1af860f1c8eed7eeec4", - "end_line": 526, + "content_sha256": "f93bf15bed5d65558494d2cf42dafa1295a4c2c489fb38441f8fc9b70cdb5380", + "end_line": 533, "hard_limit": 100, "kind": "production_function", - "observed_lines": 155, + "observed_lines": 158, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": "AuthorizationService._prepare_prelocked", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 372 + "start_line": 376 }, { "capability": "oversized_authorization_structure", "content_sha256": "ca3ded1b5b0ffa0dfa405485bb880ae03de6f7ed643d05aa4325d87533dcbd33", - "end_line": 898, + "end_line": 905, "hard_limit": 100, "kind": "production_function", "observed_lines": 115, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": "AuthorizationService._require_prelocked", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 784 + "start_line": 791 }, { "capability": "oversized_authorization_structure", - "content_sha256": "e70030fa8e350cfb2d5a98205612cc4c7364e13e2fc3ac5fbb3c065550249fd7", - "end_line": 938, + "content_sha256": "923219f4cb69a173b533a46ee66a3abe1a3c0d849b360b0d2d164f6bdc343bf3", + "end_line": 945, "hard_limit": 100, "kind": "production_function", - "observed_lines": 118, + "observed_lines": 119, "path": "backend/app/modules/authorization/prepared.py", "qualified_symbol": "PreparedAuthorizationService._binding", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 821 + "start_line": 827 }, { "capability": "oversized_authorization_structure", diff --git a/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md b/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md index 928f11599..1c7846c3b 100644 --- a/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md +++ b/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md @@ -1,7 +1,7 @@ # REV-12A4A — Scoped first-contribution lifecycle control - Initiative: `WS-REV-001` -- Durable disposition: `Planned` +- Durable disposition: `Complete` - Risk: L1 — Operator authority, lifecycle concurrency and immutable evidence. - Intended merge outcome: the existing lifecycle controller has an authorized, generation-bound transition operation for the first contribution path. This @@ -24,7 +24,7 @@ availability switch, bootstrap SQL, provider, generic drain framework or compatibility path. Exact routing AUTH receipts and complete atomic consequences remain the subsequent ARCH-04E2-B prerequisite before a production consumer. -## Plan +## Bounded change 1. Reconcile canonical lifecycle/compensation specifications and the current first-layer sequence with the selected scope. Preserve the four phases and @@ -82,14 +82,14 @@ remain the subsequent ARCH-04E2-B prerequisite before a production consumer. resource/digest/audit contracts, `backend/app/modules/audit/schemas.py` for the exact UUID lifecycle target kind, and focused lifecycle resource/PREP adapter modules. Existing composition roots only for explicit controller construction. -- One successor Alembic migration, model registration, exact schema inventory; +- One successor Alembic migration, current-head preflight and graph inventory, model registration, exact schema inventory; focused REV lifecycle and AUTH tests plus affected acceptance/participation tests and shared fixtures. Existing production inventory tests prove absent surfaces; do not create a parallel registry. -- Exact behavior ownership and lane inventory files/tests; no CI gate changes. +- Exact behavior ownership and lane inventory files/tests; refresh existing structural-debt fingerprints without adding debt or relaxing limits. No CI gate changes. - This record; REV/AUTH/CON/ARCH/POL current overviews, ARCH/AUTH/POL current plans/maps, Commitrail index; canonical review/compensation specifications, - relevant authorization custody docs, canonical authorization/data-model specifications, README and roadmap. Local roadmap exports + relevant authorization custody and roles/permissions docs, canonical authorization/data-model specifications, README and roadmap. Local roadmap exports only if present. Historical completed records remain historical. ## Prohibited @@ -100,7 +100,7 @@ human queue/decision/revision runtime; new role/action/controller; invented Revi retained-data deletion or rewriting; default permissive generation; generic service locator; timeout/completeness/boundary weakening; unrelated cleanup. -## Acceptance and verification +## Acceptance criteria - Real PostgreSQL and real AUTH prove the allowed edges, exact grant provenance, revocation, wrong actor/scope, stale generation, expired deadline, request and @@ -125,7 +125,7 @@ service locator; timeout/completeness/boundary weakening; unrelated cleanup. - Run focused PostgreSQL suites, lint, module boundaries, ownership/inventory, links, stale wording, Commitrail and diff checks, then exact-head hosted CI. -## Review and human focus +## Risk and review routing Plan review before implementation; architecture/reuse, security, QA/test delta, documentation/product operations and CI integrity review the clean candidate. @@ -134,7 +134,7 @@ facts atomic, and cannot enable payment or human runtime. Controller permission is not routing or acceptance authority. Scope and phase history must be verifiable without relying on receipt-shaped caller values or misleading drain counts. -## Scope and retained proof +## Evidence This L1 change exceeds the preferred 500-line guideline because the same transition must bind AUTH, REV storage and all three existing participant gates @@ -150,3 +150,10 @@ retains worker inventory coverage. Existing review catalogue tests now allow only the scoped controller action and keep other review actions planned. These composition checks are not represented as runtime drain observations. Full-suite execution remains required; partial local batches are not completeness evidence. + +Review repairs retain the canonical append-only audit guard: direct PostgreSQL +receipt-edit and unrelated-event transformation tests reject through that owner, +so no duplicate immutability trigger is added. New transition closure independently +checks active actor, exact link and Operator grant. Owner-specific phase probes +avoid sibling guards masking each other. Reset proof begins at authorized LIVE +and preserves singleton identity/time while restoring only the isolated test DB. diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 58f65e70d..49461dead 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -21,7 +21,7 @@ target_metadata = Base.metadata _BASELINE_REVISION = "0001_uuid7_v01" -_CURRENT_HEAD_REVISION = "0026_task_guide_read" +_CURRENT_HEAD_REVISION = "0027_lifecycle_transitions" _RECREATE_GUIDANCE = ( "Workstream v0.1 requires a fresh database; recreate this database before " "running the 0001_uuid7_v01 migration" @@ -52,7 +52,7 @@ def do_run_migrations(connection: Connection) -> None: .scalars() .all() ) - if revisions not in ((), (_BASELINE_REVISION,), ("0002_task_queue_authority",), ("0003_task_read_authority",), ("0004_task_context_authority",), ("0005_task_evidence_authority",), ("0006_history_read_authority",), ("0007_checker_output_custody",), ("0008_checker_execution",), ("0009_checker_material_lineage",), ("0010_post_submit_authority",), ("0011_task_routing_source",), ("0012_review_packet",), ("0013_review_source",), ("0014_final_acceptance",), ("0015_contribution_awards",), ("0016_review_lifecycle_fence",), ("0017_acceptance_source_contracts",), ("0018_task_routing_request",), ("0019_submitter_awards",), ("0020_review_admission_lock_order",), ("0021_submission_manifest",), ("0022_submission_packet_custody",), ("0023_remove_task_payment_policy",), ("0024_require_second_review_false",), ("0025_submission_dispatch",), (_CURRENT_HEAD_REVISION,)): + if revisions not in ((), (_BASELINE_REVISION,), ("0002_task_queue_authority",), ("0003_task_read_authority",), ("0004_task_context_authority",), ("0005_task_evidence_authority",), ("0006_history_read_authority",), ("0007_checker_output_custody",), ("0008_checker_execution",), ("0009_checker_material_lineage",), ("0010_post_submit_authority",), ("0011_task_routing_source",), ("0012_review_packet",), ("0013_review_source",), ("0014_final_acceptance",), ("0015_contribution_awards",), ("0016_review_lifecycle_fence",), ("0017_acceptance_source_contracts",), ("0018_task_routing_request",), ("0019_submitter_awards",), ("0020_review_admission_lock_order",), ("0021_submission_manifest",), ("0022_submission_packet_custody",), ("0023_remove_task_payment_policy",), ("0024_require_second_review_false",), ("0025_submission_dispatch",), ("0026_task_guide_read",), (_CURRENT_HEAD_REVISION,)): raise RuntimeError(_RECREATE_GUIDANCE) # The read-only preflight autobegins a SQLAlchemy transaction. End that # transaction before Alembic establishes the migration transaction; diff --git a/backend/alembic/versions/0027_lifecycle_transitions.py b/backend/alembic/versions/0027_lifecycle_transitions.py index 236989368..7ca58b922 100644 --- a/backend/alembic/versions/0027_lifecycle_transitions.py +++ b/backend/alembic/versions/0027_lifecycle_transitions.py @@ -25,6 +25,10 @@ def upgrade(): sa.Column("resource_context_digest", sa.String(71), nullable=False), sa.Column("authorization_decision_event_id", sa.Uuid(), sa.ForeignKey("public.audit_events.id"), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("clock_timestamp()"), nullable=False), + sa.CheckConstraint( + "(get_byte(uuid_send(id), 6) >> 4) = 7 and (get_byte(uuid_send(id), 8) & 192) = 128", + name="id_uuid7", + ), sa.UniqueConstraint("operation_id"), sa.UniqueConstraint("singleton_id", "generation"), sa.UniqueConstraint("authorization_decision_event_id"), @@ -140,10 +144,10 @@ def _functions(): JOIN public.actor_identity_links l ON l.actor_profile_id=p.id WHERE a.id=t.authorization_decision_event_id AND a.event_domain='authority' AND a.event_type='SensitiveAuthorizationAllowed' - AND a.actor_ref_kind='actor_profile' AND p.actor_kind='human' + AND a.actor_ref_kind='actor_profile' AND p.actor_kind='human' AND p.status='active' AND p.id::text=t.facts_json->'command'->>'actor_profile_id' - AND l.id::text=t.facts_json->'command'->>'identity_link_id' - AND g.target_actor_profile_id=p.id AND g.role='operator' + AND l.id::text=t.facts_json->'command'->>'identity_link_id' AND l.status='active' + AND g.target_actor_profile_id=p.id AND g.role='operator' AND g.status='active' AND g.scope_type='system' AND g.scope_project_id IS NULL AND a.action_id='review.lifecycle.activation.manage' AND a.permission_id='operations.reconcile.run' AND a.request_id IS NOT NULL AND a.correlation_id=t.operation_id diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index f7b50cfbb..c8d7e3236 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -450,7 +450,8 @@ async def _prepare_prelocked( else: locked = await self._admin.lock_request_actor( context.identity_link_id, context.actor_profile_id, - preserve_foreign_key_reads=(action_id is ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE), + **({"preserve_foreign_key_reads": True} + if action_id is ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE else {}), ) context = self._locked_human_context(locked, context) if action_id is ActionId.PROJECT_ROLE_GRANT_REVOKE and scope.grant_id is None: diff --git a/backend/app/modules/reviews/lifecycle/models.py b/backend/app/modules/reviews/lifecycle/models.py index 0b35c83c2..66f4f002e 100644 --- a/backend/app/modules/reviews/lifecycle/models.py +++ b/backend/app/modules/reviews/lifecycle/models.py @@ -53,6 +53,10 @@ class JointLifecycleTransition(Base): __tablename__ = "joint_lifecycle_transitions" __table_args__ = ( + CheckConstraint( + "(get_byte(uuid_send(id), 6) >> 4) = 7 and (get_byte(uuid_send(id), 8) & 192) = 128", + name="id_uuid7", + ), UniqueConstraint("operation_id"), UniqueConstraint("singleton_id", "generation"), UniqueConstraint("authorization_decision_event_id"), @@ -69,8 +73,8 @@ class JointLifecycleTransition(Base): phase: Mapped[str] = mapped_column(String(16), nullable=False) facts_json: Mapped[dict] = mapped_column(JSONB, nullable=False) resource_context_digest: Mapped[str] = mapped_column(String(71), nullable=False) - authorization_decision_event_id: Mapped[UUID] = mapped_column( - Uuid(), ForeignKey("audit_events.id"), nullable=False, + authorization_decision_event_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("audit_events.id"), nullable=False, ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=text("clock_timestamp()"), diff --git a/backend/app/modules/reviews/lifecycle/service.py b/backend/app/modules/reviews/lifecycle/service.py index fe2fc9b7f..177bdf7f8 100644 --- a/backend/app/modules/reviews/lifecycle/service.py +++ b/backend/app/modules/reviews/lifecycle/service.py @@ -1,6 +1,7 @@ """Authorized transitions for the existing atomic-participant lifecycle scope.""" import json +from uuid import UUID from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -52,7 +53,7 @@ async def transition(self, command: LifecycleTransitionCommand) -> LifecycleTran ) if retained.command != checked or current.singleton_id != history.singleton_id: raise JointLifecycleUnavailable("lifecycle operation conflicts") - await authority.validate_replay(retained, history.authorization_decision_event_id) + await authority.validate_replay(retained, UUID(history.authorization_decision_event_id)) return _receipt(history) now = await self._session.scalar(select(func.clock_timestamp())) @@ -88,7 +89,7 @@ async def transition(self, command: LifecycleTransitionCommand) -> LifecycleTran previous_phase=current.phase.value, phase=checked.target_phase.value, facts_json=facts.model_dump(mode="json"), resource_context_digest=receipt.resource_context_digest, - authorization_decision_event_id=receipt.decision_event_id, + authorization_decision_event_id=str(receipt.decision_event_id), ) self._session.add(row) await self._session.flush() @@ -102,6 +103,6 @@ def _receipt(row: JointLifecycleTransition) -> LifecycleTransitionReceipt: return LifecycleTransitionReceipt( operation_id=row.operation_id, singleton_id=row.singleton_id, generation=row.generation, phase=JointLifecyclePhase(row.phase), - authorization_decision_event_id=row.authorization_decision_event_id, + authorization_decision_event_id=UUID(row.authorization_decision_event_id), created_at=row.created_at, ) diff --git a/backend/tests/authorization/setup_finalization/test_catalogue.py b/backend/tests/authorization/setup_finalization/test_catalogue.py index 6e8bc6b9b..ea8894ae7 100644 --- a/backend/tests/authorization/setup_finalization/test_catalogue.py +++ b/backend/tests/authorization/setup_finalization/test_catalogue.py @@ -157,6 +157,7 @@ def test_exact_active_action_inventory(): ActionId.ACTOR_IDENTITY_LINK_READ, ActionId.ACTOR_SERVICE_PROVISION, ActionId.ACTOR_PROFILE_SUSPEND, + ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, ActionId.ACTOR_PROFILE_REACTIVATE, ActionId.ACTOR_PROFILE_DEACTIVATE, ActionId.ACTOR_IDENTITY_LINK_REVOKE, diff --git a/backend/tests/authorization/test_catalogue.py b/backend/tests/authorization/test_catalogue.py index 9a6d44c2a..2bf04daad 100644 --- a/backend/tests/authorization/test_catalogue.py +++ b/backend/tests/authorization/test_catalogue.py @@ -104,8 +104,8 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> } assert all(not owner.value.startswith("WS-REV-") for owner in ActionOwner) assert Counter(definition.availability for definition in ACTION_DEFINITIONS) == { - ActionAvailability.ACTIVE: 106, - ActionAvailability.PLANNED: 38, + ActionAvailability.ACTIVE: 107, + ActionAvailability.PLANNED: 37, } assert resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id is PermissionId.ACTOR_PROFILE_READ_SELF with pytest.raises(ValueError, match="not active"): diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index ff080a2af..17ac1c250 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -23,7 +23,7 @@ DDL_LOCK_DIRECTORY = Path("/tmp") # Match the PostgreSQL 16 engine used by Backend CI. Catalog identity rendering # differs across major versions; regenerate only after comparing actual objects. -EXPECTED_PUBLIC_SCHEMA_SHA256 = "3bb7c5924fc8061b49f8f1c131e7be842ae02831e943e72b8e56fb73a7483c2d" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "4b466958f72c7dcbc0527a20091afe0802a412545023b54673a56e3576d01dc5" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", diff --git a/backend/tests/reviews/lifecycle/test_authority_binding.py b/backend/tests/reviews/lifecycle/test_authority_binding.py index 95392c92d..5491f773f 100644 --- a/backend/tests/reviews/lifecycle/test_authority_binding.py +++ b/backend/tests/reviews/lifecycle/test_authority_binding.py @@ -1,5 +1,7 @@ """Actual PREP consumption rejects independent changes to the selected command.""" +from datetime import timedelta + import pytest from app.core.identifiers import new_record_id @@ -12,13 +14,17 @@ @pytest.mark.parametrize("field", [ "operation_id", "singleton_id", "actor_profile_id", "identity_link_id", - "expected_generation", "target_phase", "reviewed_manifest_digest", "reason", + "expected_generation", "current_phase", "target_phase", "deadline", "reviewed_manifest_digest", "reason", ]) async def test_prepared_command_substitution_rejects_before_allow(admin_access, field): await admin_access.signed.grant(admin_access.admin, admin_access.target) - command = await command_for(admin_access.target.id, "shadow") + if field == "current_phase": + await transition(await command_for(admin_access.target.id, "shadow")) + command = await command_for(admin_access.target.id, "live" if field == "current_phase" else "shadow") value = { "expected_generation": 1, + "current_phase": JointLifecyclePhase.DISABLED, + "deadline": command.deadline + timedelta(seconds=1), "target_phase": JointLifecyclePhase.LIVE, "reviewed_manifest_digest": "sha256:" + "1" * 64, "reason": "different selected operation", @@ -37,4 +43,4 @@ async def test_prepared_command_substitution_rejects_before_allow(admin_access, await prepared.consume_new(facts) await session.rollback() assert await snapshot() == before - assert (await transition(command)).generation == 1 + assert (await transition(command)).generation == command.expected_generation + 1 diff --git a/backend/tests/reviews/lifecycle/test_migration.py b/backend/tests/reviews/lifecycle/test_migration.py index ba96b5c4a..70da08604 100644 --- a/backend/tests/reviews/lifecycle/test_migration.py +++ b/backend/tests/reviews/lifecycle/test_migration.py @@ -120,7 +120,7 @@ async def test_transition_upgrade_preserves_genesis_and_retained_sources( await connection.close() await asyncio.to_thread(command.upgrade, _config(), "0026_task_guide_read") async with contribution_source( - tmp_path, isolated_database_env, paid=True, material_source=historical_material_fixture, + tmp_path, isolated_database_env, paid=True, ): connection = await asyncpg.connect(url) try: diff --git a/backend/tests/reviews/lifecycle/test_participant_control.py b/backend/tests/reviews/lifecycle/test_participant_control.py index 796c8f40a..b0300df62 100644 --- a/backend/tests/reviews/lifecycle/test_participant_control.py +++ b/backend/tests/reviews/lifecycle/test_participant_control.py @@ -191,3 +191,58 @@ async def test_generation_zero_cannot_create_acceptance(tmp_path, isolated_datab await participant(session).participate(request) async with h.factory() as session: assert await stored_effects(session, h.acceptance.task_id) == before + + +@pytest.mark.parametrize("sequence", ["genesis", "stopped"]) +async def test_each_owner_independently_rejects_new_effects( + tmp_path, isolated_database_env, admin_access, sequence, +): + from unittest.mock import AsyncMock + + from app.modules.contributions.api import ContributionParticipationUnavailable + from app.modules.reviews.acceptance.participant import FinalAcceptanceParticipant + from app.modules.reviews.lifecycle.fence import PostgresJointLifecycleMutationFence + from app.modules.tasks.accepted_effects import TaskAcceptedEffectsParticipant + from app.modules.tasks.api.accepted_effects import TaskAcceptedEffectsUnavailable, TaskAcceptedPreparation + from tests.contributions.participation.support import participant as contribution_participant + from tests.contributions.participation.support import request_for as contribution_request + + await admin_access.signed.grant(admin_access.admin, admin_access.target) + generation = 0 + if sequence == "stopped": + await transition(await command_for(admin_access.target.id, "shadow")) + await transition(await command_for(admin_access.target.id, "live")) + async with contribution_source(tmp_path, isolated_database_env, paid=True) as h: + await prepare_review_pending(h) + phases = ("disabled", "shadow") if sequence == "genesis" else ("draining", "disabled", "shadow") + for phase in phases: + if not (sequence == "genesis" and phase == "disabled"): + generation = (await transition(await command_for(admin_access.target.id, phase))).generation + request = await request_for(h, expected_generation=generation) + async with h.factory() as session: + before = await stored_effects(session, h.acceptance.task_id) + async with h.factory() as session, session.begin(): + task = TaskAcceptedEffectsParticipant(session, fence=PostgresJointLifecycleMutationFence(session)) + with pytest.raises(TaskAcceptedEffectsUnavailable, match="lifecycle is not live"): + await task.lock_accepted_effects(request.task_effects, expected_generation=generation) + async with h.factory() as session, session.begin(): + with pytest.raises(ContributionParticipationUnavailable, match="lifecycle is not live"): + await contribution_participant(session).participate_submitter(contribution_request( + h, acceptance_disposition="new", expected_generation=generation, + )) + async with h.factory() as session, session.begin(): + # Isolate REV's own check: TASK supplies a correctly shaped new + # preparation; neither TASK nor CON can mask a missing REV gate. + tasks = SimpleNamespace(lock_accepted_effects=AsyncMock(return_value=TaskAcceptedPreparation( + disposition="new", locked_review_policy_id=request.acceptance.policy_context_ref, + ))) + owner = FinalAcceptanceParticipant( + session, fence=PostgresJointLifecycleMutationFence(session), + tasks=tasks, contributions=AsyncMock(), + ) + owner._repository.persist = AsyncMock(side_effect=AssertionError("REV reached persistence")) + with pytest.raises(FinalAcceptanceConflict, match="lifecycle is not live"): + await owner.participate(request) + owner._repository.persist.assert_not_awaited() + async with h.factory() as session: + assert await stored_effects(session, h.acceptance.task_id) == before diff --git a/backend/tests/reviews/lifecycle/test_transition_storage.py b/backend/tests/reviews/lifecycle/test_transition_storage.py index 67e4a4088..5eef527a0 100644 --- a/backend/tests/reviews/lifecycle/test_transition_storage.py +++ b/backend/tests/reviews/lifecycle/test_transition_storage.py @@ -85,3 +85,72 @@ async def test_shadow_audit_table_cannot_substitute_a_real_receipt(admin_access) await publish_history(session, history_row(substituted, receipt, facts=facts, digest=digest)) await session.commit() assert await snapshot() == before + + +@pytest.mark.parametrize("principal", ["actor", "link", "grant"]) +async def test_closure_rejects_independently_inactive_authority(admin_access, principal): + grant = await admin_access.signed.grant(admin_access.admin, admin_access.target) + command = await command_for(admin_access.target.id, "shadow") + before = await snapshot() + statements = { + "actor": ("actor_profiles", command.actor_profile_id, + "status='suspended',suspended_by='storage-proof',suspended_at=clock_timestamp(),suspension_reason='Suspended'"), + "link": ("actor_identity_links", command.identity_link_id, + "status='revoked',revoked_by='storage-proof',revoked_at=clock_timestamp(),revoked_reason='Revoked'"), + "grant": ("admin_role_grants", grant, + "status='revoked',version=2,revoked_by_actor_profile_id=:admin,revoked_by_admin_role_grant_id=:admin_grant,revoked_reason='Revoked',revoked_at=clock_timestamp()"), + } + table, identifier, update = statements[principal] + async with get_session_factory()() as session: + await session.begin() + receipt = await issue_authority(session, command) + await publish_history(session, history_row(command, receipt)) + await session.execute(text(f"UPDATE public.{table} SET {update} WHERE id=:id"), { + "id": identifier, "admin": admin_access.admin.id, + "admin_grant": admin_access.bootstrap_grant_id, + }) + assert await session.scalar(text(f"SELECT status FROM public.{table} WHERE id=:id"), { + "id": identifier, + }) != "active" + # Valid audit facts, digests, identities and history have already been + # written. Only the independently changed principal state can deny now. + with pytest.raises(DBAPIError, match="joint lifecycle authority closure invalid"): + await session.commit() + assert await snapshot() == before + assert (await transition(command)).generation == 1 + + +async def test_committed_authority_receipt_cannot_be_changed_or_manufactured(admin_access): + from app.core.identifiers import new_record_id + + await admin_access.signed.grant(admin_access.admin, admin_access.target) + command = await command_for(admin_access.target.id, "shadow") + receipt = await transition(command) + before = await snapshot() + for field, value in (("request_id", new_record_id()), + ("matched_grant_id", admin_access.bootstrap_grant_id)): + async with get_session_factory()() as session: + with pytest.raises(DBAPIError, match="audit events are append-only"): + await session.execute(text(f"UPDATE public.audit_events SET {field}=:value WHERE id=:id"), { + "value": value, "id": receipt.authorization_decision_event_id, + }) + await session.commit() + assert await snapshot() == before + # Turning a different retained allow into this exact lifecycle shape must + # also reject; an INSERT-only closure cannot see such an UPDATE. + async with get_session_factory()() as session: + target = await session.scalar(text( + "SELECT id FROM public.audit_events WHERE event_type='SensitiveAuthorizationAllowed' " + "AND action_id <> 'review.lifecycle.activation.manage' ORDER BY id LIMIT 1" + )) + assert target is not None + fields = list((await session.execute(text("SELECT * FROM public.audit_events LIMIT 1"))).keys()) + columns = ",".join('"' + key + '"' for key in fields if key != "id") + with pytest.raises(DBAPIError, match="audit events are append-only"): + await session.execute(text(f"UPDATE public.audit_events SET ({columns})=" + f"(SELECT {columns} FROM public.audit_events WHERE id=:source) WHERE id=:target"), { + "source": receipt.authorization_decision_event_id, "target": target, + }) + await session.commit() + assert await snapshot() == before + assert await transition(command) == receipt diff --git a/backend/tests/reviews/lifecycle/test_transitions.py b/backend/tests/reviews/lifecycle/test_transitions.py index 5e520a8c0..18d3ca5ed 100644 --- a/backend/tests/reviews/lifecycle/test_transitions.py +++ b/backend/tests/reviews/lifecycle/test_transitions.py @@ -107,6 +107,12 @@ async def test_no_operator_revoked_operator_and_operation_collision(admin_access with pytest.raises(PreparedAuthorizationUnsupported): await transition(command) assert await snapshot() == before + replacement = await admin_access.signed.grant(admin_access.admin, admin_access.target) + assert replacement != grant + # Fresh authority permits replay; the original revoked grant remains part + # of the immutable historical receipt, not rewritten to today's grant. + assert await transition(command) == receipt + assert await snapshot() == before async def test_transition_replay_does_not_issue_sql_writes(admin_access): @@ -134,3 +140,30 @@ def observe(_connection, _cursor, statement, _parameters, _context, _many): finally: event.remove(engine, "before_cursor_execute", observe) assert writes == [] + + +async def test_shared_reset_restores_activated_controller(admin_access, isolated_database_env): + from conftest import _reset_test_database_state + + before = (await snapshot())["control"][0] + await admin_access.signed.grant(admin_access.admin, admin_access.target) + await transition(await command_for(admin_access.target.id, "shadow")) + await transition(await command_for(admin_access.target.id, "live")) + activated = await snapshot() + assert len(activated["history"]) == 2 + for _ in range(2): + await _reset_test_database_state(isolated_database_env) + after = await snapshot() + assert after["control"] == [before] + assert after["history"] == after["events"] == [] + async with get_session_factory()() as session: + assert await session.scalar(text(""" + SELECT count(*) FROM pg_catalog.pg_trigger + WHERE tgrelid IN ('public.joint_lifecycle_release_control'::regclass, + 'public.joint_lifecycle_transitions'::regclass, 'public.audit_events'::regclass) + AND tgenabled <> 'O' + """)) == 0 + with pytest.raises(DBAPIError, match="transition custody invalid"): + await session.execute(text( + "UPDATE public.joint_lifecycle_release_control SET phase='live',generation=1" + )) diff --git a/backend/tests/reviews/lifecycle/transition_support.py b/backend/tests/reviews/lifecycle/transition_support.py index ba9aaea85..7202905c8 100644 --- a/backend/tests/reviews/lifecycle/transition_support.py +++ b/backend/tests/reviews/lifecycle/transition_support.py @@ -88,7 +88,7 @@ def history_row(command, receipt, *, facts=None, digest=None): generation=command.expected_generation + 1, previous_phase=command.current_phase.value, phase=command.target_phase.value, facts_json=(facts or facts_for(command)).model_dump(mode="json"), resource_context_digest=digest or receipt.resource_context_digest, - authorization_decision_event_id=receipt.decision_event_id, + authorization_decision_event_id=str(receipt.decision_event_id), ) diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 0cf9ce18f..0f4c93835 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -79,7 +79,7 @@ def test_v01_graph_has_one_root_and_head() -> None: script = ScriptDirectory.from_config(config) revisions = list(script.walk_revisions()) - assert [revision.revision for revision in revisions] == [HEAD_REVISION, "0025_submission_dispatch", "0024_require_second_review_false", "0023_remove_task_payment_policy", "0022_submission_packet_custody", "0021_submission_manifest", "0020_review_admission_lock_order", "0019_submitter_awards", "0018_task_routing_request", "0017_acceptance_source_contracts", "0016_review_lifecycle_fence", "0015_contribution_awards", "0014_final_acceptance", "0013_review_source", "0012_review_packet", "0011_task_routing_source", "0010_post_submit_authority", "0009_checker_material_lineage", "0008_checker_execution", "0007_checker_output_custody", "0006_history_read_authority", "0005_task_evidence_authority", "0004_task_context_authority", "0003_task_read_authority", "0002_task_queue_authority", BASELINE_REVISION] + assert [revision.revision for revision in revisions] == [HEAD_REVISION, "0026_task_guide_read", "0025_submission_dispatch", "0024_require_second_review_false", "0023_remove_task_payment_policy", "0022_submission_packet_custody", "0021_submission_manifest", "0020_review_admission_lock_order", "0019_submitter_awards", "0018_task_routing_request", "0017_acceptance_source_contracts", "0016_review_lifecycle_fence", "0015_contribution_awards", "0014_final_acceptance", "0013_review_source", "0012_review_packet", "0011_task_routing_source", "0010_post_submit_authority", "0009_checker_material_lineage", "0008_checker_execution", "0007_checker_output_custody", "0006_history_read_authority", "0005_task_evidence_authority", "0004_task_context_authority", "0003_task_read_authority", "0002_task_queue_authority", BASELINE_REVISION] assert revisions[-1].down_revision is None assert script.get_heads() == [HEAD_REVISION] diff --git a/docs/engineering/review_authorization_action_custody.md b/docs/engineering/review_authorization_action_custody.md index 83550c5b2..74c564531 100644 --- a/docs/engineering/review_authorization_action_custody.md +++ b/docs/engineering/review_authorization_action_custody.md @@ -1,6 +1,6 @@ # Canonical REV-AUTH Action Custody -This table is the planning source of truth for the v0.1 review and human-revision authorization surface. `WS-XINT-003-02C` completes all approved REV registration, fixed-principal, matrix, and database parity while keeping every lifecycle action unavailable. XINT-002 rows retain their runtime owners. +This table is the planning source of truth for the v0.1 review and human-revision authorization surface. `WS-XINT-003-02C` completes all approved REV registration, fixed-principal, matrix, and database parity without activating lifecycle behavior. REV-12A4A subsequently activates only the internal Operator controller slice; other review/lifecycle actions remain unavailable. XINT-002 rows retain their runtime owners. ## Human and privileged actions @@ -27,7 +27,7 @@ This table is the planning source of truth for the v0.1 review and human-revisio | `review.revision_context.repair` | `project.task.manage` | Project Manager grant for exact project | invalid revision context | REV | registered planned/unavailable | `WS-XINT-003-08A` | | `review.revision_obligation.close` | `project.task.manage` | Project Manager grant for exact project | exact unfulfillable obligation | REV | registered planned/unavailable | `WS-XINT-003-08A` | | `review.revision_context.legacy_close` | `operations.reconcile.run` | Operator; canonical reason required | exact legacy revision context | REV | registered planned/unavailable | `WS-XINT-003-08A` | -| `review.lifecycle.activation.manage` | `operations.reconcile.run` | Operator; exact phase and reason | lifecycle release controller | REV | registered planned/unavailable | `WS-XINT-003-08B` | +| `review.lifecycle.activation.manage` | `operations.reconcile.run` | Operator; exact phase and reason | lifecycle release controller | REV | active internally; no public route | `REV-12A4A` scoped `WS-XINT-003-08B` slice | | `review.reconcile.run` | `operations.reconcile.run` | one of two fixed reconciler identities | invalidation or general reconciliation batch | REV | registered planned | `WS-XINT-003-08B` | | `review.artifact_reference.reconcile` | `operations.reconcile.run` | fixed artifact-reference reconciler only | bounded review artifact reference batch | REV | registered planned | `WS-XINT-003-08B` | | `review.projection.rebuild` | `operations.projection.rebuild` | fixed projection rebuilder only | derived review projection batch | REV | registered planned | `WS-XINT-003-08B` | diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index 43f34cb26..5b30e6e3d 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -743,6 +743,7 @@ Delivered foundations (not a claim of full public integration) ARCH-04E1B-B2 exact source proposal + B3 ZIP metadata + B4 checked packet custody + B5 bounded evaluation content ARCH-04E1B-B6 atomic Submission/dispatch + exact AUTH receipts + replay (no delivery/publication authority) ARCH-04E1B-B7 hidden request delivery (unregistered) + REV-12A4A scoped Operator transitions + immutable AUTH/history custody + current-generation gates Remaining integration both branches: hidden completion routing 04E1B-B @@ -954,3 +955,5 @@ ARCH-04E1B-B3 proof: [verified ZIP metadata custody](../.commitrail/initiatives/ ARCH-04E1B-B6 proof: [atomic Submission, exact receipts and initial request custody](../.commitrail/initiatives/WS-ARCH-001/WS-ARCH-001-04E1BB6.md). ARCH-04E1B-B7 proof: [hidden request delivery and invocation custody](../.commitrail/initiatives/WS-ARCH-001/WS-ARCH-001-04E1BB7.md). Production registration, completion routing and public intake remain unavailable. + +REV-12A4A proof: [scoped lifecycle control and payment-delivery deferral](../.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md). Exact production source receipts and acceptance activation remain required. diff --git a/docs/roles_permissions.md b/docs/roles_permissions.md index 785807700..54e5b8c13 100644 --- a/docs/roles_permissions.md +++ b/docs/roles_permissions.md @@ -46,8 +46,10 @@ authorize submission, review, or adjudication. Normal project repair uses covered Project Manager permission `project.task.manage`. The planned review/revision repair, obligation-close, -legacy-close, and lifecycle-control actions are unavailable until their owning -hidden behavior, AUTH activation, and REV-13 release. Task start override uses +and legacy-close actions remain unavailable until their owning hidden behavior, +AUTH activation and release. REV-12A4A activates internal lifecycle control for +live system Operators, binding the exact singleton, generation and manifest. +It supplies no public endpoint, human-review, acceptance or payment authority. Task start override uses `operations.task.start_override`. Submission-gate repair (`operations.submission_gate.repair`) and checker retry (`operations.checker.retry`) are planned and unavailable; their old routes and From 0fb09f5b70a5213e9c8bc098cac4e36edb8a82f3 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Thu, 8 Oct 2026 18:38:37 +0100 Subject: [PATCH 05/17] fix(reviews): bind lifecycle deadlines to database time and simplify AUTH owners --- .ci/auth-boundaries/TEST_STRUCTURE_DEBT.json | 34 ++++-------- .ci/behavior-ownership/partition.v1.json | 6 ++- .../WS-REV-001/WS-REV-001-12A4A.md | 10 +++- .../versions/0027_lifecycle_transitions.py | 14 +++++ .../authorization/domain/guide_mutations.py | 18 +++++++ .../domain/prepared_guide_mutations.py | 25 +++++++++ backend/app/modules/authorization/kernel.py | 43 ++------------- backend/app/modules/authorization/prepared.py | 47 ---------------- .../authorization/prepared_admin_authority.py | 54 +++++++++++++++++++ backend/app/modules/authorization/runtime.py | 50 ++--------------- backend/scripts/behavior_ownership.py | 1 + backend/tests/conftest.py | 2 +- .../lifecycle/test_transition_storage.py | 22 ++++++++ backend/tests/test_behavior_ownership.py | 1 + 14 files changed, 167 insertions(+), 160 deletions(-) create mode 100644 backend/app/modules/authorization/prepared_admin_authority.py diff --git a/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json b/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json index de1c5e0e1..ce5a92c60 100644 --- a/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json +++ b/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json @@ -2,11 +2,11 @@ "entries": [ { "capability": "oversized_authorization_structure", - "content_sha256": "9af1e5ce3199398267d171f4b0dcb7760af7ca5e170a6ac53adfaf9b69d0a9a0", - "end_line": 1354, + "content_sha256": "e9f5a282cf016d7d6aeff32a6cbb978b033f5d6f7799b0d661396f5a4b191cb7", + "end_line": 1319, "hard_limit": 1200, "kind": "production_file", - "observed_lines": 1354, + "observed_lines": 1319, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -26,11 +26,11 @@ }, { "capability": "oversized_authorization_structure", - "content_sha256": "5f0cc6950755629f053c545cfafcc803ec80e9ee9f74ac48d6c87f644756f5ab", - "end_line": 1245, + "content_sha256": "bb866814258a7e8d17572e5c46102313e71b29a02c1acb3e0b6e5261ac951997", + "end_line": 1201, "hard_limit": 1200, "kind": "production_file", - "observed_lines": 1245, + "observed_lines": 1201, "path": "backend/app/modules/authorization/runtime.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -38,11 +38,11 @@ }, { "capability": "oversized_authorization_structure", - "content_sha256": "f93bf15bed5d65558494d2cf42dafa1295a4c2c489fb38441f8fc9b70cdb5380", - "end_line": 533, + "content_sha256": "695afd40f7e2504c32f3fc7a5309c8677baff352acb6488bc63cf208ff83a161", + "end_line": 498, "hard_limit": 100, "kind": "production_function", - "observed_lines": 158, + "observed_lines": 123, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": "AuthorizationService._prepare_prelocked", "removal_chunk": "WS-AUTH-003-CLOSE", @@ -51,26 +51,14 @@ { "capability": "oversized_authorization_structure", "content_sha256": "ca3ded1b5b0ffa0dfa405485bb880ae03de6f7ed643d05aa4325d87533dcbd33", - "end_line": 905, + "end_line": 870, "hard_limit": 100, "kind": "production_function", "observed_lines": 115, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": "AuthorizationService._require_prelocked", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 791 - }, - { - "capability": "oversized_authorization_structure", - "content_sha256": "923219f4cb69a173b533a46ee66a3abe1a3c0d849b360b0d2d164f6bdc343bf3", - "end_line": 945, - "hard_limit": 100, - "kind": "production_function", - "observed_lines": 119, - "path": "backend/app/modules/authorization/prepared.py", - "qualified_symbol": "PreparedAuthorizationService._binding", - "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 827 + "start_line": 756 }, { "capability": "oversized_authorization_structure", diff --git a/.ci/behavior-ownership/partition.v1.json b/.ci/behavior-ownership/partition.v1.json index 78f2a0803..ba2cd927c 100644 --- a/.ci/behavior-ownership/partition.v1.json +++ b/.ci/behavior-ownership/partition.v1.json @@ -708,6 +708,10 @@ "group": "auth", "target": "backend/app/modules/authorization/prepared.py" }, + { + "group": "shared", + "target": "backend/app/modules/authorization/prepared_admin_authority.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/prepared_lifecycle_replay.py" @@ -1729,7 +1733,7 @@ "target": "backend/scripts/validate_test_lane_evidence.py" } ], - "authority_digest": "5cd1d20885b35481f54dc89d4534ffa397fa02751448f7d7d93fb4d83ad103e6", + "authority_digest": "6f884f95fbfd8e0d3613218ee629db963224f63bd856c0befc55f449d9c5a525", "protected_base_commit": "7676ce4347db0c9694962a9b587a20765e16eac6", "schema": "workstream.behavior-ownership-partition.v1" } diff --git a/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md b/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md index 1c7846c3b..8333824d8 100644 --- a/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md +++ b/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md @@ -81,12 +81,14 @@ remain the subsequent ARCH-04E2-B prerequisite before a production consumer. `repository.py` for lifecycle-only actor NO KEY UPDATE custody, resource/digest/audit contracts, `backend/app/modules/audit/schemas.py` for the exact UUID lifecycle target kind, and focused lifecycle resource/PREP adapter - modules. Existing composition roots only for explicit controller construction. + modules, including `prepared_admin_authority.py` for existing admin lock orchestration + and existing `domain/{guide_mutations,prepared_guide_mutations}.py` for shared + review/revision lineage validation and binding. Existing composition roots only for explicit controller construction. - One successor Alembic migration, current-head preflight and graph inventory, model registration, exact schema inventory; focused REV lifecycle and AUTH tests plus affected acceptance/participation tests and shared fixtures. Existing production inventory tests prove absent surfaces; do not create a parallel registry. -- Exact behavior ownership and lane inventory files/tests; refresh existing structural-debt fingerprints without adding debt or relaxing limits. No CI gate changes. +- Exact behavior ownership and lane inventory files/tests; shrink affected frozen AUTH structural debt through cohesive owner extraction, then refresh fingerprints without adding debt or relaxing limits. No CI gate changes. - This record; REV/AUTH/CON/ARCH/POL current overviews, ARCH/AUTH/POL current plans/maps, Commitrail index; canonical review/compensation specifications, relevant authorization custody and roles/permissions docs, canonical authorization/data-model specifications, README and roadmap. Local roadmap exports @@ -157,3 +159,7 @@ so no duplicate immutability trigger is added. New transition closure independen checks active actor, exact link and Operator grant. Owner-specific phase probes avoid sibling guards masking each other. Reset proof begins at authorized LIVE and preserves singleton identity/time while restoring only the isolated test DB. + +PostgreSQL stamps transition history time independently of caller input; an expired +command with an explicitly backdated timestamp rejects and rolls back all three +owners. This closes deadline custody without trusting application timestamps. diff --git a/backend/alembic/versions/0027_lifecycle_transitions.py b/backend/alembic/versions/0027_lifecycle_transitions.py index 7ca58b922..3544382cf 100644 --- a/backend/alembic/versions/0027_lifecycle_transitions.py +++ b/backend/alembic/versions/0027_lifecycle_transitions.py @@ -46,6 +46,11 @@ def upgrade(): op.execute("DROP FUNCTION public.guard_joint_lifecycle_genesis()") _functions() op.execute(""" +CREATE TRIGGER joint_lifecycle_history_time BEFORE INSERT +ON public.joint_lifecycle_transitions FOR EACH ROW +EXECUTE FUNCTION public.stamp_joint_lifecycle_history_time() +""") + op.execute(""" CREATE TRIGGER joint_lifecycle_control_change BEFORE INSERT OR UPDATE OR DELETE ON public.joint_lifecycle_release_control FOR EACH ROW EXECUTE FUNCTION public.guard_joint_lifecycle_control_change() @@ -110,6 +115,15 @@ def _replace(name, definition): def _functions(): op.execute(""" +CREATE FUNCTION public.stamp_joint_lifecycle_history_time() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +BEGIN + NEW.created_at := pg_catalog.clock_timestamp(); + RETURN NEW; +END; +$$ +""") + op.execute(""" CREATE FUNCTION public.deny_joint_lifecycle_mutation() RETURNS trigger LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ BEGIN RAISE EXCEPTION 'joint lifecycle custody is immutable' USING ERRCODE='23514'; END; diff --git a/backend/app/modules/authorization/domain/guide_mutations.py b/backend/app/modules/authorization/domain/guide_mutations.py index 44a9759ed..c7d4e5059 100644 --- a/backend/app/modules/authorization/domain/guide_mutations.py +++ b/backend/app/modules/authorization/domain/guide_mutations.py @@ -97,3 +97,21 @@ def require_snapshot_identity(self): if (self.predecessor_snapshot_id is None) != (self.predecessor_snapshot_hash is None): raise ValueError("source snapshot predecessor facts must be bound together") return self + + +def validate_policy_mutation_identity(resource, policy_id: UUID, policy_kind: str): + """Keep review and revision edits bound to the same complete successor lineage.""" + if resource.resource_id != policy_id: + raise ValueError(f"{policy_kind} policy resource must match policy") + if len({resource.predecessor_policy_id is None, + resource.predecessor_policy_generation is None, + resource.current_policy_digest is None}) != 1: + raise ValueError(f"{policy_kind} policy predecessor facts must be bound together") + if resource.policy_generation == 1 and resource.predecessor_policy_id is not None: + raise ValueError(f"first {policy_kind} policy cannot have a predecessor") + if resource.policy_generation > 1 and resource.predecessor_policy_id is None: + raise ValueError(f"replacement {policy_kind} policy requires a predecessor") + if (resource.predecessor_policy_generation is not None + and resource.policy_generation != resource.predecessor_policy_generation + 1): + raise ValueError(f"{policy_kind} policy successor generation must be exact") + return resource diff --git a/backend/app/modules/authorization/domain/prepared_guide_mutations.py b/backend/app/modules/authorization/domain/prepared_guide_mutations.py index 73412d23e..cdc173728 100644 --- a/backend/app/modules/authorization/domain/prepared_guide_mutations.py +++ b/backend/app/modules/authorization/domain/prepared_guide_mutations.py @@ -9,6 +9,8 @@ def parse_prepared_guide_mutation(action: ActionId, request: Mapping[str, object]) -> dict: """Bind existing guide selectors and require exact create-time example facts.""" + if action in {ActionId.PROJECT_REVIEW_POLICY_UPDATE, ActionId.PROJECT_REVISION_POLICY_UPDATE}: + return _policy_mutation_binding(request) if action not in { ActionId.PROJECT_GUIDE_CREATE, ActionId.PROJECT_GUIDE_UPDATE, ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE, @@ -38,3 +40,26 @@ def parse_prepared_guide_mutation(action: ActionId, request: Mapping[str, object return result except (KeyError, TypeError, ValueError) as exc: raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") from exc + + +def _policy_mutation_binding(request: Mapping[str, object]) -> dict: + """Bind the policy and optional predecessor selected by guide review/revision edits.""" + try: + predecessor_id = request["predecessor_policy_id"] + predecessor_generation = request["predecessor_policy_generation"] + predecessor_digest = request["predecessor_policy_digest"] + return { + "policy_mutation_project_id": UUID(str(request["project_id"])), + "policy_mutation_guide_id": UUID(str(request["guide_id"])), + "policy_mutation_policy_id": UUID(str(request["policy_id"])), + "policy_mutation_operation_id": UUID(str(request["operation_id"])), + "policy_mutation_request_digest": str(request["request_digest"]), + "policy_mutation_policy_digest": str(request["policy_digest"]), + "policy_mutation_generation": int(request["policy_generation"]), + "policy_mutation_predecessor_id": UUID(str(predecessor_id)) if predecessor_id is not None else None, + "policy_mutation_predecessor_generation": int(predecessor_generation) if predecessor_generation is not None else None, + "policy_mutation_predecessor_digest": str(predecessor_digest) if predecessor_digest is not None else None, + "policy_mutation_guide_status": str(request["guide_status"]), + } + except (KeyError, TypeError, ValueError) as exc: + raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") from exc diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index c8d7e3236..085ea486d 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -27,7 +27,7 @@ from app.modules.authorization.domain import adapter_bindings, contribution_policies, guide_compilation as compilation from app.modules.authorization.domain.action_groups import ( GUIDE_BOUND_PROJECT_MANAGER_ACTIONS as _GUIDE_BOUND_PROJECT_MANAGER_ACTIONS, - PROJECT_SCOPED_ADMIN_MUTATIONS, CONTEXT_DIGEST_ACTIONS, EXACT_PROJECT_MANAGER_SCOPE_ACTIONS, + CONTEXT_DIGEST_ACTIONS, EXACT_PROJECT_MANAGER_SCOPE_ACTIONS, supports_prepared_denial, ) from app.modules.authorization.domain.audit import CONTEXT_DIGEST_RESOURCE_TYPES @@ -41,6 +41,7 @@ from app.modules.authorization.catalogue import HISTORY_READ_ACTIONS from app.modules.authorization.domain.task_queues import TASK_QUEUE_ACTIONS, queue_read_denial from app.modules.authorization.domain.project_reads import project_read_denial +from app.modules.authorization.prepared_admin_authority import lock_admin_mutation_authority from app.modules.authorization.repository import AdminAuthorizationRepository from app.modules.authorization.schemas import AdminRole from app.modules.authorization.runtime import ( @@ -100,7 +101,6 @@ PROJECT_AUTHORITY_ACTIONS, evaluate_project_authority, lock_project_authority, - lock_project_role_mutation_principals, ) ContextRevalidator = Callable[ @@ -434,44 +434,9 @@ async def _prepare_prelocked( ) context = self._locked_human_context(locked, context) elif action_id in _ADMIN_MUTATIONS: - if not isinstance(context, HumanAuthorizationContext): - raise PreparedAuthorizationUnsupported( - AuthorizationDenialCode.PERMISSION_NOT_GRANTED - ) - if scope.kind not in {PreparedAuthorityScopeKind.SYSTEM, PreparedAuthorityScopeKind.PROJECT}: - raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED) - if scope.kind is PreparedAuthorityScopeKind.PROJECT and action_id not in PROJECT_SCOPED_ADMIN_MUTATIONS: - raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED) - await self._admin.lock_control() - if action_id in {ActionId.PROJECT_ROLE_GRANT_ISSUE, ActionId.PROJECT_ROLE_GRANT_REVOKE}: - locked = await lock_project_role_mutation_principals( - self._admin, context, scope, action_id, - ) - else: - locked = await self._admin.lock_request_actor( - context.identity_link_id, context.actor_profile_id, - **({"preserve_foreign_key_reads": True} - if action_id is ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE else {}), - ) - context = self._locked_human_context(locked, context) - if action_id is ActionId.PROJECT_ROLE_GRANT_REVOKE and scope.grant_id is None: - raise PreparedAuthorizationUnsupported( - AuthorizationDenialCode.RESOURCE_GUARD_DENIED - ) - grant = await self._admin.find_effective_grant( - context.actor_profile_id, - action.permission_id, - scope_project_id=scope.project_id, - system_scope_only=scope.project_id is None, - for_update=True, - **({"allowed_roles": (AdminRole.OPERATOR,)} if action_id is ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE else {}), - **adapter_bindings.finance_authority_grant_filters(action_id), - **contribution_policies.policy_finance_grant_filters(action_id), + context, grant = await lock_admin_mutation_authority( + self._admin, context, scope, action_id, action.permission_id, self._locked_human_context, ) - if grant is None: - raise PreparedAuthorizationUnsupported( - AuthorizationDenialCode.PERMISSION_NOT_GRANTED - ) elif action_id in _GUIDE_BOUND_PROJECT_MANAGER_ACTIONS: context, grant = await self._prepare_project_manager( context, action.permission_id, scope, action_id, diff --git a/backend/app/modules/authorization/prepared.py b/backend/app/modules/authorization/prepared.py index 228760b15..fdce7c40e 100644 --- a/backend/app/modules/authorization/prepared.py +++ b/backend/app/modules/authorization/prepared.py @@ -842,9 +842,6 @@ def _binding( ): raise PreparedAuthorizationHandleInvalid("invalid prepared routing request") operation_id = project_id = operation_generation = None - policy_mutation_project_id = policy_mutation_guide_id = policy_mutation_policy_id = policy_mutation_operation_id = None - policy_mutation_request_digest = policy_mutation_policy_digest = policy_mutation_predecessor_digest = None - policy_mutation_generation = policy_mutation_predecessor_generation = policy_mutation_predecessor_id = policy_mutation_guide_status = None setup_bindings = parse_setup_bindings(action_id, caller_input, scope, self._context) if action_id is ActionId.PROJECT_CREATE: operation_id, project_id, operation_generation = parse_project_create_binding( @@ -854,39 +851,6 @@ def _binding( action_id, caller_input.request_value, setup_bindings.get("guide_projection_prepare_context"), ) sufficiency = _sufficiency_prepare_binding(action_id, caller_input, setup_bindings) - if action_id in { - ActionId.PROJECT_REVIEW_POLICY_UPDATE, - ActionId.PROJECT_REVISION_POLICY_UPDATE, - }: - try: - policy_mutation_project_id = UUID(str(caller_input.request_value["project_id"])) - policy_mutation_guide_id = UUID(str(caller_input.request_value["guide_id"])) - policy_mutation_policy_id = UUID(str(caller_input.request_value["policy_id"])) - policy_mutation_operation_id = UUID(str(caller_input.request_value["operation_id"])) - policy_mutation_request_digest = str(caller_input.request_value["request_digest"]) - policy_mutation_policy_digest = str(caller_input.request_value["policy_digest"]) - policy_mutation_generation = int(caller_input.request_value["policy_generation"]) - raw_predecessor_id = caller_input.request_value["predecessor_policy_id"] - policy_mutation_predecessor_id = ( - UUID(str(raw_predecessor_id)) if raw_predecessor_id is not None else None - ) - raw_predecessor_generation = caller_input.request_value[ - "predecessor_policy_generation" - ] - policy_mutation_predecessor_generation = ( - int(raw_predecessor_generation) - if raw_predecessor_generation is not None - else None - ) - raw_predecessor_digest = caller_input.request_value["predecessor_policy_digest"] - policy_mutation_predecessor_digest = ( - str(raw_predecessor_digest) if raw_predecessor_digest is not None else None - ) - policy_mutation_guide_status = str(caller_input.request_value["guide_status"]) - except (KeyError, TypeError, ValueError) as exc: - raise PreparedAuthorizationHandleInvalid( - "invalid prepared authorization handle" - ) from exc return _PreparedAuthorizationBinding( action_id=action_id, actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, @@ -899,17 +863,6 @@ def _binding( project_create_project_id=project_id, project_create_generation=operation_generation, **parse_prepared_guide_mutation(action_id, caller_input.request_value), - policy_mutation_project_id=policy_mutation_project_id, - policy_mutation_guide_id=policy_mutation_guide_id, - policy_mutation_policy_id=policy_mutation_policy_id, - policy_mutation_operation_id=policy_mutation_operation_id, - policy_mutation_request_digest=policy_mutation_request_digest, - policy_mutation_policy_digest=policy_mutation_policy_digest, - policy_mutation_generation=policy_mutation_generation, - policy_mutation_predecessor_id=policy_mutation_predecessor_id, - policy_mutation_predecessor_generation=(policy_mutation_predecessor_generation), - policy_mutation_predecessor_digest=policy_mutation_predecessor_digest, - policy_mutation_guide_status=policy_mutation_guide_status, sufficiency_project_id=sufficiency.get("project_id"), sufficiency_guide_id=sufficiency.get("guide_id"), sufficiency_guide_version=sufficiency.get("guide_version"), diff --git a/backend/app/modules/authorization/prepared_admin_authority.py b/backend/app/modules/authorization/prepared_admin_authority.py new file mode 100644 index 000000000..f1a6aeec5 --- /dev/null +++ b/backend/app/modules/authorization/prepared_admin_authority.py @@ -0,0 +1,54 @@ +"""Lock human administration authority before the kernel takes product custody.""" + +from app.modules.authorization.artifact_project_authority import lock_project_role_mutation_principals +from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.domain import adapter_bindings, contribution_policies +from app.modules.authorization.domain.action_groups import PROJECT_SCOPED_ADMIN_MUTATIONS +from app.modules.authorization.runtime import ( + AuthorizationDenialCode, HumanAuthorizationContext, PreparedAuthorizationUnsupported, + PreparedAuthorityScopeKind, +) +from app.modules.authorization.schemas import AdminRole + + +async def lock_admin_mutation_authority(repository, context, scope, action_id, permission_id, locked_context): + """Preserve control, principal and grant order for the kernel's admin mutations.""" + if not isinstance(context, HumanAuthorizationContext): + raise PreparedAuthorizationUnsupported( + AuthorizationDenialCode.PERMISSION_NOT_GRANTED + ) + if scope.kind not in {PreparedAuthorityScopeKind.SYSTEM, PreparedAuthorityScopeKind.PROJECT}: + raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED) + if scope.kind is PreparedAuthorityScopeKind.PROJECT and action_id not in PROJECT_SCOPED_ADMIN_MUTATIONS: + raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED) + await repository.lock_control() + if action_id in {ActionId.PROJECT_ROLE_GRANT_ISSUE, ActionId.PROJECT_ROLE_GRANT_REVOKE}: + locked = await lock_project_role_mutation_principals( + repository, context, scope, action_id, + ) + else: + locked = await repository.lock_request_actor( + context.identity_link_id, context.actor_profile_id, + **({"preserve_foreign_key_reads": True} + if action_id is ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE else {}), + ) + context = locked_context(locked, context) + if action_id is ActionId.PROJECT_ROLE_GRANT_REVOKE and scope.grant_id is None: + raise PreparedAuthorizationUnsupported( + AuthorizationDenialCode.RESOURCE_GUARD_DENIED + ) + grant = await repository.find_effective_grant( + context.actor_profile_id, + permission_id, + scope_project_id=scope.project_id, + system_scope_only=scope.project_id is None, + for_update=True, + **({"allowed_roles": (AdminRole.OPERATOR,)} if action_id is ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE else {}), + **adapter_bindings.finance_authority_grant_filters(action_id), + **contribution_policies.policy_finance_grant_filters(action_id), + ) + if grant is None: + raise PreparedAuthorizationUnsupported( + AuthorizationDenialCode.PERMISSION_NOT_GRANTED + ) + return context, grant diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index c4b93e3fc..b98471aac 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -26,7 +26,7 @@ from app.modules.authorization.domain import task_authority, task_queues, submission_history from app.modules.authorization.domain.guide_mutations import ( ProjectGuideMutationResourceContext, ProjectGuideMutationPrepareDenialResourceContext, - ProjectGuideSourceSnapshotMutationResourceContext, + ProjectGuideSourceSnapshotMutationResourceContext, validate_policy_mutation_identity, ) from app.modules.actors.api import ServiceIdentity from app.modules.authorization.domain.post_policy import PostPolicyResourceContext @@ -491,29 +491,7 @@ class ProjectReviewPolicyMutationResourceContext(BaseModel): @model_validator(mode="after") def require_review_policy_identity(self): """Bind the resource selector to the review policy only.""" - if self.resource_id != self.review_policy_id: - raise ValueError("review policy resource must match policy") - if ( - len( - { - self.predecessor_policy_id is None, - self.predecessor_policy_generation is None, - self.current_policy_digest is None, - } - ) - != 1 - ): - raise ValueError("review policy predecessor facts must be bound together") - if self.policy_generation == 1 and self.predecessor_policy_id is not None: - raise ValueError("first review policy cannot have a predecessor") - if self.policy_generation > 1 and self.predecessor_policy_id is None: - raise ValueError("replacement review policy requires a predecessor") - if ( - self.predecessor_policy_generation is not None - and self.policy_generation != self.predecessor_policy_generation + 1 - ): - raise ValueError("review policy successor generation must be exact") - return self + return validate_policy_mutation_identity(self, self.review_policy_id, "review") class ProjectRevisionPolicyMutationResourceContext(BaseModel): @@ -539,29 +517,7 @@ class ProjectRevisionPolicyMutationResourceContext(BaseModel): @model_validator(mode="after") def require_revision_policy_identity(self): """Bind the resource selector to the revision policy only.""" - if self.resource_id != self.revision_policy_id: - raise ValueError("revision policy resource must match policy") - if ( - len( - { - self.predecessor_policy_id is None, - self.predecessor_policy_generation is None, - self.current_policy_digest is None, - } - ) - != 1 - ): - raise ValueError("revision policy predecessor facts must be bound together") - if self.policy_generation == 1 and self.predecessor_policy_id is not None: - raise ValueError("first revision policy cannot have a predecessor") - if self.policy_generation > 1 and self.predecessor_policy_id is None: - raise ValueError("replacement revision policy requires a predecessor") - if ( - self.predecessor_policy_generation is not None - and self.policy_generation != self.predecessor_policy_generation + 1 - ): - raise ValueError("revision policy successor generation must be exact") - return self + return validate_policy_mutation_identity(self, self.revision_policy_id, "revision") class ProjectPolicyMutationPrepareDenialResourceContext(BaseModel): diff --git a/backend/scripts/behavior_ownership.py b/backend/scripts/behavior_ownership.py index 4374190b2..729767948 100644 --- a/backend/scripts/behavior_ownership.py +++ b/backend/scripts/behavior_ownership.py @@ -174,6 +174,7 @@ "backend/app/modules/authorization/domain/lifecycle.py", "backend/app/modules/authorization/lifecycle_authorization.py", "backend/app/modules/authorization/prepared_lifecycle_replay.py", + "backend/app/modules/authorization/prepared_admin_authority.py", "backend/app/modules/reviews/lifecycle/service.py", }) REV_12A1_FENCE_TARGETS = frozenset({"backend/app/modules/reviews/api/lifecycle.py", "backend/app/modules/reviews/lifecycle/models.py", "backend/app/modules/reviews/lifecycle/fence.py"}) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 17ac1c250..5d0602da7 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -23,7 +23,7 @@ DDL_LOCK_DIRECTORY = Path("/tmp") # Match the PostgreSQL 16 engine used by Backend CI. Catalog identity rendering # differs across major versions; regenerate only after comparing actual objects. -EXPECTED_PUBLIC_SCHEMA_SHA256 = "4b466958f72c7dcbc0527a20091afe0802a412545023b54673a56e3576d01dc5" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "bbe49011920f47fda30db8ae80e77d50ba5914772e4a53c1aa1ee3d7f6e9a048" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", diff --git a/backend/tests/reviews/lifecycle/test_transition_storage.py b/backend/tests/reviews/lifecycle/test_transition_storage.py index 5eef527a0..90beaf9db 100644 --- a/backend/tests/reviews/lifecycle/test_transition_storage.py +++ b/backend/tests/reviews/lifecycle/test_transition_storage.py @@ -154,3 +154,25 @@ async def test_committed_authority_receipt_cannot_be_changed_or_manufactured(adm await session.commit() assert await snapshot() == before assert await transition(command) == receipt + + +async def test_backdated_history_cannot_evade_database_deadline(admin_access): + from datetime import timedelta + + await admin_access.signed.grant(admin_access.admin, admin_access.target) + async with get_session_factory()() as session: + deadline = await session.scalar(text("SELECT pg_catalog.clock_timestamp() - interval '1 second'")) + command = await command_for(admin_access.target.id, "shadow", deadline=deadline) + before = await snapshot() + async with get_session_factory()() as session: + with pytest.raises(DBAPIError, match="joint lifecycle transition custody invalid"): + await session.begin() + receipt = await issue_authority(session, command) + row = history_row(command, receipt) + row.created_at = deadline - timedelta(seconds=1) + # All command, audit and digest facts agree. A caller-controlled + # history timestamp must not make this expired transition valid. + await publish_history(session, row) + await session.commit() + assert await snapshot() == before + assert (await transition(await command_for(admin_access.target.id, "shadow"))).generation == 1 diff --git a/backend/tests/test_behavior_ownership.py b/backend/tests/test_behavior_ownership.py index 817772e79..7ae136596 100644 --- a/backend/tests/test_behavior_ownership.py +++ b/backend/tests/test_behavior_ownership.py @@ -2439,6 +2439,7 @@ def test_scoped_lifecycle_controller_has_exact_ownership() -> None: "backend/app/modules/authorization/domain/lifecycle.py", "backend/app/modules/authorization/lifecycle_authorization.py", "backend/app/modules/authorization/prepared_lifecycle_replay.py", + "backend/app/modules/authorization/prepared_admin_authority.py", "backend/app/modules/reviews/lifecycle/service.py", } assert ownership.REV_12A4A_CONTROL_TARGETS == expected From a644331276f3f255c1fc94ec16f32a6e4d399451 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Thu, 8 Oct 2026 18:41:55 +0100 Subject: [PATCH 06/17] fix(ci): register extracted preparation helper under AUTH ownership --- .ci/behavior-ownership/partition.v1.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.ci/behavior-ownership/partition.v1.json b/.ci/behavior-ownership/partition.v1.json index ba2cd927c..9dc0ccf8f 100644 --- a/.ci/behavior-ownership/partition.v1.json +++ b/.ci/behavior-ownership/partition.v1.json @@ -709,7 +709,7 @@ "target": "backend/app/modules/authorization/prepared.py" }, { - "group": "shared", + "group": "auth", "target": "backend/app/modules/authorization/prepared_admin_authority.py" }, { @@ -1733,7 +1733,7 @@ "target": "backend/scripts/validate_test_lane_evidence.py" } ], - "authority_digest": "6f884f95fbfd8e0d3613218ee629db963224f63bd856c0befc55f449d9c5a525", + "authority_digest": "eeba7e6c4053f1f0aa9e2fb5aaae44537b4dc44b4bbbbce01225aac39c4b9eb1", "protected_base_commit": "7676ce4347db0c9694962a9b587a20765e16eac6", "schema": "workstream.behavior-ownership-partition.v1" } From 79c901363f936e0f224c1398a15feceb18863759 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Thu, 8 Oct 2026 18:51:21 +0100 Subject: [PATCH 07/17] docs(reviews): mark scoped lifecycle controller delivered --- docs/spec_review_lifecycle.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/spec_review_lifecycle.md b/docs/spec_review_lifecycle.md index 8090b9a52..4621bb0a1 100644 --- a/docs/spec_review_lifecycle.md +++ b/docs/spec_review_lifecycle.md @@ -1173,7 +1173,7 @@ is a prerequisite of this bounded manifest. The initial controller implementation is [REV-12A4A](../.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md). -It remains planned. It extends the same Operator action, singleton and fence; +It extends the same Operator action, singleton and fence; it does not grant routing or acceptance authority. Every enabled writer must appear in its reviewed manifest. Server-derived observations are checked under the canonical lock. Unsupported surfaces and unlisted writers deny. Human From d5dbe52c12ca67ab1cc466541f1ad4d16825e97b Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Thu, 8 Oct 2026 19:18:09 +0100 Subject: [PATCH 08/17] test(auth): include scoped lifecycle action in audit allow inventory --- backend/tests/authorization/catalogue_fixtures.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/tests/authorization/catalogue_fixtures.py b/backend/tests/authorization/catalogue_fixtures.py index eab7817f5..27edafc10 100644 --- a/backend/tests/authorization/catalogue_fixtures.py +++ b/backend/tests/authorization/catalogue_fixtures.py @@ -439,6 +439,7 @@ AUDIT_ALLOWED_ACTION_VALUES = { + "review.lifecycle.activation.manage", "checker.post_submit.execute", "checker.post_submit.finalize", "artifact.post_submit.checker_input.materialize", From cf28cfc7ece4faaad1f189d7bd38939419de2a27 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Thu, 8 Oct 2026 19:26:43 +0100 Subject: [PATCH 09/17] test(reviews): share stopped-phase setup and align participant lane ownership --- .../WS-REV-001/WS-REV-001-12A4A.md | 10 +++++++++ backend/scripts/test_lane_catalogue.py | 2 +- .../lifecycle/test_participant_control.py | 22 +++++++++---------- backend/tests/test_ci_lane_catalogue.py | 2 +- 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md b/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md index 8333824d8..00d1dd02d 100644 --- a/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md +++ b/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md @@ -163,3 +163,13 @@ and preserves singleton identity/time while restoring only the isolated test DB. PostgreSQL stamps transition history time independently of caller input; an expired command with an explicitly backdated timestamp rejects and rolls back all three owners. This closes deadline custody without trusting application timestamps. + +The stopped-new-effect integration proof shares one source across sequential +`draining`, `disabled` and `shadow` transitions. Each denial uses a fresh caller +transaction and must preserve the original effect snapshot. This removes two +redundant graph setups while retaining all phase assertions and the separate +owner-isolation proofs. Hosted evidence also exposed inadequate TASK-lane +capacity. The whole lifecycle-participant module therefore runs beside the +existing acceptance and contribution participant modules in the three project +partitions, which have measured headroom. Exact node ownership/completeness is +retained; no execution limit is relaxed. diff --git a/backend/scripts/test_lane_catalogue.py b/backend/scripts/test_lane_catalogue.py index 3a5d0ee44..3f757ea00 100644 --- a/backend/scripts/test_lane_catalogue.py +++ b/backend/scripts/test_lane_catalogue.py @@ -269,6 +269,7 @@ class TestLane: "tests/reviews/acceptance/test_participant_contracts.py", "tests/reviews/acceptance/test_participation.py", "tests/reviews/acceptance/test_participation_transactions.py", + "tests/reviews/lifecycle/test_participant_control.py", "tests/tasks/post_submit_routing/test_evaluation_guard.py", "tests/tasks/post_submit_routing/test_evaluation_currentness.py", "tests/tasks/post_submit_routing/test_review_admission_currentness.py", @@ -398,7 +399,6 @@ class TestLane: ) TASK_MODULES = ( - "tests/reviews/lifecycle/test_participant_control.py", "tests/reviews/decision/test_contracts.py", "tests/reviews/decision/test_storage.py", "tests/reviews/decision/test_migration.py", diff --git a/backend/tests/reviews/lifecycle/test_participant_control.py b/backend/tests/reviews/lifecycle/test_participant_control.py index b0300df62..a97cf63a8 100644 --- a/backend/tests/reviews/lifecycle/test_participant_control.py +++ b/backend/tests/reviews/lifecycle/test_participant_control.py @@ -61,25 +61,23 @@ def observe(_connection, _cursor, statement, _parameters, _context, _many): assert facts["awards"] == len(instruments) -@pytest.mark.parametrize("phase", ["draining", "disabled", "shadow"]) async def test_current_generation_cannot_admit_new_effects_when_stopped( - tmp_path, isolated_database_env, live_acceptance_lifecycle, phase, + tmp_path, isolated_database_env, live_acceptance_lifecycle, ): access = live_acceptance_lifecycle async with contribution_source(tmp_path, isolated_database_env, persist_acceptance=False) as h: await prepare_review_pending(h) - for target in {"draining": ("draining",), "disabled": ("draining", "disabled"), - "shadow": ("draining", "disabled", "shadow")}[phase]: - receipt = await transition(await command_for(access.target.id, target)) - request = await request_for(h, expected_generation=receipt.generation) async with h.factory() as session: before = await stored_effects(session, h.acceptance.task_id) - async with h.factory() as session: - with pytest.raises(FinalAcceptanceConflict): - async with session.begin(): - await participant(session).participate(request) - async with h.factory() as session: - assert await stored_effects(session, h.acceptance.task_id) == before + for phase in ("draining", "disabled", "shadow"): + receipt = await transition(await command_for(access.target.id, phase)) + request = await request_for(h, expected_generation=receipt.generation) + async with h.factory() as session: + with pytest.raises(FinalAcceptanceConflict): + async with session.begin(): + await participant(session).participate(request) + async with h.factory() as session: + assert await stored_effects(session, h.acceptance.task_id) == before, phase async def test_operator_who_is_submitter_does_not_deadlock_prior_acceptance( diff --git a/backend/tests/test_ci_lane_catalogue.py b/backend/tests/test_ci_lane_catalogue.py index 15daa26da..a005325e3 100644 --- a/backend/tests/test_ci_lane_catalogue.py +++ b/backend/tests/test_ci_lane_catalogue.py @@ -63,6 +63,7 @@ def test_measured_hotspots_have_explicit_semantic_owners() -> None: "tests/reviews/acceptance/test_participant_contracts.py", "tests/reviews/acceptance/test_participation.py", "tests/reviews/acceptance/test_participation_transactions.py", + "tests/reviews/lifecycle/test_participant_control.py", "tests/tasks/post_submit_routing/test_evaluation_guard.py", "tests/tasks/post_submit_routing/test_evaluation_currentness.py", "tests/tasks/post_submit_routing/test_review_admission_currentness.py", @@ -202,7 +203,6 @@ def test_measured_hotspots_have_explicit_semantic_owners() -> None: "tests/authorization/submission_history/test_migration.py", "tests/authorization/submission_history/test_absence.py", "tests/authorization/submission_history/test_failures.py", - "tests/reviews/lifecycle/test_participant_control.py", "tests/reviews/decision/test_contracts.py", "tests/reviews/decision/test_storage.py", "tests/reviews/decision/test_migration.py", From 73b9d9862c1d148c7deec6c8e5e060191980f452 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 9 Oct 2026 13:43:06 +0100 Subject: [PATCH 10/17] fix(reviews): order lifecycle custody before authority and refresh state --- .../WS-REV-001/WS-REV-001-12A4A.md | 19 +++- backend/app/modules/reviews/api/lifecycle.py | 2 +- .../app/modules/reviews/lifecycle/service.py | 8 +- .../lifecycle/test_participant_control.py | 89 +++++++++++++++++++ .../reviews/lifecycle/test_transitions.py | 25 ++++++ .../reviews/lifecycle/transition_support.py | 2 +- docs/spec_authorization_service.md | 4 +- docs/spec_review_lifecycle.md | 5 +- 8 files changed, 145 insertions(+), 9 deletions(-) diff --git a/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md b/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md index 00d1dd02d..16c6dd874 100644 --- a/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md +++ b/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md @@ -33,12 +33,16 @@ remain the subsequent ARCH-04E2-B prerequisite before a production consumer. replay. REV owns legal edges: disabled -> shadow -> live -> draining -> disabled, plus shadow -> disabled. Each successful transition increments the generation. Database time governs deadlines. No transition seeds an allow. -3. Use existing AUTH PREP with live system Operator authority before the REV - advisory/controller lock. Bind the exact singleton, operation, expected +3. Acquire the REV advisory/controller fence before existing AUTH PREP obtains + live system Operator authority. This respects acceptance REV -> TASK and + task-read TASK -> AUTH custody; transitions never retain AUTH while waiting + for REV. The fence is mechanical serialization, not permission to change state. Bind the exact singleton, operation, expected generation, source/target phases, request, manifest and server observations. For this action only, AUTH locks the actor profile FOR NO KEY UPDATE, retaining link/grant FOR UPDATE: actor foreign-key reads may complete under a prior REV writer, while actor status changes/revocation still serialize. + Refresh the ORM controller from locked database state before assigning the + successor, even when the caller previously cached it. AUTH consumes after locked validation; history and controller change commit with its immutable decision in the caller's root transaction. Commit-time closure requires exactly one N/P -> N+1/Q update, history row and matching @@ -114,6 +118,9 @@ service locator; timeout/completeness/boundary weakening; unrelated cleanup. existing records survive. - Independent sessions prove writer-first and transition-first ordering through actual participant writes; rollback removes state/history/AUTH effects together. + A three-session read/acceptance/transition regression must observe acceptance + waiting on TASK and transition waiting on REV before resuming the reader. + A cached controller must accept a valid command after another transaction advances it. Managed and raw savepoints remain rejected by the canonical root fence. - New participant effects fail in disabled/shadow/draining and at generation zero. Mechanically composed live paid and unpaid controls preserve exact contribution and complete award @@ -173,3 +180,11 @@ capacity. The whole lifecycle-participant module therefore runs beside the existing acceptance and contribution participant modules in the three project partitions, which have measured headroom. Exact node ownership/completeness is retained; no execution limit is relaxed. + +External review reproduced a three-transaction cycle through actual task detail, +acceptance and transition owners, plus an ORM update that omitted the phase after +another session committed a transition. The repair orders the controller fence +before authority custody and refreshes the locked controller before mutation. +The regressions fail against the prior ordering and cached update at their actual +PostgreSQL boundaries; the prior two-session proof remains required. No runtime +activation or capability scope changes follow from these repairs. diff --git a/backend/app/modules/reviews/api/lifecycle.py b/backend/app/modules/reviews/api/lifecycle.py index e1f0c6d96..fc6861046 100644 --- a/backend/app/modules/reviews/api/lifecycle.py +++ b/backend/app/modules/reviews/api/lifecycle.py @@ -114,7 +114,7 @@ class LifecycleAuthorityReceipt(BaseModel): class PreparedLifecycleTransition(Protocol): - """AUTH-owned live Operator custody held before acquiring REV locks.""" + """AUTH-owned live Operator custody acquired under the REV controller fence.""" async def consume_new(self, facts: LifecycleTransitionFacts) -> LifecycleAuthorityReceipt: """Append exact authorization evidence in the caller's transaction.""" diff --git a/backend/app/modules/reviews/lifecycle/service.py b/backend/app/modules/reviews/lifecycle/service.py index 177bdf7f8..9bd14f931 100644 --- a/backend/app/modules/reviews/lifecycle/service.py +++ b/backend/app/modules/reviews/lifecycle/service.py @@ -32,7 +32,7 @@ class JointLifecycleController: - """Flush-only REV owner; AUTH locks precede the shared REV mutation fence.""" + """Flush-only REV owner; the shared fence precedes AUTH principal custody.""" def __init__(self, session: AsyncSession, *, authorization: LifecycleTransitionAuthorization): self._session = session @@ -42,8 +42,8 @@ def __init__(self, session: AsyncSession, *, authorization: LifecycleTransitionA async def transition(self, command: LifecycleTransitionCommand) -> LifecycleTransitionReceipt: """Commit control, history and exact AUTH together, or let the caller roll back.""" checked = LifecycleTransitionCommand.model_validate(command) + current = await self._fence.lock_controller() async with self._authorization.lock_scope(checked) as authority: - current = await self._fence.lock_controller() history = await self._session.scalar(select(JointLifecycleTransition).where( JointLifecycleTransition.operation_id == checked.operation_id, )) @@ -93,7 +93,9 @@ async def transition(self, command: LifecycleTransitionCommand) -> LifecycleTran ) self._session.add(row) await self._session.flush() - control = await self._session.get(JointLifecycleReleaseControl, current.singleton_id) + control = await self._session.get( + JointLifecycleReleaseControl, current.singleton_id, populate_existing=True, + ) control.phase, control.generation, control.transition_id = row.phase, row.generation, row.id await self._session.flush() return _receipt(row) diff --git a/backend/tests/reviews/lifecycle/test_participant_control.py b/backend/tests/reviews/lifecycle/test_participant_control.py index a97cf63a8..2dd485ea9 100644 --- a/backend/tests/reviews/lifecycle/test_participant_control.py +++ b/backend/tests/reviews/lifecycle/test_participant_control.py @@ -244,3 +244,92 @@ async def test_each_owner_independently_rejects_new_effects( owner._repository.persist.assert_not_awaited() async with h.factory() as session: assert await stored_effects(session, h.acceptance.task_id) == before + + +async def test_task_read_acceptance_and_transition_intermediate_waits( + tmp_path, isolated_database_env, live_acceptance_lifecycle, monkeypatch, +): + from app.adapters.audit import task_transition_audit + from app.adapters.tasks import task_commands + from app.core.config import get_settings + from app.modules.authorization.task_authorization import PreparedTaskAuthorization + from tests.authorization.task_authority.test_concurrency import actor_context + + access = live_acceptance_lifecycle + async with contribution_source(tmp_path, isolated_database_env, persist_acceptance=False) as h: + await prepare_review_pending(h) + actor_id = h.acceptance.accepted_submitter_id + await access.signed.grant(access.admin, SimpleNamespace(id=actor_id)) + actor = await actor_context(str(actor_id)) + command = await command_for(actor_id, "draining") + request = await request_for(h) + task_locked, proceed = asyncio.Event(), asyncio.Event() + original = PreparedTaskAuthorization.prepare + + async def pause_after_task(owner, facts): + task_locked.set() + await proceed.wait() + return await original(owner, facts) + + monkeypatch.setattr(PreparedTaskAuthorization, "prepare", pause_after_task) + pids = {} + + async def identify(session, name): + pids[name] = await session.scalar(text("SELECT pg_catalog.pg_backend_pid()")) + + async def read(): + async with h.factory() as session: + await identify(session, "read") + await session.commit() + owner = task_commands( + session, settings=get_settings(), authorization=PreparedTaskAuthorization(session, actor), + audit=task_transition_audit(session), actor_profile_id=actor_id, + ) + return await owner.management_detail(h.acceptance.project_id, h.acceptance.task_id) + + async def accept(): + async with h.factory() as session, session.begin(): + await identify(session, "accept") + return await participant(session).participate(request) + + async def stop(): + async with h.factory() as session, session.begin(): + await identify(session, "stop") + return await controller(session, command).transition(command) + + async def wait_for_blocker(waiter, blocker, pending): + async with h.factory() as observer, asyncio.timeout(10): + while True: + if pending.done(): + await pending + raise AssertionError("expected intermediate database wait") + if waiter in pids and await observer.scalar(text( + "SELECT :blocker = ANY(pg_catalog.pg_blocking_pids(:waiter))" + ), {"blocker": pids[blocker], "waiter": pids[waiter]}): + return + await asyncio.sleep(0.01) + + jobs = [asyncio.create_task(read())] + try: + await asyncio.wait_for(task_locked.wait(), 10) + jobs.append(asyncio.create_task(accept())) + # Acceptance retains REV while the real task read retains TASK. + await wait_for_blocker("accept", "read", jobs[1]) + jobs.append(asyncio.create_task(stop())) + # Transition must wait for REV without retaining the reader's actor. + await wait_for_blocker("stop", "accept", jobs[2]) + proceed.set() + detail, accepted, stopped = await asyncio.wait_for(asyncio.gather(*jobs), 20) + assert detail.task_id == h.acceptance.task_id + assert accepted.acceptance.id == h.acceptance.id + assert stopped.phase == "draining" and stopped.generation == 3 + finally: + proceed.set() + for job in jobs: + if not job.done(): + job.cancel() + await asyncio.gather(*jobs, return_exceptions=True) + async with h.factory() as session: + facts = await stored_effects(session, h.acceptance.task_id) + assert facts["task_status"] == "accepted" + assert facts["acceptances"] == facts["contributions"] == 1 diff --git a/backend/tests/reviews/lifecycle/test_transitions.py b/backend/tests/reviews/lifecycle/test_transitions.py index 18d3ca5ed..ffb885725 100644 --- a/backend/tests/reviews/lifecycle/test_transitions.py +++ b/backend/tests/reviews/lifecycle/test_transitions.py @@ -167,3 +167,28 @@ async def test_shared_reset_restores_activated_controller(admin_access, isolated await session.execute(text( "UPDATE public.joint_lifecycle_release_control SET phase='live',generation=1" )) + + +async def test_transition_refreshes_controller_cached_before_another_commit(admin_access): + from app.modules.reviews.lifecycle.models import JointLifecycleReleaseControl + + await admin_access.signed.grant(admin_access.admin, admin_access.target) + async with get_session_factory()() as session: + cached = await session.scalar(select(JointLifecycleReleaseControl)) + assert (cached.phase, cached.generation) == ("disabled", 0) + await session.commit() + advanced = await transition(await command_for(admin_access.target.id, "shadow")) + assert advanced.generation == 1 + command = await command_for(admin_access.target.id, "disabled") + assert (cached.phase, cached.generation) == ("disabled", 0) + async with session.begin(): + receipt = await controller(session, command).transition(command) + assert (cached.phase, cached.generation) == ("disabled", 2) + async with get_session_factory()() as session: + stored = await session.get(JointLifecycleReleaseControl, command.singleton_id) + history = await session.scalar(select(JointLifecycleTransition).where( + JointLifecycleTransition.operation_id == command.operation_id, + )) + assert (stored.phase, stored.generation, stored.transition_id) == ("disabled", 2, history.id) + assert (history.previous_phase, history.phase) == ("shadow", "disabled") + assert history.authorization_decision_event_id == str(receipt.authorization_decision_event_id) diff --git a/backend/tests/reviews/lifecycle/transition_support.py b/backend/tests/reviews/lifecycle/transition_support.py index 7202905c8..c63a0d7d6 100644 --- a/backend/tests/reviews/lifecycle/transition_support.py +++ b/backend/tests/reviews/lifecycle/transition_support.py @@ -75,8 +75,8 @@ def facts_for(command): async def issue_authority(session, command): """Real PREP and root locking; caller must attach the atomic transition.""" owner = controller(session, command) + await owner._fence.lock_controller() async with owner._authorization.lock_scope(command) as prepared: - await owner._fence.lock_controller() return await prepared.consume_new(facts_for(command)) diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index c8de5d4d1..a09676b91 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -442,7 +442,9 @@ while the exact activation wave installs the corresponding AUTH evaluator. REV-12A4A now activates the internal `review.lifecycle.activation.manage` slice with a strict nested command/observations resource owned by AUTH's lifecycle contract. It replaces the earlier inert scalar shape. Live system Operator -PREP precedes the REV root fence; consumption binds the singleton, operation, +PREP follows the REV root fence, so a transition cannot retain actor custody +while waiting for an acceptance writer that needs TASK. The fence grants no +authority; consumption still precedes effects and binds the singleton, operation, actor/link, generation, phases, deadline, manifest and observations. Controller, immutable history and exact AUTH evidence commit together. Same-command replay requires fresh same-actor authority and writes nothing. Other review actions diff --git a/docs/spec_review_lifecycle.md b/docs/spec_review_lifecycle.md index 4621bb0a1..9ed7bb959 100644 --- a/docs/spec_review_lifecycle.md +++ b/docs/spec_review_lifecycle.md @@ -1143,7 +1143,10 @@ shutdown stages in historical plans are not additional current persisted phases. REV-12A1 alone implemented no transition or usable generation. REV-12A4A now extends it with Operator-authorized legal adjacency, immutable history, exact -AUTH/controller closure and current-generation read-only replay. It does not +AUTH/controller closure and current-generation read-only replay. Transitions take +the REV fence before AUTH control and principal locks; acceptance takes REV +before TASK, and task reads take TASK before AUTH. Controller mutation refreshes +any cached ORM state from the already locked database row. It does not activate an acceptance consumer or fulfillment. Actual CON root storage and real authorized writer-versus-cutoff proof are required before a successor manifest activates fulfillment admission, dispatch or callbacks. They do not From 603b0426bb69af6e1c2432b9b750bd5a464b9ae8 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 9 Oct 2026 13:54:30 +0100 Subject: [PATCH 11/17] docs: reconcile merged task compensation capability --- docs/roadmap_status.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index e43aabc0a..0c2ee0128 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -111,7 +111,7 @@ Exact assignment-invalidation authority, atomic originating publication and production delivery with enforced prefork topology are implemented. Manager task create/screen/release now use exact project authority, atomic evidence and durable replay. ARCH-03C4 adds the three exact-authorized public task queues. PILOT-14 -remains open pending human merge; its intended outcome adds locked +adds locked ContributionPolicyVersion compensation to ready rows and Contributor detail for active exact-project Submitters or Reviewers, without Finance internals or claim authority. ARCH-03C5 adds exact-authorized Contributor/Manager @@ -228,7 +228,7 @@ cannot be reused as post-submission review-gate evidence. See the | Project Guide source custody | **Live foundation** | Guide creation declares documents and task examples; public document upload; immutable internal metadata snapshots; exact run-scoped setup reads and assigned-task locked-original reads; S3-backed originals and isolated agent document inspection; exact document generation through separate manager approvals and public exact-authorized CP07 activation | Public activation-to-intake drill after evaluation/remediation prerequisites; prove each enabled document reader | | Unified Project Guide compilation | **Live setup and manager proposal operations** | Committed original-document readiness dispatches one immutable attempt through Celery; complete result and crash/recovery custody; distinct pre/post proposals; deterministic sufficiency and submission-artifact-policy projections; immutable authorized setup finalization; public exact manager review, pre-submit approval and manual correction dispatch; automatic deterministic post-policy derivation, public complete policy read, separate approval and shared correction custody | Public intake cutover after evaluation/remediation prerequisites | | Contribution policy administration | **Public Finance policy workflow; binding administration internal** | Internal Finance Authority adapter-binding lifecycle; public ContributionPolicy discovery/read/create/update/publish/retire with exact Finance Authority and recoverable draft selectors; immutable operation and event history; internal exact selected-version validation; CP07 binding with live exact-project manager authority of the selected published version to the active guide generation | Public intake cutover after evaluation/remediation prerequisites | -| Task readiness and claim | **Foundation with grant-backed manager and contributor commands** | Task records, assignments and locked work context; guide-bound ContributionPolicyVersion locked before `READY` and copied to TaskAssignment; detached project/guide display; public project-scoped ready, management and operational queues with distinct current grant authority and signed bounded live pagination; public contributor/management detail with exact project and assignment authority; separate current live work-context projections with exact receipt-selected review/revision/ContributionPolicy identities and no obsolete economic fields; explicit management/operational/audit locked-context projections using one historical resolver, with separate public manager, system-Operator and Audit Authority access; immutable contributor/management requirements using one historical translator, with public exact Contributor and Manager authority; bounded public Audit Authority lifecycle evidence with atomic project/task scoping and exact transition references; public manager create/screen/release use exact covered Project Manager authority, atomic audit and replay; claim/start/contributor context use exact-project Submitter grants; assigned context lists documents only and distinct `task.guide.read` verifies locked originals before delivery; separate manager context and system-Operator start; durable claim/start retry receipts with fresh authority and exact assignment checks; hidden exact-assignment invalidation with committed cause verification, delivery fencing, exact fixed-service authority and decision-bound immutable release evidence; atomic authority-loss publication and registered prefork delivery | PILOT-14 remains open pending human merge: add both locked contribution types to ready rows and Contributor detail for exact-project Submitters or Reviewers as unpaid or exact instrument/unit/quantity awards, without Finance binding facts. Public intake cutover follows evaluation/remediation prerequisites | +| Task readiness and claim | **Foundation with grant-backed manager and contributor commands** | Task records, assignments and locked work context; guide-bound ContributionPolicyVersion locked before `READY` and copied to TaskAssignment; detached project/guide display; public project-scoped ready, management and operational queues with distinct current grant authority and signed bounded live pagination; public contributor/management detail with exact project and assignment authority; separate current live work-context projections with exact receipt-selected review/revision/ContributionPolicy identities and no obsolete economic fields; explicit management/operational/audit locked-context projections using one historical resolver, with separate public manager, system-Operator and Audit Authority access; immutable contributor/management requirements using one historical translator, with public exact Contributor and Manager authority; bounded public Audit Authority lifecycle evidence with atomic project/task scoping and exact transition references; public manager create/screen/release use exact covered Project Manager authority, atomic audit and replay; claim/start/contributor context use exact-project Submitter grants; assigned context lists documents only and distinct `task.guide.read` verifies locked originals before delivery; separate manager context and system-Operator start; durable claim/start retry receipts with fresh authority and exact assignment checks; hidden exact-assignment invalidation with committed cause verification, delivery fencing, exact fixed-service authority and decision-bound immutable release evidence; atomic authority-loss publication and registered prefork delivery; PILOT-14 exposes both locked contribution types in ready rows and Contributor detail for active exact-project Submitters or Reviewers as unpaid or exact instrument/unit/quantity awards, without Finance binding facts; claim/start remain Submitter-only | Public intake cutover follows evaluation/remediation prerequisites | | Contributor artifact preparation | **Hidden and proven** | One outer ZIP; bounded scratch inspection; canonical manifest; shared evaluation-content capacity and exact locked-policy checks before attempts or durable intent; platform and project prechecks; unchanged-work rejection; durable put intent; verification; capacity-charged ready admission; hidden final handoff validates the exact activated historical guide through owner ports | Complete the later public admission-only cutover | | Pre-submission intake checking | **Hidden with approved-guide lineage** | Separate versioned pre-submission catalogue, locked effective-plan compilation, platform/project checks during continuous preparation, blocking feedback before Submission creation, and one internal phase command covering execution/replay with the JSON precheck removed; ARCH-03D connects approved-guide lineage through the final durable handoff | Complete the canonical public cutover after evaluation/remediation prerequisites; passing intake must never substitute for post-submit evaluation | | Immutable Submission creation | **Hidden foundation; public packet creation retired** | Contributor preparation authority; durable pre-submit reservation and exact completed-evidence recovery without rerunning checks; atomic admission consumption; TASK-owned admission-backed creation with exact assignment ContributionPolicyVersion, locked policy lineage and service/database checked-packet custody; fixed-service artifact binding; replay/concurrency/rollback proof | Finish downstream evaluation and the canonical public integration. The retained submission-list GET is not a usable creation POST | @@ -343,7 +343,7 @@ cannot be reused as post-submission review-gate evidence. See the [CLI contributor discovery](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-05.md) adds ready-task pages and contributor instructions through public reads, preserving exact-project contributor scope and assignment visibility without - claiming work. PILOT-14 remains open pending human merge; its intended outcome + claiming work. PILOT-14 displays the task-locked ContributionPolicyVersion UUID and both unpaid or exact award terms in ready rows and task detail to a Submitter or Reviewer, while claim/start remain Submitter-only. @@ -906,7 +906,7 @@ remaining trace sequence is: invalidation has atomic producer fan-out and registered delivery in ARCH-03C2. Manager readiness commands are public with exact authority in ARCH-03C3; ARCH-03C4 exposes ready, management and Operator-status queues. PILOT-14 - remains open pending human merge; its intended outcome adds contributor-safe + adds contributor-safe locked compensation to ready/detail without restoring removed task payment fields or exposing Finance bindings. ARCH-03C5 exposes task detail and requirements; ARCH-03C6 exposes distinct From 70a71ecfbede251441b409cd75678465a946c073 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 9 Oct 2026 14:05:43 +0100 Subject: [PATCH 12/17] docs: align remaining lifecycle lock-order references --- docs/architecture_data_model.md | 4 ++-- docs/spec_review_lifecycle.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index e52da4396..4025ed0f8 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -2313,8 +2313,8 @@ exact transition reference. Each generation increment requires one immutable `joint_lifecycle_transitions` row and its exact Operator AUTH event in the same transaction. Database closure rejects missing or substituted custody; controller identity and creation time remain unchanged. The root fence takes its advisory -lock before the controller row. The transition operation takes AUTH custody -first; the fence itself grants no business authority. New participant effects +lock before the controller row. The transition operation takes this REV fence +before AUTH custody; the fence itself grants no business authority. New participant effects require live/nonzero, while terminal replay stays read-only under the current generation. Fulfillment roots, ordinals and cutoffs remain deferred until fulfillment activation; conditional awards are separate atomic facts. diff --git a/docs/spec_review_lifecycle.md b/docs/spec_review_lifecycle.md index 9ed7bb959..ec316b040 100644 --- a/docs/spec_review_lifecycle.md +++ b/docs/spec_review_lifecycle.md @@ -1046,8 +1046,8 @@ revalidation. - Guards: one canonical singleton, exact generation/phase/digest, legal adjacent transition, required drain/cutoff readiness, exact replay or changed-replay conflict. Lease force release keeps its own action. -- Transaction revalidation: prepared authority, shared/exclusive advisory fence, - row locks, final observations, one caller commit. +- Transaction revalidation: root advisory fence and controller row, then AUTH + control and principal custody, final observations, one caller commit. - Hidden behavior dependency: `WS-REV-001-12A1` through `WS-REV-001-12A4`. ## Fixed Service Identity Manifests From f81867ef313941320095e5386063d08ae1237370 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 9 Oct 2026 14:12:41 +0100 Subject: [PATCH 13/17] docs: reconcile planned review mutations with owner lock order --- .../WS-REV-001/WS-REV-001-12A4A.md | 8 +++- docs/operations_authorization_service.md | 12 +++-- docs/spec_contribution_compensation.md | 15 ++++-- docs/spec_review_lifecycle.md | 46 +++++++++++-------- 4 files changed, 52 insertions(+), 29 deletions(-) diff --git a/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md b/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md index 16c6dd874..dc9d0c624 100644 --- a/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md +++ b/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md @@ -95,7 +95,7 @@ remain the subsequent ARCH-04E2-B prerequisite before a production consumer. - Exact behavior ownership and lane inventory files/tests; shrink affected frozen AUTH structural debt through cohesive owner extraction, then refresh fingerprints without adding debt or relaxing limits. No CI gate changes. - This record; REV/AUTH/CON/ARCH/POL current overviews, ARCH/AUTH/POL current plans/maps, Commitrail index; canonical review/compensation specifications, - relevant authorization custody and roles/permissions docs, canonical authorization/data-model specifications, README and roadmap. Local roadmap exports + relevant authorization custody, operations and roles/permissions docs, canonical authorization/data-model specifications, README and roadmap. Local roadmap exports only if present. Historical completed records remain historical. ## Prohibited @@ -188,3 +188,9 @@ before authority custody and refreshes the locked controller before mutation. The regressions fail against the prior ordering and cached update at their actual PostgreSQL boundaries; the prior two-session proof remains required. No runtime activation or capability scope changes follow from these repairs. + +Current planned review claim/decision and compensation choreography also obeys +REV-before-TASK-before-AUTH custody, with TASK before CHECKERS currentness. Their +exact idempotency, queue, lease and dependent-row order still requires the owning +runtime chunk's PostgreSQL proof before activation; this correction activates +none of those surfaces. Historical pre-cutover records remain unchanged. diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 8d1f36784..81595a8cb 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -590,8 +590,11 @@ ART-owned `artifact.verification_job.retry` action through `ArtifactOperatorRecoveryPort`; shared outbox dispatch/retry remains outside REV ownership. -Review and other sensitive mutations must wait for `WS-AUTH-001-PREP`. That -protocol locks `AuthorityControl(id=1)` first when final-admin safety applies, +Sensitive mutations use `WS-AUTH-001-PREP` within each feature owner's lock +contract. REV-fenced review mutations acquire REV and TASK parent custody before +AUTH preparation; TASK also precedes any required CHECKERS currentness custody. +Human review runtime remains planned. Within AUTH custody, the protocol locks +`AuthorityControl(id=1)` first when final-admin safety applies, orders multiple principals by ActorProfile ID, then locks each human profile, exact link, and exact matched grant or each service profile and exact link. Service identity, static matrix membership, and action availability are @@ -599,8 +602,9 @@ code-owned validations, not database lock targets. Only then does AUTH create an internal, non-Pydantic `PreparedAuthorizationHandle` bound to the exact session, ActionId, actor reference kind, actor reference, idempotency key, and canonical request digest. It is never a route schema or caller input. Consumption matches -every binding before the feature locks rows and recomposes final facts, then AUTH -evaluates and stages evidence once before one route/service-command commit. +every binding against the final facts recomposed from locked feature rows, then +AUTH evaluates and stages evidence once before durable feature effects and one +route/service-command commit. Crossed tests must cover link revoke, actor suspend/deactivate, exact grant revoke, final-admin mutation, and same-session/action cross-actor or cross-request substitution. Never serialize or reuse the handle, let dependency diff --git a/docs/spec_contribution_compensation.md b/docs/spec_contribution_compensation.md index 87a35869e..15a25719b 100644 --- a/docs/spec_contribution_compensation.md +++ b/docs/spec_contribution_compensation.md @@ -591,13 +591,14 @@ shared acceptance operation must add shared audit/outbox staging. A combined request carrying nullable FinalAcceptance or both actors' source and policy facts is prohibited. -Human decision order (false routing omits this reviewer operation): +Planned human decision order (false routing omits this reviewer operation): ```text -AUTH locks and revalidates exact reviewer authority --> AUTH prepares review.decision for this session/action/actor/request --> REV locks idempotency, release fence, queue, lease, task, assignment, - Submission, predecessor Review, findings, and resolutions +REV acquires its root lifecycle fence, then task, assignment and Submission +-> REV acquires queue, lease, predecessor Review, findings and resolutions + in the future decision chunk's proven deterministic order +-> AUTH locks exact reviewer authority and prepares review.decision + for this session/action/actor/request -> REV recomposes final typed resource facts -> AUTH consumes the handle, evaluates once, and stages decision evidence -> REV appends immutable Review, findings, and resolutions @@ -608,6 +609,10 @@ AUTH locks and revalidates exact reviewer authority -> request route or service command commits once ``` +Exact idempotency and dependent-row ordering remains part of the decision +chunk's required PostgreSQL concurrency proof before activation. AUTH custody +must not precede the REV fence or TASK parent locks. + ### Reviewer operation The reviewer operation runs for every valid `accept`, `needs_revision`, or diff --git a/docs/spec_review_lifecycle.md b/docs/spec_review_lifecycle.md index ec316b040..3d1ab0801 100644 --- a/docs/spec_review_lifecycle.md +++ b/docs/spec_review_lifecycle.md @@ -138,16 +138,16 @@ freshly verify the Flow token and resolve canonical ActorProfile.id -> create no ReviewLease, packet manifest, queue mutation, or policy freeze ``` -`POST /api/v1/reviews/claim` uses this exact AUTH-first order: +`POST /api/v1/reviews/claim` must respect this planned cross-owner order: ```text -freshly verify the Flow token +freshly verify the Flow token and start the caller root transaction +-> acquire the shared REV lifecycle fence +-> lock and revalidate Task, TaskAssignment, and Submission +-> acquire required CHECKERS currentness and claim-owned REV facts + in the owning chunk's proven order, always TASK before CHECKERS -> AUTH PREP review.claim with exact request bindings --> lock claim idempotency --> lock the review lifecycle fence --> lock ReviewQueueEntry --> lock Task, TaskAssignment, Submission, and CheckerRun facts --> recompose canonical final facts +-> recompose canonical final locked facts -> AUTH validates all prepared-handle bindings, consumes the handle once, evaluates exact current authority once, and stages bounded evidence -> verify canonical admission's Submission-stamped ContributionPolicyVersion, copy it @@ -156,17 +156,23 @@ freshly verify the Flow token -> stage audit/outbox rows and commit once ``` +The owning claim chunk must define and prove the exact idempotency, queue and +source-row order before activation. This partial order does not enable claim. + Any denial or race before the append follows the prepared-protocol rollback path and creates no lease, manifest, policy freeze, audit, or product outbox effect. ## Prepared Mutation Protocol -Every protected review/revision mutation uses the AUTH-owned prepared protocol: +Protected review/revision mutations use the AUTH-owned prepared protocol under +their owner's lock contract. REV-fenced operations must establish REV before +TASK parent custody and TASK before any CHECKERS currentness locks; they must +not retain AUTH principal custody while waiting for those earlier locks: ```text -AUTH locks current authority and returns an opaque prepared handle --> REV locks canonical feature rows --> REV recomposes final typed facts +feature owner establishes its required root fence and canonical feature custody +-> AUTH locks current authority and returns an opaque prepared handle +-> REV recomposes final typed facts from the locked rows -> AUTH validates bindings and current authority, consumes once, evaluates once, and stages bounded decision evidence -> REV, task, ART, CON, audit, and outbox participants flush @@ -427,17 +433,16 @@ these contracts and storage tables do not supply authority. No canonical Review may commit without the mandatory WS-CON flush-only participant. No production or test no-op participant exists. -Every valid decision follows this order: +The planned decision operation must respect this cross-owner order: ```text -freshly verify the Flow token +freshly verify the Flow token and start the caller root transaction +-> acquire the shared REV lifecycle fence +-> lock Task, the exact Submission.task_assignment_id row, and Submission +-> lock ReviewLease, ReviewQueueEntry, predecessor Review, finding/resolution + lineage and stabilized binding facts in the owning chunk's proven order -> AUTH PREP review.decision with exact request bindings --> lock review idempotency --> lock the review lifecycle fence --> lock ReviewLease, ReviewQueueEntry, task, the exact - Submission.task_assignment_id row, Submission, - predecessor Review, finding/resolution lineage, and stabilized binding facts --> recompose canonical final facts +-> recompose canonical final locked facts -> AUTH validates all prepared-handle bindings, consumes the handle once, evaluates exact current authority once, and stages bounded evidence -> append immutable Review, submitted findings, and resolutions @@ -450,6 +455,9 @@ freshly verify the Flow token -> request route or service command commits once ``` +The decision chunk must define and prove its exact idempotency, queue, lease +and source-row order before activation; this sequence does not expose a route. + The decision transaction performs no ART capability call, provider I/O, or contribution-evidence projection. It consumes the stabilized server-derived Submission `artifact_hash` as lineage. From 69c58112a11d8409bcc8769f3d5168a05f48b444 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 9 Oct 2026 14:15:48 +0100 Subject: [PATCH 14/17] docs: bind generic PREP guidance to owner lock contracts --- docs/engineering/authorization_activation_custody.md | 12 ++++++++---- docs/operations_authorization_service.md | 10 ++++++---- docs/spec_authorization_service.md | 12 +++++++++--- docs/spec_contribution_compensation.md | 10 +++++++--- 4 files changed, 30 insertions(+), 14 deletions(-) diff --git a/docs/engineering/authorization_activation_custody.md b/docs/engineering/authorization_activation_custody.md index 1c5ea72aa..1f3adbf19 100644 --- a/docs/engineering/authorization_activation_custody.md +++ b/docs/engineering/authorization_activation_custody.md @@ -204,12 +204,16 @@ it. Any activation requires a separate approved REV-owned intent. ## Completed prepared mutation prerequisite `WS-AUTH-001-PREP` delivered a session-bound, action-bound, opaque, single-use, -nonserializable prepared authority handle: +nonserializable prepared authority handle. Lock placement follows the feature +owner's contract: REV-fenced operations take REV and required TASK custody before +AUTH, with TASK before required CHECKERS currentness; other operations retain +their owner-specific order. No human review runtime is enabled by this protocol: ```text -AUTH locks canonical current authority --> feature locks its records --> feature recomposes final typed facts +feature establishes prerequisite custody under its owner lock contract +-> AUTH prepares canonical current authority +-> feature completes required custody without reversing that contract + and recomposes final typed facts -> AUTH evaluates exactly once and stages decision evidence -> feature participants flush -> route or service command commits once diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 81595a8cb..7958c490b 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -591,8 +591,9 @@ ART-owned `artifact.verification_job.retry` action through REV ownership. Sensitive mutations use `WS-AUTH-001-PREP` within each feature owner's lock -contract. REV-fenced review mutations acquire REV and TASK parent custody before -AUTH preparation; TASK also precedes any required CHECKERS currentness custody. +contract. REV-fenced review mutations acquire REV before AUTH preparation; +when an operation touches TASK or CHECKERS, required TASK parent custody precedes +AUTH and any required CHECKERS currentness custody. Human review runtime remains planned. Within AUTH custody, the protocol locks `AuthorityControl(id=1)` first when final-admin safety applies, orders multiple principals by ActorProfile ID, then locks each human profile, @@ -624,8 +625,9 @@ Project Manager `project.guide_compilation.review_package.read`, `project.guide_compilation.correction.request`; POL-05B exposes these with exact Project Manager authority and manual correction dispatch. Checker, review, generic artifact-read, and the public Submission cutover remain planned. Callers begin and own one root -transaction, call `prepare`, -lock their participant rows, compose final typed facts, call `consume` with the +transaction and follow their owner's lock contract for participant rows and +`prepare`; REV-fenced operations acquire REV and any required TASK custody +before preparation. They compose final locked facts and call `consume` with the independently expected ActionId and the same strict request/idempotency input, flush participant work, and commit once. AUTH never commits in dependency teardown. Roll back the caller transaction on denial, evidence/SQL failure, diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index a09676b91..90afedb97 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -845,16 +845,22 @@ denies even when both actions share one `PermissionId`. An own-row action still denies while its availability is `planned`. Sensitive mutations use the prepared protocol instead of evaluating final -authority against unlocked feature facts: +authority against unlocked feature facts. Each action retains its owner-specific +lock contract; REV-fenced operations acquire REV and any required TASK custody +before AUTH preparation, with TASK before required CHECKERS currentness locks. +The authority-row order below applies within AUTH custody, not ahead of required +owner locks: ```text -AUTH locks AuthorityControl first when final-admin safety applies +feature establishes the prerequisite custody required by its owner contract +-> AUTH locks AuthorityControl first within AUTH when final-admin safety applies -> AUTH orders principals by ActorProfile ID -> human: ActorProfile -> exact ActorIdentityLink -> exact matched grant -> service: ActorProfile -> exact ActorIdentityLink -> code-owned validations -> AUTH creates one internal non-Pydantic PreparedAuthorizationHandle bound to session, action, actor reference, idempotency key, and request digest --> feature locks its canonical rows and recomposes final typed facts +-> feature completes any remaining owner-ordered custody and recomposes final + typed facts, without reversing its lock contract -> AUTH consumes the handle, evaluates once, and stages decision evidence -> feature participants flush -> route or service command commits once diff --git a/docs/spec_contribution_compensation.md b/docs/spec_contribution_compensation.md index 15a25719b..a3fac1ec9 100644 --- a/docs/spec_contribution_compensation.md +++ b/docs/spec_contribution_compensation.md @@ -785,8 +785,11 @@ CompensationAward inserts inside `review.decision` add no ### Prepared mutations -For mutations, AUTH first locks and revalidates current human actor/link/exact -grant rows or fixed-service actor/link rows. AUTH returns one opaque, +Mutations follow their owning feature's lock contract. REV-fenced operations +acquire REV and any required TASK custody before AUTH; TASK precedes required +CHECKERS currentness custody. Other callers retain their owner-specific order. +During preparation, AUTH locks and revalidates current human actor/link/exact +grant rows or fixed-service actor/link rows and returns one opaque, non-serializable, single-use `PreparedAuthorizationHandle` bound to: - caller session; @@ -795,7 +798,8 @@ non-serializable, single-use `PreparedAuthorizationHandle` bound to: - idempotency key; - canonical request digest. -The owning feature then locks product rows and recomposes final typed facts. +With all required product and authority locks retained, the owning feature +recomposes final typed facts. AUTH consumes the handle, evaluates exactly once, and stages evidence. Reused, serialized, caller-constructed, cross-session/action/actor/request, binding-mismatched, or authority-lost handles fail before product mutation. A From c7ef0146e6d9780d2d49d594209e274a3e012feb Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 9 Oct 2026 14:18:18 +0100 Subject: [PATCH 15/17] docs: name scoped lifecycle PREP availability --- docs/operations_authorization_service.md | 7 +++++-- docs/spec_authorization_service.md | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 7958c490b..6b32c26e6 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -623,8 +623,11 @@ hidden human `submission.create`, and fixed-service Project Manager `project.guide_compilation.review_package.read`, `project.submission_artifact_policy.approve`, and `project.guide_compilation.correction.request`; POL-05B exposes these with -exact Project Manager authority and manual correction dispatch. Checker, review, generic artifact-read, and -the public Submission cutover remain planned. Callers begin and own one root +exact Project Manager authority and manual correction dispatch. REV-12A4A also +supports the internal `review.lifecycle.activation.manage` action as the sole +active review exception. Human review and generic artifact-read remain planned, +as does the public Submission cutover. The controller activates no public +review route, routing, acceptance or payment delivery. Callers begin and own one root transaction and follow their owner's lock contract for participant rows and `prepare`; REV-fenced operations acquire REV and any required TASK custody before preparation. They compose final locked facts and call `consume` with the diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 90afedb97..88a094f6e 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -873,8 +873,11 @@ ART foundation service actions, Project Manager `artifact.guide_source.read` action, contributor `artifact.submission_bundle.prepare`, hidden human `submission.create`, and fixed-service `artifact.submission.binding.create`. -Checker, review, generic artifact-read, and the public Submission cutover remain -planned and issue no handle. +REV-12A4A additionally issues PREP for internal +`review.lifecycle.activation.manage`, the sole active review exception. Human +review, generic artifact-read and the public Submission cutover remain planned +and issue no handle. The controller activates no public review route, routing, +acceptance or payment delivery. ARCH-04E1B-B6 retains the exact creation and binding decision IDs in their owner receipts. Fresh PREP authority validates those immutable events on replay, including exact actor/service, action, permission, project, resource and canonical From 7aa49debb443044c5e9758991abd1889c61dc2ad Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 9 Oct 2026 14:27:29 +0100 Subject: [PATCH 16/17] docs: include merged Markdown admission in completed ledger --- docs/roadmap_status.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index bd769d82b..2a4713912 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -452,7 +452,7 @@ cannot be reused as post-submission review-gate evidence. See the response supplies document IDs for the public binary upload route; no separate source-snapshot creation call remains. A starting idea is sufficient and optional example fields do not repeat requirements from the guide. Each snapshot/run - binds the exact version's examples. Upload admission accepts PDF, DOCX and PPTX + binds the exact version's examples. Upload admission accepts PDF, DOCX, PPTX and byte-preserved UTF-8 Markdown (`.md`) and checks bounded format, digest and size. Committed originals do not bypass the separate verification required for submission ZIPs. Embedded document bodies and extractors are removed. From 2ee30071b8547ba2b633fe38cdcbe57d036e7da3 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 9 Oct 2026 14:54:51 +0100 Subject: [PATCH 17/17] docs: preserve merged guide-format navigation --- .commitrail/initiatives/WS-CLI-001/OVERVIEW.md | 4 ++-- .commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md b/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md index 0357b2a21..d264ee4c4 100644 --- a/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md +++ b/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md @@ -113,8 +113,8 @@ CLIs. Keep the package independent of backend and MCP runtime dependencies. 9. **WS-CLI-001-09:** Declare a guide, required illustrative tasks and source document targets through public POST using an exact bounded JSON file. Setup awaits actual original upload. -10. **WS-CLI-001-10:** Upload one declared PDF/DOCX/PPTX original through public - binary POST; validate storage receipt against local bytes and preserve manual +10. **WS-CLI-001-10:** Upload one declared PDF/DOCX/PPTX or UTF-8 Markdown (`.md`) + original through public binary POST; validate storage receipt against local bytes and preserve manual replay custody. Approval and activation remain. 11. **WS-CLI-001-11:** Inspect latest exact-guide setup and compilation lineage through the public diagnostic read. No polling, local readiness rules, diff --git a/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md b/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md index 1da1414de..a1f68cb3a 100644 --- a/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md +++ b/.commitrail/initiatives/WS-REV-001/WS-REV-001-12A4A.md @@ -93,7 +93,8 @@ remain the subsequent ARCH-04E2-B prerequisite before a production consumer. tests and shared fixtures. Existing production inventory tests prove absent surfaces; do not create a parallel registry. - Exact behavior ownership and lane inventory files/tests; shrink affected frozen AUTH structural debt through cohesive owner extraction, then refresh fingerprints without adding debt or relaxing limits. No CI gate changes. -- This record; REV/AUTH/CON/ARCH/POL current overviews, ARCH/AUTH/POL current +- This record; REV/AUTH/CON/ARCH/POL current overviews, the CLI current overview + for merged guide-format consistency, ARCH/AUTH/POL current plans/maps, Commitrail index; canonical review/compensation specifications, relevant authorization custody, operations and roles/permissions docs, canonical authorization/data-model specifications, README and roadmap. Local roadmap exports only if present. Historical completed records remain historical.