Skip to main content

nasaga_runtime_core/
orchestrator.rs

1//! Orchestrator:Saga 的创建、结果推进、durable timer 裁决与管理命令。
2//!
3//! 每次推进都是一个"单一本地事务":Inbox claim、attempt journal
4//! 记账、CAS + transition 审计行、下一条命令 Outbox 与 timer 的生死在同一 COMMIT 内
5//! 生效或一起消失。CAS 输掉竞争时整个事务回滚且不 ACK,重投后基于新快照重新裁决——
6//! 绝不用旧快照写 Outbox。
7//!
8//! 安全裁决的三条主线:
9//! - **超时不是失败证据**:可取消步骤先进入 `CANCELLING` 建屏障;`resolve_only` 步骤
10//!   直接进入 `WAITING_RESOLUTION`。两者都必须等待真实裁决,不能把 timeout 伪造成拒绝。
11//! - **未知必须收敛**:`Unknown` 进入 `WAITING_RESOLUTION`,靠 resolve 命令/回调/人工
12//!   得到唯一裁决;解决预算耗尽升级人工介入,绝不自动降级为 `Rejected`。
13//! - **补偿计划冻结**:进入 `COMPENSATING` 前由已提交 journal 一次性冻结计划并落库,
14//!   之后只幂等完成计划内步骤。
15
16use std::{
17    sync::{Arc, RwLock},
18    time::{Duration, Instant},
19};
20
21use nainbox_core::{InboxClaim, InboxStore};
22use naoutbox_core::DurableOutboxAppend;
23use nasaga_backend::{
24    AttemptConflictFact, AttemptOutcomeRecord, CasOutcome, ControlCasOutcome,
25    ControlTransitionSpec, ManagementAuditOutcome, NewSagaInstance, SagaAuditEventCursor,
26    SagaAuditEventRecord, SagaAuditStore, SagaBackend, SagaBackendErrorKind, SagaBackendFactory,
27    SagaConflictKind, SagaCreation, SagaGovernanceStore, SagaInstanceQuery, SagaInstanceRow,
28    SagaInstanceStore, SagaInstanceSummary, SagaJournalStore, SagaStepRow, SagaTimerRow,
29    SagaTimerStore, StepJournalPatch, TimerFencing, TimerFencingToken, TimerFencingTokenIssuer,
30    TimerScope, TimerSpec, TransitionSpec,
31};
32use nasaga_core::{
33    freeze_compensation_plan, AttemptNo, BusinessKey, CancelMode, CommandId, DefinitionVersion,
34    Direction, EffectId, ResolutionMode, SagaId, SagaStatus, ServiceIdentity, StepAttemptStatus,
35    StepCancelStatus, StepCompensationStatus, StepDefinition, StepForwardStatus, StepJournalEntry,
36    StepName, StepPhase, StepResolutionStatus, TenantId, TimeoutPolicy, TriggerKind,
37    WorkflowDefinition, WorkflowName,
38};
39use natelemetry::TraceContext;
40use sha2::{Digest as _, Sha256};
41use uuid::Uuid;
42
43use crate::envelope::{canonical_bytes, SagaCommandEnvelope, SagaResultEnvelope, VerifiedIdentity};
44use crate::management::{
45    SagaAuditPage, SagaAuditPageCursor, SagaAuditRecord, SagaAuditTrail, SagaManagementContext,
46    SagaManagementError, SagaManagementExpectation, SagaManagementPermission,
47};
48use crate::observability::{
49    record_action_rate_rejection, record_quota_rejection, SagaOperationalMetrics,
50};
51use crate::registry::DefinitionRegistry;
52use crate::timers::{
53    derive_timer_id, parse_step_scope, phase_timeout_kind, resolution_budget_kind,
54    KIND_CANCEL_TIMEOUT, KIND_COMPENSATE_TIMEOUT, KIND_COMPENSATION_RESOLUTION_BUDGET,
55    KIND_FORWARD_RESOLUTION_BUDGET, KIND_INSTANCE_DEADLINE, KIND_RESOLUTION_BUDGET,
56    KIND_RESOLVE_TIMEOUT, KIND_STEP_TIMEOUT,
57};
58
59/// 派生补偿立即收敛 trigger 的固定命名空间(ASCII `nasasaga-v1-cvns`)。
60///
61/// 使用定长 UUID 而不是在外部 cause id 后追加文本,避免合法的 190 字节 trigger
62/// 在 `#converge` 后缀处越过数据库索引上限并永久卡在 `COMPENSATING`。
63const CONVERGE_TRIGGER_NAMESPACE: Uuid = Uuid::from_bytes(*b"nasasaga-v1-cvns");
64
65/// 业务作用:Orchestrator 的运行参数;全部预算有界,自动状态不允许无限滞留。
66#[derive(Debug, Clone)]
67pub struct OrchestratorConfig {
68    /// Orchestrator result Inbox 的小写 canonical consumer 名称。
69    pub inbox_consumer: String,
70    /// 取消命令的最大 attempt 数;耗尽升级人工介入。
71    pub cancel_max_attempts: u32,
72    /// 补偿命令的最大 attempt 数;耗尽升级人工介入,不伪造 `COMPENSATED`。
73    pub compensate_max_attempts: u32,
74    /// 解决查询的最大 attempt 数;耗尽升级人工介入,不自动转 `Rejected`。
75    pub resolve_max_attempts: u32,
76    /// 单轮 timer 领取上限。
77    pub timer_claim_limit: u32,
78    /// timer 租约时长(毫秒)。
79    pub timer_lease_ms: i64,
80    /// 实例暂停时 timer 的轮询退避(毫秒);只是轮询节奏,不顺延业务 deadline。
81    pub pause_backoff_ms: i64,
82    /// 启动预检扫描非终态实例的上限。
83    pub startup_scan_limit: u32,
84    /// 每租户在飞实例上限(冻结表)。
85    ///
86    /// 未列出的租户不设限但仍精确记账("先观测,不拒绝"档);列出的租户在创建事务内
87    /// 原子预留,超限以稳定原因码拒绝新建、不影响已在飞实例收敛。上限只作用于创建,
88    /// 配额拒绝与系统故障可区分,且不泄漏其它租户用量。
89    pub tenant_quotas: std::collections::BTreeMap<String, u64>,
90    /// 每租户变更类管理动作的速率上限(冻结表)。
91    ///
92    /// 覆盖 pause/resume/retry-compensation/retry-resolution/manual-close 五个会向恢复
93    /// 通道注入外部副作用的动作;只读动作(检索、审计、用量查询)不占预算。未列出的
94    /// 租户不限速且不写账本;`max_actions = 0` 表示完全封禁该租户的变更类管理动作。
95    /// 预算与动作事务同提交:动作失败回滚时预算退还,拒绝携带稳定原因码并与系统
96    /// 故障可区分,不泄漏其它租户用量。
97    pub tenant_action_rates: std::collections::BTreeMap<String, TenantActionRate>,
98    /// 人工关闭(`MANUALLY_CLOSED`)管理动作的受信能力开关。
99    ///
100    /// 滚动升级门禁:新终态一旦落库就不可回退,必须先让**全部**副本升级为能解析
101    /// `MANUALLY_CLOSED` 的读者,再由部署方打开本开关放行管理动作。默认关闭——
102    /// 未显式开启的部署是"可读不产生"的兼容读者。
103    pub enable_manual_close: bool,
104}
105
106impl Default for OrchestratorConfig {
107    /// 业务作用:给出保守默认预算,保证未显式配置的部署也有有界收敛行为。
108    ///
109    /// 参数说明: 无。
110    ///
111    /// 返回:默认配置。
112    fn default() -> Self {
113        Self {
114            inbox_consumer: "nasaga-orchestrator".to_string(),
115            cancel_max_attempts: 5,
116            compensate_max_attempts: 5,
117            resolve_max_attempts: 10,
118            timer_claim_limit: 32,
119            timer_lease_ms: 60_000,
120            pause_backoff_ms: 30_000,
121            startup_scan_limit: 1_000,
122            tenant_quotas: std::collections::BTreeMap::new(),
123            tenant_action_rates: std::collections::BTreeMap::new(),
124            // 默认保持"可读不产生":人工关闭动作须在全部副本可解析新终态后显式开启。
125            enable_manual_close: false,
126        }
127    }
128}
129
130/// 业务作用:单租户变更类管理动作的固定窗口速率上限。
131///
132/// 窗口边界由**数据库时钟**对齐,多副本 Orchestrator 共享同一套窗口;预算只按首次
133/// 提交的新 operation 计,完全相同的已提交 operation 重放不重复计数,失败回滚不消耗。
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub struct TenantActionRate {
136    /// 单窗口允许提交的动作数;0 表示完全封禁。
137    pub max_actions: u64,
138    /// 窗口长度(毫秒),必须为正;非法值在动作路径上报配置错误而不是稳定拒绝。
139    pub window_ms: i64,
140}
141
142/// 业务作用:区分创建入口的两种结果;`AlreadyExists` 表示业务幂等键命中,无新副作用。
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub enum StartOutcome {
145    /// 实例已创建,首步命令、timer 与初始 transition 已同事务提交。
146    Started(SagaInstanceRow),
147    /// 同一业务意图已有实例;返回其当前快照。
148    AlreadyExists(SagaInstanceRow),
149}
150
151/// 业务作用:封闭 Saga 创建的确定性拒绝原因,供协议适配器按类型映射稳定状态。
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153#[non_exhaustive]
154pub enum StartSagaError {
155    /// 指定 workflow/version 当前不允许创建新实例。
156    DefinitionInactive,
157    /// 正文格式、schema 或双重输入来源不符合冻结步骤合同。
158    InvalidPayload,
159    /// 已发布 definition 没有可执行步骤。
160    DefinitionEmpty,
161    /// Saga 身份或业务幂等槽已被不同启动请求占用。
162    RequestConflict,
163    /// 租户在飞实例配额已耗尽。
164    TenantQuotaExceeded,
165}
166
167impl StartSagaError {
168    /// 业务作用:从完整错误链中提取协议层可以安全公开的 Saga 创建拒绝类别。
169    ///
170    /// 参数说明:
171    /// - `error`:是运行核心返回且可能附加调用上下文的完整错误链。
172    ///
173    /// 返回:包含封闭创建拒绝时返回对应类别;基础设施或未知失败返回 `None`。
174    pub fn from_error(error: &anyhow::Error) -> Option<Self> {
175        error
176            .chain()
177            .find_map(|cause| cause.downcast_ref::<Self>().copied())
178    }
179}
180
181impl std::fmt::Display for StartSagaError {
182    /// 业务作用:输出不含租户用量、请求正文或持久化细节的稳定创建拒绝摘要。
183    ///
184    /// 参数说明:
185    /// - `formatter`:是标准格式化输出目标。
186    ///
187    /// 返回:摘要写入成功时返回 `Ok`;格式化失败返回对应错误。
188    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        formatter.write_str(match self {
190            Self::InvalidPayload => "Saga payload contract is invalid",
191            Self::DefinitionInactive => "Saga workflow definition is not active",
192            Self::DefinitionEmpty => "Saga workflow definition has no steps",
193            Self::RequestConflict => "Saga identity is already bound to a different start request",
194            Self::TenantQuotaExceeded => "saga_tenant_quota_exceeded",
195        })
196    }
197}
198
199impl std::error::Error for StartSagaError {}
200
201/// 业务作用:保留状态迁移 CAS 未生效的确定性并发裁决,禁止协议层解析错误文本。
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203#[non_exhaustive]
204pub enum SagaConcurrencyError {
205    /// 调用方使用的实例状态或版本快照已经过期。
206    StaleSnapshot,
207    /// 相同触发身份已经提交过另一条迁移。
208    DuplicateTrigger,
209}
210
211impl SagaConcurrencyError {
212    /// 业务作用:从事务与调用上下文包裹的错误链中提取 CAS 并发裁决。
213    ///
214    /// 参数说明:
215    /// - `error`:是状态迁移调用返回的完整错误链。
216    ///
217    /// 返回:包含确定性并发裁决时返回对应类别;其它失败返回 `None`。
218    pub fn from_error(error: &anyhow::Error) -> Option<Self> {
219        error
220            .chain()
221            .find_map(|cause| cause.downcast_ref::<Self>().copied())
222    }
223}
224
225impl std::fmt::Display for SagaConcurrencyError {
226    /// 业务作用:输出不携带实例身份与业务数据的稳定 CAS 裁决摘要。
227    ///
228    /// 参数说明:
229    /// - `formatter`:是标准格式化输出目标。
230    ///
231    /// 返回:摘要写入成功时返回 `Ok`;格式化失败返回对应错误。
232    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233        formatter.write_str(match self {
234            Self::StaleSnapshot => "Saga state snapshot is stale",
235            Self::DuplicateTrigger => "Saga transition trigger was already applied",
236        })
237    }
238}
239
240impl std::error::Error for SagaConcurrencyError {}
241
242/// 业务作用:区分结果处理的两种可 ACK 结论。
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub enum HandleOutcome {
245    /// 已推进(或迟到结果已记账);`status` 为提交后的实例状态。
246    Applied {
247        /// 事务提交后的实例业务状态。
248        status: SagaStatus,
249    },
250    /// Inbox 命中重复结果事件;无新副作用,直接 ACK。
251    Duplicate,
252}
253
254/// 业务作用:区分单个 timer 的处理结论,驱动领取循环决定交还还是继续。
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub enum TimerOutcome {
257    /// timer 已消费并完成对应裁决。
258    Applied {
259        /// 事务提交后的实例业务状态。
260        status: SagaStatus,
261    },
262    /// 实例处于 `PAUSED`:不消费,由调用方按轮询退避交还。
263    SkippedPaused,
264    /// fencing 失败:租约已被其它副本接管,旧 owner 立即停止。
265    SkippedFencingLost,
266    /// timer 语义已过时(状态/步骤已迁移),已按已消费吸收,无业务动作。
267    SkippedStale,
268}
269
270/// 业务作用:描述一次 Saga 创建请求。
271#[derive(Debug, Clone)]
272pub struct StartSagaRequest<'a> {
273    /// 实例身份,由调用方生成(如 UUID 文本)。
274    pub saga_id: &'a SagaId,
275    /// 规范化租户;无租户部署传固定 system tenant。
276    pub tenant: &'a TenantId,
277    /// workflow 名称,必须已在注册表注册。
278    pub workflow: &'a WorkflowName,
279    /// definition 版本。
280    pub version: DefinitionVersion,
281    /// 业务幂等键。
282    pub business_key: &'a BusinessKey,
283    /// 实例级业务 deadline(epoch 毫秒);为空则不设全局期限。
284    pub deadline_at_ms: Option<i64>,
285    /// 初始 transition 的触发来源。
286    pub trigger_kind: TriggerKind,
287    /// 初始 transition 的触发身份(event id 或管理 operation id)。
288    pub trigger_id: &'a str,
289    /// 首步 execute 命令携带的业务输入;后续步骤由参与方本地事实解析。
290    pub first_command_payload: Option<serde_json::Value>,
291    /// 原始首步正文;与兼容 JSON 输入互斥,媒体类型与 schema 纳入请求摘要。
292    pub first_command_raw_payload: Option<nasaga_core::SagaPayload>,
293    /// 当前时刻(epoch 毫秒),由调用方注入统一时钟。
294    pub now_ms: i64,
295}
296
297/// 业务作用:保留调用方选择的首步正文合同,兼容 JSON envelope 与原始字节 envelope 不相互转换。
298enum CommandInput {
299    Json(serde_json::Value),
300    Raw(nasaga_core::SagaPayload),
301}
302
303/// 业务作用:Saga Orchestrator——所有推进都以数据库 CAS 与单一本地事务落库。
304///
305/// 无进程内可变业务状态(仅 fencing token 发行器):多副本并发安全完全由 store 的
306/// CAS、唯一键与 fencing token 承担,任何副本崩溃后另一副本可无缝接管。
307pub struct Orchestrator<B: SagaBackend> {
308    backend: B,
309    registry: RwLock<Arc<DefinitionRegistry>>,
310    config: OrchestratorConfig,
311    /// 每个 runtime 实例独立的 fencing token 发行域;不承载业务状态或租约归属。
312    fencing_tokens: TimerFencingTokenIssuer,
313}
314
315/// 业务作用:一次结果推进的裁决上下文,聚合已证身份与已提交快照。
316struct ResultContext<'a> {
317    instance: &'a SagaInstanceRow,
318    definition: &'a WorkflowDefinition,
319    step_def: &'a StepDefinition,
320    identity: &'a VerifiedIdentity,
321    status: StepAttemptStatus,
322    terminal: Option<StepForwardStatus>,
323    reason_code: Option<&'a str>,
324    event_id: &'a str,
325    now_ms: i64,
326}
327
328/// 业务作用:在 Orchestrator 获得任何自动推进能力前校验 Inbox 命名空间与全部预算。
329///
330/// 参数说明:
331/// - `config`: 待装载的运行参数。
332///
333/// 返回:consumer 满足小写 canonical Inbox key 且预算有界时返回 `Ok`;否则拒绝构造。
334fn validate_config(config: &OrchestratorConfig) -> anyhow::Result<()> {
335    if config.inbox_consumer.is_empty()
336        || config.inbox_consumer.trim() != config.inbox_consumer
337        || config.inbox_consumer.len() > 128
338        || config.inbox_consumer.contains('\0')
339        || !config.inbox_consumer.bytes().all(|byte| {
340            byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-' | b'.')
341        })
342        || config.cancel_max_attempts == 0
343        || config.compensate_max_attempts == 0
344        || config.resolve_max_attempts == 0
345        || config.timer_claim_limit == 0
346        || config.timer_lease_ms <= 0
347        || config.pause_backoff_ms <= 0
348        || config.startup_scan_limit == 0
349    {
350        anyhow::bail!("orchestrator config violates the Inbox key contract or positive budgets");
351    }
352    // 冻结表在构造期整体校验:非法租户键会静默成为永不匹配的配置,窗口非法则要到
353    // 首次管理动作才以系统错误暴露——不合理值必须在 Ready 之前失败。
354    for tenant in config.tenant_quotas.keys() {
355        if TenantId::new(tenant.as_str()).is_err() {
356            anyhow::bail!("tenant quota key is not a valid tenant id");
357        }
358    }
359    for (tenant, rate) in &config.tenant_action_rates {
360        if TenantId::new(tenant.as_str()).is_err() {
361            anyhow::bail!("tenant action rate key is not a valid tenant id");
362        }
363        if rate.window_ms <= 0 {
364            anyhow::bail!("tenant action rate window_ms must be positive");
365        }
366    }
367    Ok(())
368}
369
370/// 业务作用:把 definition 中的 Duration 安全转换成数据库 epoch 毫秒增量。
371///
372/// 参数说明:
373/// - `duration`: 步骤超时或解决预算。
374///
375/// 返回:可由 i64 表示且大于零的毫秒数;截断为零或溢出时返回错误并停止自动调度。
376fn duration_millis(duration: Duration) -> anyhow::Result<i64> {
377    let millis = i64::try_from(duration.as_millis())
378        .map_err(|_| anyhow::anyhow!("saga duration exceeds the supported millisecond range"))?;
379    if millis <= 0 {
380        anyhow::bail!("saga duration must be at least one millisecond");
381    }
382    Ok(millis)
383}
384
385/// 业务作用:以 checked arithmetic 计算 timer 到期时刻,防止溢出后立即或负时间触发。
386///
387/// 参数说明:
388/// - `now_ms`: 当前 epoch 毫秒。
389/// - `delay_ms`: 正数延迟毫秒。
390///
391/// 返回:安全的到期时刻;延迟非正或加法溢出时返回错误并回滚当前推进事务。
392fn checked_deadline(now_ms: i64, delay_ms: i64) -> anyhow::Result<i64> {
393    if delay_ms <= 0 {
394        anyhow::bail!("saga timer delay must be positive");
395    }
396    now_ms
397        .checked_add(delay_ms)
398        .ok_or_else(|| anyhow::anyhow!("saga timer deadline overflow"))
399}
400
401/// 业务作用:为管理状态门禁保留脱敏诊断,同时让协议层从错误链按类型分类。
402///
403/// 参数说明:
404/// - `message`:是不含实例身份与业务数据的稳定前置条件摘要。
405///
406/// 返回:外层显示具体门禁、内层保留 [`SagaManagementError::PreconditionFailed`] 的错误。
407fn management_precondition(message: &'static str) -> anyhow::Error {
408    anyhow::Error::new(SagaManagementError::PreconditionFailed).context(message)
409}
410
411/// 业务作用:把 JSON 值按类型、数组顺序和排序后的对象键写入摘要,消除对象插入顺序差异。
412///
413/// 参数说明:
414/// - `hasher`: 当前启动请求摘要状态。
415/// - `value`: 首步命令 payload 的一个 JSON 节点。
416///
417/// 返回:无;节点的类型边界和内容被追加到摘要状态。
418fn hash_canonical_json(hasher: &mut Sha256, value: &serde_json::Value) {
419    match value {
420        serde_json::Value::Null => hasher.update(canonical_bytes(&[b"null"])),
421        serde_json::Value::Bool(value) => {
422            hasher.update(canonical_bytes(&[b"bool", &[u8::from(*value)]]));
423        }
424        serde_json::Value::Number(value) => {
425            hasher.update(canonical_bytes(&[b"number", value.to_string().as_bytes()]));
426        }
427        serde_json::Value::String(value) => {
428            hasher.update(canonical_bytes(&[b"string", value.as_bytes()]));
429        }
430        serde_json::Value::Array(values) => {
431            hasher.update(canonical_bytes(&[b"array", &values.len().to_be_bytes()]));
432            for value in values {
433                hash_canonical_json(hasher, value);
434            }
435        }
436        serde_json::Value::Object(values) => {
437            let mut keys: Vec<_> = values.keys().collect();
438            keys.sort_unstable();
439            hasher.update(canonical_bytes(&[b"object", &keys.len().to_be_bytes()]));
440            for key in keys {
441                hasher.update(canonical_bytes(&[b"key", key.as_bytes()]));
442                hash_canonical_json(hasher, &values[key]);
443            }
444        }
445    }
446}
447
448/// 业务作用:为 business-key 幂等入口生成不含敏感明文的 canonical 请求摘要。
449///
450/// 摘要覆盖会改变 Saga 合同或首个业务效果的全部输入;不覆盖 `saga_id`、transport
451/// trigger 与 `now_ms`,因此同一业务请求的传输重试可使用新的请求身份而仍命中幂等结果。
452///
453/// 参数说明:
454/// - `request`: 调用方提交的启动请求。
455/// - `definition_digest`: 已注册 definition 的固定内容摘要。
456/// - `first_step`: definition 的首步身份。
457///
458/// 返回:六十四位小写十六进制 SHA-256 摘要。
459fn derive_legacy_start_request_digest(
460    request: &StartSagaRequest<'_>,
461    definition_digest: &str,
462    first_step: &StepName,
463) -> String {
464    let version = request.version.get().to_be_bytes();
465    let deadline = request.deadline_at_ms.unwrap_or_default().to_be_bytes();
466    let deadline_present = [u8::from(request.deadline_at_ms.is_some())];
467    let payload_present = [u8::from(request.first_command_payload.is_some())];
468    let mut hasher = Sha256::new();
469    hasher.update(canonical_bytes(&[
470        request.tenant.as_str().as_bytes(),
471        request.workflow.as_str().as_bytes(),
472        &version,
473        definition_digest.as_bytes(),
474        request.business_key.as_str().as_bytes(),
475        first_step.as_str().as_bytes(),
476        &deadline_present,
477        &deadline,
478        &payload_present,
479    ]));
480    if let Some(payload) = request.first_command_payload.as_ref() {
481        hash_canonical_json(&mut hasher, payload);
482    }
483    hasher
484        .finalize()
485        .iter()
486        .map(|byte| format!("{byte:02x}"))
487        .collect()
488}
489
490/// 业务作用:以规范领域字段和原始正文构造跨协议相同的创建摘要。
491///
492/// 参数说明:
493/// - `request`:包含租户、业务键等创建身份及可选正文声明的请求。
494/// - `definition_digest`:创建所绑定的不可变流程定义摘要。
495/// - `first_step`:该流程首个待执行步骤。
496/// - `payload`:已经规范化的可选原始命令正文与媒体、schema 信息。
497///
498/// 返回:无 payload 时返回去除请求中两种正文声明后的既有领域摘要;有 payload 时将该摘要、媒体类型、schema 与原始正文纳入独立 SHA-256 域。不修改原请求,不验证 payload 合同。
499fn derive_start_request_digest(
500    request: &StartSagaRequest<'_>,
501    definition_digest: &str,
502    first_step: &StepName,
503    payload: Option<&nasaga_core::SagaPayload>,
504) -> String {
505    let mut empty = request.clone();
506    empty.first_command_payload = None;
507    empty.first_command_raw_payload = None;
508    let domain = derive_legacy_start_request_digest(&empty, definition_digest, first_step);
509    let Some(payload) = payload else {
510        return domain;
511    };
512    let mut hasher = Sha256::new();
513    hasher.update(canonical_bytes(&[
514        b"saga-start-payload",
515        domain.as_bytes(),
516        payload.content_type.as_bytes(),
517        payload.schema_id.as_bytes(),
518        &payload.body,
519    ]));
520    hex::encode(hasher.finalize())
521}
522
523/// 业务作用:从任意有界 cause id 派生定长补偿收敛 trigger,保持同一原因重试身份稳定。
524///
525/// 参数说明:
526/// - `cause_id`: 引发进入补偿或完成最后一项补偿的触发身份。
527///
528/// 返回:确定性的 UUIDv5 文本,不会超过 store 的 trigger_id 长度上限。
529fn derive_converge_trigger_id(cause_id: &str) -> String {
530    Uuid::new_v5(
531        &CONVERGE_TRIGGER_NAMESPACE,
532        &canonical_bytes(&[cause_id.as_bytes(), b"converge"]),
533    )
534    .to_string()
535}
536
537/// 业务作用:把 timer 领取后的单调流逝时间叠加到注入的 epoch 时钟,供租约复验使用。
538///
539/// 使用 `Instant` 而不是再次读取墙上时钟,可避免 NTP 回拨让已经消耗的租约时间倒退;
540/// 注入的 `base_now_ms` 同时承担确定裁决时刻与统一业务时钟的语义。
541///
542/// 参数说明:
543/// - `base_now_ms`: 领取开始时注入的 epoch 毫秒。
544/// - `claimed_at`: 领取开始前记录的单调时刻。
545///
546/// 返回:叠加后的当前 epoch 毫秒;流逝时间或加法超出 i64 时返回错误并停止使用租约。
547fn elapsed_epoch_ms(base_now_ms: i64, claimed_at: Instant) -> anyhow::Result<i64> {
548    let elapsed_ms = i64::try_from(claimed_at.elapsed().as_millis())
549        .map_err(|_| anyhow::anyhow!("timer lease elapsed duration overflow"))?;
550    base_now_ms
551        .checked_add(elapsed_ms)
552        .ok_or_else(|| anyhow::anyhow!("timer lease clock overflow"))
553}
554
555impl<B> Orchestrator<B>
556where
557    B: SagaBackend + SagaBackendFactory,
558{
559    /// 业务作用:构造 Orchestrator。
560    ///
561    /// 参数说明:
562    /// - `registry`: 启动阶段构建完成的 definition 注册表(运行期只读)。
563    /// - `config`: 运行参数。
564    ///
565    /// 返回:配置预算全部为正时返回 Orchestrator;零/负预算返回错误并拒绝启动。
566    pub fn new(registry: DefinitionRegistry, config: OrchestratorConfig) -> anyhow::Result<Self> {
567        validate_config(&config)?;
568        Ok(Self {
569            backend: B::default_backend()?,
570            registry: RwLock::new(Arc::new(registry)),
571            config,
572            fencing_tokens: TimerFencingTokenIssuer::new(),
573        })
574    }
575
576    /// 业务作用:构造绑定命名 datasource 的 Orchestrator,使状态、Inbox、Outbox、timer 与审计共享一次本地提交。
577    ///
578    /// 参数说明:
579    /// - `registry`: 启动期已冻结的 definition 注册表。
580    /// - `config`: Orchestrator 运行预算与租户门禁。
581    /// - `datasource`: 所有 Saga 本地持久与事务边界共用的数据源。
582    ///
583    /// 返回:配置与 datasource 合法时返回同源 Orchestrator;否则在任何数据库 I/O 前拒绝。
584    pub fn with_datasource(
585        registry: DefinitionRegistry,
586        config: OrchestratorConfig,
587        datasource: impl AsRef<str>,
588    ) -> anyhow::Result<Self> {
589        validate_config(&config)?;
590        Ok(Self {
591            backend: B::backend_for(datasource.as_ref())?,
592            registry: RwLock::new(Arc::new(registry)),
593            config,
594            fencing_tokens: TimerFencingTokenIssuer::new(),
595        })
596    }
597
598    /// 业务作用:读取 Orchestrator 全部本地持久与事务链绑定的 datasource 身份。
599    ///
600    /// 参数说明: 无。
601    ///
602    /// 返回:store、Inbox、Outbox 与 timer 共用的不可变 qualifier。
603    pub fn datasource_ref(&self) -> &natx_core::DatasourceRef {
604        self.backend.datasource_ref()
605    }
606
607    /// 业务作用:取得当前完整 registry generation 的共享快照,使一次状态机操作不会跨代读取定义。
608    ///
609    /// 参数说明: 无。
610    ///
611    /// 返回:当前不可变快照;锁中毒时仍读取最后一次完整发布值。
612    fn registry_snapshot(&self) -> Arc<DefinitionRegistry> {
613        self.registry
614            .read()
615            .unwrap_or_else(std::sync::PoisonError::into_inner)
616            .clone()
617    }
618
619    /// 业务作用:原子发布已经完成 Catalog、历史实例与 capability 门禁的新 registry generation。
620    ///
621    /// 参数说明:
622    /// - `registry`:同时包含 active definition 和旧实例仍需的 deprecated definition。
623    ///
624    /// 返回:新快照成为后续操作唯一入口后完成;进行中的操作继续持有旧快照直到事务结束。
625    pub fn replace_registry(&self, registry: DefinitionRegistry) {
626        *self
627            .registry
628            .write()
629            .unwrap_or_else(std::sync::PoisonError::into_inner) = Arc::new(registry);
630    }
631
632    /// 业务作用:在动态 Catalog 快照发布前确认全部非终态实例仍能找到相同摘要的 definition。
633    ///
634    /// 参数说明:
635    /// - `registry`:是 watcher 刚从共享 generation 完整装载的候选快照。
636    ///
637    /// 返回:所有在途实例均可安全继续时成功;缺失或摘要漂移时拒绝切换。
638    pub async fn verify_registry_snapshot(
639        &self,
640        registry: &DefinitionRegistry,
641    ) -> anyhow::Result<()> {
642        registry
643            .verify_non_terminal(self.backend.store(), self.config.startup_scan_limit)
644            .await
645    }
646
647    /// 业务作用:启动预检——非终态实例引用的 definition 必须可用且摘要一致,否则拒绝 Ready。
648    ///
649    /// 参数说明: 无。
650    ///
651    /// 返回:预检通过返回 `Ok`;缺失或摘要漂移返回错误,调用方不得宣告 Ready。
652    pub async fn verify_startup(&self) -> anyhow::Result<()> {
653        // 本进程收集的步骤合同必须先于历史实例扫描校验;否则同一 binary 内重复 handler 或
654        // 合同漂移可能在数据库事实通过后才随首条命令暴露,形成已 Ready 但不可安全推进的窗口。
655        let registry = self.registry_snapshot();
656        crate::verify_descriptors(&registry)?;
657        // 设限租户的配额账本必须已完成初始化回填:存量非终态实例未入账时,其终态释放
658        // 会扣掉新实例的名额、上限被静默穿透。回填只能在全部写入方都运行记账版本后
659        // 经 reconcile 的受锁窗口执行——这里 fail-fast,不把部署错误拖到首次预留。
660        for tenant in self.config.tenant_quotas.keys() {
661            let tenant = TenantId::new(tenant.as_str())
662                .map_err(|_| anyhow::anyhow!("tenant quota key is not a valid tenant id"))?;
663            if !self
664                .backend
665                .store()
666                .tenant_quota_initialized(&tenant)
667                .await?
668            {
669                anyhow::bail!(
670                    "tenant quota ledger for a capped tenant is not initialized; \
671                     run reconcile_tenant_quota after every writer runs the accounting binary"
672                );
673            }
674        }
675        registry
676            .verify_non_terminal(self.backend.store(), self.config.startup_scan_limit)
677            .await
678    }
679
680    /// 业务作用:读取当前 registry 快照中指定 definition 的 canonical 摘要,供协议兼容门禁在创建前校验。
681    ///
682    /// 参数说明:
683    /// - `workflow`:待查询的流程名称。
684    /// - `version`:不可变业务流程定义版本。
685    ///
686    /// 返回:当前 registry 快照存在对应条目时返回其摘要,否则返回 None;本方法不单独检查持久 Catalog 的实时生命周期。
687    pub fn definition_digest(
688        &self,
689        workflow: &WorkflowName,
690        version: DefinitionVersion,
691    ) -> Option<String> {
692        self.registry_snapshot()
693            .get(workflow, version)
694            .map(WorkflowDefinition::digest)
695    }
696
697    /// 业务作用:按租户读取当前可用于运行期复验的 definition 摘要。
698    ///
699    /// 参数说明:
700    /// - `tenant`:用于选择租户专属或可用全局条目的租户身份。
701    /// - `workflow`:待查询的流程名称。
702    /// - `version`:不可变业务流程定义版本。
703    ///
704    /// 返回:当前快照能按租户与定义键解析到条目时返回其摘要,否则 None;本方法不读取数据库或单独复验生命周期。
705    pub fn definition_digest_for_tenant(
706        &self,
707        tenant: &TenantId,
708        workflow: &WorkflowName,
709        version: DefinitionVersion,
710    ) -> Option<String> {
711        self.registry_snapshot()
712            .get_for_tenant(tenant, workflow, version)
713            .map(WorkflowDefinition::digest)
714    }
715
716    /// 业务作用:为管理面读取指定租户拥有的 Saga 快照,阻止仅凭 saga_id 跨租户枚举。
717    ///
718    /// 参数说明:
719    /// - `tenant`: 已认证管理主体被授权访问的租户。
720    /// - `saga_id`: 实例身份。
721    ///
722    /// 返回:实例存在且属于该租户时返回快照;不存在或属于其他租户均返回
723    /// `None`,防止 saga_id 成为跨租户存在性 oracle。
724    pub async fn load_instance(
725        &self,
726        tenant: &TenantId,
727        saga_id: &SagaId,
728    ) -> anyhow::Result<Option<SagaInstanceRow>> {
729        Ok(self
730            .backend
731            .store()
732            .load_instance(saga_id)
733            .await?
734            .filter(|instance| &instance.tenant == tenant))
735    }
736
737    /// 业务作用:按租户和 `saga.audit.read` 权限读取一份有界、可恢复的完整审计视图。
738    ///
739    /// 参数说明:
740    /// - `management`: 已认证主体与权限快照;reason 用于宿主管理访问日志。
741    /// - `tenant`: 主体获授权访问的租户。
742    /// - `saga_id`: 目标实例。
743    /// - `after_transition_seq`: 业务迁移分页游标。
744    /// - `after_control_seq`: 控制迁移分页游标。
745    /// - `limit`: 每类事实最大返回数,范围 1..=1000。
746    ///
747    /// 返回:实例存在且属于租户时返回 attempt/transition/control/management/conflict
748    /// 聚合视图;无权限、跨租户、上限非法或持久层失败返回错误。
749    pub async fn load_audit_trail(
750        &self,
751        management: &SagaManagementContext,
752        tenant: &TenantId,
753        saga_id: &SagaId,
754        after_transition_seq: u64,
755        after_control_seq: u64,
756        limit: u32,
757    ) -> anyhow::Result<SagaAuditTrail> {
758        // 审计包含 actor、触发身份与业务效果 id,必须先鉴权再检查实例是否存在。
759        management.require(SagaManagementPermission::ReadAudit)?;
760        self.backend
761            .store()
762            .load_instance(saga_id)
763            .await?
764            .filter(|instance| &instance.tenant == tenant)
765            .ok_or(SagaManagementError::NotFound)?;
766        Ok(SagaAuditTrail {
767            attempts: self
768                .backend
769                .store()
770                .load_attempt_audit(saga_id, None, limit)
771                .await?
772                .into_iter()
773                .map(|row| row.attempt)
774                .collect(),
775            transitions: self
776                .backend
777                .store()
778                .load_transition_audit(saga_id, after_transition_seq, limit)
779                .await?,
780            controls: self
781                .backend
782                .store()
783                .load_control_audit(saga_id, after_control_seq, limit)
784                .await?,
785            management_operations: self
786                .backend
787                .store()
788                .load_management_audit(saga_id, None, limit)
789                .await?,
790            conflicts: self
791                .backend
792                .store()
793                .load_conflict_audit(saga_id, None, limit)
794                .await?,
795        })
796    }
797
798    /// 业务作用:按数据库全局序号读取不可变审计事件,使运行中新增事实和 attempt 终态变化
799    /// 都能出现在既有游标之后。
800    ///
801    /// 参数说明:
802    /// - `management`: 已认证主体与审计读取权限。
803    /// - `tenant`: 主体获授权访问的租户。
804    /// - `saga_id`: 目标实例。
805    /// - `cursor`: 上一页末项的全局序号;`None` 表示从事件流起点开始。
806    /// - `limit`: 单页最大事实数,范围 1..=1000。
807    ///
808    /// 返回:按 `audit_seq` 返回至多 `limit` 条事实;非空页同时返回末项 checkpoint,
809    /// 供历史遍历或后续追加事实续读。越权、跨租户或持久化失败返回错误。
810    pub async fn load_audit_page(
811        &self,
812        management: &SagaManagementContext,
813        tenant: &TenantId,
814        saga_id: &SagaId,
815        cursor: Option<&SagaAuditPageCursor>,
816        limit: u32,
817    ) -> anyhow::Result<SagaAuditPage> {
818        if limit == 0 || limit > 1_000 {
819            return Err(SagaManagementError::InvalidContext.into());
820        }
821        // 审计事实含业务身份和人工主体,必须在解析游标和查询目标之前完成权限门禁。
822        management.require(SagaManagementPermission::ReadAudit)?;
823        self.backend
824            .store()
825            .load_instance(saga_id)
826            .await?
827            .filter(|instance| &instance.tenant == tenant)
828            .ok_or(SagaManagementError::NotFound)?;
829        let after = SagaAuditEventCursor {
830            audit_seq: cursor.map_or(0, |cursor| cursor.audit_seq),
831        };
832        let events = self
833            .backend
834            .store()
835            .load_audit_events(saga_id, after, limit)
836            .await?;
837        // 非满页也必须签发末项 checkpoint;运行中 Saga 可在读取瞬间的尾部之后继续
838        // 追加事实,调用方据此断线续读,不必重新遍历或依赖类别内排序。
839        let next_cursor = events.last().map(|event| SagaAuditPageCursor {
840            audit_seq: event.audit_seq,
841        });
842        let records = events
843            .into_iter()
844            .map(|event| match event.record {
845                SagaAuditEventRecord::Attempt(row) => SagaAuditRecord::Attempt(row),
846                SagaAuditEventRecord::Transition(row) => SagaAuditRecord::Transition(row),
847                SagaAuditEventRecord::Control(row) => SagaAuditRecord::Control(row),
848                SagaAuditEventRecord::Management(row) => SagaAuditRecord::Management(row),
849                SagaAuditEventRecord::Conflict(row) => SagaAuditRecord::Conflict(row),
850            })
851            .collect();
852        Ok(SagaAuditPage {
853            records,
854            next_cursor,
855        })
856    }
857
858    /// 业务作用:租户受限的实例只读检索——按状态与创建时间窗过滤、keyset 分页,让运维
859    /// 无需直连数据库即可定位待处置对象(如全部 `MANUAL_INTERVENTION` 实例)。
860    ///
861    /// 检索是纯读动作,使用与写动作分离的 `saga.instance.list` 权限;响应只含身份、
862    /// 状态、当前步骤、版本、稳定原因码与时间戳,不携带业务 payload。租户过滤由查询
863    /// 条件强制,不同租户实例的存在性不经本入口泄漏。
864    ///
865    /// 参数说明:
866    /// - `management`: 已认证主体与权限快照。
867    /// - `query`: 租户、状态集合、时间窗、cursor 与页大小。
868    ///
869    /// 返回:无时间条件时按 saga_id 升序;有时间条件时按创建时刻、saga_id 升序;无权限、
870    /// 参数非法或持久层失败返回错误。
871    pub async fn list_instances(
872        &self,
873        management: &SagaManagementContext,
874        query: &SagaInstanceQuery<'_>,
875    ) -> anyhow::Result<Vec<SagaInstanceSummary>> {
876        // 检索响应含实例身份与状态,必须先鉴权;权限独立于任何写动作。
877        management.require(SagaManagementPermission::ListInstances)?;
878        Ok(self.backend.store().list_instances(query).await?)
879    }
880
881    /// 业务作用:读取单个租户的精确在飞配额用量——只经受鉴权管理查询返回,不进指标标签。
882    ///
883    /// 参数说明:
884    /// - `management`: 已认证主体与权限快照(要求 `saga.audit.read`)。
885    /// - `tenant`: 目标租户。
886    ///
887    /// 返回:账本记录的在飞实例数;无权限返回错误。
888    pub async fn tenant_quota_usage(
889        &self,
890        management: &SagaManagementContext,
891        tenant: &TenantId,
892    ) -> anyhow::Result<u64> {
893        management.require(SagaManagementPermission::ReadAudit)?;
894        Ok(self.backend.store().tenant_quota_usage(tenant).await?)
895    }
896
897    /// 业务作用:读取单个租户当前窗口的管理动作用量——只经受鉴权管理查询返回,不进指标标签。
898    ///
899    /// 参数说明:
900    /// - `management`: 已认证主体与权限快照(要求 `saga.audit.read`)。
901    /// - `tenant`: 目标租户。
902    ///
903    /// 返回:`Some((窗口起点毫秒, 已用动作数))`;该租户未配置速率时返回 `None`。
904    pub async fn tenant_action_rate_usage(
905        &self,
906        management: &SagaManagementContext,
907        tenant: &TenantId,
908    ) -> anyhow::Result<Option<(u64, u64)>> {
909        management.require(SagaManagementPermission::ReadAudit)?;
910        let Some(rate) = self.config.tenant_action_rates.get(tenant.as_str()) else {
911            return Ok(None);
912        };
913        Ok(Some(
914            self.backend
915                .store()
916                .tenant_action_rate_usage(tenant, rate.window_ms)
917                .await?,
918        ))
919    }
920
921    /// 业务作用:在变更类管理动作事务内按租户预留一次速率预算——单租户刷重试类动作
922    /// 不能挤占其它租户的恢复通道。
923    ///
924    /// 未配置速率的租户直接放行且不写账本;超限以稳定原因码拒绝并使动作事务回滚,
925    /// 与系统故障可区分。必须在 ambient 事务内调用:动作失败回滚时预算一并退还。
926    ///
927    /// 参数说明:
928    /// - `tenant`: 已认证管理主体被授权访问的租户。
929    ///
930    /// 返回:预算可用返回 `Ok`;超限或底层失败返回错误。
931    async fn reserve_action_rate_budget(&self, tenant: &TenantId) -> anyhow::Result<()> {
932        let Some(rate) = self.config.tenant_action_rates.get(tenant.as_str()) else {
933            return Ok(());
934        };
935        if matches!(
936            self.backend
937                .store()
938                .reserve_tenant_action_rate(tenant, rate.max_actions, rate.window_ms)
939                .await?,
940            nasaga_backend::ActionRateReservation::Exceeded
941        ) {
942            record_action_rate_rejection();
943            // 类型化拒绝使协议层无需读取错误文本,也不会暴露其它租户的用量。
944            return Err(SagaManagementError::RateLimitExceeded.into());
945        }
946        Ok(())
947    }
948
949    /// 业务作用:读取一份不含业务身份标签的 Saga 运行指标快照。
950    ///
951    /// 生命周期与状态计数来自当前 Saga 后端的已提交事实;Kafka 处理指标来自当前进程。
952    /// 宿主应将该入口放在受保护的运维 `/metrics` 端口,不要与业务 API 公网暴露。
953    ///
954    /// 参数说明:
955    /// - `now_ms`: 当前 epoch 毫秒,用于统计已到期 durable timer。
956    ///
957    /// 返回:数据库聚合成功时返回可渲染 Prometheus 文本的快照;连接或查询失败返回错误。
958    pub async fn load_operational_metrics(
959        &self,
960        now_ms: i64,
961    ) -> anyhow::Result<SagaOperationalMetrics> {
962        Ok(self
963            .backend
964            .store()
965            .load_operational_metrics(now_ms)
966            .await?
967            .into())
968    }
969
970    /// 业务作用:在占用 Orchestrator Inbox 身份前,把 transport 认证出的 producer
971    /// 与 definition 登记的步骤 owner 绑定,阻止共享 result topic 上的越权作证。
972    ///
973    /// 该校验只接受 transport 信任策略给出的逻辑身份;envelope 自报字段会先完成派生复验,
974    /// 随后再定位固定版本的 definition。失败时调用方必须隔离消息,不得进入 Inbox claim,
975    /// 否则攻击者可以用合法 event id 抢先污染真实 owner 的去重身份。
976    ///
977    /// 参数说明:
978    /// - `envelope`: transport 已解码但尚未进入持久化路径的结果 envelope。
979    /// - `producer`: mTLS/SASL principal 或独占 topic 经信任策略映射出的逻辑服务身份。
980    ///
981    /// 返回:producer 与步骤 owner 精确一致时返回成功;身份、definition、步骤或 owner
982    /// 任一不一致时返回协议错误,且没有任何数据库副作用。
983    pub fn verify_result_producer(
984        &self,
985        envelope: &SagaResultEnvelope,
986        producer: &ServiceIdentity,
987    ) -> anyhow::Result<()> {
988        let identity = envelope.verified_for_delivery()?;
989        let registry = self.registry_snapshot();
990        let definition = registry
991            .get_for_tenant(
992                &identity.tenant,
993                &identity.workflow,
994                identity.definition_version,
995            )
996            .ok_or(crate::SagaResultProcessingError::ContractInvalid)?;
997        if definition.digest() != identity.definition_digest {
998            return Err(crate::SagaResultProcessingError::ContractInvalid.into());
999        }
1000        let step = definition
1001            .step(&identity.step)
1002            .ok_or(crate::SagaResultProcessingError::ContractInvalid)?;
1003        // 能写共享 topic 不代表有权为任意步骤作证;owner 绑定必须先于 Inbox claim。
1004        if step.owner() != producer {
1005            return Err(crate::SagaResultProcessingError::ProducerUnauthorized.into());
1006        }
1007        Ok(())
1008    }
1009
1010    /// 业务作用:消费一条已经获得 transport producer 身份的结果,并在认证通过后推进 Saga。
1011    ///
1012    /// 参数说明:
1013    /// - `envelope`: 参与方结果 envelope。
1014    /// - `producer`: transport 信任策略解析出的逻辑服务身份。
1015    /// - `now_ms`: 当前 epoch 毫秒。
1016    ///
1017    /// 返回:认证和本地事务均成功时返回可 ACK 结论;认证失败无持久化副作用,推进失败则
1018    /// 事务回滚,两者都返回错误供 transport 选择 DLT 或保留 offset 重投。
1019    pub async fn handle_authenticated_result(
1020        &self,
1021        envelope: &SagaResultEnvelope,
1022        producer: &ServiceIdentity,
1023        now_ms: i64,
1024    ) -> anyhow::Result<HandleOutcome> {
1025        self.handle_authenticated_result_traced(envelope, producer, None, now_ms)
1026            .await
1027    }
1028
1029    /// 业务作用:携带 transport 收据链路上下文消费结果——参与方到编排端的 trace 由此续接。
1030    ///
1031    /// `receipt_trace` 必须来自 transport 收据(如 Kafka 消息 header)的显式解析,框架不
1032    /// 读取 ambient 状态。推进真实生效时,该上下文在同一事务内写入实例作为最新因果上下文,
1033    /// 本事务内发出的下一条命令与之后的 timer/恢复命令都从它派生 child span。
1034    ///
1035    /// 参数说明:
1036    /// - `envelope`: 参与方结果 envelope。
1037    /// - `producer`: transport 信任策略解析出的逻辑服务身份。
1038    /// - `receipt_trace`: 收据中已校验的链路上下文;`None` 表示收据未携带。
1039    /// - `now_ms`: 当前 epoch 毫秒。
1040    ///
1041    /// 返回:语义与 [`handle_authenticated_result`](Self::handle_authenticated_result)
1042    /// 完全一致;trace 不改变认证、去重与推进边界。
1043    pub async fn handle_authenticated_result_traced(
1044        &self,
1045        envelope: &SagaResultEnvelope,
1046        producer: &ServiceIdentity,
1047        receipt_trace: Option<&TraceContext>,
1048        now_ms: i64,
1049    ) -> anyhow::Result<HandleOutcome> {
1050        self.handle_authenticated_result_authorized_traced(
1051            envelope,
1052            producer,
1053            receipt_trace,
1054            now_ms,
1055            &|| Ok(()),
1056        )
1057        .await
1058    }
1059
1060    /// 业务作用:在宿主冻结的资格下接收已认证结果,数据库等待期间失权时回滚全部推进事实。
1061    ///
1062    /// 参数说明:
1063    /// - `envelope`:已认证入口接收的结果信封,仍需本层复验消息合同。
1064    /// - `producer`:认证得到的参与方服务身份,必须与结果生产权限相符。
1065    /// - `receipt_trace`:已由入口取得的可选追踪上下文,推进时可随实例保存。
1066    /// - `now_ms`:本次状态裁决使用的 Unix 毫秒时刻。
1067    /// - `authorize`:固定本次操作资格的同步复验闭包,应检查期限、撤销代际与安全合同。
1068    ///
1069    /// 返回:返回结果推进或 Inbox 重复收据,矛盾尝试结论可留存冲突并冻结自动动作后返回 Applied;资格、消息合同、持久化或 CAS 失败返回原分类。独立事务须确认提交后才能 ACK,加入外层事务时仍须等外层最终提交并复验资格;提交阶段失败可能结果未知。
1070    pub async fn handle_authenticated_result_authorized_traced(
1071        &self,
1072        envelope: &SagaResultEnvelope,
1073        producer: &ServiceIdentity,
1074        receipt_trace: Option<&TraceContext>,
1075        now_ms: i64,
1076        authorize: &(dyn Fn() -> anyhow::Result<()> + Send + Sync),
1077    ) -> anyhow::Result<HandleOutcome> {
1078        // 失效操作不能先占用 Inbox 身份,也不能读取新 registry 后借新的确认资格继续。
1079        authorize()?;
1080        // producer 权限必须在 Inbox claim 之前复验,防止越权消息抢占 event id。
1081        self.verify_result_producer(envelope, producer)?;
1082        let _latency =
1083            crate::latency::LatencyGuard::new(crate::latency::LatencyStage::TransitionTransaction);
1084        self.handle_verified_result(envelope, receipt_trace, now_ms, authorize)
1085            .await
1086    }
1087
1088    /// 业务作用:以受保护事务幂等创建 Saga 并发布首步命令。
1089    ///
1090    /// 创建事务同时提交:`version/transition_seq = 1` 的 `NONE -> RUNNING`、步骤骨架、
1091    /// 首步 execute 命令 Outbox、step 超时 timer 与(可选)实例 deadline timer。
1092    /// 业务幂等键冲突时返回既有实例,不产生任何新副作用。
1093    ///
1094    /// 参数说明:
1095    /// - `request`: 创建请求。
1096    ///
1097    /// 返回:真实创建返回 [`StartOutcome::Started`];幂等命中返回 `AlreadyExists`;
1098    /// 确定性拒绝可下转为 [`StartSagaError`];事务失败保留回滚、提交结果不明或回滚失败的封闭分类。
1099    pub async fn start_saga(&self, request: &StartSagaRequest<'_>) -> anyhow::Result<StartOutcome> {
1100        self.start_saga_traced(request, None).await
1101    }
1102
1103    /// 业务作用:携带显式链路上下文创建 Saga——发起入口的调用链由此接入 Saga 全程追踪。
1104    ///
1105    /// 框架不读取线程或任务 ambient 状态:调用方(Web 入口、调度任务)显式传入已校验的
1106    /// [`TraceContext`],其 canonical `traceparent` 随实例同事务持久化;首步命令与之后
1107    /// 每一条自动命令(含 timer、崩溃恢复发出的)都从已提交实例读取同一 trace 并派生
1108    /// 新 child span。缺少上下文时传 `None`,实例照常创建与投递。
1109    ///
1110    /// 参数说明:
1111    /// - `request`: 创建请求。
1112    /// - `trace`: 发起入口已校验的链路上下文;`None` 表示调用链未提供。
1113    ///
1114    /// 返回:语义与 [`start_saga`](Self::start_saga) 完全一致;trace 不改变创建、幂等
1115    /// 与拒绝边界。
1116    pub async fn start_saga_traced(
1117        &self,
1118        request: &StartSagaRequest<'_>,
1119        trace: Option<&TraceContext>,
1120    ) -> anyhow::Result<StartOutcome> {
1121        self.start_saga_authorized_traced(request, trace, &|| Ok(()))
1122            .await
1123    }
1124
1125    /// 业务作用:在调用方冻结的执行资格下裁决 Saga 创建与重复收据,连接池、行锁和事务等待不能延长权限。
1126    ///
1127    /// 参数说明:
1128    /// - `request`:是创建事实
1129    /// - `trace`:是受信链路上下文
1130    /// - `authorize`:同步复验本次操作的资格与期限。
1131    ///
1132    /// 返回:资格有效且创建事务提交后返回创建或重复收据;失权走事务回滚,底层提交不确定分类保持不变。
1133    /// deprecated 定义仍按持久请求摘要裁决重复与冲突;只有真正的新建适用 active 门禁。
1134    /// 此门禁覆盖本次创建操作;加入已有同源事务时,外层事务还须在其最终提交前复验自己的执行资格。
1135    pub async fn start_saga_authorized_traced(
1136        &self,
1137        request: &StartSagaRequest<'_>,
1138        trace: Option<&TraceContext>,
1139        authorize: &(dyn Fn() -> anyhow::Result<()> + Send + Sync),
1140    ) -> anyhow::Result<StartOutcome> {
1141        // 快照必须在执行资格复验之后读取,认证时持有的旧资格不能授权当前创建。
1142        authorize()?;
1143        let registry = self.registry_snapshot();
1144        // 停用只撤销新建资格,已提交请求仍需用同一不可变定义计算摘要,不能失去持久提交的确认能力。
1145        let definition = registry
1146            .get_for_tenant(request.tenant, request.workflow, request.version)
1147            .ok_or(StartSagaError::DefinitionInactive)?;
1148        let digest = definition.digest();
1149        let first_step = definition
1150            .steps()
1151            .first()
1152            .ok_or(StartSagaError::DefinitionEmpty)?;
1153        let _latency =
1154            crate::latency::LatencyGuard::new(crate::latency::LatencyStage::StartTransaction);
1155        if request.first_command_payload.is_some() && request.first_command_raw_payload.is_some() {
1156            return Err(StartSagaError::InvalidPayload.into());
1157        }
1158        let payload = request.first_command_raw_payload.clone().or_else(|| {
1159            request
1160                .first_command_payload
1161                .clone()
1162                .map(nasaga_core::SagaPayload::json)
1163        });
1164        if let Some(payload) = &payload {
1165            payload
1166                .validate()
1167                .map_err(|_| StartSagaError::InvalidPayload)?;
1168            if &payload.contract() != first_step.payload_contract() {
1169                return Err(StartSagaError::InvalidPayload.into());
1170            }
1171        }
1172        let start_request_digest =
1173            derive_start_request_digest(request, &digest, first_step.name(), payload.as_ref());
1174        let legacy_start_request_digest = match payload.as_ref() {
1175            Some(payload) if payload.contract() == nasaga_core::SagaPayloadContract::default() => {
1176                let mut legacy = request.clone();
1177                legacy.first_command_payload = Some(
1178                    serde_json::from_slice(&payload.body)
1179                        .map_err(|_| StartSagaError::InvalidPayload)?,
1180                );
1181                Some(derive_legacy_start_request_digest(
1182                    &legacy,
1183                    &digest,
1184                    first_step.name(),
1185                ))
1186            }
1187            _ => None,
1188        };
1189        let canonical_trace = trace.map(TraceContext::to_traceparent);
1190
1191        let outcome = crate::transaction::run_authorized_for(&self.backend, authorize, async {
1192            let creation = self
1193                .backend
1194                .store()
1195                .create_instance(&NewSagaInstance {
1196                    saga_id: request.saga_id,
1197                    tenant: request.tenant,
1198                    workflow: request.workflow,
1199                    business_key: request.business_key,
1200                    definition_version: request.version,
1201                    definition_digest: &digest,
1202                    start_request_digest: &start_request_digest,
1203                    legacy_start_request_digest: legacy_start_request_digest.as_deref(),
1204                    deadline_at_ms: request.deadline_at_ms,
1205                    // 创建即定位首步:step 超时裁决要求 current_step 与 timer 步骤一致。
1206                    current_step: Some(first_step.name()),
1207                    trigger_kind: request.trigger_kind,
1208                    trigger_id: request.trigger_id,
1209                    // 因果上下文与实例同事务落库:首步命令在同一事务内从创建回读的
1210                    // 实例行取 trace,发起入口的调用链从第一跳起就不断开。
1211                    traceparent: canonical_trace.as_deref(),
1212                })
1213                .await
1214                .map_err(|error| {
1215                    if error.kind() == SagaBackendErrorKind::Conflict {
1216                        anyhow::Error::new(StartSagaError::RequestConflict)
1217                    } else {
1218                        error.into()
1219                    }
1220                })?;
1221            let instance = match creation {
1222                // 幂等命中:绝不重复发布首步命令、timer 或初始 transition。
1223                SagaCreation::Existing(row) => return Ok(StartOutcome::AlreadyExists(row)),
1224                SagaCreation::Created(row) => row,
1225            };
1226            // 唯一键与摘要已在同一事务中裁决;新建遇到停用必须回滚实例及初始 transition,
1227            // 在此之前不预留配额、不发布命令或 timer,不能让拒绝的启动留下可见事实。
1228            if registry
1229                .get_for_start(request.tenant, request.workflow, request.version)
1230                .is_none()
1231            {
1232                return Err(StartSagaError::DefinitionInactive.into());
1233            }
1234            // 配额预留只对真实创建执行且与创建同事务:幂等命中不占额,创建回滚时预留
1235            // 一并回滚。条件自增在租户行锁上串行化并发创建——无锁计数会穿透上限。
1236            let cap = self
1237                .config
1238                .tenant_quotas
1239                .get(request.tenant.as_str())
1240                .copied();
1241            if matches!(
1242                self.backend
1243                    .store()
1244                    .reserve_tenant_quota(request.tenant, cap)
1245                    .await?,
1246                nasaga_backend::QuotaReservation::Exceeded
1247            ) {
1248                record_quota_rejection();
1249                // 类型化拒绝使协议层无需读取错误文本,也不会暴露其它租户的用量。
1250                return Err(StartSagaError::TenantQuotaExceeded.into());
1251            }
1252            self.backend
1253                .store()
1254                .register_steps(request.saga_id, definition)
1255                .await?;
1256            self.issue_command(
1257                &instance,
1258                first_step,
1259                StepPhase::Execute,
1260                AttemptNo::FIRST,
1261                request
1262                    .first_command_raw_payload
1263                    .clone()
1264                    .map(CommandInput::Raw)
1265                    .or_else(|| {
1266                        request
1267                            .first_command_payload
1268                            .clone()
1269                            .map(CommandInput::Json)
1270                    }),
1271                None,
1272                request.now_ms,
1273                instance.version,
1274            )
1275            .await?;
1276            // 实例级 deadline 与创建同事务:实例一旦可见即受全局期限保护。
1277            if let Some(deadline) = request.deadline_at_ms {
1278                self.schedule_timer(
1279                    request.saga_id,
1280                    TimerScope::Instance,
1281                    KIND_INSTANCE_DEADLINE,
1282                    AttemptNo::FIRST,
1283                    deadline,
1284                    instance.version,
1285                )
1286                .await?;
1287            }
1288            Ok(StartOutcome::Started(instance))
1289        })
1290        .await?;
1291        match &outcome {
1292            StartOutcome::Started(instance) => tracing::info!(
1293                saga_id = instance.saga_id.as_str(),
1294                tenant = instance.tenant.as_str(),
1295                workflow = instance.workflow.as_str(),
1296                definition_version = instance.definition_version.get(),
1297                saga_status = instance.status.as_str(),
1298                "Saga 创建事务已提交"
1299            ),
1300            StartOutcome::AlreadyExists(instance) => tracing::info!(
1301                saga_id = instance.saga_id.as_str(),
1302                tenant = instance.tenant.as_str(),
1303                workflow = instance.workflow.as_str(),
1304                saga_status = instance.status.as_str(),
1305                "Saga 启动重放已幂等吸收"
1306            ),
1307        }
1308        Ok(outcome)
1309    }
1310
1311    /// 业务作用:在本次执行资格下以 Inbox、尝试账本和状态 CAS 原子编排结果推进,重复事实不再产生路由副作用。
1312    ///
1313    /// 参数说明:
1314    /// - `envelope`:已认证入口接收的结果信封,仍需本层复验消息合同。
1315    /// - `receipt_trace`:已由入口取得的可选追踪上下文,推进时可随实例保存。
1316    /// - `now_ms`:本次状态裁决使用的 Unix 毫秒时刻。
1317    /// - `authorize`:固定本次操作资格的同步复验闭包,应检查期限、撤销代际与安全合同。
1318    ///
1319    /// 返回:返回结果推进或 Inbox 重复收据,矛盾尝试结论可留存冲突并冻结自动动作后返回 Applied;资格、消息合同、持久化或 CAS 失败返回原分类。独立事务须确认提交后才能 ACK,加入外层事务时仍须等外层最终提交并复验资格;提交阶段失败可能结果未知。
1320    async fn handle_verified_result(
1321        &self,
1322        envelope: &SagaResultEnvelope,
1323        receipt_trace: Option<&TraceContext>,
1324        now_ms: i64,
1325        authorize: &(dyn Fn() -> anyhow::Result<()> + Send + Sync),
1326    ) -> anyhow::Result<HandleOutcome> {
1327        // 身份复验在任何持久化动作之前:伪造 envelope 不允许占用去重身份。
1328        let identity = envelope.verified_for_delivery()?;
1329        let status = envelope
1330            .parsed_status()
1331            .map_err(|_| crate::SagaResultProcessingError::ContractInvalid)?;
1332        let terminal = envelope
1333            .parsed_terminal()
1334            .map_err(|_| crate::SagaResultProcessingError::ContractInvalid)?;
1335        let registry = self.registry_snapshot();
1336
1337        let outcome = crate::transaction::run_authorized_for(&self.backend, authorize, async {
1338            if matches!(
1339                self.backend
1340                    .inbox()
1341                    .claim(&self.config.inbox_consumer, &envelope.event_id)
1342                    .await?,
1343                InboxClaim::Duplicate
1344            ) {
1345                return Ok(HandleOutcome::Duplicate);
1346            }
1347            let instance = self
1348                .backend
1349                .store()
1350                .load_instance(&identity.saga_id)
1351                .await?
1352                .ok_or(crate::SagaResultProcessingError::ContractInvalid)?;
1353            verify_instance_contract(&instance, &identity)
1354                .map_err(|_| crate::SagaResultProcessingError::ContractInvalid)?;
1355            // 实例读取或前序 Inbox 竞争可能已经等待;任何 journal 与后续状态写入前复验原资格。
1356            authorize()?;
1357            if !instance.control_state.allows_automatic_actions() {
1358                // PAUSED 的核心语义是不发出任何自动动作;结果也不能先被 Inbox 吞掉再悬空。
1359                // 类型化延后使同事务 Inbox claim 回滚且不消耗毒消息预算;恢复后再裁决。
1360                return Err(crate::SagaResultProcessingError::Paused.into());
1361            }
1362            let definition = registry
1363                .get_for_tenant(
1364                    &instance.tenant,
1365                    &instance.workflow,
1366                    instance.definition_version,
1367                )
1368                .ok_or_else(|| anyhow::anyhow!("instance definition is not registered"))?;
1369            let step_def = definition.step(&identity.step).ok_or_else(|| {
1370                anyhow::Error::from(crate::SagaResultProcessingError::ContractInvalid)
1371            })?;
1372
1373            // 真实 outcome 必须先落 journal:即使实例已在人工介入,迟到事实也不许丢
1374            // (拒收会造成恢复时重复补偿)。
1375            let recorded = self
1376                .backend
1377                .store()
1378                .record_attempt_outcome(
1379                    &identity.saga_id,
1380                    &identity.step,
1381                    identity.phase,
1382                    identity.attempt,
1383                    status,
1384                    Some(&envelope.event_id),
1385                )
1386                .await?;
1387            match recorded {
1388                // 同一 attempt 的相同结论已经处理过(换了 event id 的 DLT/管理重放绕过了
1389                // Inbox 去重):journal 与 Inbox 都已留证,此处必须停止一切路由副作用,
1390                // 否则重放会重复推进状态机并重发业务命令。
1391                AttemptOutcomeRecord::AlreadyRecorded => {
1392                    return Ok(HandleOutcome::Applied {
1393                        status: instance.status,
1394                    });
1395                }
1396                AttemptOutcomeRecord::Conflicting {
1397                    existing,
1398                } => {
1399                    // 后到互斥事实不能覆盖 journal 先到结论,也不能通过返回错误让 Inbox 与
1400                    // 冲突证据一起回滚形成永久毒消息;先留存双方状态,再同事务冻结自动动作。
1401                    self.backend
1402                        .store()
1403                        .record_attempt_conflict(&AttemptConflictFact {
1404                            saga_id: &identity.saga_id,
1405                            step: &identity.step,
1406                            phase: identity.phase,
1407                            attempt: identity.attempt,
1408                            existing_status: existing,
1409                            incoming_status: status,
1410                            incoming_event_id: &envelope.event_id,
1411                            conflict_kind: SagaConflictKind::AttemptTerminal,
1412                        })
1413                        .await?;
1414                    let status = if instance.status.is_terminal()
1415                        || instance.status == SagaStatus::ManualIntervention
1416                    {
1417                        // 终态是不可重开的已提交结论;冲突事实仍与 Inbox 同事务
1418                        // 留存,但不得尝试非法的终态 -> Manual 迁移导致证据整体回滚。
1419                        instance.status
1420                    } else {
1421                        self.escalate_manual(
1422                            &instance,
1423                            TriggerKind::Event,
1424                            &envelope.event_id,
1425                            "attempt_outcome_conflict",
1426                        )
1427                        .await?
1428                    };
1429                    return Ok(HandleOutcome::Applied {
1430                        status,
1431                    });
1432                }
1433                AttemptOutcomeRecord::Recorded => {}
1434            }
1435
1436            // 已认证结果是实例最新的因果事实:在同一推进事务内落库,并刷新内存快照——
1437            // 本事务随后发出的下一条命令、之后的 timer 与崩溃恢复命令都从同一 trace
1438            // 派生 child。推进输掉 CAS 时整个事务回滚,上下文不会先于推进生效。
1439            let instance = match receipt_trace {
1440                Some(trace) => {
1441                    let canonical = trace.to_traceparent();
1442                    self.backend
1443                        .store()
1444                        .update_trace_context(&identity.saga_id, &canonical)
1445                        .await?;
1446                    SagaInstanceRow {
1447                        traceparent: Some(canonical),
1448                        ..instance
1449                    }
1450                }
1451                None => instance,
1452            };
1453
1454            let context = ResultContext {
1455                instance: &instance,
1456                definition,
1457                step_def,
1458                identity: &identity,
1459                status,
1460                terminal,
1461                reason_code: envelope.reason_code.as_deref(),
1462                event_id: &envelope.event_id,
1463                now_ms,
1464            };
1465            // 路由分支各自内嵌多层持久化 future,合并成单个状态机会让 handle_result
1466            // 的 poll 栈占用以 MB 计(debug 态)并压垮调用方线程栈;逐分支装箱,
1467            // 让大状态机落在堆上。
1468            let new_status = match identity.phase {
1469                StepPhase::Execute => Box::pin(self.on_execute_result(&context)).await?,
1470                StepPhase::Cancel => Box::pin(self.on_cancel_result(&context)).await?,
1471                StepPhase::Compensate => Box::pin(self.on_compensate_result(&context)).await?,
1472                StepPhase::Resolve => Box::pin(self.on_resolve_result(&context)).await?,
1473            };
1474            Ok(HandleOutcome::Applied {
1475                status: new_status,
1476            })
1477        })
1478        .await?;
1479        match &outcome {
1480            HandleOutcome::Applied {
1481                status,
1482            } => tracing::info!(
1483                saga_id = identity.saga_id.as_str(),
1484                workflow = identity.workflow.as_str(),
1485                step = identity.step.as_str(),
1486                phase = identity.phase.as_str(),
1487                attempt = identity.attempt.get(),
1488                saga_status = status.as_str(),
1489                "Saga 结果事务已提交"
1490            ),
1491            HandleOutcome::Duplicate => tracing::info!(
1492                saga_id = identity.saga_id.as_str(),
1493                workflow = identity.workflow.as_str(),
1494                step = identity.step.as_str(),
1495                phase = identity.phase.as_str(),
1496                attempt = identity.attempt.get(),
1497                "Saga 重复结果已由 Inbox 吸收"
1498            ),
1499        }
1500        Ok(outcome)
1501    }
1502
1503    /// 业务作用:领取到期 timer 并逐个裁决,多副本可并发竞争。
1504    ///
1505    /// **必须在 ambient 事务之外调用**:租约领取独立提交;每个 timer 的裁决各自开启
1506    /// 单一本地事务。实例处于 `PAUSED` 时不消费,按轮询退避交还(只是轮询节奏,
1507    /// 不顺延业务 deadline)。
1508    ///
1509    /// 参数说明:
1510    /// - `owner`: 本副本稳定标识。
1511    /// - `now_ms`: 当前时刻(epoch 毫秒)。
1512    ///
1513    /// 返回:本轮实际完成业务裁决的 timer 数;领取或裁决基础设施失败返回错误
1514    /// (已领取未处理的 timer 由租约到期自动回收)。
1515    pub async fn run_due_timers(&self, owner: &str, now_ms: i64) -> anyhow::Result<u32> {
1516        // 单调起点必须早于领取 SQL:数据库等待和后续逐条处理都消耗同一份租约预算,
1517        // 不能让整批 timer 永远复用领取瞬间的旧 now_ms。
1518        let claimed_at = Instant::now();
1519        // token 额外绑定不可注入的 runtime nonce:即使两个副本误配同一 owner,旧副本也无法
1520        // 派生新持有者的 token 完成过期租约,不能把防脑裂正确性押在部署配置上。
1521        let token = self.fencing_tokens.issue(owner, now_ms);
1522        let claimed = self
1523            .backend
1524            .store()
1525            .claim_due_timers(
1526                owner,
1527                token,
1528                now_ms,
1529                self.config.timer_lease_ms,
1530                self.config.timer_claim_limit,
1531            )
1532            .await?;
1533        let mut applied = 0u32;
1534        for timer in claimed.timers() {
1535            match self
1536                .fire_timer_inner(timer, claimed.token(), now_ms, Some(claimed_at))
1537                .await?
1538            {
1539                TimerOutcome::Applied {
1540                    status,
1541                } => {
1542                    applied += 1;
1543                    tracing::info!(
1544                        saga_id = timer.saga_id.as_str(),
1545                        timer_kind = %timer.kind,
1546                        timer_scope = %timer.scope_kind,
1547                        attempt = timer.attempt.get(),
1548                        saga_status = status.as_str(),
1549                        "Saga durable timer 裁决事务已提交"
1550                    );
1551                }
1552                TimerOutcome::SkippedPaused => {
1553                    // 暂停只停止发出新动作;timer 保留原语义,按退避节奏交还等待恢复。
1554                    // 交还前重新读取流逝时间;租约到期的旧 owner 必须认输,不能把
1555                    // 即将由新 owner 接管的 timer 又推回未来。
1556                    let release_now = elapsed_epoch_ms(now_ms, claimed_at)?;
1557                    let _ = self
1558                        .backend
1559                        .store()
1560                        .release_timer(
1561                            &timer.timer_id,
1562                            claimed.token(),
1563                            release_now,
1564                            checked_deadline(release_now, self.config.pause_backoff_ms)?,
1565                        )
1566                        .await?;
1567                }
1568                TimerOutcome::SkippedFencingLost => tracing::warn!(
1569                    saga_id = timer.saga_id.as_str(),
1570                    timer_kind = %timer.kind,
1571                    "Saga timer 已失去 fencing 权威,旧 owner 停止动作"
1572                ),
1573                TimerOutcome::SkippedStale => tracing::debug!(
1574                    saga_id = timer.saga_id.as_str(),
1575                    timer_kind = %timer.kind,
1576                    "Saga 过期 timer 已幂等吸收"
1577                ),
1578            }
1579        }
1580        Ok(applied)
1581    }
1582
1583    /// 业务作用:裁决单个已领取 timer;消费与它触发的状态迁移同一事务。
1584    ///
1585    /// 参数说明:
1586    /// - `timer`: 已领取的 timer 行。
1587    /// - `token`: 本轮 fencing token。
1588    /// - `now_ms`: 当前时刻(epoch 毫秒)。
1589    ///
1590    /// 返回:完成裁决返回 [`TimerOutcome::Applied`];暂停/失权/语义过时返回对应跳过
1591    /// 结论;CAS 冲突返回错误(事务回滚,timer 留在租约内等待重试或回收)。
1592    pub async fn fire_timer(
1593        &self,
1594        timer: &SagaTimerRow,
1595        token: &TimerFencingToken,
1596        now_ms: i64,
1597    ) -> anyhow::Result<TimerOutcome> {
1598        self.fire_timer_inner(timer, token, now_ms, None).await
1599    }
1600
1601    /// 业务作用:执行单个 timer 的实际裁决,并在批量领取路径中按单调时钟复验租约。
1602    ///
1603    /// 参数说明:
1604    /// - `timer`: 已领取的 timer 行。
1605    /// - `token`: 本轮 fencing token。
1606    /// - `base_now_ms`: 领取时注入的 epoch 毫秒,同时作为本批次裁决时刻。
1607    /// - `claimed_at`: 批量领取的单调起点;为空表示调用方已传入最新裁决时刻。
1608    ///
1609    /// 返回:完成裁决返回 [`TimerOutcome::Applied`];暂停/失权/语义过时返回对应跳过
1610    /// 结论;基础设施或状态机失败返回错误并回滚 timer 消费事务。
1611    async fn fire_timer_inner(
1612        &self,
1613        timer: &SagaTimerRow,
1614        token: &TimerFencingToken,
1615        base_now_ms: i64,
1616        claimed_at: Option<Instant>,
1617    ) -> anyhow::Result<TimerOutcome> {
1618        let _latency =
1619            crate::latency::LatencyGuard::new(crate::latency::LatencyStage::TransitionTransaction);
1620        let step_scope = parse_step_scope(&timer.scope_kind, &timer.scope_key)?;
1621        let registry = self.registry_snapshot();
1622        crate::transaction::run_for(&self.backend, async {
1623            let Some(instance) = self.backend.store().load_instance(&timer.saga_id).await? else {
1624                // 实例已被归档清理的孤儿 timer:消费吸收,不再触发。
1625                let fencing_now = match claimed_at {
1626                    Some(started) => elapsed_epoch_ms(base_now_ms, started)?,
1627                    None => base_now_ms,
1628                };
1629                return Ok(
1630                    match self
1631                        .backend
1632                        .store()
1633                        .complete_timer(&timer.timer_id, token, fencing_now)
1634                        .await?
1635                    {
1636                        TimerFencing::Applied => TimerOutcome::SkippedStale,
1637                        TimerFencing::Lost => TimerOutcome::SkippedFencingLost,
1638                    },
1639                );
1640            };
1641            if !instance.control_state.allows_automatic_actions() {
1642                return Ok(TimerOutcome::SkippedPaused);
1643            }
1644            let now_ms = match claimed_at {
1645                Some(started) => elapsed_epoch_ms(base_now_ms, started)?,
1646                None => base_now_ms,
1647            };
1648            // 消费必须与迁移同事务:先以 fencing 占住消费权,失权立即停止推进。
1649            if matches!(
1650                self.backend
1651                    .store()
1652                    .complete_timer(&timer.timer_id, token, now_ms)
1653                    .await?,
1654                TimerFencing::Lost
1655            ) {
1656                return Ok(TimerOutcome::SkippedFencingLost);
1657            }
1658            crate::latency::observe(
1659                crate::latency::LatencyStage::TimerLateness,
1660                Duration::from_millis(now_ms.saturating_sub(timer.due_at_ms).max(0) as u64),
1661            );
1662            // attempt 级 timeout 只对调度它的实例版本有效;版本已推进说明相应结果或
1663            // 另一计时器先完成了裁决,旧 timer 必须被吸收,绝不能再次发命令或改状态。
1664            // 实例 deadline 与解决总预算跨越多个实例版本,故意不采用该精确版本条件。
1665            if matches!(
1666                timer.kind.as_str(),
1667                KIND_STEP_TIMEOUT
1668                    | KIND_CANCEL_TIMEOUT
1669                    | KIND_COMPENSATE_TIMEOUT
1670                    | KIND_RESOLVE_TIMEOUT
1671            ) && timer.expected_saga_version != instance.version
1672            {
1673                return Ok(TimerOutcome::SkippedStale);
1674            }
1675            let definition = registry
1676                .get_for_tenant(
1677                    &instance.tenant,
1678                    &instance.workflow,
1679                    instance.definition_version,
1680                )
1681                .ok_or_else(|| anyhow::anyhow!("instance definition is not registered"))?;
1682
1683            // 与 handle_result 同理:超时裁决分支装箱,防止 fire_timer 状态机撑爆调用栈。
1684            let status = match timer.kind.as_str() {
1685                KIND_STEP_TIMEOUT => {
1686                    Box::pin(self.on_step_timeout(
1687                        &instance,
1688                        definition,
1689                        step_scope.as_ref(),
1690                        timer,
1691                        now_ms,
1692                    ))
1693                    .await?
1694                }
1695                KIND_CANCEL_TIMEOUT => {
1696                    Box::pin(self.on_phase_retry_timeout(
1697                        &instance,
1698                        definition,
1699                        step_scope.as_ref(),
1700                        timer,
1701                        StepPhase::Cancel,
1702                        SagaStatus::Cancelling,
1703                        self.config.cancel_max_attempts,
1704                        "cancel_attempts_exhausted",
1705                        now_ms,
1706                    ))
1707                    .await?
1708                }
1709                KIND_COMPENSATE_TIMEOUT => {
1710                    Box::pin(self.on_phase_retry_timeout(
1711                        &instance,
1712                        definition,
1713                        step_scope.as_ref(),
1714                        timer,
1715                        StepPhase::Compensate,
1716                        SagaStatus::Compensating,
1717                        self.config.compensate_max_attempts,
1718                        "compensation_attempts_exhausted",
1719                        now_ms,
1720                    ))
1721                    .await?
1722                }
1723                KIND_RESOLVE_TIMEOUT => {
1724                    Box::pin(self.on_phase_retry_timeout(
1725                        &instance,
1726                        definition,
1727                        step_scope.as_ref(),
1728                        timer,
1729                        StepPhase::Resolve,
1730                        SagaStatus::WaitingResolution,
1731                        self.config.resolve_max_attempts,
1732                        "resolve_attempts_exhausted",
1733                        now_ms,
1734                    ))
1735                    .await?
1736                }
1737                KIND_RESOLUTION_BUDGET
1738                | KIND_FORWARD_RESOLUTION_BUDGET
1739                | KIND_COMPENSATION_RESOLUTION_BUDGET => {
1740                    // 解决预算耗尽只升级人工介入并告警:绝不自动降级为 Rejected,
1741                    // 否则支付已成功但响应丢失时会同时退款与继续成功流程。
1742                    if instance.status == SagaStatus::WaitingResolution {
1743                        Some(
1744                            self.escalate_manual(
1745                                &instance,
1746                                TriggerKind::Timer,
1747                                &timer.timer_id,
1748                                "resolution_budget_exhausted",
1749                            )
1750                            .await?,
1751                        )
1752                    } else {
1753                        None
1754                    }
1755                }
1756                KIND_INSTANCE_DEADLINE => {
1757                    Box::pin(self.on_instance_deadline(&instance, definition, timer, now_ms))
1758                        .await?
1759                }
1760                _ => anyhow::bail!("unknown timer kind"),
1761            };
1762            Ok(match status {
1763                Some(status) => TimerOutcome::Applied {
1764                    status,
1765                },
1766                // 语义已过时(状态或步骤已迁移):timer 已消费,无业务动作。
1767                None => TimerOutcome::SkippedStale,
1768            })
1769        })
1770        .await
1771    }
1772
1773    /// 业务作用:管理面暂停实例——只停止自动动作,不改业务状态、不停业务时钟。
1774    ///
1775    /// 参数说明:
1776    /// - `management`: 已认证主体、原因与 `saga.pause` 权限快照。
1777    /// - `tenant`: 已认证管理主体被授权访问的租户。
1778    /// - `saga_id`: 实例身份。
1779    /// - `operation_id`: 稳定管理 operation id;与控制态切换同事务写入本地幂等审计链。
1780    ///
1781    /// 返回:暂停生效返回 `Ok`;实例版本或控制状态过期返回错误,调用方重读后重试。
1782    pub async fn pause(
1783        &self,
1784        management: &SagaManagementContext,
1785        tenant: &TenantId,
1786        saga_id: &SagaId,
1787        operation_id: &str,
1788    ) -> anyhow::Result<()> {
1789        self.pause_with_expectation(
1790            management,
1791            tenant,
1792            saga_id,
1793            operation_id,
1794            SagaManagementExpectation::default(),
1795        )
1796        .await
1797    }
1798
1799    /// 业务作用:在事务内复验调用方版本后暂停实例,避免旧管理快照覆盖并发状态或控制迁移。
1800    ///
1801    /// 参数说明:
1802    /// - `management`:已经认证的管理主体、权限与操作原因上下文。
1803    /// - `tenant`:必须与持久实例归属一致的租户身份。
1804    /// - `saga_id`:本次管理动作定位的实例身份。
1805    /// - `operation_id`:持久幂等管理操作标识,重放应保持动作和主体语义一致。
1806    /// - `expectation`:首次操作必须在事务内命中的实例与控制版本前置条件;已提交的相同操作重放优先识别。
1807    ///
1808    /// 返回:有权限、租户归属和首次版本前置条件满足时完成暂停,相同持久操作重放直接成功且不重复占用预算。非法状态、过期快照、并发冲突或数据库失败返回分类错误;恢复只唤醒原 timer,不重置业务期限。加入外层事务时不代表已最终提交,提交失败可能结果未知。
1809    pub async fn pause_with_expectation(
1810        &self,
1811        management: &SagaManagementContext,
1812        tenant: &TenantId,
1813        saga_id: &SagaId,
1814        operation_id: &str,
1815        expectation: SagaManagementExpectation,
1816    ) -> anyhow::Result<()> {
1817        // 权限门禁必须早于实例查询,避免无权主体利用存在性与租户错误枚举 saga_id。
1818        management.require(SagaManagementPermission::Pause)?;
1819        crate::transaction::run_for(&self.backend, async {
1820            let instance = self
1821                .backend
1822                .store()
1823                .load_instance(saga_id)
1824                .await?
1825                .filter(|instance| &instance.tenant == tenant)
1826                .ok_or(SagaManagementError::NotFound)?;
1827            // 先让持久层识别完全相同的 operation;首次动作即使暂时完成 UPDATE,也仍在
1828            // 当前事务内,随后 expected version 失配会整体回滚,不能绕过并发门禁。
1829            match self
1830                .backend
1831                .store()
1832                .set_control_state(&ControlTransitionSpec {
1833                    saga_id,
1834                    expected_version: instance.version,
1835                    expected_control_version: instance.control_version,
1836                    from: nasaga_core::ControlState::Active,
1837                    to: nasaga_core::ControlState::Paused,
1838                    operation_id,
1839                    actor: management.actor(),
1840                    reason: management.reason(),
1841                })
1842                .await?
1843            {
1844                ControlCasOutcome::Applied => {
1845                    expectation.verify(instance.version, instance.control_version)?;
1846                    // 只有首次 operation 才占用动作预算;若额度不足,控制态和审计与
1847                    // 本次预留在同一事务回滚。已提交重放在上面的持久事实分支直接返回。
1848                    self.reserve_action_rate_budget(tenant).await?;
1849                    Ok(())
1850                }
1851                ControlCasOutcome::AlreadyApplied => Ok(()),
1852                ControlCasOutcome::Conflict => {
1853                    expectation.verify(instance.version, instance.control_version)?;
1854                    if instance.control_state == nasaga_core::ControlState::Active {
1855                        // 初次快照允许暂停而 CAS 未命中,说明提交前已经失去控制权威;
1856                        // 返回可重试并发类别,让协议层要求调用方重新加载。
1857                        Err(anyhow::Error::new(SagaConcurrencyError::StaleSnapshot)
1858                            .context("pause lost the control-state race; reload and retry"))
1859                    } else {
1860                        Err(management_precondition(
1861                            "pause requires an active control state",
1862                        ))
1863                    }
1864                }
1865            }
1866        })
1867        .await?;
1868        tracing::info!(
1869            saga_id = saga_id.as_str(),
1870            tenant = tenant.as_str(),
1871            actor = management.actor(),
1872            operation_id,
1873            control_state = "PAUSED",
1874            "Saga 暂停操作已提交"
1875        );
1876        Ok(())
1877    }
1878
1879    /// 业务作用:管理面恢复实例;已逾期的 deadline 由 timer 在恢复后立即合并触发一次,
1880    /// 不重置到未来,也不逐 tick 补烧。
1881    ///
1882    /// 参数说明:
1883    /// - `management`: 已认证主体、原因与 `saga.resume` 权限快照。
1884    /// - `tenant`: 已认证管理主体被授权访问的租户。
1885    /// - `saga_id`: 实例身份。
1886    /// - `operation_id`: 稳定管理 operation id;重放已提交操作不会再次改变控制态。
1887    ///
1888    /// 返回:恢复生效返回 `Ok`;实例版本或控制状态过期返回错误。
1889    pub async fn resume(
1890        &self,
1891        management: &SagaManagementContext,
1892        tenant: &TenantId,
1893        saga_id: &SagaId,
1894        operation_id: &str,
1895    ) -> anyhow::Result<()> {
1896        self.resume_with_expectation(
1897            management,
1898            tenant,
1899            saga_id,
1900            operation_id,
1901            SagaManagementExpectation::default(),
1902        )
1903        .await
1904    }
1905
1906    /// 业务作用:在事务内复验调用方版本后恢复实例,并保持原业务 deadline 语义。
1907    ///
1908    /// 参数说明:
1909    /// - `management`:已经认证的管理主体、权限与操作原因上下文。
1910    /// - `tenant`:必须与持久实例归属一致的租户身份。
1911    /// - `saga_id`:本次管理动作定位的实例身份。
1912    /// - `operation_id`:持久幂等管理操作标识,重放应保持动作和主体语义一致。
1913    /// - `expectation`:首次操作必须在事务内命中的实例与控制版本前置条件;已提交的相同操作重放优先识别。
1914    ///
1915    /// 返回:有权限、租户归属和首次版本前置条件满足时完成恢复,相同持久操作重放直接成功且不重复占用预算。非法状态、过期快照、并发冲突或数据库失败返回分类错误;恢复只唤醒原 timer,不重置业务期限。加入外层事务时不代表已最终提交,提交失败可能结果未知。
1916    pub async fn resume_with_expectation(
1917        &self,
1918        management: &SagaManagementContext,
1919        tenant: &TenantId,
1920        saga_id: &SagaId,
1921        operation_id: &str,
1922        expectation: SagaManagementExpectation,
1923    ) -> anyhow::Result<()> {
1924        // 与 pause 相同,先鉴权再读实例,防止管理读侧成为跨租户枚举 oracle。
1925        management.require(SagaManagementPermission::Resume)?;
1926        crate::transaction::run_for(&self.backend, async {
1927            let instance = self
1928                .backend
1929                .store()
1930                .load_instance(saga_id)
1931                .await?
1932                .filter(|instance| &instance.tenant == tenant)
1933                .ok_or(SagaManagementError::NotFound)?;
1934            // 持久 operation 事实优先于调用方旧快照;首次恢复仍在同一事务内复验版本,
1935            // 失配会连同控制态 UPDATE 一起回滚,已提交重放则不再次唤醒 timer。
1936            match self
1937                .backend
1938                .store()
1939                .set_control_state(&ControlTransitionSpec {
1940                    saga_id,
1941                    expected_version: instance.version,
1942                    expected_control_version: instance.control_version,
1943                    from: nasaga_core::ControlState::Paused,
1944                    to: nasaga_core::ControlState::Active,
1945                    operation_id,
1946                    actor: management.actor(),
1947                    reason: management.reason(),
1948                })
1949                .await?
1950            {
1951                ControlCasOutcome::Applied => {
1952                    expectation.verify(instance.version, instance.control_version)?;
1953                    // 新恢复动作才消耗预算;超限会回滚刚完成的控制态 CAS 与审计,
1954                    // 完全相同的已提交重放不改变速率账本。
1955                    self.reserve_action_rate_budget(tenant).await?;
1956                    // 恢复不重置业务期限:只清除暂停产生的 available_at 退避,
1957                    // 已逾期 timer 会在下一轮立即可领,未来 timer 仍等待原 due_at。
1958                    self.backend.store().wake_saga_timers(saga_id).await?;
1959                    Ok(())
1960                }
1961                ControlCasOutcome::AlreadyApplied => Ok(()),
1962                ControlCasOutcome::Conflict => {
1963                    expectation.verify(instance.version, instance.control_version)?;
1964                    if instance.control_state == nasaga_core::ControlState::Paused {
1965                        // 初次快照允许恢复而 CAS 未命中,说明提交前已经失去控制权威;
1966                        // 返回可重试并发类别,让协议层要求调用方重新加载。
1967                        Err(anyhow::Error::new(SagaConcurrencyError::StaleSnapshot)
1968                            .context("resume lost the control-state race; reload and retry"))
1969                    } else {
1970                        Err(management_precondition(
1971                            "resume requires a paused control state",
1972                        ))
1973                    }
1974                }
1975            }
1976        })
1977        .await?;
1978        tracing::info!(
1979            saga_id = saga_id.as_str(),
1980            tenant = tenant.as_str(),
1981            actor = management.actor(),
1982            operation_id,
1983            control_state = "ACTIVE",
1984            "Saga 恢复操作已提交"
1985        );
1986        Ok(())
1987    }
1988
1989    /// 业务作用:管理面在人工介入后重试补偿——基于持久化事实继续冻结计划内的补偿。
1990    ///
1991    /// 只允许 `MANUAL_INTERVENTION -> COMPENSATING` 且实例已有冻结计划;计划首项为
1992    /// `HALTED` 时,同事务管理审计会显式重开 Orchestrator 投影,并把 operation id 写入
1993    /// command 供 Participant 二次门禁。`Unknown` 仍禁止直接重做;无剩余计划项时收敛。
1994    ///
1995    /// 参数说明:
1996    /// - `management`: 已认证主体、原因与 `saga.retry_compensation` 权限快照。
1997    /// - `tenant`: 已认证管理主体被授权访问的租户。
1998    /// - `saga_id`: 实例身份。
1999    /// - `operation_id`: 稳定管理 operation id,作为迁移触发身份进入审计链。
2000    /// - `now_ms`: 当前时刻(epoch 毫秒)。
2001    ///
2002    /// 返回:推进后的实例状态;PAUSED、实例不在人工介入、无冻结计划或 CAS 冲突返回错误。
2003    pub async fn retry_compensation(
2004        &self,
2005        management: &SagaManagementContext,
2006        tenant: &TenantId,
2007        saga_id: &SagaId,
2008        operation_id: &str,
2009        now_ms: i64,
2010    ) -> anyhow::Result<SagaStatus> {
2011        self.retry_compensation_with_expectation(
2012            management,
2013            tenant,
2014            saga_id,
2015            operation_id,
2016            now_ms,
2017            SagaManagementExpectation::default(),
2018        )
2019        .await
2020    }
2021
2022    /// 业务作用:在事务内命中调用方版本后重开冻结补偿计划,禁止旧快照发布新的外部命令。
2023    ///
2024    /// 参数说明:
2025    /// - `management`:已经认证的管理主体、权限与操作原因上下文。
2026    /// - `tenant`:必须与持久实例归属一致的租户身份。
2027    /// - `saga_id`:本次管理动作定位的实例身份。
2028    /// - `operation_id`:持久幂等管理操作标识,重放应保持动作和主体语义一致。
2029    /// - `now_ms`:用于新补偿命令期限与裁决的 Unix 毫秒时刻。
2030    /// - `expectation`:首次操作必须在事务内命中的实例与控制版本前置条件;已提交的相同操作重放优先识别。
2031    ///
2032    /// 返回:首次操作通过权限、版本、ACTIVE 控制态及冻结计划门禁后,重开待补偿步骤并写命令 Outbox;无剩余步骤时验证完成后收敛。相同已提交操作返回当前状态,不再分配尝试;失败返回分类错误。加入外层事务仍需等待最终提交,提交失败可能结果未知。
2033    pub async fn retry_compensation_with_expectation(
2034        &self,
2035        management: &SagaManagementContext,
2036        tenant: &TenantId,
2037        saga_id: &SagaId,
2038        operation_id: &str,
2039        now_ms: i64,
2040        expectation: SagaManagementExpectation,
2041    ) -> anyhow::Result<SagaStatus> {
2042        // 人工恢复会再次发布外部补偿命令,必须先做最小权限校验,再接触实例数据。
2043        management.require(SagaManagementPermission::RetryCompensation)?;
2044        let registry = self.registry_snapshot();
2045        let status = crate::transaction::run_for(&self.backend, async {
2046            let instance = self
2047                .backend
2048                .store()
2049                .load_instance(saga_id)
2050                .await?
2051                .filter(|instance| &instance.tenant == tenant)
2052                .ok_or(SagaManagementError::NotFound)?;
2053            match self
2054                .backend
2055                .store()
2056                .record_management_operation(
2057                    saga_id,
2058                    operation_id,
2059                    "retry_frozen_compensation",
2060                    management.actor(),
2061                    management.reason(),
2062                )
2063                .await?
2064            {
2065                // 同一人工操作已与状态迁移、命令 Outbox 一起提交;重放必须直接返回
2066                // 当前状态,绝不能再分配新 attempt 或发第二条补偿命令。
2067                ManagementAuditOutcome::AlreadyRecorded => return Ok(instance.status),
2068                ManagementAuditOutcome::Recorded => {}
2069            }
2070            // operation 审计先区分首次调用与已提交重放;仅首次调用预留额度,后续任何
2071            // 前置条件或命令发布失败都会让审计与预算一起回滚。
2072            self.reserve_action_rate_budget(tenant).await?;
2073            // 新 operation 只有命中调用方观察到的版本才可发布外部补偿命令;失配时本事务
2074            // 会回滚刚写入的管理审计,原 operation id 仍可由调用方更正后使用。
2075            expectation.verify(instance.version, instance.control_version)?;
2076            if !instance.control_state.allows_automatic_actions() {
2077                return Err(management_precondition(
2078                    "retry_compensation requires ACTIVE control state; resume first",
2079                ));
2080            }
2081            if instance.status != SagaStatus::ManualIntervention {
2082                return Err(management_precondition(
2083                    "retry_compensation requires MANUAL_INTERVENTION",
2084                ));
2085            }
2086            if instance.compensation_plan_version.is_none() {
2087                return Err(management_precondition(
2088                    "no frozen compensation plan to retry",
2089                ));
2090            }
2091            let definition = registry
2092                .get_for_tenant(
2093                    &instance.tenant,
2094                    &instance.workflow,
2095                    instance.definition_version,
2096                )
2097                .ok_or_else(|| management_precondition("instance definition is not registered"))?;
2098            let steps = self.backend.store().load_steps(saga_id).await?;
2099            match next_pending_compensation(&instance, definition, &steps, true)? {
2100                Some(step) => {
2101                    let recovering_halted = steps.iter().any(|row| {
2102                        row.step == step
2103                            && row.compensation_status == StepCompensationStatus::Halted
2104                    });
2105                    if recovering_halted {
2106                        // HALTED 只能由本事务已写入的管理审计解除;普通投影补丁和 timer
2107                        // 均不能访问该路径,参与方还会复验命令上的 recovery operation。
2108                        self.backend
2109                            .store()
2110                            .reopen_halted_compensation(saga_id, &step, operation_id)
2111                            .await?;
2112                    }
2113                    let version = self
2114                        .advance(
2115                            &instance,
2116                            SagaStatus::Compensating,
2117                            Direction::Compensating,
2118                            Some(&step),
2119                            TriggerKind::Admin,
2120                            operation_id,
2121                            None,
2122                            None,
2123                        )
2124                        .await?;
2125                    let step_def = definition.step(&step).ok_or_else(|| {
2126                        management_precondition("plan step absent from definition")
2127                    })?;
2128                    let attempt = self
2129                        .next_attempt(saga_id, &step, StepPhase::Compensate)
2130                        .await?;
2131                    self.issue_command(
2132                        &instance,
2133                        step_def,
2134                        StepPhase::Compensate,
2135                        attempt,
2136                        None,
2137                        Some(operation_id),
2138                        now_ms,
2139                        version,
2140                    )
2141                    .await?;
2142                    Ok(SagaStatus::Compensating)
2143                }
2144                None => {
2145                    // 计划内已无待补偿项:经 COMPENSATING 立即收敛为 COMPENSATED,
2146                    // 两次迁移共享同一管理操作的派生触发身份。
2147                    let version = self
2148                        .advance(
2149                            &instance,
2150                            SagaStatus::Compensating,
2151                            Direction::Compensating,
2152                            None,
2153                            TriggerKind::Admin,
2154                            operation_id,
2155                            None,
2156                            None,
2157                        )
2158                        .await?;
2159                    self.converge_compensated(&instance, version, TriggerKind::Admin, operation_id)
2160                        .await?;
2161                    Ok(SagaStatus::Compensated)
2162                }
2163            }
2164        })
2165        .await?;
2166        tracing::info!(
2167            saga_id = saga_id.as_str(),
2168            tenant = tenant.as_str(),
2169            actor = management.actor(),
2170            operation_id,
2171            saga_status = status.as_str(),
2172            "Saga 人工补偿恢复操作已提交"
2173        );
2174        Ok(status)
2175    }
2176
2177    /// 业务作用:在 Unknown 解决硬预算耗尽后,由经授权的操作员恢复一次查询周期。
2178    ///
2179    /// 本入口只重发 typed resolve;若查询投影为 `HALTED`,只在同事务审计成立后重开查询,
2180    /// `UNKNOWN/HALTED` 补偿事实本身绝不被直接改回 `PENDING`,不会二次退款。
2181    ///
2182    /// 参数说明:
2183    /// - `management`: 已认证主体、原因与 `saga.retry_resolution` 权限快照。
2184    /// - `tenant`: 已授权租户。
2185    /// - `saga_id`: 人工介入实例身份。
2186    /// - `operation_id`: 稳定管理操作身份,重放不再发新命令。
2187    /// - `now_ms`: 当前 epoch 毫秒,用于新的 resolve timeout 与硬预算。
2188    ///
2189    /// 返回:成功返回 `WAITING_RESOLUTION`;PAUSED、非人工态、当前没有 Unknown 事实、
2190    /// 步骤不支持 Poll、租户/权限不匹配或 CAS 冲突返回错误。
2191    pub async fn retry_resolution(
2192        &self,
2193        management: &SagaManagementContext,
2194        tenant: &TenantId,
2195        saga_id: &SagaId,
2196        operation_id: &str,
2197        now_ms: i64,
2198    ) -> anyhow::Result<SagaStatus> {
2199        self.retry_resolution_with_expectation(
2200            management,
2201            tenant,
2202            saga_id,
2203            operation_id,
2204            now_ms,
2205            SagaManagementExpectation::default(),
2206        )
2207        .await
2208    }
2209
2210    /// 业务作用:在事务内命中调用方版本后重开 Unknown 解决周期,禁止旧快照发布新的查询命令。
2211    ///
2212    /// 参数说明:
2213    /// - `management`:已经认证的管理主体、权限与操作原因上下文。
2214    /// - `tenant`:必须与持久实例归属一致的租户身份。
2215    /// - `saga_id`:本次管理动作定位的实例身份。
2216    /// - `operation_id`:持久幂等管理操作标识,重放应保持动作和主体语义一致。
2217    /// - `now_ms`:新解决预算与命令使用的 Unix 毫秒时刻。
2218    /// - `expectation`:首次操作必须在事务内命中的实例与控制版本前置条件;已提交的相同操作重放优先识别。
2219    ///
2220    /// 返回:首次操作满足权限、版本、ACTIVE、人工介入和 Poll 未决效果门禁时,重开查询周期并写 timer 与 Outbox;相同操作重放返回当前状态。任何前置条件或持久化失败返回错误,加入外层事务不代表最终提交,提交阶段失败可能结果未知。
2221    pub async fn retry_resolution_with_expectation(
2222        &self,
2223        management: &SagaManagementContext,
2224        tenant: &TenantId,
2225        saga_id: &SagaId,
2226        operation_id: &str,
2227        now_ms: i64,
2228        expectation: SagaManagementExpectation,
2229    ) -> anyhow::Result<SagaStatus> {
2230        management.require(SagaManagementPermission::RetryResolution)?;
2231        let registry = self.registry_snapshot();
2232        let status = crate::transaction::run_for(&self.backend, async {
2233            let instance = self
2234                .backend
2235                .store()
2236                .load_instance(saga_id)
2237                .await?
2238                .filter(|instance| &instance.tenant == tenant)
2239                .ok_or(SagaManagementError::NotFound)?;
2240            match self
2241                .backend
2242                .store()
2243                .record_management_operation(
2244                    saga_id,
2245                    operation_id,
2246                    "retry_resolution",
2247                    management.actor(),
2248                    management.reason(),
2249                )
2250                .await?
2251            {
2252                ManagementAuditOutcome::AlreadyRecorded => return Ok(instance.status),
2253                ManagementAuditOutcome::Recorded => {}
2254            }
2255            // 已提交 operation 不再占用额度;首次操作的审计、预算、HALTED 重开和
2256            // resolve command 仍处于同一事务,要么全部提交,要么全部撤销。
2257            self.reserve_action_rate_budget(tenant).await?;
2258            // 已提交重放直接返回;首次操作必须在同一事务内命中 expected version,
2259            // 才能解除 HALTED 门禁并产生新的 resolve command。
2260            expectation.verify(instance.version, instance.control_version)?;
2261            if !instance.control_state.allows_automatic_actions() {
2262                return Err(management_precondition(
2263                    "retry_resolution requires ACTIVE control state; resume first",
2264                ));
2265            }
2266            if instance.status != SagaStatus::ManualIntervention {
2267                return Err(management_precondition(
2268                    "retry_resolution requires MANUAL_INTERVENTION",
2269                ));
2270            }
2271            let step = instance
2272                .current_step
2273                .as_ref()
2274                .ok_or_else(|| management_precondition("manual resolution has no current step"))?;
2275            let definition = registry
2276                .get_for_tenant(
2277                    &instance.tenant,
2278                    &instance.workflow,
2279                    instance.definition_version,
2280                )
2281                .ok_or_else(|| management_precondition("instance definition is not registered"))?;
2282            let step_def = definition
2283                .step(step)
2284                .ok_or_else(|| management_precondition("resolution step absent from definition"))?;
2285            if step_def.resolution().mode() != Some(ResolutionMode::Poll) {
2286                return Err(management_precondition(
2287                    "retry_resolution requires a Poll resolver",
2288                ));
2289            }
2290            let row = self
2291                .backend
2292                .store()
2293                .load_steps(saga_id)
2294                .await?
2295                .into_iter()
2296                .find(|row| row.step == *step)
2297                .ok_or_else(|| management_precondition("resolution step journal is missing"))?;
2298            let has_unknown_target = match instance.direction {
2299                Direction::Forward => row.forward_status == StepForwardStatus::Unknown,
2300                Direction::Compensating => {
2301                    row.compensation_status == StepCompensationStatus::Unknown
2302                }
2303            };
2304            if !has_unknown_target
2305                || !matches!(
2306                    row.resolution_status,
2307                    StepResolutionStatus::Pending | StepResolutionStatus::Halted
2308                )
2309            {
2310                return Err(management_precondition(
2311                    "retry_resolution requires an unresolved Unknown effect",
2312                ));
2313            }
2314
2315            let attempt = self.next_attempt(saga_id, step, StepPhase::Resolve).await?;
2316            if row.resolution_status == StepResolutionStatus::Halted {
2317                // 普通单调投影故意拒绝 HALTED→PENDING;这里只允许同事务中已经落审计的
2318                // 管理操作重开,避免自动 timeout 或迟到结果解除人工冻结。
2319                self.backend
2320                    .store()
2321                    .reopen_halted_resolution(saga_id, step, operation_id)
2322                    .await?;
2323            }
2324            let version = self
2325                .advance(
2326                    &instance,
2327                    SagaStatus::WaitingResolution,
2328                    instance.direction,
2329                    Some(step),
2330                    TriggerKind::Admin,
2331                    operation_id,
2332                    None,
2333                    None,
2334                )
2335                .await?;
2336            self.schedule_timer(
2337                saga_id,
2338                TimerScope::Step(step),
2339                resolution_budget_kind(instance.direction),
2340                attempt,
2341                checked_deadline(now_ms, duration_millis(step_def.resolution().budget())?)?,
2342                version,
2343            )
2344            .await?;
2345            self.issue_command(
2346                &instance,
2347                step_def,
2348                StepPhase::Resolve,
2349                attempt,
2350                None,
2351                Some(operation_id),
2352                now_ms,
2353                version,
2354            )
2355            .await?;
2356            Ok(SagaStatus::WaitingResolution)
2357        })
2358        .await?;
2359        tracing::info!(
2360            saga_id = saga_id.as_str(),
2361            tenant = tenant.as_str(),
2362            actor = management.actor(),
2363            operation_id,
2364            saga_status = status.as_str(),
2365            "Saga 人工解决查询恢复操作已提交"
2366        );
2367        Ok(status)
2368    }
2369
2370    /// 业务作用:系统外人工处置完成后关闭自动化——`MANUAL_INTERVENTION -> MANUALLY_CLOSED`
2371    /// 的唯一管理入口,实例由此离开非终态集合,`nasaga_manual_intervention` 回落。
2372    ///
2373    /// 关闭只记录"自动化已由人工关闭"这一事实:不写 `COMPLETED`/`COMPENSATED`、不伪造
2374    /// 未经系统证明的业务结果;未完成的补偿计划仍以快照保留。动作与
2375    /// [`MANUAL_CLOSE_ACTION`](nasaga_backend::MANUAL_CLOSE_ACTION) 审计同事务提交,
2376    /// 状态机受限出边只放行携带该同事务证据的迁移,自动 worker 无法触发。
2377    ///
2378    /// 参数说明:
2379    /// - `management`: 已认证主体、强制原因与 `saga.manual_close` 权限快照。
2380    /// - `tenant`: 已认证管理主体被授权访问的租户。
2381    /// - `saga_id`: 实例身份。
2382    /// - `operation_id`: 一次性管理 operation 身份;重放按幂等成功返回,不产生第二次迁移。
2383    ///
2384    /// 返回:关闭后(或幂等重放时)的实例状态。能力开关未开启、无权限、实例不在
2385    /// `MANUAL_INTERVENTION`、PAUSED 或 CAS 冲突返回错误。
2386    pub async fn manual_close(
2387        &self,
2388        management: &SagaManagementContext,
2389        tenant: &TenantId,
2390        saga_id: &SagaId,
2391        operation_id: &str,
2392    ) -> anyhow::Result<SagaStatus> {
2393        self.manual_close_with_expectation(
2394            management,
2395            tenant,
2396            saga_id,
2397            operation_id,
2398            SagaManagementExpectation::default(),
2399        )
2400        .await
2401    }
2402
2403    /// 业务作用:在事务内命中调用方版本后人工关闭自动化,避免旧快照终结已并发变化的实例。
2404    ///
2405    /// 参数说明:
2406    /// - `management`:已经认证的管理主体、权限与操作原因上下文。
2407    /// - `tenant`:必须与持久实例归属一致的租户身份。
2408    /// - `saga_id`:本次管理动作定位的实例身份。
2409    /// - `operation_id`:持久幂等管理操作标识,重放应保持动作和主体语义一致。
2410    /// - `expectation`:首次操作必须在事务内命中的实例与控制版本前置条件;已提交的相同操作重放优先识别。
2411    ///
2412    /// 返回:关闭能力已显式开放且权限、版本、控制态及人工介入条件满足时,将状态迁为 ManuallyClosed 并撤销实例 timer;相同操作重放返回当前状态。失败返回分类错误;加入外层事务须等最终提交,提交阶段失败可能结果未知。
2413    pub async fn manual_close_with_expectation(
2414        &self,
2415        management: &SagaManagementContext,
2416        tenant: &TenantId,
2417        saga_id: &SagaId,
2418        operation_id: &str,
2419        expectation: SagaManagementExpectation,
2420    ) -> anyhow::Result<SagaStatus> {
2421        management.require(SagaManagementPermission::ManualClose)?;
2422        // 滚动升级门禁:新终态一旦落库不可回退,旧副本读到未知状态会按数据损坏停止推进。
2423        // 必须先全量部署可解析 MANUALLY_CLOSED 的读者,再由部署方开启本能力。
2424        if !self.config.enable_manual_close {
2425            return Err(management_precondition(
2426                "manual close is disabled: deploy MANUALLY_CLOSED-aware readers to every replica, then enable it explicitly",
2427            ));
2428        }
2429        let status = crate::transaction::run_for(&self.backend, async {
2430            let instance = self
2431                .backend
2432                .store()
2433                .load_instance(saga_id)
2434                .await?
2435                .filter(|instance| &instance.tenant == tenant)
2436                .ok_or(SagaManagementError::NotFound)?;
2437            // 审计先行且与迁移同事务:状态机对本边的放行证据就是这行审计,先写审计
2438            // 再 CAS 的顺序保证证据与迁移一起提交或一起消失。
2439            match self
2440                .backend
2441                .store()
2442                .record_management_operation(
2443                    saga_id,
2444                    operation_id,
2445                    nasaga_backend::MANUAL_CLOSE_ACTION,
2446                    management.actor(),
2447                    management.reason(),
2448                )
2449                .await?
2450            {
2451                // 同一 operation 已提交过关闭;重放直接返回当前状态,不再迁移。
2452                ManagementAuditOutcome::AlreadyRecorded => return Ok(instance.status),
2453                ManagementAuditOutcome::Recorded => {}
2454            }
2455            // 人工关闭的同一 operation 重放直接返回;只有新关闭事实才消耗额度。
2456            // 后续门禁或状态迁移失败会使审计和预算共同回滚。
2457            self.reserve_action_rate_budget(tenant).await?;
2458            // 先识别已提交 operation,保证丢失响应后的原请求可重放;新关闭动作仍必须
2459            // 命中调用方版本和 ACTIVE 控制态,否则审计插入随事务一起回滚。
2460            expectation.verify(instance.version, instance.control_version)?;
2461            if !instance.control_state.allows_automatic_actions() {
2462                return Err(management_precondition(
2463                    "manual_close requires ACTIVE control state; resume first",
2464                ));
2465            }
2466            if instance.status != SagaStatus::ManualIntervention {
2467                return Err(management_precondition(
2468                    "manual_close requires MANUAL_INTERVENTION",
2469                ));
2470            }
2471            self.advance(
2472                &instance,
2473                SagaStatus::ManuallyClosed,
2474                instance.direction,
2475                None,
2476                TriggerKind::Admin,
2477                operation_id,
2478                None,
2479                None,
2480            )
2481            .await?;
2482            // 终态后实例级 deadline 不再有业务含义;残留 timer 由 stale 吸收,但在此
2483            // 作废可让 due_timer 观测面立即回落。
2484            self.backend
2485                .store()
2486                .cancel_scope_timers(saga_id, TimerScope::Instance, None)
2487                .await?;
2488            Ok(SagaStatus::ManuallyClosed)
2489        })
2490        .await?;
2491        tracing::info!(
2492            saga_id = saga_id.as_str(),
2493            tenant = tenant.as_str(),
2494            actor = management.actor(),
2495            operation_id,
2496            saga_status = status.as_str(),
2497            "Saga 人工关闭操作已提交"
2498        );
2499        Ok(status)
2500    }
2501
2502    // ---- 结果路由 ----
2503
2504    /// 业务作用:实例已离开结果所属阶段时,只投影当前 attempt 的迟到事实,不自动推进状态机。
2505    ///
2506    /// 迟到事实仍会影响人工恢复与冻结计划;完全只写 attempt journal 会使
2507    /// `retry_compensation` 看不到已经成功的补偿。旧 attempt 则只能留在完整 journal 中,
2508    /// 不得覆盖较新 attempt 的步骤投影。
2509    ///
2510    /// 参数说明:
2511    /// - `context`: 已完成身份、状态和 attempt journal 校验的结果上下文。
2512    ///
2513    /// 返回:当前 attempt 的投影与对应 timeout 同事务更新后返回实例状态;旧 attempt
2514    /// 保持原状态;跨 phase 强事实矛盾时保留旧投影并把非终态实例提交到人工介入。
2515    /// 步骤骨架缺失或持久化失败返回错误并回滚 Inbox claim。
2516    async fn project_deferred_result(
2517        &self,
2518        context: &ResultContext<'_>,
2519    ) -> anyhow::Result<SagaStatus> {
2520        let saga_id = &context.identity.saga_id;
2521        let step = &context.identity.step;
2522        let row = self
2523            .backend
2524            .store()
2525            .load_steps(saga_id)
2526            .await?
2527            .into_iter()
2528            .find(|row| row.step == *step)
2529            .ok_or_else(|| anyhow::anyhow!("step journal row is missing"))?;
2530        let current_attempt = match context.identity.phase {
2531            StepPhase::Execute => row.execute_attempt,
2532            StepPhase::Cancel => row.cancel_attempt,
2533            StepPhase::Compensate => row.compensate_attempt,
2534            StepPhase::Resolve => row.resolve_attempt,
2535        };
2536        if current_attempt != Some(context.identity.attempt.get()) {
2537            return Ok(context.instance.status);
2538        }
2539
2540        let mut patch = StepJournalPatch {
2541            last_error_code: context.reason_code,
2542            ..Default::default()
2543        };
2544        let timeout_kind = match context.identity.phase {
2545            StepPhase::Execute => {
2546                patch.forward_status = Some(match context.status {
2547                    StepAttemptStatus::Succeeded => StepForwardStatus::Succeeded,
2548                    StepAttemptStatus::Rejected => StepForwardStatus::Rejected,
2549                    StepAttemptStatus::Unknown => StepForwardStatus::Unknown,
2550                    StepAttemptStatus::Halted => StepForwardStatus::Halted,
2551                    _ => anyhow::bail!("invalid deferred execute status"),
2552                });
2553                patch.mark_finished = !matches!(context.status, StepAttemptStatus::Unknown);
2554                KIND_STEP_TIMEOUT
2555            }
2556            StepPhase::Cancel => {
2557                match context.status {
2558                    StepAttemptStatus::CancelConfirmed => {
2559                        patch.forward_status = Some(StepForwardStatus::Cancelled);
2560                        patch.cancel_status = Some(StepCancelStatus::Confirmed);
2561                        patch.mark_finished = true;
2562                    }
2563                    StepAttemptStatus::AlreadyTerminal => {
2564                        patch.forward_status = context.terminal;
2565                        patch.cancel_status = Some(StepCancelStatus::AlreadyTerminal);
2566                        patch.mark_finished = true;
2567                    }
2568                    StepAttemptStatus::ResolutionPending => {
2569                        patch.cancel_status = Some(StepCancelStatus::ResolutionPending);
2570                    }
2571                    _ => anyhow::bail!("invalid deferred cancel status"),
2572                }
2573                KIND_CANCEL_TIMEOUT
2574            }
2575            StepPhase::Compensate => {
2576                patch.compensation_status = Some(match context.status {
2577                    StepAttemptStatus::Succeeded => StepCompensationStatus::Succeeded,
2578                    StepAttemptStatus::Unknown => StepCompensationStatus::Unknown,
2579                    StepAttemptStatus::Halted => StepCompensationStatus::Halted,
2580                    _ => anyhow::bail!("invalid deferred compensation status"),
2581                });
2582                KIND_COMPENSATE_TIMEOUT
2583            }
2584            StepPhase::Resolve => {
2585                patch.resolution_status = Some(match context.status {
2586                    StepAttemptStatus::Succeeded => StepResolutionStatus::Succeeded,
2587                    StepAttemptStatus::Rejected => StepResolutionStatus::Rejected,
2588                    StepAttemptStatus::Unknown => StepResolutionStatus::Pending,
2589                    StepAttemptStatus::Halted => StepResolutionStatus::Halted,
2590                    _ => anyhow::bail!("invalid deferred resolution status"),
2591                });
2592                if context.instance.direction == Direction::Forward {
2593                    patch.forward_status = match context.status {
2594                        StepAttemptStatus::Succeeded => Some(StepForwardStatus::Succeeded),
2595                        StepAttemptStatus::Rejected => Some(StepForwardStatus::Rejected),
2596                        StepAttemptStatus::Halted => Some(StepForwardStatus::Halted),
2597                        StepAttemptStatus::Unknown => Some(StepForwardStatus::Unknown),
2598                        _ => None,
2599                    };
2600                } else {
2601                    patch.compensation_status = match context.status {
2602                        StepAttemptStatus::Succeeded => Some(StepCompensationStatus::Succeeded),
2603                        StepAttemptStatus::Rejected => Some(StepCompensationStatus::Pending),
2604                        StepAttemptStatus::Halted => Some(StepCompensationStatus::Halted),
2605                        StepAttemptStatus::Unknown => Some(StepCompensationStatus::Unknown),
2606                        _ => None,
2607                    };
2608                }
2609                KIND_RESOLVE_TIMEOUT
2610            }
2611        };
2612
2613        // 补偿方向的 Resolve=Rejected 只能发生在当前补偿尚未确认生效时;若投影已经
2614        // 是 Succeeded,说明两个 phase 给出了不可同时成立的事实,不能把成功补偿降回
2615        // Pending,否则恢复器会重复执行外部副作用。
2616        let resolve_after_compensation_success = context.identity.phase == StepPhase::Resolve
2617            && context.instance.direction == Direction::Compensating
2618            && context.status == StepAttemptStatus::Rejected
2619            && row.compensation_status == StepCompensationStatus::Succeeded
2620            && patch.compensation_status == Some(StepCompensationStatus::Pending);
2621        if resolve_after_compensation_success
2622            || merge_deferred_projection(&row, &mut patch).is_err()
2623        {
2624            // attempt 事实已经先记入统一 journal;这里不能返回错误让它随 Inbox 一起
2625            // 回滚成永久毒消息。先保留冲突双方证据,再将非终态实例同事务
2626            // 转人工等待核验;两者不得拆成两次 COMMIT,否则崩溃窗口会产生无证据 Manual。
2627            let existing = conflicting_projection_status(&row, context)
2628                .ok_or_else(|| anyhow::anyhow!("conflicting projection has no settled fact"))?;
2629            self.backend
2630                .store()
2631                .record_attempt_conflict(&AttemptConflictFact {
2632                    saga_id,
2633                    step,
2634                    phase: context.identity.phase,
2635                    attempt: context.identity.attempt,
2636                    existing_status: existing,
2637                    incoming_status: context.status,
2638                    incoming_event_id: context.event_id,
2639                    conflict_kind: SagaConflictKind::CrossPhaseFact,
2640                })
2641                .await?;
2642            if !context.instance.status.is_terminal()
2643                && context.instance.status != SagaStatus::ManualIntervention
2644            {
2645                return self
2646                    .escalate_manual(
2647                        context.instance,
2648                        TriggerKind::Event,
2649                        context.event_id,
2650                        "cross_phase_fact_conflict",
2651                    )
2652                    .await;
2653            }
2654            return Ok(context.instance.status);
2655        }
2656        self.backend
2657            .store()
2658            .cancel_scope_timers(saga_id, TimerScope::Step(step), Some(timeout_kind))
2659            .await?;
2660        self.patch_step(saga_id, step, patch).await?;
2661        Ok(context.instance.status)
2662    }
2663
2664    /// 业务作用:在当前阶段的热路径中单调合并 step 投影,防止后到 phase 改写强事实。
2665    ///
2666    /// attempt outcome 已由调用方先落统一 journal。投影互斥时,本函数在同一
2667    /// 事务留存冲突证据并冻结非终态实例,不返回错误导致 Inbox/证据回滚。
2668    ///
2669    /// 参数说明:
2670    /// - `context`: 当前结果、实例快照与 phase 身份。
2671    /// - `patch`: 热路径准备写入的阶段投影。
2672    ///
2673    /// 返回:单调合并并写入成功返回 `None`;冲突已留证并收敛时返回
2674    /// `Some(status)`,调用方必须立即停止路由;持久化或内部不变量失败返回错误。
2675    async fn apply_monotonic_projection(
2676        &self,
2677        context: &ResultContext<'_>,
2678        mut patch: StepJournalPatch<'_>,
2679    ) -> anyhow::Result<Option<SagaStatus>> {
2680        let saga_id = &context.identity.saga_id;
2681        let step = &context.identity.step;
2682        let row = self
2683            .backend
2684            .store()
2685            .load_steps(saga_id)
2686            .await?
2687            .into_iter()
2688            .find(|row| row.step == *step)
2689            .ok_or_else(|| anyhow::anyhow!("step journal row is missing"))?;
2690
2691        // Resolve 否定补偿发生与已确认的补偿成功不可并存;若改回 Pending,
2692        // 恢复器会再次执行同一外部撤销,因此必须作为跨 phase 冲突留证。
2693        let resolve_after_compensation_success = context.identity.phase == StepPhase::Resolve
2694            && context.instance.direction == Direction::Compensating
2695            && context.status == StepAttemptStatus::Rejected
2696            && row.compensation_status == StepCompensationStatus::Succeeded
2697            && patch.compensation_status == Some(StepCompensationStatus::Pending);
2698        if resolve_after_compensation_success
2699            || merge_deferred_projection(&row, &mut patch).is_err()
2700        {
2701            let existing = conflicting_projection_status(&row, context)
2702                .ok_or_else(|| anyhow::anyhow!("conflicting projection has no settled fact"))?;
2703            self.backend
2704                .store()
2705                .record_attempt_conflict(&AttemptConflictFact {
2706                    saga_id,
2707                    step,
2708                    phase: context.identity.phase,
2709                    attempt: context.identity.attempt,
2710                    existing_status: existing,
2711                    incoming_status: context.status,
2712                    incoming_event_id: context.event_id,
2713                    conflict_kind: SagaConflictKind::CrossPhaseFact,
2714                })
2715                .await?;
2716            if !context.instance.status.is_terminal()
2717                && context.instance.status != SagaStatus::ManualIntervention
2718            {
2719                let status = self
2720                    .escalate_manual(
2721                        context.instance,
2722                        TriggerKind::Event,
2723                        context.event_id,
2724                        "cross_phase_fact_conflict",
2725                    )
2726                    .await?;
2727                return Ok(Some(status));
2728            }
2729            return Ok(Some(context.instance.status));
2730        }
2731        self.patch_step(saga_id, step, patch).await?;
2732        Ok(None)
2733    }
2734
2735    /// 业务作用:裁决 execute 阶段结果,驱动正向推进、进补偿或进解决通道。
2736    ///
2737    /// 参数说明:
2738    /// - `context`: 结果裁决上下文。
2739    ///
2740    /// 返回:提交后的实例状态;协议违规或 CAS 冲突返回错误。
2741    async fn on_execute_result(&self, context: &ResultContext<'_>) -> anyhow::Result<SagaStatus> {
2742        // 迟到结果(实例已离开 RUNNING):journal 已记账,不绕过当前状态推进——
2743        // 取消屏障或解决通道会基于 journal 事实给出裁决。
2744        if context.instance.status != SagaStatus::Running
2745            || context.instance.current_step.as_ref() != Some(&context.identity.step)
2746        {
2747            return self.project_deferred_result(context).await;
2748        }
2749        let saga_id = &context.identity.saga_id;
2750        let step = &context.identity.step;
2751        match context.status {
2752            StepAttemptStatus::Succeeded => {
2753                self.patch_step(
2754                    saga_id,
2755                    step,
2756                    StepJournalPatch {
2757                        forward_status: Some(StepForwardStatus::Succeeded),
2758                        mark_finished: true,
2759                        ..Default::default()
2760                    },
2761                )
2762                .await?;
2763                self.backend
2764                    .store()
2765                    .cancel_scope_timers(saga_id, TimerScope::Step(step), Some(KIND_STEP_TIMEOUT))
2766                    .await?;
2767                self.proceed_forward(context).await
2768            }
2769            StepAttemptStatus::Rejected => {
2770                self.patch_step(
2771                    saga_id,
2772                    step,
2773                    StepJournalPatch {
2774                        forward_status: Some(StepForwardStatus::Rejected),
2775                        last_error_code: context.reason_code,
2776                        mark_finished: true,
2777                        ..Default::default()
2778                    },
2779                )
2780                .await?;
2781                self.backend
2782                    .store()
2783                    .cancel_scope_timers(saga_id, TimerScope::Step(step), Some(KIND_STEP_TIMEOUT))
2784                    .await?;
2785                // 确定性拒绝已可靠提交,串行定义保证无在途未知命令:可直接进补偿。
2786                self.enter_compensation(context).await
2787            }
2788            StepAttemptStatus::Unknown => {
2789                self.enter_waiting_resolution(context, Direction::Forward)
2790                    .await
2791            }
2792            StepAttemptStatus::Halted => {
2793                self.patch_step(
2794                    saga_id,
2795                    step,
2796                    StepJournalPatch {
2797                        forward_status: Some(StepForwardStatus::Halted),
2798                        last_error_code: context.reason_code,
2799                        mark_finished: true,
2800                        ..Default::default()
2801                    },
2802                )
2803                .await?;
2804                self.backend
2805                    .store()
2806                    .cancel_scope_timers(saga_id, TimerScope::Step(step), None)
2807                    .await?;
2808                self.escalate_manual(
2809                    context.instance,
2810                    TriggerKind::Event,
2811                    context.event_id,
2812                    "step_halted",
2813                )
2814                .await
2815            }
2816            _ => anyhow::bail!("cancel-vocabulary status on an execute result"),
2817        }
2818    }
2819
2820    /// 业务作用:裁决取消屏障结果——只有屏障裁决完成后才允许进入补偿。
2821    ///
2822    /// 参数说明:
2823    /// - `context`: 结果裁决上下文。
2824    ///
2825    /// 返回:提交后的实例状态;缺失终态、协议违规或 CAS 冲突返回错误。
2826    async fn on_cancel_result(&self, context: &ResultContext<'_>) -> anyhow::Result<SagaStatus> {
2827        if context.instance.status != SagaStatus::Cancelling
2828            || context.instance.current_step.as_ref() != Some(&context.identity.step)
2829        {
2830            return self.project_deferred_result(context).await;
2831        }
2832        let saga_id = &context.identity.saga_id;
2833        let step = &context.identity.step;
2834        match context.status {
2835            StepAttemptStatus::CancelConfirmed => {
2836                // admission fence 已建立,该步骤零正向效果:不纳入补偿集合。
2837                if let Some(status) = self
2838                    .apply_monotonic_projection(
2839                        context,
2840                        StepJournalPatch {
2841                            forward_status: Some(StepForwardStatus::Cancelled),
2842                            cancel_status: Some(StepCancelStatus::Confirmed),
2843                            mark_finished: true,
2844                            ..Default::default()
2845                        },
2846                    )
2847                    .await?
2848                {
2849                    return Ok(status);
2850                }
2851                self.backend
2852                    .store()
2853                    .cancel_scope_timers(saga_id, TimerScope::Step(step), Some(KIND_CANCEL_TIMEOUT))
2854                    .await?;
2855                self.enter_compensation(context).await
2856            }
2857            StepAttemptStatus::AlreadyTerminal => {
2858                let terminal = context.terminal.ok_or_else(|| {
2859                    anyhow::anyhow!("ALREADY_TERMINAL requires a terminal status")
2860                })?;
2861                if let Some(status) = self
2862                    .apply_monotonic_projection(
2863                        context,
2864                        StepJournalPatch {
2865                            forward_status: Some(terminal),
2866                            cancel_status: Some(StepCancelStatus::AlreadyTerminal),
2867                            mark_finished: true,
2868                            ..Default::default()
2869                        },
2870                    )
2871                    .await?
2872                {
2873                    return Ok(status);
2874                }
2875                self.backend
2876                    .store()
2877                    .cancel_scope_timers(saga_id, TimerScope::Step(step), Some(KIND_CANCEL_TIMEOUT))
2878                    .await?;
2879                match terminal {
2880                    StepForwardStatus::Succeeded => match context.step_def.timeout_policy() {
2881                        // 迟到成功按成功收敛:回正向或直接完成,绝不补偿成功的 pivot 上游。
2882                        TimeoutPolicy::AcceptLateSuccess => self.proceed_forward(context).await,
2883                        // 业务期限已过:成功效果按失效处理,纳入冻结计划撤销。
2884                        TimeoutPolicy::CompensateLateSuccess => {
2885                            self.enter_compensation(context).await
2886                        }
2887                    },
2888                    StepForwardStatus::Rejected => self.enter_compensation(context).await,
2889                    StepForwardStatus::Halted => {
2890                        self.escalate_manual(
2891                            context.instance,
2892                            TriggerKind::Event,
2893                            context.event_id,
2894                            "step_halted",
2895                        )
2896                        .await
2897                    }
2898                    _ => anyhow::bail!("ALREADY_TERMINAL carried a non-terminal forward status"),
2899                }
2900            }
2901            StepAttemptStatus::ResolutionPending => {
2902                // 取消不能证明外部效果未发生:进入解决通道,不谎报取消成功。
2903                if let Some(status) = self
2904                    .apply_monotonic_projection(
2905                        context,
2906                        StepJournalPatch {
2907                            cancel_status: Some(StepCancelStatus::ResolutionPending),
2908                            ..Default::default()
2909                        },
2910                    )
2911                    .await?
2912                {
2913                    return Ok(status);
2914                }
2915                self.backend
2916                    .store()
2917                    .cancel_scope_timers(saga_id, TimerScope::Step(step), Some(KIND_CANCEL_TIMEOUT))
2918                    .await?;
2919                self.enter_waiting_resolution(context, Direction::Forward)
2920                    .await
2921            }
2922            _ => anyhow::bail!("non-cancel status on a cancel result"),
2923        }
2924    }
2925
2926    /// 业务作用:裁决补偿阶段结果,推进冻结计划或收敛终态。
2927    ///
2928    /// 参数说明:
2929    /// - `context`: 结果裁决上下文。
2930    ///
2931    /// 返回:提交后的实例状态;协议违规或 CAS 冲突返回错误。
2932    async fn on_compensate_result(
2933        &self,
2934        context: &ResultContext<'_>,
2935    ) -> anyhow::Result<SagaStatus> {
2936        if context.instance.status != SagaStatus::Compensating
2937            || context.instance.current_step.as_ref() != Some(&context.identity.step)
2938        {
2939            return self.project_deferred_result(context).await;
2940        }
2941        let saga_id = &context.identity.saga_id;
2942        let step = &context.identity.step;
2943        self.backend
2944            .store()
2945            .cancel_scope_timers(
2946                saga_id,
2947                TimerScope::Step(step),
2948                Some(KIND_COMPENSATE_TIMEOUT),
2949            )
2950            .await?;
2951        match context.status {
2952            StepAttemptStatus::Succeeded => {
2953                self.patch_step(
2954                    saga_id,
2955                    step,
2956                    StepJournalPatch {
2957                        compensation_status: Some(StepCompensationStatus::Succeeded),
2958                        ..Default::default()
2959                    },
2960                )
2961                .await?;
2962                self.continue_compensation(context).await
2963            }
2964            StepAttemptStatus::Unknown => {
2965                self.patch_step(
2966                    saga_id,
2967                    step,
2968                    StepJournalPatch {
2969                        compensation_status: Some(StepCompensationStatus::Unknown),
2970                        ..Default::default()
2971                    },
2972                )
2973                .await?;
2974                self.enter_waiting_resolution(context, Direction::Compensating)
2975                    .await
2976            }
2977            StepAttemptStatus::Halted => {
2978                self.patch_step(
2979                    saga_id,
2980                    step,
2981                    StepJournalPatch {
2982                        compensation_status: Some(StepCompensationStatus::Halted),
2983                        last_error_code: context.reason_code,
2984                        ..Default::default()
2985                    },
2986                )
2987                .await?;
2988                self.escalate_manual(
2989                    context.instance,
2990                    TriggerKind::Event,
2991                    context.event_id,
2992                    "compensation_halted",
2993                )
2994                .await
2995            }
2996            _ => anyhow::bail!("invalid status on a compensate result"),
2997        }
2998    }
2999
3000    /// 业务作用:在消费解决应答前复验步骤投影,让已经提交的正向或补偿成功优先于更早查询形成的迟到应答。
3001    ///
3002    /// resolve handler 可能先读取到未知事实,随后原业务 attempt 才提交成功,最终两个结果按相反顺序
3003    /// 到达 Orchestrator。attempt journal 会保留两条原始结论;步骤投影必须以已提交副作用为准,不能因
3004    /// 迟到的 `Halted` 或 `Rejected` 把真实成功冻结成人工介入。
3005    ///
3006    /// 参数说明:
3007    /// - `context`: 当前解决应答、实例快照、步骤合同与事件身份。
3008    ///
3009    /// 返回:发现已提交成功时写入单调解决投影、作废解决 timer,并返回推进后的状态;尚无成功事实时
3010    /// 返回 `None` 交给正常解决分支;投影冲突、CAS 或持久化失败返回错误。
3011    async fn settle_resolution_from_committed_success(
3012        &self,
3013        context: &ResultContext<'_>,
3014    ) -> anyhow::Result<Option<SagaStatus>> {
3015        let saga_id = &context.identity.saga_id;
3016        let step = &context.identity.step;
3017        let row = self
3018            .backend
3019            .store()
3020            .load_steps(saga_id)
3021            .await?
3022            .into_iter()
3023            .find(|row| row.step == *step)
3024            .ok_or_else(|| anyhow::anyhow!("step journal row is missing"))?;
3025        let committed_success = match context.instance.direction {
3026            Direction::Forward => row.forward_status == StepForwardStatus::Succeeded,
3027            Direction::Compensating => row.compensation_status == StepCompensationStatus::Succeeded,
3028        };
3029        if !committed_success {
3030            return Ok(None);
3031        }
3032
3033        let mut patch = StepJournalPatch {
3034            resolution_status: Some(StepResolutionStatus::Succeeded),
3035            ..Default::default()
3036        };
3037        match context.instance.direction {
3038            Direction::Forward => {
3039                patch.forward_status = Some(StepForwardStatus::Succeeded);
3040                patch.mark_finished = true;
3041            }
3042            Direction::Compensating => {
3043                patch.compensation_status = Some(StepCompensationStatus::Succeeded);
3044            }
3045        }
3046        if let Some(status) = self.apply_monotonic_projection(context, patch).await? {
3047            return Ok(Some(status));
3048        }
3049
3050        // 业务成功事实和解决投影必须先在本事务固定,再作废重查预算并推进;否则下一轮 timer
3051        // 可能在状态迁移前再次发布查询,把已经收敛的副作用重新暴露给旧应答竞态。
3052        self.cancel_resolution_timers(saga_id, step).await?;
3053        let status = match context.instance.direction {
3054            Direction::Forward => match context.step_def.timeout_policy() {
3055                TimeoutPolicy::AcceptLateSuccess => self.proceed_forward(context).await?,
3056                TimeoutPolicy::CompensateLateSuccess => self.enter_compensation(context).await?,
3057            },
3058            Direction::Compensating => self.continue_compensation(context).await?,
3059        };
3060        Ok(Some(status))
3061    }
3062
3063    /// 业务作用:裁决解决通道结果,按方向把唯一裁决送回正向或补偿推进。
3064    ///
3065    /// 参数说明:
3066    /// - `context`: 结果裁决上下文。
3067    ///
3068    /// 返回:提交后的实例状态;协议违规或 CAS 冲突返回错误。
3069    async fn on_resolve_result(&self, context: &ResultContext<'_>) -> anyhow::Result<SagaStatus> {
3070        if context.instance.status != SagaStatus::WaitingResolution
3071            || context.instance.current_step.as_ref() != Some(&context.identity.step)
3072        {
3073            return self.project_deferred_result(context).await;
3074        }
3075        if let Some(status) = self
3076            .settle_resolution_from_committed_success(context)
3077            .await?
3078        {
3079            return Ok(status);
3080        }
3081        let saga_id = &context.identity.saga_id;
3082        let step = &context.identity.step;
3083        match context.instance.direction {
3084            Direction::Forward => match context.status {
3085                StepAttemptStatus::Succeeded => {
3086                    if let Some(status) = self
3087                        .apply_monotonic_projection(
3088                            context,
3089                            StepJournalPatch {
3090                                forward_status: Some(StepForwardStatus::Succeeded),
3091                                resolution_status: Some(StepResolutionStatus::Succeeded),
3092                                mark_finished: true,
3093                                ..Default::default()
3094                            },
3095                        )
3096                        .await?
3097                    {
3098                        return Ok(status);
3099                    }
3100                    self.cancel_resolution_timers(saga_id, step).await?;
3101                    self.proceed_forward(context).await
3102                }
3103                StepAttemptStatus::Rejected => {
3104                    // 判 Rejected 的前提(排除迟到生效)由 resolve handler 保证;
3105                    // 这里只消费其唯一裁决。
3106                    if let Some(status) = self
3107                        .apply_monotonic_projection(
3108                            context,
3109                            StepJournalPatch {
3110                                forward_status: Some(StepForwardStatus::Rejected),
3111                                resolution_status: Some(StepResolutionStatus::Rejected),
3112                                last_error_code: context.reason_code,
3113                                mark_finished: true,
3114                                ..Default::default()
3115                            },
3116                        )
3117                        .await?
3118                    {
3119                        return Ok(status);
3120                    }
3121                    self.cancel_resolution_timers(saga_id, step).await?;
3122                    self.enter_compensation(context).await
3123                }
3124                StepAttemptStatus::Unknown => self.reissue_resolve(context).await,
3125                StepAttemptStatus::Halted => {
3126                    if let Some(status) = self
3127                        .apply_monotonic_projection(
3128                            context,
3129                            StepJournalPatch {
3130                                resolution_status: Some(StepResolutionStatus::Halted),
3131                                last_error_code: context.reason_code,
3132                                ..Default::default()
3133                            },
3134                        )
3135                        .await?
3136                    {
3137                        return Ok(status);
3138                    }
3139                    self.escalate_manual(
3140                        context.instance,
3141                        TriggerKind::Event,
3142                        context.event_id,
3143                        "resolution_halted",
3144                    )
3145                    .await
3146                }
3147                _ => anyhow::bail!("invalid status on a resolve result"),
3148            },
3149            Direction::Compensating => match context.status {
3150                StepAttemptStatus::Succeeded => {
3151                    // 裁决:补偿效果已真实发生。
3152                    if let Some(status) = self
3153                        .apply_monotonic_projection(
3154                            context,
3155                            StepJournalPatch {
3156                                compensation_status: Some(StepCompensationStatus::Succeeded),
3157                                resolution_status: Some(StepResolutionStatus::Succeeded),
3158                                ..Default::default()
3159                            },
3160                        )
3161                        .await?
3162                    {
3163                        return Ok(status);
3164                    }
3165                    self.cancel_resolution_timers(saga_id, step).await?;
3166                    self.continue_compensation(context).await
3167                }
3168                StepAttemptStatus::Rejected => {
3169                    // 裁决:补偿并未发生——重新排队补偿该步骤,新 attempt 复用同一 effect。
3170                    if let Some(status) = self
3171                        .apply_monotonic_projection(
3172                            context,
3173                            StepJournalPatch {
3174                                compensation_status: Some(StepCompensationStatus::Pending),
3175                                resolution_status: Some(StepResolutionStatus::Rejected),
3176                                ..Default::default()
3177                            },
3178                        )
3179                        .await?
3180                    {
3181                        return Ok(status);
3182                    }
3183                    self.cancel_resolution_timers(saga_id, step).await?;
3184                    let attempt = self
3185                        .next_attempt(saga_id, step, StepPhase::Compensate)
3186                        .await?;
3187                    if attempt.get() > self.config.compensate_max_attempts {
3188                        return self
3189                            .escalate_manual(
3190                                context.instance,
3191                                TriggerKind::Event,
3192                                context.event_id,
3193                                "compensation_attempts_exhausted",
3194                            )
3195                            .await;
3196                    }
3197                    let version = self
3198                        .advance(
3199                            context.instance,
3200                            SagaStatus::Compensating,
3201                            Direction::Compensating,
3202                            Some(step),
3203                            TriggerKind::Event,
3204                            context.event_id,
3205                            None,
3206                            None,
3207                        )
3208                        .await?;
3209                    self.issue_command(
3210                        context.instance,
3211                        context.step_def,
3212                        StepPhase::Compensate,
3213                        attempt,
3214                        None,
3215                        None,
3216                        context.now_ms,
3217                        version,
3218                    )
3219                    .await?;
3220                    Ok(SagaStatus::Compensating)
3221                }
3222                StepAttemptStatus::Unknown => self.reissue_resolve(context).await,
3223                StepAttemptStatus::Halted => {
3224                    if let Some(status) = self
3225                        .apply_monotonic_projection(
3226                            context,
3227                            StepJournalPatch {
3228                                resolution_status: Some(StepResolutionStatus::Halted),
3229                                last_error_code: context.reason_code,
3230                                ..Default::default()
3231                            },
3232                        )
3233                        .await?
3234                    {
3235                        return Ok(status);
3236                    }
3237                    self.escalate_manual(
3238                        context.instance,
3239                        TriggerKind::Event,
3240                        context.event_id,
3241                        "resolution_halted",
3242                    )
3243                    .await
3244                }
3245                _ => anyhow::bail!("invalid status on a resolve result"),
3246            },
3247        }
3248    }
3249
3250    // ---- timer 裁决 ----
3251
3252    /// 业务作用:step 超时裁决——可取消步骤先建屏障,resolve-only 步骤直接进入有界查询。
3253    ///
3254    /// 参数说明:
3255    /// - `instance`: 已提交实例快照。
3256    /// - `definition`: 实例固定版本的定义。
3257    /// - `step_scope`: timer 的步骤作用域。
3258    /// - `timer`: timer 行。
3259    /// - `now_ms`: 当前时刻。
3260    ///
3261    /// 返回:裁决生效返回新状态;timer 语义过时返回 `None`。
3262    async fn on_step_timeout(
3263        &self,
3264        instance: &SagaInstanceRow,
3265        definition: &WorkflowDefinition,
3266        step_scope: Option<&StepName>,
3267        timer: &SagaTimerRow,
3268        now_ms: i64,
3269    ) -> anyhow::Result<Option<SagaStatus>> {
3270        let Some(step) = step_scope else {
3271            anyhow::bail!("step-timeout timer without a step scope");
3272        };
3273        // 只有实例仍停在该步骤的正向执行上,超时才有效;否则结果已经到达,timer 过时。
3274        if instance.status != SagaStatus::Running || instance.current_step.as_ref() != Some(step) {
3275            return Ok(None);
3276        }
3277        let step_def = definition
3278            .step(step)
3279            .ok_or_else(|| anyhow::anyhow!("timer step absent from definition"))?;
3280        if step_def.cancel_mode() == CancelMode::ResolveOnly {
3281            // resolve-only 无法建立取消屏障;误发 cancel 只会让实例在无实现的通道耗尽重试。
3282            // 因此先发布 Unknown/解决投影,再进入带硬期限的查询通道,绝不因超时直接补偿。
3283            self.patch_step(
3284                &instance.saga_id,
3285                step,
3286                StepJournalPatch {
3287                    forward_status: Some(StepForwardStatus::Unknown),
3288                    resolution_status: Some(StepResolutionStatus::Pending),
3289                    ..Default::default()
3290                },
3291            )
3292            .await?;
3293            let version = self
3294                .advance(
3295                    instance,
3296                    SagaStatus::WaitingResolution,
3297                    Direction::Forward,
3298                    Some(step),
3299                    TriggerKind::Timer,
3300                    &timer.timer_id,
3301                    None,
3302                    None,
3303                )
3304                .await?;
3305            let resolution = step_def.resolution();
3306            self.schedule_timer(
3307                &instance.saga_id,
3308                TimerScope::Step(step),
3309                resolution_budget_kind(Direction::Forward),
3310                timer.attempt,
3311                checked_deadline(now_ms, duration_millis(resolution.budget())?)?,
3312                version,
3313            )
3314            .await?;
3315            if resolution.mode() == Some(ResolutionMode::Poll) {
3316                let attempt = self
3317                    .next_attempt(&instance.saga_id, step, StepPhase::Resolve)
3318                    .await?;
3319                self.issue_command(
3320                    instance,
3321                    step_def,
3322                    StepPhase::Resolve,
3323                    attempt,
3324                    None,
3325                    None,
3326                    now_ms,
3327                    version,
3328                )
3329                .await?;
3330            }
3331            return Ok(Some(SagaStatus::WaitingResolution));
3332        }
3333
3334        // 可取消步骤的超时只说明结果未知:标 TIMED_OUT 后先建取消屏障,由参与方真实裁决,
3335        // 绝不能直接补偿并与可能迟到生效的外部效果并存。
3336        self.patch_step(
3337            &instance.saga_id,
3338            step,
3339            StepJournalPatch {
3340                forward_status: Some(StepForwardStatus::TimedOut),
3341                ..Default::default()
3342            },
3343        )
3344        .await?;
3345        let version = self
3346            .advance(
3347                instance,
3348                SagaStatus::Cancelling,
3349                Direction::Forward,
3350                Some(step),
3351                TriggerKind::Timer,
3352                &timer.timer_id,
3353                None,
3354                None,
3355            )
3356            .await?;
3357        let attempt = self
3358            .next_attempt(&instance.saga_id, step, StepPhase::Cancel)
3359            .await?;
3360        self.issue_command(
3361            instance,
3362            step_def,
3363            StepPhase::Cancel,
3364            attempt,
3365            None,
3366            None,
3367            now_ms,
3368            version,
3369        )
3370        .await?;
3371        Ok(Some(SagaStatus::Cancelling))
3372    }
3373
3374    /// 业务作用:cancel/compensate/resolve 命令的有界重发裁决;预算耗尽升级人工介入。
3375    ///
3376    /// 参数说明:
3377    /// - `instance`: 已提交实例快照。
3378    /// - `definition`: 实例固定版本的定义。
3379    /// - `step_scope`: timer 的步骤作用域。
3380    /// - `timer`: timer 行。
3381    /// - `phase`: 重发的命令阶段。
3382    /// - `required_status`: 该 timer 语义有效所要求的实例状态。
3383    /// - `max_attempts`: 阶段预算上限。
3384    /// - `exhausted_code`: 预算耗尽时写入的稳定失败码。
3385    /// - `now_ms`: 当前时刻。
3386    ///
3387    /// 返回:重发或升级后返回新状态;timer 语义过时返回 `None`。
3388    #[allow(clippy::too_many_arguments)]
3389    async fn on_phase_retry_timeout(
3390        &self,
3391        instance: &SagaInstanceRow,
3392        definition: &WorkflowDefinition,
3393        step_scope: Option<&StepName>,
3394        timer: &SagaTimerRow,
3395        phase: StepPhase,
3396        required_status: SagaStatus,
3397        max_attempts: u32,
3398        exhausted_code: &str,
3399        now_ms: i64,
3400    ) -> anyhow::Result<Option<SagaStatus>> {
3401        let Some(step) = step_scope else {
3402            anyhow::bail!("phase retry timer without a step scope");
3403        };
3404        if instance.status != required_status || instance.current_step.as_ref() != Some(step) {
3405            return Ok(None);
3406        }
3407        let step_def = definition
3408            .step(step)
3409            .ok_or_else(|| anyhow::anyhow!("timer step absent from definition"))?;
3410        let exhausted = if phase == StepPhase::Resolve {
3411            // resolve attempt 号跨正向/补偿全局单调,但预算必须按当前业务方向计数;
3412            // 否则正向用完十次查询后,补偿方向第一次 timeout 会被误判耗尽。
3413            self.backend
3414                .store()
3415                .count_resolution_attempts(&instance.saga_id, step, instance.direction)
3416                .await?
3417                >= max_attempts
3418        } else {
3419            self.next_attempt(&instance.saga_id, step, phase)
3420                .await?
3421                .get()
3422                > max_attempts
3423        };
3424        // 有界重发:同一 effect 下新 attempt/new command;预算耗尽即冻结转人工,
3425        // 自动状态不允许无限滞留,也不伪造终态。
3426        if exhausted {
3427            return Ok(Some(
3428                self.escalate_manual(
3429                    instance,
3430                    TriggerKind::Timer,
3431                    &timer.timer_id,
3432                    exhausted_code,
3433                )
3434                .await?,
3435            ));
3436        }
3437        // 方向预算裁决完成后再分配全局单调 attempt;身份序号不能按方向复用。
3438        let attempt = self.next_attempt(&instance.saga_id, step, phase).await?;
3439        let version = self
3440            .advance(
3441                instance,
3442                required_status,
3443                instance.direction,
3444                Some(step),
3445                TriggerKind::Timer,
3446                &timer.timer_id,
3447                None,
3448                None,
3449            )
3450            .await?;
3451        self.issue_command(
3452            instance, step_def, phase, attempt, None, None, now_ms, version,
3453        )
3454        .await?;
3455        Ok(Some(required_status))
3456    }
3457
3458    /// 业务作用:实例级 deadline 裁决——FORWARD 段走取消屏障,其余段只升级人工介入。
3459    ///
3460    /// 参数说明:
3461    /// - `instance`: 已提交实例快照。
3462    /// - `definition`: 实例固定版本的定义。
3463    /// - `timer`: timer 行。
3464    /// - `now_ms`: 当前时刻。
3465    ///
3466    /// 返回:裁决生效返回新状态;终态/人工介入下 timer 过时返回 `None`。
3467    async fn on_instance_deadline(
3468        &self,
3469        instance: &SagaInstanceRow,
3470        definition: &WorkflowDefinition,
3471        timer: &SagaTimerRow,
3472        now_ms: i64,
3473    ) -> anyhow::Result<Option<SagaStatus>> {
3474        match instance.status {
3475            // FORWARD 段:对当前步骤建取消屏障,后续按 timeout policy 收敛。
3476            SagaStatus::Running => {
3477                let Some(step) = instance.current_step.clone() else {
3478                    return Ok(None);
3479                };
3480                self.on_step_timeout(instance, definition, Some(&step), timer, now_ms)
3481                    .await
3482            }
3483            // 屏障/解决/补偿段:只升级告警与人工介入,不向执行器伪造"已取消",
3484            // 也不抹掉已经发出的取消、查询或补偿。
3485            SagaStatus::Cancelling | SagaStatus::WaitingResolution | SagaStatus::Compensating => {
3486                Ok(Some(
3487                    self.escalate_manual(
3488                        instance,
3489                        TriggerKind::Timer,
3490                        &timer.timer_id,
3491                        "deadline_exceeded",
3492                    )
3493                    .await?,
3494                ))
3495            }
3496            _ => Ok(None),
3497        }
3498    }
3499
3500    // ---- 推进原语 ----
3501
3502    /// 业务作用:正向收敛——推进到下一 PENDING 步骤或完成实例。
3503    ///
3504    /// CAS 的 from 取自已提交实例快照(Running 自身、取消屏障迟到成功回正向、
3505    /// 解决确认成功回正向三条入口共用)。
3506    ///
3507    /// 参数说明:
3508    /// - `context`: 结果裁决上下文。
3509    ///
3510    /// 返回:提交后的实例状态。
3511    async fn proceed_forward(&self, context: &ResultContext<'_>) -> anyhow::Result<SagaStatus> {
3512        let saga_id = &context.identity.saga_id;
3513        let steps = self.backend.store().load_steps(saga_id).await?;
3514        match next_pending_forward(context.definition, &steps)? {
3515            Some(next) => {
3516                let version = self
3517                    .advance(
3518                        context.instance,
3519                        SagaStatus::Running,
3520                        Direction::Forward,
3521                        Some(&next),
3522                        TriggerKind::Event,
3523                        context.event_id,
3524                        None,
3525                        None,
3526                    )
3527                    .await?;
3528                let next_def = context
3529                    .definition
3530                    .step(&next)
3531                    .ok_or_else(|| anyhow::anyhow!("next step absent from definition"))?;
3532                let attempt = self
3533                    .next_attempt(saga_id, &next, StepPhase::Execute)
3534                    .await?;
3535                // 后续步骤命令只携带身份:业务输入由参与方按 saga_id + step 从本地事实解析。
3536                self.issue_command(
3537                    context.instance,
3538                    next_def,
3539                    StepPhase::Execute,
3540                    attempt,
3541                    None,
3542                    None,
3543                    context.now_ms,
3544                    version,
3545                )
3546                .await?;
3547                Ok(SagaStatus::Running)
3548            }
3549            None => {
3550                self.advance(
3551                    context.instance,
3552                    SagaStatus::Completed,
3553                    Direction::Forward,
3554                    None,
3555                    TriggerKind::Event,
3556                    context.event_id,
3557                    None,
3558                    None,
3559                )
3560                .await?;
3561                // 终态后实例级 deadline 不再有意义,同事务作废。
3562                self.backend
3563                    .store()
3564                    .cancel_scope_timers(saga_id, TimerScope::Instance, None)
3565                    .await?;
3566                Ok(SagaStatus::Completed)
3567            }
3568        }
3569    }
3570
3571    /// 业务作用:冻结补偿计划并进入 `COMPENSATING`;空计划立即收敛为 `COMPENSATED`。
3572    ///
3573    /// 冻结、CAS 迁移、计划标记与首条补偿命令在同一事务提交,保证"计划已冻结"与
3574    /// "成员已标记"不可分割。
3575    ///
3576    /// 参数说明:
3577    /// - `context`: 结果裁决上下文。
3578    ///
3579    /// 返回:提交后的实例状态;journal 存在未裁决步骤或成功 pivot 时冻结被 core
3580    /// 拒绝——按不变量破坏升级人工介入,绝不让该消息在重投里热循环。
3581    async fn enter_compensation(&self, context: &ResultContext<'_>) -> anyhow::Result<SagaStatus> {
3582        let saga_id = &context.identity.saga_id;
3583        let steps = self.backend.store().load_steps(saga_id).await?;
3584        let journal: Vec<StepJournalEntry> = steps
3585            .iter()
3586            .map(|row| StepJournalEntry::new(row.step.clone(), row.forward_status))
3587            .collect();
3588        // 冻结被拒 = 不变量已破坏(未裁决步骤/已成功 pivot):这不是可重试故障,
3589        // 回滚重投只会热循环;唯一安全出路是提交人工介入冻结并 ACK。
3590        let plan = match freeze_compensation_plan(context.definition, &journal) {
3591            Ok(plan) => plan,
3592            Err(violation) => {
3593                return self
3594                    .escalate_manual(
3595                        context.instance,
3596                        TriggerKind::Event,
3597                        context.event_id,
3598                        violation.code(),
3599                    )
3600                    .await;
3601            }
3602        };
3603        let first = plan.entries().first().map(|entry| entry.step().clone());
3604        let version = self
3605            .advance(
3606                context.instance,
3607                SagaStatus::Compensating,
3608                Direction::Compensating,
3609                first.as_ref(),
3610                TriggerKind::Event,
3611                context.event_id,
3612                None,
3613                Some(plan.version()),
3614            )
3615            .await?;
3616        self.backend
3617            .store()
3618            .mark_compensation_plan(saga_id, &plan)
3619            .await?;
3620        match first {
3621            Some(step) => {
3622                let step_def = context
3623                    .definition
3624                    .step(&step)
3625                    .ok_or_else(|| anyhow::anyhow!("plan step absent from definition"))?;
3626                let attempt = self
3627                    .next_attempt(saga_id, &step, StepPhase::Compensate)
3628                    .await?;
3629                self.issue_command(
3630                    context.instance,
3631                    step_def,
3632                    StepPhase::Compensate,
3633                    attempt,
3634                    None,
3635                    None,
3636                    context.now_ms,
3637                    version,
3638                )
3639                .await?;
3640                Ok(SagaStatus::Compensating)
3641            }
3642            None => {
3643                // 首步即拒绝/取消时计划为空:COMPENSATING 立即收敛,不引入额外终态。
3644                self.converge_compensated(
3645                    context.instance,
3646                    version,
3647                    TriggerKind::Event,
3648                    context.event_id,
3649                )
3650                .await?;
3651                Ok(SagaStatus::Compensated)
3652            }
3653        }
3654    }
3655
3656    /// 业务作用:补偿计划推进——发出下一计划项或收敛为 `COMPENSATED`。
3657    ///
3658    /// 关键顺序:**先**从已提交 journal 找出下一计划项,**再**以它作为 `current_step`
3659    /// 执行 CAS 迁移——`current_step` 与在途补偿命令必须一致,否则该命令的
3660    /// compensate-timeout 会被语义校验判为过时,补偿失去超时保护而无限滞留。
3661    /// 补偿结果与解决通道确认补偿成功两条入口共用(from 分别为 Compensating 与
3662    /// WaitingResolution,均为合法边)。
3663    ///
3664    /// 参数说明:
3665    /// - `context`: 结果裁决上下文。
3666    ///
3667    /// 返回:提交后的实例状态。
3668    async fn continue_compensation(
3669        &self,
3670        context: &ResultContext<'_>,
3671    ) -> anyhow::Result<SagaStatus> {
3672        let saga_id = &context.identity.saga_id;
3673        let steps = self.backend.store().load_steps(saga_id).await?;
3674        let next =
3675            match next_pending_compensation(context.instance, context.definition, &steps, false) {
3676                Ok(next) => next,
3677                Err(_) => {
3678                    // 冻结计划在执行中漂移时绝不能继续发补偿命令;先保留本次真实结果,
3679                    // 再同事务冻结人工介入,避免错误计划扩大副作用面。
3680                    return self
3681                        .escalate_manual(
3682                            context.instance,
3683                            TriggerKind::Event,
3684                            context.event_id,
3685                            "compensation_plan_drift",
3686                        )
3687                        .await;
3688                }
3689            };
3690        let version = self
3691            .advance(
3692                context.instance,
3693                SagaStatus::Compensating,
3694                Direction::Compensating,
3695                next.as_ref(),
3696                TriggerKind::Event,
3697                context.event_id,
3698                None,
3699                None,
3700            )
3701            .await?;
3702        match next {
3703            Some(step) => {
3704                let step_def = context
3705                    .definition
3706                    .step(&step)
3707                    .ok_or_else(|| anyhow::anyhow!("plan step absent from definition"))?;
3708                let attempt = self
3709                    .next_attempt(saga_id, &step, StepPhase::Compensate)
3710                    .await?;
3711                self.issue_command(
3712                    context.instance,
3713                    step_def,
3714                    StepPhase::Compensate,
3715                    attempt,
3716                    None,
3717                    None,
3718                    context.now_ms,
3719                    version,
3720                )
3721                .await?;
3722                Ok(SagaStatus::Compensating)
3723            }
3724            None => {
3725                self.converge_compensated_at(
3726                    saga_id,
3727                    version,
3728                    TriggerKind::Event,
3729                    context.event_id,
3730                )
3731                .await?;
3732                Ok(SagaStatus::Compensated)
3733            }
3734        }
3735    }
3736
3737    /// 业务作用:进入 `WAITING_RESOLUTION` 并布置解决预算与查询通道。
3738    ///
3739    /// 参数说明:
3740    /// - `context`: 结果裁决上下文。
3741    /// - `direction`: 解决完成后应回到的推进方向。
3742    ///
3743    /// 返回:提交后的实例状态;definition 不允许 Unknown 时升级人工介入。
3744    async fn enter_waiting_resolution(
3745        &self,
3746        context: &ResultContext<'_>,
3747        direction: Direction,
3748    ) -> anyhow::Result<SagaStatus> {
3749        let saga_id = &context.identity.saga_id;
3750        let step = &context.identity.step;
3751        let resolution = context.step_def.resolution();
3752        // 纯本地步骤不允许 Unknown:参与方本应提交 Halted;走到这里说明 descriptor
3753        // 与 definition 漂移,按合同违规冻结转人工,不能继续正向推进。
3754        if !resolution.allow_unknown() {
3755            if context.identity.phase == StepPhase::Execute {
3756                self.patch_step(
3757                    saga_id,
3758                    step,
3759                    StepJournalPatch {
3760                        forward_status: Some(StepForwardStatus::Unknown),
3761                        ..Default::default()
3762                    },
3763                )
3764                .await?;
3765            }
3766            // compensation/cancel 的 Unknown 已由各自阶段列留证;绝不能把已确认的
3767            // forward=SUCCEEDED 覆盖成 UNKNOWN,否则人工恢复会失去“确有待撤销效果”的证明。
3768            return self
3769                .escalate_manual(
3770                    context.instance,
3771                    TriggerKind::Event,
3772                    context.event_id,
3773                    "unknown_not_allowed",
3774                )
3775                .await;
3776        }
3777        if context.identity.phase == StepPhase::Execute {
3778            self.patch_step(
3779                saga_id,
3780                step,
3781                StepJournalPatch {
3782                    forward_status: Some(StepForwardStatus::Unknown),
3783                    resolution_status: Some(StepResolutionStatus::Pending),
3784                    ..Default::default()
3785                },
3786            )
3787            .await?;
3788        } else {
3789            self.patch_step(
3790                saga_id,
3791                step,
3792                StepJournalPatch {
3793                    resolution_status: Some(StepResolutionStatus::Pending),
3794                    ..Default::default()
3795                },
3796            )
3797            .await?;
3798        }
3799        self.backend
3800            .store()
3801            .cancel_scope_timers(saga_id, TimerScope::Step(step), Some(KIND_STEP_TIMEOUT))
3802            .await?;
3803        let version = self
3804            .advance(
3805                context.instance,
3806                SagaStatus::WaitingResolution,
3807                direction,
3808                Some(step),
3809                TriggerKind::Event,
3810                context.event_id,
3811                None,
3812                None,
3813            )
3814            .await?;
3815        // Unknown 不是可以永久停留的状态:解决预算是硬期限,超期升级人工介入。
3816        self.schedule_timer(
3817            saga_id,
3818            TimerScope::Step(step),
3819            resolution_budget_kind(direction),
3820            context.identity.attempt,
3821            checked_deadline(context.now_ms, duration_millis(resolution.budget())?)?,
3822            version,
3823        )
3824        .await?;
3825        if resolution.mode() == Some(ResolutionMode::Poll) {
3826            let attempt = self.next_attempt(saga_id, step, StepPhase::Resolve).await?;
3827            self.issue_command(
3828                context.instance,
3829                context.step_def,
3830                StepPhase::Resolve,
3831                attempt,
3832                None,
3833                None,
3834                context.now_ms,
3835                version,
3836            )
3837            .await?;
3838        }
3839        Ok(SagaStatus::WaitingResolution)
3840    }
3841
3842    /// 业务作用:解决结果仍为 Unknown 时的有界重查;预算耗尽升级人工介入。
3843    ///
3844    /// 参数说明:
3845    /// - `context`: 结果裁决上下文。
3846    ///
3847    /// 返回:提交后的实例状态。
3848    async fn reissue_resolve(&self, context: &ResultContext<'_>) -> anyhow::Result<SagaStatus> {
3849        let saga_id = &context.identity.saga_id;
3850        let step = &context.identity.step;
3851        let used = self
3852            .backend
3853            .store()
3854            .count_resolution_attempts(saga_id, step, context.instance.direction)
3855            .await?;
3856        if used >= self.config.resolve_max_attempts {
3857            return self
3858                .escalate_manual(
3859                    context.instance,
3860                    TriggerKind::Event,
3861                    context.event_id,
3862                    "resolve_attempts_exhausted",
3863                )
3864                .await;
3865        }
3866        // attempt 序号仍在 resolve phase 内全局单调,用于 command/effect 身份;
3867        // 预算则依据上面的方向计数,不得把正向查询次数扣到补偿上。
3868        let attempt = self.next_attempt(saga_id, step, StepPhase::Resolve).await?;
3869        let version = self
3870            .advance(
3871                context.instance,
3872                SagaStatus::WaitingResolution,
3873                context.instance.direction,
3874                Some(step),
3875                TriggerKind::Event,
3876                context.event_id,
3877                None,
3878                None,
3879            )
3880            .await?;
3881        self.issue_command(
3882            context.instance,
3883            context.step_def,
3884            StepPhase::Resolve,
3885            attempt,
3886            None,
3887            None,
3888            context.now_ms,
3889            version,
3890        )
3891        .await?;
3892        Ok(SagaStatus::WaitingResolution)
3893    }
3894
3895    /// 业务作用:升级人工介入——停止发出新的自动命令,冻结待人工裁决。
3896    ///
3897    /// 参数说明:
3898    /// - `instance`: 已提交实例快照。
3899    /// - `trigger_kind`: 触发来源。
3900    /// - `trigger_id`: 触发身份。
3901    /// - `failure_code`: 稳定失败原因码。
3902    ///
3903    /// 返回:`ManualIntervention`;CAS 冲突返回错误。
3904    async fn escalate_manual(
3905        &self,
3906        instance: &SagaInstanceRow,
3907        trigger_kind: TriggerKind,
3908        trigger_id: &str,
3909        failure_code: &str,
3910    ) -> anyhow::Result<SagaStatus> {
3911        self.advance(
3912            instance,
3913            SagaStatus::ManualIntervention,
3914            instance.direction,
3915            instance.current_step.as_ref(),
3916            trigger_kind,
3917            trigger_id,
3918            Some(failure_code),
3919            None,
3920        )
3921        .await?;
3922        Ok(SagaStatus::ManualIntervention)
3923    }
3924
3925    /// 业务作用:把已处于 `COMPENSATING` 的实例收敛为 `COMPENSATED` 终态。
3926    ///
3927    /// 参数说明:
3928    /// - `instance`:提供实例 ID 的既有快照,本方法只转交其身份。
3929    /// - `version`:当前 Compensating 状态预期的实例版本。
3930    /// - `trigger_kind`:引发本次收敛的事件、timer 或管理触发类别。
3931    /// - `cause_id`:原始触发身份,用于派生本次收敛的唯一触发 ID。
3932    ///
3933    /// 返回:先按持久账本验证冻结补偿计划已经完成,再用派生触发身份 CAS 收敛并取消实例 timer;验证失败、冲突或持久化失败返回错误。本 helper 不自行开启或提交事务,调用方须保证整体事务边界。
3934    async fn converge_compensated(
3935        &self,
3936        instance: &SagaInstanceRow,
3937        version: u64,
3938        trigger_kind: TriggerKind,
3939        cause_id: &str,
3940    ) -> anyhow::Result<()> {
3941        self.converge_compensated_at(&instance.saga_id, version, trigger_kind, cause_id)
3942            .await
3943    }
3944
3945    /// 业务作用:`converge_compensated` 的按身份变体,供推进后没有完整快照的路径使用。
3946    ///
3947    /// 参数说明:
3948    /// - `saga_id`:待验证补偿完成并收敛的实例身份。
3949    /// - `version`:当前 Compensating 状态预期的实例版本。
3950    /// - `trigger_kind`:本次收敛的触发类别。
3951    /// - `cause_id`:用于派生收敛触发 ID 的原始触发身份。
3952    ///
3953    /// 返回:先按持久账本验证冻结补偿计划已经完成,再用派生触发身份 CAS 收敛并取消实例 timer;验证失败、冲突或持久化失败返回错误。本 helper 不自行开启或提交事务,调用方须保证整体事务边界。
3954    async fn converge_compensated_at(
3955        &self,
3956        saga_id: &SagaId,
3957        version: u64,
3958        trigger_kind: TriggerKind,
3959        cause_id: &str,
3960    ) -> anyhow::Result<()> {
3961        // COMPENSATED 是不可逆业务声明;迁移前必须从已提交 journal 重建原计划,
3962        // 并逐项证明计划摘要、顺序和成功状态一致,不能仅凭“没有 PENDING 行”推断完成。
3963        let definition_version = self
3964            .verify_compensation_completion(saga_id, version)
3965            .await?;
3966        // 同一原因引发两次迁移(进入补偿 + 立即收敛):第二次使用确定性 UUID,
3967        // 既保住"同一触发只推进一次",也不会让接近索引上限的 cause id 拼接后越界。
3968        let converge_trigger = derive_converge_trigger_id(cause_id);
3969        let outcome = self
3970            .backend
3971            .store()
3972            .advance(
3973                saga_id,
3974                version,
3975                SagaStatus::Compensating,
3976                &TransitionSpec {
3977                    to_status: SagaStatus::Compensated,
3978                    direction: Direction::Compensating,
3979                    current_step: None,
3980                    trigger_kind,
3981                    trigger_id: &converge_trigger,
3982                    definition_version,
3983                    failure_code: None,
3984                    compensation_plan_version: None,
3985                },
3986            )
3987            .await?;
3988        let _ = ensure_applied(outcome)?;
3989        self.backend
3990            .store()
3991            .cancel_scope_timers(saga_id, TimerScope::Instance, None)
3992            .await?;
3993        Ok(())
3994    }
3995
3996    /// 业务作用:在写入 `COMPENSATED` 前证明冻结计划未漂移且每个计划成员都已补偿成功。
3997    ///
3998    /// 参数说明:
3999    /// - `saga_id`: 待收敛实例身份。
4000    /// - `expected_version`: 调用方刚推进到 `COMPENSATING` 后持有的实例版本。
4001    ///
4002    /// 返回:全部证据成立时返回 definition 版本;状态、版本、计划摘要、成员顺序或成员终态
4003    /// 任一不一致时返回错误并回滚当前事务,绝不发布假 `COMPENSATED` 终态。
4004    async fn verify_compensation_completion(
4005        &self,
4006        saga_id: &SagaId,
4007        expected_version: u64,
4008    ) -> anyhow::Result<DefinitionVersion> {
4009        let instance = self
4010            .backend
4011            .store()
4012            .load_instance(saga_id)
4013            .await?
4014            .ok_or_else(|| anyhow::anyhow!("saga instance not found while converging"))?;
4015        if instance.status != SagaStatus::Compensating || instance.version != expected_version {
4016            anyhow::bail!("compensation completion proof used a stale instance snapshot");
4017        }
4018        let frozen_version = instance
4019            .compensation_plan_version
4020            .as_deref()
4021            .ok_or_else(|| anyhow::anyhow!("compensating instance has no frozen plan"))?;
4022        let registry = self.registry_snapshot();
4023        let definition = registry
4024            .get_for_tenant(
4025                &instance.tenant,
4026                &instance.workflow,
4027                instance.definition_version,
4028            )
4029            .ok_or_else(|| anyhow::anyhow!("instance definition is not registered"))?;
4030        let steps = self.backend.store().load_steps(saga_id).await?;
4031        let journal: Vec<StepJournalEntry> = steps
4032            .iter()
4033            .map(|row| StepJournalEntry::new(row.step.clone(), row.forward_status))
4034            .collect();
4035        let rebuilt = freeze_compensation_plan(definition, &journal)
4036            .map_err(|violation| anyhow::anyhow!("{}", violation.code()))?;
4037        if rebuilt.version() != frozen_version {
4038            anyhow::bail!("frozen compensation plan no longer matches durable forward facts");
4039        }
4040        for entry in rebuilt.entries() {
4041            let row = steps
4042                .iter()
4043                .find(|row| row.step == *entry.step())
4044                .ok_or_else(|| anyhow::anyhow!("frozen compensation member is missing"))?;
4045            if row.compensation_plan_version.as_deref() != Some(frozen_version)
4046                || row.compensation_order != Some(entry.order())
4047                || row.compensation_status != StepCompensationStatus::Succeeded
4048            {
4049                anyhow::bail!("frozen compensation member is not durably succeeded");
4050            }
4051        }
4052        let marked_count = steps
4053            .iter()
4054            .filter(|row| row.compensation_plan_version.as_deref() == Some(frozen_version))
4055            .count();
4056        if marked_count != rebuilt.entries().len() {
4057            anyhow::bail!("durable compensation plan contains unexpected members");
4058        }
4059        Ok(instance.definition_version)
4060    }
4061
4062    /// 业务作用:以实例快照的既有状态为预期执行一次 CAS 推进。
4063    ///
4064    /// 参数说明:
4065    /// - `instance`:提供实例身份、定义版本和 CAS 预期实例版本的快照。
4066    /// - `to`:本次迁移希望到达的业务状态。
4067    /// - `direction`:迁移后的执行或补偿方向。
4068    /// - `current_step`:迁移后当前步骤,None 表示没有当前步骤。
4069    /// - `trigger_kind`:参与状态迁移唯一身份的触发类别。
4070    /// - `trigger_id`:用于拒绝重复推进的稳定触发 ID。
4071    /// - `failure_code`:需要交给持久层保存的可选稳定失败码。
4072    /// - `plan_version`:需要交给持久层保存的可选冻结补偿计划摘要。
4073    ///
4074    /// 返回:后端 CAS 被接受时返回新实例版本;重复触发、快照冲突或持久化失败返回错误。调用方负责所在事务的提交或回滚,本方法不单独确认最终持久提交。
4075    #[allow(clippy::too_many_arguments)]
4076    async fn advance(
4077        &self,
4078        instance: &SagaInstanceRow,
4079        to: SagaStatus,
4080        direction: Direction,
4081        current_step: Option<&StepName>,
4082        trigger_kind: TriggerKind,
4083        trigger_id: &str,
4084        failure_code: Option<&str>,
4085        plan_version: Option<&str>,
4086    ) -> anyhow::Result<u64> {
4087        self.advance_from(
4088            instance,
4089            instance.status,
4090            to,
4091            direction,
4092            current_step,
4093            trigger_kind,
4094            trigger_id,
4095            failure_code,
4096            plan_version,
4097        )
4098        .await
4099    }
4100
4101    /// 业务作用:`advance` 的显式 from 变体,供裁决入口已知快照状态的路径复用。
4102    ///
4103    /// 参数说明:
4104    /// - `instance`:提供实例身份、定义版本和 CAS 预期实例版本的快照。
4105    /// - `from`:CAS 预期的迁移前状态,可独立于传入快照的 status 指定。
4106    /// - `to`:本次迁移希望到达的业务状态。
4107    /// - `direction`:迁移后的执行或补偿方向。
4108    /// - `current_step`:迁移后当前步骤,None 表示没有当前步骤。
4109    /// - `trigger_kind`:参与状态迁移唯一身份的触发类别。
4110    /// - `trigger_id`:用于拒绝重复推进的稳定触发 ID。
4111    /// - `failure_code`:需要交给持久层保存的可选稳定失败码。
4112    /// - `plan_version`:需要交给持久层保存的可选冻结补偿计划摘要。
4113    ///
4114    /// 返回:后端 CAS 被接受时返回新实例版本;重复触发、快照冲突或持久化失败返回错误。调用方负责所在事务的提交或回滚,本方法不单独确认最终持久提交。
4115    #[allow(clippy::too_many_arguments)]
4116    async fn advance_from(
4117        &self,
4118        instance: &SagaInstanceRow,
4119        from: SagaStatus,
4120        to: SagaStatus,
4121        direction: Direction,
4122        current_step: Option<&StepName>,
4123        trigger_kind: TriggerKind,
4124        trigger_id: &str,
4125        failure_code: Option<&str>,
4126        plan_version: Option<&str>,
4127    ) -> anyhow::Result<u64> {
4128        let outcome = self
4129            .backend
4130            .store()
4131            .advance(
4132                &instance.saga_id,
4133                instance.version,
4134                from,
4135                &TransitionSpec {
4136                    to_status: to,
4137                    direction,
4138                    current_step,
4139                    trigger_kind,
4140                    trigger_id,
4141                    definition_version: instance.definition_version,
4142                    failure_code,
4143                    compensation_plan_version: plan_version,
4144                },
4145            )
4146            .await?;
4147        ensure_applied(outcome)
4148    }
4149
4150    /// 业务作用:登记 attempt、写命令 Outbox 并布置阶段超时 timer——命令发出的原子三件套。
4151    ///
4152    /// 参数说明:
4153    /// - `instance`: 实例快照(身份与 definition 绑定来源)。
4154    /// - `step_def`: 目标步骤定义。
4155    /// - `phase`: 命令阶段。
4156    /// - `attempt`: 尝试序号。
4157    /// - `payload`: 已校验的业务输入及原始输入形式;仅首步 execute 携带。
4158    /// - `recovery_operation_id`: 已审计人工恢复身份;普通自动命令为空。
4159    /// - `now_ms`: 当前时刻。
4160    /// - `expected_version`: timer 登记的实例版本(推进后的版本)。
4161    ///
4162    /// 返回:三件套全部落库返回 `Ok`;任一失败返回错误使整个事务回滚。
4163    #[allow(clippy::too_many_arguments)]
4164    async fn issue_command(
4165        &self,
4166        instance: &SagaInstanceRow,
4167        step_def: &StepDefinition,
4168        phase: StepPhase,
4169        attempt: AttemptNo,
4170        payload: Option<CommandInput>,
4171        recovery_operation_id: Option<&str>,
4172        now_ms: i64,
4173        expected_version: u64,
4174    ) -> anyhow::Result<()> {
4175        let step = step_def.name();
4176        let effect = EffectId::derive(&instance.saga_id, instance.definition_version, step, phase);
4177        let command = CommandId::derive(&effect, attempt);
4178        // journal 先占身份,再写命令:结果事件永远能找到去重锚点。
4179        self.backend
4180            .store()
4181            .record_attempt_started(&instance.saga_id, step, phase, attempt, &effect, &command)
4182            .await?;
4183        // 兼容输入继续写原有 JSON 字段;显式 bytes 输入只写 raw_payload,不能丢失字节或 schema。
4184        let (payload, raw_payload) = match payload {
4185            Some(CommandInput::Json(value)) => (Some(value), None),
4186            Some(CommandInput::Raw(value)) => (None, Some(value)),
4187            None => (None, None),
4188        };
4189        let envelope = SagaCommandEnvelope {
4190            saga_id: instance.saga_id.as_str().to_string(),
4191            tenant_id: instance.tenant.as_str().to_string(),
4192            workflow: instance.workflow.as_str().to_string(),
4193            definition_version: instance.definition_version.get(),
4194            definition_digest: instance.definition_digest.clone(),
4195            step: step.as_str().to_string(),
4196            phase: phase.as_str().to_string(),
4197            attempt: attempt.get(),
4198            effect_id: effect.to_string(),
4199            command_id: command.to_string(),
4200            recovery_operation_id: recovery_operation_id.map(str::to_owned),
4201            payload,
4202            raw_payload,
4203        };
4204        // 命令继承实例已提交的因果上下文并派生新 span:同一 trace-id 串联发起入口、
4205        // 编排端与参与方,每一跳都是新的 child;列值损坏或缺失时按无上下文投递——
4206        // trace 是观测面,不阻塞业务命令。
4207        let event = match instance
4208            .traceparent
4209            .as_deref()
4210            .and_then(TraceContext::parse_traceparent)
4211        {
4212            Some(base) => envelope
4213                .to_outbox_event()?
4214                .with_traceparent(base.child(natelemetry::random_span_id()).to_traceparent()),
4215            None => envelope.to_outbox_event()?,
4216        };
4217        // 关键双写:命令进 Outbox 必须与状态迁移同事务,事务外 autocommit 被拒绝。
4218        // 租户归因取已提交实例身份(受信写入上下文),供 Outbox 层配额与对账使用;
4219        // 绝不从命令 payload 解析租户。
4220        let write_context = naoutbox_core::OutboxWriteContext::new(instance.tenant.as_str())
4221            .map_err(|reason| anyhow::anyhow!("outbox write context rejected: {reason}"))?;
4222        self.backend
4223            .outbox()
4224            .append_transactional_with_context(&write_context, &event)
4225            .await?;
4226        // 命令与它的超时保护同生:写命令的事务同时写 timer,步骤不会无限滞留。
4227        let timeout_ms = duration_millis(step_def.timeout())?;
4228        self.schedule_timer(
4229            &instance.saga_id,
4230            TimerScope::Step(step),
4231            phase_timeout_kind(phase),
4232            attempt,
4233            checked_deadline(now_ms, timeout_ms)?,
4234            expected_version,
4235        )
4236        .await?;
4237        Ok(())
4238    }
4239
4240    /// 业务作用:以确定性派生的 timer 身份调度 durable timer。
4241    ///
4242    /// 参数说明:
4243    /// - `saga_id`: 实例身份。
4244    /// - `scope`: timer 作用域。
4245    /// - `kind`: timer 种类。
4246    /// - `attempt`: 关联尝试序号。
4247    /// - `due_at_ms`: 到期时刻。
4248    /// - `expected_version`: 调度时刻的实例版本。
4249    ///
4250    /// 返回:调度或幂等命中返回 `Ok`;底层失败返回错误。
4251    async fn schedule_timer(
4252        &self,
4253        saga_id: &SagaId,
4254        scope: TimerScope<'_>,
4255        kind: &str,
4256        attempt: AttemptNo,
4257        due_at_ms: i64,
4258        expected_version: u64,
4259    ) -> anyhow::Result<()> {
4260        let timer_id = derive_timer_id(saga_id, &scope, kind, attempt);
4261        self.backend
4262            .store()
4263            .schedule_timer(&TimerSpec {
4264                timer_id: &timer_id,
4265                saga_id,
4266                scope,
4267                kind,
4268                due_at_ms,
4269                attempt,
4270                expected_saga_version: expected_version,
4271            })
4272            .await?;
4273        Ok(())
4274    }
4275
4276    /// 业务作用:作废某步骤的解决类 timer(预算 + 重查),在解决收敛后调用。
4277    ///
4278    /// 参数说明:
4279    /// - `saga_id`: 实例身份。
4280    /// - `step`: 步骤名称。
4281    ///
4282    /// 返回:作废完成返回 `Ok`。
4283    async fn cancel_resolution_timers(
4284        &self,
4285        saga_id: &SagaId,
4286        step: &StepName,
4287    ) -> anyhow::Result<()> {
4288        // 滚动升级期可能同时存在旧版无方向 kind 与新版双方向 kind;终态提交前必须
4289        // 全部作废,否则旧预算会在后续补偿阶段误把实例升级人工介入。
4290        for kind in [
4291            KIND_RESOLUTION_BUDGET,
4292            KIND_FORWARD_RESOLUTION_BUDGET,
4293            KIND_COMPENSATION_RESOLUTION_BUDGET,
4294        ] {
4295            self.backend
4296                .store()
4297                .cancel_scope_timers(saga_id, TimerScope::Step(step), Some(kind))
4298                .await?;
4299        }
4300        self.backend
4301            .store()
4302            .cancel_scope_timers(saga_id, TimerScope::Step(step), Some(KIND_RESOLVE_TIMEOUT))
4303            .await?;
4304        Ok(())
4305    }
4306
4307    /// 业务作用:更新 step journal 投影的便捷入口。
4308    ///
4309    /// 参数说明:
4310    /// - `saga_id`: 实例身份。
4311    /// - `step`: 步骤名称。
4312    /// - `patch`: 投影补丁。
4313    ///
4314    /// 返回:更新成功返回 `Ok`。
4315    async fn patch_step(
4316        &self,
4317        saga_id: &SagaId,
4318        step: &StepName,
4319        patch: StepJournalPatch<'_>,
4320    ) -> anyhow::Result<()> {
4321        self.backend
4322            .store()
4323            .update_step_journal(saga_id, step, &patch)
4324            .await?;
4325        Ok(())
4326    }
4327
4328    /// 业务作用:由 attempt journal 推导某阶段的下一尝试序号。
4329    ///
4330    /// 参数说明:
4331    /// - `saga_id`: 实例身份。
4332    /// - `step`: 步骤名称。
4333    /// - `phase`: 阶段。
4334    ///
4335    /// 返回:journal 无该阶段记录返回 1;否则返回最大序号加一。
4336    async fn next_attempt(
4337        &self,
4338        saga_id: &SagaId,
4339        step: &StepName,
4340        phase: StepPhase,
4341    ) -> anyhow::Result<AttemptNo> {
4342        let attempts = self.backend.store().load_attempts(saga_id, step).await?;
4343        let max = attempts
4344            .iter()
4345            .filter(|row| row.phase == phase)
4346            .map(|row| row.attempt.get())
4347            .max();
4348        match max {
4349            None => Ok(AttemptNo::FIRST),
4350            Some(value) => AttemptNo::new(value)
4351                .and_then(|attempt| attempt.next())
4352                .map_err(|violation| anyhow::anyhow!("attempt overflow: {}", violation.code())),
4353        }
4354    }
4355}
4356
4357/// 业务作用:校验结果 envelope 与实例合同一致(tenant/workflow/version/digest)。
4358///
4359/// tenant 不匹配的 envelope 即使 producer 已认证也不得推进:认证只证明"谁在说话",
4360/// 不证明它有权把既有 saga_id 切换到另一租户。
4361///
4362/// 参数说明:
4363/// - `instance`: 已提交实例快照。
4364/// - `identity`: envelope 已证身份。
4365///
4366/// 返回:一致返回 `Ok`;任一字段不匹配返回错误,调用方走隔离/DLT 路径。
4367fn verify_instance_contract(
4368    instance: &SagaInstanceRow,
4369    identity: &VerifiedIdentity,
4370) -> anyhow::Result<()> {
4371    if instance.tenant != identity.tenant {
4372        anyhow::bail!("envelope tenant does not match the instance tenant");
4373    }
4374    if instance.workflow != identity.workflow
4375        || instance.definition_version != identity.definition_version
4376    {
4377        anyhow::bail!("envelope workflow/version does not match the instance");
4378    }
4379    if instance.definition_digest != identity.definition_digest {
4380        anyhow::bail!("envelope definition digest does not match the instance");
4381    }
4382    Ok(())
4383}
4384
4385/// 业务作用:从步骤投影中提取与迟到结果互斥的已提交强事实。
4386///
4387/// 返回值使用 attempt 状态稳定词汇,让同 attempt 与跨 phase 冲突能进入
4388/// 同一审计结构。只有确定事实才能作为 `existing_status`;Pending 类弱投影
4389/// 返回 `None`,由调用方按内部不变量破坏处理。
4390///
4391/// 参数说明:
4392/// - `row`: 写入前的步骤投影。
4393/// - `context`: 迟到结果所属 phase 与当前补偿方向。
4394///
4395/// 返回:找到冲突强事实时返回对应稳定状态;仅有弱投影时返回 `None`。
4396fn conflicting_projection_status(
4397    row: &SagaStepRow,
4398    context: &ResultContext<'_>,
4399) -> Option<StepAttemptStatus> {
4400    let forward = || match row.forward_status {
4401        StepForwardStatus::Succeeded => Some(StepAttemptStatus::Succeeded),
4402        StepForwardStatus::Rejected => Some(StepAttemptStatus::Rejected),
4403        StepForwardStatus::Cancelled => Some(StepAttemptStatus::CancelConfirmed),
4404        StepForwardStatus::Halted => Some(StepAttemptStatus::Halted),
4405        StepForwardStatus::Pending | StepForwardStatus::Unknown | StepForwardStatus::TimedOut => {
4406            None
4407        }
4408    };
4409    let cancellation = || match row.cancel_status {
4410        StepCancelStatus::Confirmed => Some(StepAttemptStatus::CancelConfirmed),
4411        StepCancelStatus::AlreadyTerminal => Some(StepAttemptStatus::AlreadyTerminal),
4412        StepCancelStatus::None | StepCancelStatus::ResolutionPending => None,
4413    };
4414    let compensation = || match row.compensation_status {
4415        StepCompensationStatus::Succeeded => Some(StepAttemptStatus::Succeeded),
4416        StepCompensationStatus::Halted => Some(StepAttemptStatus::Halted),
4417        StepCompensationStatus::None
4418        | StepCompensationStatus::Pending
4419        | StepCompensationStatus::Unknown => None,
4420    };
4421    let resolution = || match row.resolution_status {
4422        StepResolutionStatus::Succeeded => Some(StepAttemptStatus::Succeeded),
4423        StepResolutionStatus::Rejected => Some(StepAttemptStatus::Rejected),
4424        StepResolutionStatus::Halted => Some(StepAttemptStatus::Halted),
4425        StepResolutionStatus::None | StepResolutionStatus::Pending => None,
4426    };
4427
4428    match context.identity.phase {
4429        StepPhase::Execute => forward().or_else(cancellation),
4430        StepPhase::Cancel => cancellation().or_else(forward),
4431        StepPhase::Compensate => compensation().or_else(resolution),
4432        StepPhase::Resolve => resolution().or_else(|| {
4433            if context.instance.direction == Direction::Compensating {
4434                compensation()
4435            } else {
4436                forward()
4437            }
4438        }),
4439    }
4440}
4441
4442/// 业务作用:把迟到 result 合并进步骤投影,保证跨 phase 投影只能单调增强而不能改写强事实。
4443///
4444/// `PENDING/UNKNOWN/RESOLUTION_PENDING` 等弱事实可以被确定终态增强;确定终态收到更晚
4445/// 的弱事实时保持不动;两个互斥确定终态同时存在则返回冲突,由调用方保留 attempt 证据并
4446/// 把非终态 Saga 转人工,而不是选择“最后写入者获胜”。
4447///
4448/// 参数说明:
4449/// - `row`: 写入前的步骤强类型投影。
4450/// - `patch`: 根据迟到 result 构造的候选更新;本函数会清除无需写入的回退/重复字段。
4451///
4452/// 返回:投影可单调合并返回 `Ok`;发现两个互斥强事实返回 `Err`,调用方不得执行该 patch。
4453fn merge_deferred_projection(
4454    row: &SagaStepRow,
4455    patch: &mut StepJournalPatch<'_>,
4456) -> Result<(), ()> {
4457    if let Some(next) = patch.forward_status {
4458        patch.forward_status = if row.forward_status == next {
4459            None
4460        } else {
4461            match (row.forward_status, next) {
4462                (
4463                    StepForwardStatus::Pending
4464                    | StepForwardStatus::Unknown
4465                    | StepForwardStatus::TimedOut,
4466                    _,
4467                ) => Some(next),
4468                // 晚到的未知/超时只是较弱证据,不能把已确认终态降级回不确定。
4469                (_, StepForwardStatus::Unknown | StepForwardStatus::TimedOut) => None,
4470                _ => return Err(()),
4471            }
4472        };
4473    }
4474
4475    if let Some(next) = patch.cancel_status {
4476        patch.cancel_status = if row.cancel_status == next {
4477            None
4478        } else {
4479            match (row.cancel_status, next) {
4480                (StepCancelStatus::None | StepCancelStatus::ResolutionPending, _) => Some(next),
4481                // 已完成取消裁决后到达 ResolutionPending 不能抹掉屏障证明。
4482                (_, StepCancelStatus::ResolutionPending) => None,
4483                _ => return Err(()),
4484            }
4485        };
4486    }
4487
4488    if let Some(next) = patch.compensation_status {
4489        patch.compensation_status = if row.compensation_status == next {
4490            None
4491        } else {
4492            match (row.compensation_status, next) {
4493                (
4494                    StepCompensationStatus::None
4495                    | StepCompensationStatus::Pending
4496                    | StepCompensationStatus::Unknown,
4497                    _,
4498                ) => Some(next),
4499                // resolve 已确认补偿终态后,迟到 Unknown/Pending 只保留 attempt 事实。
4500                (_, StepCompensationStatus::Unknown | StepCompensationStatus::Pending) => None,
4501                _ => return Err(()),
4502            }
4503        };
4504    }
4505
4506    if let Some(next) = patch.resolution_status {
4507        patch.resolution_status = if row.resolution_status == next {
4508            None
4509        } else {
4510            match (row.resolution_status, next) {
4511                (StepResolutionStatus::None | StepResolutionStatus::Pending, _) => Some(next),
4512                // 最终 resolution 已提交后,迟到 Pending 不得重新打开解决通道。
4513                (_, StepResolutionStatus::Pending) => None,
4514                _ => return Err(()),
4515            }
4516        };
4517    }
4518    Ok(())
4519}
4520
4521/// 业务作用:把 CAS 结果收敛为"必须放弃本事务"的错误语义。
4522///
4523/// `Conflict` 与 `DuplicateTrigger` 都要求回滚:前者重投后按新快照重新裁决,
4524/// 后者说明该触发已生效,回滚后必须重新加载已提交结论;调用方不能据此直接 ACK 未确认状态。
4525///
4526/// 参数说明:
4527/// - `outcome`: store CAS 结果。
4528///
4529/// 返回:`Applied` 返回数据库确认的新实例版本;其余返回携带稳定原因的错误。
4530fn ensure_applied(outcome: CasOutcome) -> anyhow::Result<u64> {
4531    match outcome {
4532        CasOutcome::Applied {
4533            new_version,
4534        } => Ok(new_version),
4535        CasOutcome::Conflict => Err(SagaConcurrencyError::StaleSnapshot.into()),
4536        CasOutcome::DuplicateTrigger => Err(SagaConcurrencyError::DuplicateTrigger.into()),
4537    }
4538}
4539
4540/// 业务作用:按 definition 顺序找出下一个待执行的正向步骤。
4541///
4542/// 参数说明:
4543/// - `definition`: 实例固定版本的定义。
4544/// - `steps`: 已提交 step journal 投影。
4545///
4546/// 返回:第一个 `PENDING` 步骤;全部步骤均 `SUCCEEDED` 返回 `None`;journal 缺行、
4547/// 顺序漂移或混入其它状态时返回错误,禁止错误发布 `COMPLETED`。
4548fn next_pending_forward(
4549    definition: &WorkflowDefinition,
4550    steps: &[SagaStepRow],
4551) -> anyhow::Result<Option<StepName>> {
4552    if steps.len() != definition.steps().len() {
4553        anyhow::bail!("step journal does not completely cover the workflow definition");
4554    }
4555    let mut next = None;
4556    for (index, step_def) in definition.steps().iter().enumerate() {
4557        let row = &steps[index];
4558        if row.step != *step_def.name() || row.ordinal != index as u32 + 1 {
4559            anyhow::bail!("step journal order does not match the workflow definition");
4560        }
4561        match (next.is_some(), row.forward_status) {
4562            (false, StepForwardStatus::Succeeded) => {}
4563            (false, StepForwardStatus::Pending) => next = Some(row.step.clone()),
4564            (true, StepForwardStatus::Pending) => {}
4565            // 严格串行流程只能是 SUCCEEDED 前缀 + PENDING 后缀;若在首个 PENDING
4566            // 前看到拒绝/未知,或在其后看到已成功步骤,继续发命令会越过断裂依赖。
4567            _ => anyhow::bail!("forward journal is not a succeeded-prefix/pending-suffix"),
4568        }
4569    }
4570    Ok(next)
4571}
4572
4573/// 业务作用:复验冻结计划的摘要、成员与顺序后,找出下一个待补偿步骤。
4574///
4575/// 参数说明:
4576/// - `instance`: 持有冻结计划摘要的实例快照。
4577/// - `definition`: 实例固定版本的 definition。
4578/// - `steps`: 已提交 step journal 投影。
4579/// - `allow_halted_recovery`: 仅管理入口已落审计时为真,允许选择首个 HALTED 成员。
4580///
4581/// 返回:冻结证据完整且成员形成 `SUCCEEDED` 前缀 + `PENDING` 后缀时返回首个待补偿
4582/// 步骤;管理恢复可把首个 `HALTED` 作为待恢复项。全部成功返回 `None`;摘要漂移、计划外
4583/// 成员、顺序断裂或自动补偿态混入 Unknown/Halted 时返回错误,调用方不得发出下一命令。
4584fn next_pending_compensation(
4585    instance: &SagaInstanceRow,
4586    definition: &WorkflowDefinition,
4587    steps: &[SagaStepRow],
4588    allow_halted_recovery: bool,
4589) -> anyhow::Result<Option<StepName>> {
4590    let frozen_version = instance
4591        .compensation_plan_version
4592        .as_deref()
4593        .ok_or_else(|| anyhow::anyhow!("compensating instance has no frozen plan"))?;
4594    let journal: Vec<StepJournalEntry> = steps
4595        .iter()
4596        .map(|row| StepJournalEntry::new(row.step.clone(), row.forward_status))
4597        .collect();
4598    let rebuilt = freeze_compensation_plan(definition, &journal)
4599        .map_err(|violation| anyhow::anyhow!("{}", violation.code()))?;
4600    if rebuilt.version() != frozen_version {
4601        anyhow::bail!("frozen compensation plan digest drifted");
4602    }
4603
4604    let mut next = None;
4605    for entry in rebuilt.entries() {
4606        let row = steps
4607            .iter()
4608            .find(|row| row.step == *entry.step())
4609            .ok_or_else(|| anyhow::anyhow!("frozen compensation member is missing"))?;
4610        if row.compensation_plan_version.as_deref() != Some(frozen_version)
4611            || row.compensation_order != Some(entry.order())
4612        {
4613            anyhow::bail!("durable compensation member metadata drifted");
4614        }
4615        match (next.is_some(), row.compensation_status) {
4616            (false, StepCompensationStatus::Succeeded) => {}
4617            (false, StepCompensationStatus::Pending) => next = Some(row.step.clone()),
4618            (false, StepCompensationStatus::Halted) if allow_halted_recovery => {
4619                next = Some(row.step.clone())
4620            }
4621            (true, StepCompensationStatus::Pending) => {}
4622            _ => anyhow::bail!("compensation journal is not a succeeded-prefix/pending-suffix"),
4623        }
4624    }
4625    let marked_count = steps
4626        .iter()
4627        .filter(|row| row.compensation_plan_version.as_deref() == Some(frozen_version))
4628        .count();
4629    if marked_count != rebuilt.entries().len() {
4630        anyhow::bail!("durable compensation plan contains unexpected members");
4631    }
4632    Ok(next)
4633}