P2:reject 不是死路,锁住真正的约束

原判定「reject 后同一 session 无法再次提交,因为 CAS 门只放行 collecting /
revision_requested」不成立。提交门在 phase 判据之前先要求 session 的 activeRunId 等于
当前策划子 run,而 schema 不变量禁止 rejected / revision_requested / approved /
awaiting_* / recovery_required 保留 activeRunId——两者互斥,终态 phase 根本到不了那条
phase 判据。把 rejected 加进允许集只会多一个不可达分支,续跑仍然起不来;连既有的
revision_requested 也已经是不可达的。

真正决定 reject 能否重做的是 M1C-2b 的 continuation 起点 writer:技术方案 §8.6 要求它
「以新 activeRunId 写 revision+1 successor」,而唯一能同时带 activeRunId 又过提交门的
phase 只有 collecting。本提交不改行为,只把这条因果写进门旁注释,并加一条回归锁住它:
终态 session 挂 activeRunId 在指纹阶段就被 PLAN_INVALID_SCHEMA 拒绝,而落回 collecting
的 continuation 能过提交门。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 04:28:49 +00:00
parent 1fa56a7aee
commit 8d5338ce82
@@ -1252,6 +1252,14 @@ fn validate_current_session_cas(
"plan.submit_gdd 必须绑定当前活跃策划子 Run",
));
}
// 这条 phase 判据实际只可能看到 `collecting`:上面的 activeRunId 判据要求
// session 绑着当前策划子 run,而 schema 不变量禁止 `awaiting_user_input`、
// `awaiting_gdd_approval`、`revision_requested`、`approved`、`rejected`、
// `recovery_required` 保留 activeRunId(planning_storage.rs 的
// 「session 进入审批/终态/recovery_required 后不得保留 activeRunId」)。
// 因此 revise/reject 之后能不能重做,不由这条门决定,而由 M1C-2b 的 continuation
// 起点 writer 决定——它必须以新 activeRunId 写 revision+1 successor,phase 只能落回
// `collecting`。这里保留 `revision_requested` 作为既有冗余,不再新增更多不可达分支。
if !matches!(session.phase.as_str(), "collecting" | "revision_requested") {
return Err(submit_error(
"PLAN_PENDING_GDD_EXISTS",
@@ -3535,6 +3543,66 @@ mod tests {
cleanup_fixture(root);
}
/// reject 之后能不能在同一 lineage 重做,**不由提交门的 phase 判据决定**。
///
/// 提交门要求 session 的 activeRunId 等于当前策划子 run,而 schema 不变量禁止
/// `rejected`(以及 `revision_requested`、`approved`、`awaiting_*`)保留
/// activeRunId——两者互斥,所以终态 phase 永远到不了那条 phase 判据。真正决定重做
/// 能力的是 M1C-2b 的 continuation 起点 writer:技术方案 §8.6 要求它「以新
/// activeRunId 写 revision+1 successor」,而唯一能同时带 activeRunId 又过提交门的
/// phase 只有 `collecting`。
///
/// 这条测试把该约束锁住,免得日后有人以为「把 rejected 加进提交门允许集」就能让
/// reject 重做——那只会多一个不可达分支,真正的续跑仍然起不来。
#[test]
fn rejected_session_needs_a_collecting_continuation_not_a_wider_submit_gate() {
let (root, context, input) = submit_fixture();
execute_plan_submit_gdd(&root, &context, &input).expect("submit v1");
let gdd = read_plan_gdd_chain(&root)
.expect("read submitted GDD")
.pop()
.expect("GDD exists");
create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending");
decide_plan_gdd_at(
&root,
&approval_input(
&gdd,
"reject",
"gdd-response-00000000-0000-4000-8000-000000000040",
Some("当前方向需要重新梳理".to_string()),
),
)
.expect("commit reject receipt");
let session = read_plan_session_with_recovery(&root)
.expect("read session after reject")
.expect("session exists after reject");
assert_eq!(session.phase, "rejected");
assert!(session.active_run_id.is_none());
// 终态 session 直接挂 activeRunId 连指纹都算不出来——schema 层就禁止。
let mut forged = session.clone();
forged.active_run_id = Some(context.created_by_run_id.clone());
let error =
plan_session_fingerprint(&forged).expect_err("终态 session 不得保留 activeRunId");
assert_eq!(error.code(), "PLAN_INVALID_SCHEMA");
// continuation 起点把 phase 落回 collecting 之后,同一 lineage 才能继续提交。
let mut continuation = session.clone();
continuation.session_revision += 1;
continuation.previous_fingerprint = Some(session.session_fingerprint.clone());
continuation.phase = "collecting".to_string();
continuation.active_run_id = Some(context.created_by_run_id.clone());
continuation.session_fingerprint =
plan_session_fingerprint(&continuation).expect("continuation session fp");
let mut next_context = context.clone();
next_context.source_session_revision = continuation.session_revision;
next_context.source_session_fingerprint = continuation.session_fingerprint.clone();
validate_current_session_cas(&continuation, &next_context, &input)
.expect("reject 之后的 continuation 必须能提交同一 lineage 的下一版本");
cleanup_fixture(root);
}
#[test]
fn approval_actions_rebuild_receipt_aware_index_and_are_idempotent() {
for (action, comment, expected_status) in [