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