diff --git a/reflexio/server/services/playbook/publication.py b/reflexio/server/services/playbook/publication.py index c129a6ec..244b95df 100644 --- a/reflexio/server/services/playbook/publication.py +++ b/reflexio/server/services/playbook/publication.py @@ -21,11 +21,13 @@ LIFECYCLE_TERMINAL_STATES: frozenset[str] = frozenset( get_args(OpenWorldDeploymentLifecycleState) ) - {"provisional"} -# Mirrors user_playbook_deployment_lifecycles_terminal_reason_check -# (20260827040000). 'observed_regression' is Phase 6's and 'governed_erasure' -# is the live governance erase path's; both are accepted here because a -# terminal tuple READ BACK from an idempotent replay may legitimately carry -# either. The restoration RPC itself accepts a strictly narrower set. +# Mirrors user_playbook_deployment_lifecycles_terminal_reason_check, which is +# now NINE values wide (20260827070000). 'governed_erasure' is the live +# governance erase path's, and 'observed_regression' and +# 'confirmed_online_support' are Phase 6's two opposite outcomes; all are +# accepted here because a terminal tuple READ BACK from an idempotent replay may +# legitimately carry any of them. The restoration RPC accepts a strictly +# narrower set, and the confirm RPC accepts exactly one. LIFECYCLE_TERMINAL_REASONS: frozenset[str] = frozenset( { "insufficient_online_support", @@ -36,6 +38,7 @@ "stale_incumbent", "observed_regression", "governed_erasure", + "confirmed_online_support", } ) PublishableOptimizerKind = Literal[ @@ -658,6 +661,29 @@ def restore_user_playbook_provisional_publication( """Reselect the retained predecessor and terminalize, under a fence.""" ... + def confirm_user_playbook_provisional_publication( + self, + *, + lifecycle_id: int, + expected_fence: int, + expected_successor_fingerprint: str, + support_session_count: int, + refute_session_count: int, + global_coverage_numerator: int, + global_coverage_denominator: int, + target_coverage_numerator: int, + target_coverage_denominator: int, + ) -> LifecycleTerminalResult: + """Keep the successor and terminalize as confirmed, under a fence. + + No ``reason`` parameter: ``confirmed_online_support`` is the only reason + this transition can record, so passing it would create a second place + the pairing could drift. The six counts are the evidence the boundary + decided on; the RPC re-checks the arithmetic itself and refuses an + inadmissible set rather than obeying it. + """ + ... + def displace_user_playbook_provisional_publication( self, *, lifecycle_id: int ) -> LifecycleTerminalResult: diff --git a/reflexio/server/services/storage/storage_base/playbook/_user.py b/reflexio/server/services/storage/storage_base/playbook/_user.py index e0dec61c..05b7ef14 100644 --- a/reflexio/server/services/storage/storage_base/playbook/_user.py +++ b/reflexio/server/services/storage/storage_base/playbook/_user.py @@ -121,6 +121,32 @@ def restore_user_playbook_provisional_publication( "Storage backend does not support provisional user-playbook restoration" ) + def confirm_user_playbook_provisional_publication( + self, + *, + lifecycle_id: int, + expected_fence: int, + expected_successor_fingerprint: str, + support_session_count: int, + refute_session_count: int, + global_coverage_numerator: int, + global_coverage_denominator: int, + target_coverage_numerator: int, + target_coverage_denominator: int, + ) -> "LifecycleTerminalResult": + """Atomically keep the successor and terminalize as confirmed. + + Declared here, alongside restore and displace, rather than as an + enterprise-only extra: the three are one termination triple over the + same lifecycle row, and their shared Protocol + (``UserPlaybookLifecycleTerminationStore``) lives in the OSS package. A + backend that satisfied two thirds of it would be a surface every reader + has to special-case. + """ + raise NotImplementedError( + "Storage backend does not support provisional user-playbook confirmation" + ) + def displace_user_playbook_provisional_publication( self, *, lifecycle_id: int ) -> "LifecycleTerminalResult": diff --git a/tests/server/services/playbook/test_publication_models.py b/tests/server/services/playbook/test_publication_models.py index 61622a17..a66e8b50 100644 --- a/tests/server/services/playbook/test_publication_models.py +++ b/tests/server/services/playbook/test_publication_models.py @@ -11,6 +11,7 @@ UserPlaybook, ) from reflexio.server.services.playbook.publication import ( + LIFECYCLE_TERMINAL_REASONS, LIFECYCLE_TERMINAL_STATES, DecisionProofEnvelope, LifecycleTerminalResult, @@ -57,6 +58,23 @@ def test_lifecycle_terminal_result_is_derived_from_the_state_literal() -> None: terminal_reason="not_a_reason", terminal_at=1_700_000_000, ) + # Phase 6 made 'confirmed' representable: the SQL CHECK + # (20260827070000) admits 'confirmed_online_support', so a terminal tuple + # read back from an idempotent replay may carry it and this set must too. + confirmed = LifecycleTerminalResult( + lifecycle_id=1, + state="confirmed", + terminal_reason="confirmed_online_support", + terminal_at=1, + ) + assert confirmed.terminal_reason in LIFECYCLE_TERMINAL_REASONS + with pytest.raises(ValueError, match="reason is not enumerated"): + LifecycleTerminalResult( + lifecycle_id=1, + state="confirmed", + terminal_reason="confirmed_because_i_said_so", + terminal_at=1, + ) def test_open_world_publication_literals_and_user_playbook_field_partition() -> None: