From f453c2ca27a06a944586ac37120a458ec36b44a9 Mon Sep 17 00:00:00 2001 From: Linghong Date: Fri, 14 Aug 2026 06:47:14 +0000 Subject: [PATCH] =?UTF-8?q?=E7=AB=8B=E9=A1=B9=E7=AD=96=E5=88=92=EF=BC=9A?= =?UTF-8?q?=E8=90=BD=E5=9C=B0=20M1B-1=20planning=20storage=20=E4=B8=8E?= =?UTF-8?q?=E5=86=99=E5=85=A5=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 plan GDD、index、session 与提交输入的 strict schema、canonical JSON 和 typed 指纹。 - 落地 GDD 连续版本链、index 权威对账与锁内 recovery、session CAS/原子替换/受限恢复。 - 封锁 planning sidecar 与 fast_gdd 投影的通用写入、patch、删除和 checkpoint restore,并校验专用 writer 身份。 - 同步 Fast GDD 技术方案、决策记录与排障记忆。 --- .../src-tauri/src/agent/runtime_protocol.rs | 2 + .../runtime_protocol/planning_storage.rs | 4096 +++++++++++++++++ .../src/agent/runtime_tools/file_ops.rs | 12 + .../src-tauri/src/patchset.rs | 16 +- .../src-tauri/src/project/checkpoint.rs | 1 + .../src-tauri/src/project/filesystem.rs | 50 + .../shared-memory/decision-log.md | 12 +- docs/project-memory/shared-memory/pitfalls.md | 8 + ...方案】立项策划Agent(Fast GDD)-2026-08-10.md | 12 +- 9 files changed, 4203 insertions(+), 6 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs index 6c595a236..def206f66 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs @@ -8,6 +8,7 @@ mod context_window; mod finalization; mod json_sidecar; mod models; +mod planning_storage; mod provider_control; mod provider_retry; mod real_e2e_checkpoint; @@ -21,6 +22,7 @@ pub(in crate::agent) use context_bundle::*; pub(in crate::agent) use finalization::*; pub(in crate::agent) use json_sidecar::*; pub(in crate::agent) use models::*; +pub(in crate::agent) use planning_storage::*; pub(in crate::agent) use provider_control::*; pub(in crate::agent) use provider_retry::*; pub(in crate::agent) use real_e2e_checkpoint::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs new file mode 100644 index 000000000..49cd1d8c3 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs @@ -0,0 +1,4096 @@ +use super::*; + +use serde::de::{DeserializeOwned, DeserializeSeed, MapAccess, SeqAccess, Visitor}; +use serde::{Deserialize, Deserializer, Serialize}; +use sha2::{Digest, Sha256}; +use std::fmt; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use uuid::{Uuid, Variant}; + +struct DuplicateKeySeed; + +struct DuplicateKeyVisitor; + +impl<'de> DeserializeSeed<'de> for DuplicateKeySeed { + type Value = (); + + fn deserialize(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_any(DuplicateKeyVisitor) + } +} + +impl<'de> Visitor<'de> for DuplicateKeyVisitor { + type Value = (); + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a JSON value without duplicate object keys") + } + + fn visit_bool(self, _: bool) -> Result { + Ok(()) + } + + fn visit_i64(self, _: i64) -> Result { + Ok(()) + } + + fn visit_u64(self, _: u64) -> Result { + Ok(()) + } + + fn visit_f64(self, _: f64) -> Result { + Ok(()) + } + + fn visit_str(self, _: &str) -> Result { + Ok(()) + } + + fn visit_borrowed_str(self, _: &'de str) -> Result { + Ok(()) + } + + fn visit_string(self, _: String) -> Result { + Ok(()) + } + + fn visit_none(self) -> Result { + Ok(()) + } + + fn visit_unit(self) -> Result { + Ok(()) + } + + fn visit_some(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_any(DuplicateKeyVisitor) + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + while sequence.next_element_seed(DuplicateKeySeed)?.is_some() {} + Ok(()) + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut keys = std::collections::BTreeSet::new(); + while let Some(key) = map.next_key::()? { + if !keys.insert(key.clone()) { + return Err(serde::de::Error::custom(format!( + "duplicate JSON object key: {key}" + ))); + } + map.next_value_seed(DuplicateKeySeed)?; + } + Ok(()) + } +} + +pub(crate) const PLAN_GDD_SCHEMA_VERSION: &str = "plan-gdd.v1"; +pub(crate) const PLAN_GDD_INDEX_SCHEMA_VERSION: &str = "plan-gdd-index.v1"; +pub(crate) const PLAN_SESSION_SCHEMA_VERSION: &str = "plan-session.v1"; +pub(crate) const PLAN_SUBMIT_GDD_INPUT_SCHEMA_VERSION: &str = "plan-submit-gdd-input.v1"; +pub(crate) const PLAN_GDD_FINGERPRINT_DOMAIN: &str = "genarrative.plan.gdd.v1"; +pub(crate) const PLAN_SESSION_FINGERPRINT_DOMAIN: &str = "genarrative.plan.session.v1"; +pub(crate) const PLAN_GDD_MAX_BYTES: usize = 64 * 1024; +pub(crate) const PLAN_SESSION_MAX_BYTES: usize = 64 * 1024; +pub(crate) const PLAN_INDEX_MAX_BYTES: usize = 256 * 1024; +pub(crate) const PLAN_MAX_VERSIONS: u32 = 128; +pub(crate) const PLAN_STORAGE_ROOT: &str = ".agent/planning"; +pub(crate) const PLAN_GDD_INDEX_PATH: &str = ".agent/planning/index.json"; +pub(crate) const PLAN_SESSION_PATH: &str = ".agent/planning/session.json"; +pub(crate) const PLAN_SESSION_PREVIOUS_PATH: &str = ".agent/planning/.session.json.previous"; + +static PLANNING_TEMP_NONCE: AtomicU64 = AtomicU64::new(1); + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PlanningStorageError { + code: &'static str, + detail: String, +} + +impl PlanningStorageError { + fn new(code: &'static str, detail: impl Into) -> Self { + Self { + code, + detail: detail.into(), + } + } + + pub(crate) fn code(&self) -> &'static str { + self.code + } +} + +impl fmt::Display for PlanningStorageError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: {}", self.code, self.detail) + } +} + +impl std::error::Error for PlanningStorageError {} + +pub(crate) type FingerprintError = PlanningStorageError; + +fn invalid(detail: impl Into) -> PlanningStorageError { + PlanningStorageError::new("PLAN_INVALID_SCHEMA", detail) +} + +fn conflict(detail: impl Into) -> PlanningStorageError { + PlanningStorageError::new("PLAN_IDENTITY_CONFLICT", detail) +} + +fn io_error(label: &str, error: impl fmt::Display) -> PlanningStorageError { + PlanningStorageError::new("PLAN_STORAGE_IO", format!("{label}: {error}")) +} + +fn canonical_bytes(value: &T) -> Result, PlanningStorageError> { + serde_json::to_vec(value) + .map_err(|error| PlanningStorageError::new("PLAN_SERIALIZE_FAILED", error.to_string())) +} + +#[derive(Serialize)] +struct FingerprintEnvelope<'a, T: Serialize + ?Sized> { + domain: &'static str, + value: &'a T, +} + +/// Typed planning fingerprint. The envelope and the serialized value are +/// deliberately struct-shaped; map/string concatenation fingerprints are not +/// interchangeable with this contract. +pub(crate) fn typed_serde_fingerprint( + domain: &'static str, + value: &T, +) -> Result { + if domain.trim().is_empty() { + return Err(PlanningStorageError::new( + "PLAN_INVALID_FINGERPRINT_DOMAIN", + "fingerprint domain 不能为空", + )); + } + let bytes = typed_serde_canonical_bytes(domain, value)?; + Ok(format!("sha256-serde-json-v2:{:x}", Sha256::digest(bytes))) +} + +pub(crate) fn typed_serde_canonical_bytes( + domain: &'static str, + value: &T, +) -> Result, FingerprintError> { + if domain.trim().is_empty() { + return Err(PlanningStorageError::new( + "PLAN_INVALID_FINGERPRINT_DOMAIN", + "fingerprint domain 不能为空", + )); + } + let envelope = FingerprintEnvelope { domain, value }; + canonical_bytes(&envelope) +} + +fn is_hex(value: &str, length: usize) -> bool { + value.len() == length && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn is_lower_hex(value: &str, length: usize) -> bool { + is_hex(value, length) && value.bytes().all(|byte| !byte.is_ascii_uppercase()) +} + +fn is_typed_fingerprint(value: &str) -> bool { + value + .strip_prefix("sha256-serde-json-v2:") + .is_some_and(|digest| is_lower_hex(digest, 64)) +} + +fn is_bare_fingerprint(value: &str) -> bool { + is_lower_hex(value, 64) +} + +fn validate_text( + value: &str, + label: &str, + min: usize, + max: usize, +) -> Result<(), PlanningStorageError> { + if value.chars().count() < min || value.chars().count() > max { + return Err(invalid(format!( + "{label} 必须为 {min}..={max} 个 Unicode scalar" + ))); + } + if value != value.trim() { + return Err(invalid(format!("{label} 必须已完成首尾空白规范化"))); + } + if value.contains('\r') + || value.chars().any(|character| { + character == '\0' + || character == '\u{7f}' + || (character.is_control() && character != '\n' && character != '\t') + }) + { + return Err(invalid(format!("{label} 包含不允许的控制字符"))); + } + Ok(()) +} + +pub(crate) fn normalize_plan_text( + value: &str, + label: &str, + min: usize, + max: usize, +) -> Result { + let normalized = value.replace("\r\n", "\n").replace('\r', "\n"); + let normalized = normalized.trim().to_string(); + validate_text(&normalized, label, min, max)?; + if normalized.as_bytes().len() > max.saturating_mul(4).saturating_add(256) { + return Err(invalid(format!("{label} 序列化字节数过大"))); + } + Ok(normalized) +} + +fn validate_opaque_id( + value: &str, + label: &str, + allow_empty: bool, +) -> Result<(), PlanningStorageError> { + if value.is_empty() && allow_empty { + return Ok(()); + } + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return Err(invalid(format!("{label} 不能为空"))); + }; + if value.chars().count() > 128 + || !first.is_ascii_alphanumeric() + || !chars.all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | ':' | '-') + }) + { + return Err(invalid(format!("{label} 不是合法 opaque ID"))); + } + Ok(()) +} + +fn validate_uuid_prefixed( + value: &str, + prefix: &str, + label: &str, +) -> Result<(), PlanningStorageError> { + let Some(uuid) = value.strip_prefix(prefix) else { + return Err(invalid(format!("{label} 必须以 {prefix} 开头"))); + }; + if uuid.len() != 36 + || !uuid.bytes().enumerate().all(|(index, byte)| { + matches!(index, 8 | 13 | 18 | 23) + .then_some(byte == b'-') + .unwrap_or_else(|| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + }) + || Uuid::parse_str(uuid).ok().is_none_or(|parsed| { + parsed.hyphenated().to_string() != uuid || parsed.get_variant() != Variant::RFC4122 + }) + { + return Err(invalid(format!("{label} 不是小写 RFC 4122 UUID"))); + } + Ok(()) +} + +fn validate_action_id(value: &str, label: &str) -> Result<(), PlanningStorageError> { + let Some(digest) = value.strip_prefix("action-") else { + return Err(invalid(format!("{label} 必须以 action- 开头"))); + }; + if !is_lower_hex(digest, 24) { + return Err(invalid(format!("{label} 不是合法 actionId"))); + } + Ok(()) +} + +fn validate_timestamp(value: &str, label: &str) -> Result<(), PlanningStorageError> { + validate_text(value, label, 24, 24)?; + if value.len() != 24 || !value.is_ascii() { + return Err(invalid(format!("{label} 必须是 ASCII UTC 毫秒时间"))); + } + let bytes = value.as_bytes(); + let punctuation = [ + (4, b'-'), + (7, b'-'), + (10, b'T'), + (13, b':'), + (16, b':'), + (19, b'.'), + (23, b'Z'), + ]; + if punctuation + .iter() + .any(|(index, expected)| bytes[*index] != *expected) + || bytes.iter().enumerate().any(|(index, byte)| { + !punctuation + .iter() + .any(|(punctuation_index, _)| *punctuation_index == index) + && !byte.is_ascii_digit() + }) + { + return Err(invalid(format!("{label} 必须是 UTC 毫秒时间"))); + } + let year = value[0..4] + .parse::() + .map_err(|_| invalid(format!("{label} 年份非法")))?; + let month = value[5..7] + .parse::() + .map_err(|_| invalid(format!("{label} 月份非法")))?; + let day = value[8..10] + .parse::() + .map_err(|_| invalid(format!("{label} 日期非法")))?; + let hour = value[11..13] + .parse::() + .map_err(|_| invalid(format!("{label} 小时非法")))?; + let minute = value[14..16] + .parse::() + .map_err(|_| invalid(format!("{label} 分钟非法")))?; + let second = value[17..19] + .parse::() + .map_err(|_| invalid(format!("{label} 秒非法")))?; + let millis = value[20..23] + .parse::() + .map_err(|_| invalid(format!("{label} 毫秒非法")))?; + if !(1..=12).contains(&month) || hour > 23 || minute > 59 || second > 59 || millis > 999 { + return Err(invalid(format!("{label} 的 UTC 日期时间分量越界"))); + } + let leap_year = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + let days_in_month = match month { + 2 if leap_year => 29, + 2 => 28, + 4 | 6 | 9 | 11 => 30, + _ => 31, + }; + if day == 0 || day > days_in_month { + return Err(invalid(format!("{label} 的日期分量越界"))); + } + Ok(()) +} + +fn validate_decision_state(value: &str) -> Result<(), PlanningStorageError> { + if matches!(value, "confirmed" | "default_pending" | "prototype_pending") { + Ok(()) + } else { + Err(invalid(format!("未知 decision state:{value}"))) + } +} + +fn validate_answer_source(value: &str) -> Result<(), PlanningStorageError> { + if matches!(value, "user_freeform" | "user_option" | "default") { + Ok(()) + } else { + Err(invalid(format!("未知 answerSource:{value}"))) + } +} + +fn validate_unique<'a, I>(values: I, label: &str) -> Result<(), PlanningStorageError> +where + I: IntoIterator, +{ + let mut seen = std::collections::BTreeSet::new(); + for value in values { + if !seen.insert(value) { + return Err(invalid(format!("{label} 不能重复:{value}"))); + } + } + Ok(()) +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanGddV1 { + pub(crate) schema_version: String, + pub(crate) project_id: String, + pub(crate) gdd_id: String, + pub(crate) version: u32, + pub(crate) submission_id: String, + pub(crate) approval_request_id: String, + pub(crate) action_fingerprint: String, + pub(crate) agent_id: String, + pub(crate) source: String, + pub(crate) run_profile: String, + pub(crate) run_profile_binding_fingerprint: String, + pub(crate) root_agent_id: String, + pub(crate) root_run_id: String, + pub(crate) delegation_id: String, + pub(crate) session_id: String, + pub(crate) source_session_revision: u32, + pub(crate) source_session_fingerprint: String, + pub(crate) created_by_run_id: String, + pub(crate) created_at_utc: String, + pub(crate) game: PlanGddGame, + pub(crate) decisions: Vec, + pub(crate) prototype_validation_items: Vec, + pub(crate) fingerprint: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanGddGame { + pub(crate) title: String, + pub(crate) genre: PlanGenre, + pub(crate) art_style: PlanArtStyle, + pub(crate) one_liner: String, + pub(crate) pillars: Vec, + pub(crate) core_loop: Vec, + pub(crate) target_users: PlanTargetUsers, + pub(crate) platform_facts: PlanPlatformFacts, + pub(crate) mvp_systems: Vec, + pub(crate) out_of_scope: Vec, + pub(crate) creator_tips: PlanCreatorTips, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanGenre { + pub(crate) primary: String, + pub(crate) fusion: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanArtStyle { + pub(crate) visual_type: String, + pub(crate) keywords: Vec, + pub(crate) mood_and_color: String, + pub(crate) mvp_art_boundary: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanPillar { + pub(crate) name: String, + pub(crate) player_feel: String, + pub(crate) mechanism: String, + pub(crate) decision_state: String, + pub(crate) basis: Option<()>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanTargetUsers { + pub(crate) core_users: String, + pub(crate) preferences: String, + pub(crate) session_length: String, + pub(crate) reference_games: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanPlatformFacts { + pub(crate) runtime: String, + pub(crate) viewports: Vec, + pub(crate) inputs: Vec, + pub(crate) preview: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanMvpSystem { + pub(crate) system: String, + pub(crate) minimal_function: String, + pub(crate) why_required: String, + pub(crate) verify_method: String, + pub(crate) decision_state: String, + pub(crate) basis: Option<()>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanCreatorTips { + pub(crate) do_first: String, + pub(crate) defer_for_now: String, + pub(crate) how_to_verify: String, + pub(crate) expand_when: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanDecision { + pub(crate) id: String, + pub(crate) topic: String, + pub(crate) state: String, + pub(crate) answer_source: String, + pub(crate) round: u32, + pub(crate) answer_summary: String, + pub(crate) basis: Option<()>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanPrototypeValidationItem { + pub(crate) id: String, + pub(crate) question: String, + pub(crate) micro_prototype: String, + pub(crate) observation: String, + pub(crate) pass_criterion: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanSubmitGddInputV1 { + pub(crate) schema_version: String, + pub(crate) game: PlanSubmitGame, + pub(crate) decisions: Vec, + pub(crate) prototype_validation_items: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanSubmitGame { + pub(crate) title: String, + pub(crate) genre: PlanGenre, + pub(crate) art_style: PlanArtStyle, + pub(crate) one_liner: String, + pub(crate) pillars: Vec, + pub(crate) core_loop: Vec, + pub(crate) target_users: PlanTargetUsers, + pub(crate) mvp_systems: Vec, + pub(crate) out_of_scope: Vec, + pub(crate) creator_tips: PlanCreatorTips, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanSubmitPillar { + pub(crate) name: String, + pub(crate) player_feel: String, + pub(crate) mechanism: String, + pub(crate) decision_state: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanSubmitMvpSystem { + pub(crate) system: String, + pub(crate) minimal_function: String, + pub(crate) why_required: String, + pub(crate) verify_method: String, + pub(crate) decision_state: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanSubmitDecision { + pub(crate) id: String, + pub(crate) topic: String, + pub(crate) state: String, + pub(crate) answer_source: String, + pub(crate) round: u32, + pub(crate) answer_summary: String, +} + +fn validate_plan_platform_facts(value: &PlanPlatformFacts) -> Result<(), PlanningStorageError> { + if value.runtime != "self-contained-web" + || value.viewports != ["desktop", "mobile"] + || value.inputs != ["keyboard", "touch"] + || value.preview != "local-http" + { + return Err(invalid("platformFacts 必须是 Runtime 固定的平台事实")); + } + Ok(()) +} + +fn validate_plan_game(game: &PlanGddGame) -> Result<(), PlanningStorageError> { + validate_text(&game.title, "game.title", 1, 80)?; + validate_text(&game.genre.primary, "game.genre.primary", 1, 40)?; + if let Some(fusion) = &game.genre.fusion { + validate_text(fusion, "game.genre.fusion", 1, 40)?; + } + validate_text( + &game.art_style.visual_type, + "game.artStyle.visualType", + 1, + 80, + )?; + if !(3..=5).contains(&game.art_style.keywords.len()) { + return Err(invalid("game.artStyle.keywords 必须有 3~5 项")); + } + validate_unique( + game.art_style.keywords.iter().map(String::as_str), + "game.artStyle.keywords", + )?; + for (index, keyword) in game.art_style.keywords.iter().enumerate() { + validate_text(keyword, &format!("game.artStyle.keywords[{index}]"), 1, 32)?; + } + validate_text( + &game.art_style.mood_and_color, + "game.artStyle.moodAndColor", + 1, + 400, + )?; + validate_text( + &game.art_style.mvp_art_boundary, + "game.artStyle.mvpArtBoundary", + 1, + 400, + )?; + validate_text(&game.one_liner, "game.oneLiner", 45, 90)?; + + if !(2..=4).contains(&game.pillars.len()) { + return Err(invalid("game.pillars 必须有 2~4 条")); + } + validate_unique( + game.pillars.iter().map(|item| item.name.as_str()), + "game.pillars.name", + )?; + for (index, pillar) in game.pillars.iter().enumerate() { + validate_text(&pillar.name, &format!("game.pillars[{index}].name"), 1, 40)?; + validate_text( + &pillar.player_feel, + &format!("game.pillars[{index}].playerFeel"), + 1, + 240, + )?; + validate_text( + &pillar.mechanism, + &format!("game.pillars[{index}].mechanism"), + 1, + 240, + )?; + validate_decision_state(&pillar.decision_state)?; + if pillar.basis.is_some() { + return Err(invalid("v1 的 pillar.basis 必须为 null")); + } + } + + if !(4..=8).contains(&game.core_loop.len()) { + return Err(invalid("game.coreLoop 必须有 4~8 步")); + } + for (index, step) in game.core_loop.iter().enumerate() { + validate_text(step, &format!("game.coreLoop[{index}]"), 1, 120)?; + } + + validate_text( + &game.target_users.core_users, + "game.targetUsers.coreUsers", + 1, + 240, + )?; + validate_text( + &game.target_users.preferences, + "game.targetUsers.preferences", + 1, + 240, + )?; + validate_text( + &game.target_users.session_length, + "game.targetUsers.sessionLength", + 1, + 240, + )?; + if game.target_users.reference_games.len() > 5 { + return Err(invalid("game.targetUsers.referenceGames 最多 5 项")); + } + for (index, reference) in game.target_users.reference_games.iter().enumerate() { + validate_text( + reference, + &format!("game.targetUsers.referenceGames[{index}]"), + 1, + 80, + )?; + } + validate_plan_platform_facts(&game.platform_facts)?; + + if !(3..=6).contains(&game.mvp_systems.len()) { + return Err(invalid("game.mvpSystems 必须有 3~6 项")); + } + validate_unique( + game.mvp_systems.iter().map(|item| item.system.as_str()), + "game.mvpSystems.system", + )?; + for (index, system) in game.mvp_systems.iter().enumerate() { + validate_text( + &system.system, + &format!("game.mvpSystems[{index}].system"), + 1, + 40, + )?; + validate_text( + &system.minimal_function, + &format!("game.mvpSystems[{index}].minimalFunction"), + 1, + 240, + )?; + validate_text( + &system.why_required, + &format!("game.mvpSystems[{index}].whyRequired"), + 1, + 240, + )?; + validate_text( + &system.verify_method, + &format!("game.mvpSystems[{index}].verifyMethod"), + 1, + 240, + )?; + validate_decision_state(&system.decision_state)?; + if system.basis.is_some() { + return Err(invalid("v1 的 mvpSystem.basis 必须为 null")); + } + } + if !(1..=12).contains(&game.out_of_scope.len()) { + return Err(invalid("game.outOfScope 必须有 1~12 项")); + } + validate_unique( + game.out_of_scope.iter().map(String::as_str), + "game.outOfScope", + )?; + for (index, item) in game.out_of_scope.iter().enumerate() { + validate_text(item, &format!("game.outOfScope[{index}]"), 1, 80)?; + } + validate_text( + &game.creator_tips.do_first, + "game.creatorTips.doFirst", + 1, + 400, + )?; + validate_text( + &game.creator_tips.defer_for_now, + "game.creatorTips.deferForNow", + 1, + 400, + )?; + validate_text( + &game.creator_tips.how_to_verify, + "game.creatorTips.howToVerify", + 1, + 400, + )?; + validate_text( + &game.creator_tips.expand_when, + "game.creatorTips.expandWhen", + 1, + 400, + )?; + Ok(()) +} + +fn validate_decisions( + decisions: &[PlanDecision], + prototype_items: &[PlanPrototypeValidationItem], +) -> Result<(), PlanningStorageError> { + if !(1..=32).contains(&decisions.len()) { + return Err(invalid("decisions 必须有 1~32 项")); + } + validate_unique( + decisions.iter().map(|item| item.id.as_str()), + "decisions.id", + )?; + if decisions.first().map(|decision| decision.id.as_str()) != Some("initial-request") + || decisions + .iter() + .filter(|decision| decision.id == "initial-request") + .count() + != 1 + { + return Err(invalid("decisions 必须恰好包含首项 initial-request")); + } + let mut prototype_decisions = std::collections::BTreeSet::new(); + for (index, decision) in decisions.iter().enumerate() { + if decision.id == "initial-request" { + if index != 0 + || decision.state != "confirmed" + || decision.answer_source != "user_freeform" + || decision.round != 0 + { + return Err(invalid( + "initial-request 必须是首项 confirmed/user_freeform/round=0", + )); + } + } else { + let mut chars = decision.id.chars(); + let valid_id = chars + .next() + .is_some_and(|character| character.is_ascii_lowercase()) + && chars.all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' + }) + && decision.id.len() <= 32; + if !valid_id { + return Err(invalid(format!("decisions[{index}].id 不是合法决定 ID"))); + } + } + validate_text(&decision.id, &format!("decisions[{index}].id"), 1, 32)?; + validate_text(&decision.topic, &format!("decisions[{index}].topic"), 1, 80)?; + validate_decision_state(&decision.state)?; + validate_answer_source(&decision.answer_source)?; + if decision.round > 3 { + return Err(invalid(format!("decisions[{index}].round 不能超过 3"))); + } + if decision.round == 0 + && decision.id != "initial-request" + && !(decision.state == "default_pending" && decision.answer_source == "default") + { + return Err(invalid( + "round=0 的非 initial-request 只能是 default_pending/default", + )); + } + validate_text( + &decision.answer_summary, + &format!("decisions[{index}].answerSummary"), + 1, + 400, + )?; + if decision.basis.is_some() { + return Err(invalid("v1 的 decision.basis 必须为 null")); + } + if decision.state == "prototype_pending" { + prototype_decisions.insert(decision.id.as_str()); + } + } + if prototype_items.len() > 3 { + return Err(invalid("prototypeValidationItems 最多 3 项")); + } + validate_unique( + prototype_items.iter().map(|item| item.id.as_str()), + "prototypeValidationItems.id", + )?; + if prototype_decisions.len() != prototype_items.len() + || prototype_items + .iter() + .any(|item| !prototype_decisions.contains(item.id.as_str())) + { + return Err(invalid( + "prototypeValidationItems 必须逐项对应全部 prototype_pending 决定", + )); + } + for (index, item) in prototype_items.iter().enumerate() { + if !item.id.chars().all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' + }) { + return Err(invalid(format!( + "prototypeValidationItems[{index}].id 非法" + ))); + } + validate_text( + &item.id, + &format!("prototypeValidationItems[{index}].id"), + 1, + 32, + )?; + validate_text( + &item.question, + &format!("prototypeValidationItems[{index}].question"), + 1, + 400, + )?; + validate_text( + &item.micro_prototype, + &format!("prototypeValidationItems[{index}].microPrototype"), + 1, + 400, + )?; + validate_text( + &item.observation, + &format!("prototypeValidationItems[{index}].observation"), + 1, + 400, + )?; + validate_text( + &item.pass_criterion, + &format!("prototypeValidationItems[{index}].passCriterion"), + 1, + 400, + )?; + } + Ok(()) +} + +fn validate_plan_gdd_shape(value: &PlanGddV1) -> Result<(), PlanningStorageError> { + if value.schema_version != PLAN_GDD_SCHEMA_VERSION { + return Err(invalid("未知 GDD schemaVersion")); + } + validate_opaque_id(&value.project_id, "projectId", false)?; + validate_uuid_prefixed(&value.gdd_id, "gdd-", "gddId")?; + if !(1..=PLAN_MAX_VERSIONS).contains(&value.version) { + return Err(invalid("GDD version 超出 1..=128")); + } + validate_action_id(&value.submission_id, "submissionId")?; + validate_uuid_prefixed( + &value.approval_request_id, + "gdd-approval-", + "approvalRequestId", + )?; + if !is_bare_fingerprint(&value.action_fingerprint) { + return Err(invalid("actionFingerprint 必须是 64 位小写裸 digest")); + } + if value.agent_id != "project-planning" + || value.source != "agent-delegate" + || value.run_profile != "standard" + || value.root_agent_id != "project-supervisor" + { + return Err(invalid("GDD 的 plan identity 常量不匹配")); + } + if !is_bare_fingerprint(&value.run_profile_binding_fingerprint) { + return Err(invalid("runProfileBindingFingerprint 必须是裸 digest")); + } + validate_opaque_id(&value.root_run_id, "rootRunId", false)?; + validate_opaque_id(&value.delegation_id, "delegationId", false)?; + validate_opaque_id(&value.session_id, "sessionId", false)?; + if value.source_session_revision == 0 { + return Err(invalid("sourceSessionRevision 必须大于 0")); + } + if !is_typed_fingerprint(&value.source_session_fingerprint) { + return Err(invalid( + "sourceSessionFingerprint 必须是 planning typed fingerprint", + )); + } + validate_opaque_id(&value.created_by_run_id, "createdByRunId", false)?; + validate_timestamp(&value.created_at_utc, "createdAtUtc")?; + validate_plan_game(&value.game)?; + validate_decisions(&value.decisions, &value.prototype_validation_items)?; + if !is_typed_fingerprint(&value.fingerprint) { + return Err(invalid("GDD fingerprint 格式非法")); + } + Ok(()) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PlanGddFingerprintValue<'a> { + schema_version: &'a str, + project_id: &'a str, + gdd_id: &'a str, + version: u32, + submission_id: &'a str, + approval_request_id: &'a str, + action_fingerprint: &'a str, + agent_id: &'a str, + source: &'a str, + run_profile: &'a str, + run_profile_binding_fingerprint: &'a str, + root_agent_id: &'a str, + root_run_id: &'a str, + delegation_id: &'a str, + session_id: &'a str, + source_session_revision: u32, + source_session_fingerprint: &'a str, + created_by_run_id: &'a str, + created_at_utc: &'a str, + game: &'a PlanGddGame, + decisions: &'a Vec, + prototype_validation_items: &'a Vec, +} + +impl<'a> From<&'a PlanGddV1> for PlanGddFingerprintValue<'a> { + fn from(value: &'a PlanGddV1) -> Self { + Self { + schema_version: &value.schema_version, + project_id: &value.project_id, + gdd_id: &value.gdd_id, + version: value.version, + submission_id: &value.submission_id, + approval_request_id: &value.approval_request_id, + action_fingerprint: &value.action_fingerprint, + agent_id: &value.agent_id, + source: &value.source, + run_profile: &value.run_profile, + run_profile_binding_fingerprint: &value.run_profile_binding_fingerprint, + root_agent_id: &value.root_agent_id, + root_run_id: &value.root_run_id, + delegation_id: &value.delegation_id, + session_id: &value.session_id, + source_session_revision: value.source_session_revision, + source_session_fingerprint: &value.source_session_fingerprint, + created_by_run_id: &value.created_by_run_id, + created_at_utc: &value.created_at_utc, + game: &value.game, + decisions: &value.decisions, + prototype_validation_items: &value.prototype_validation_items, + } + } +} + +pub(crate) fn plan_gdd_fingerprint(value: &PlanGddV1) -> Result { + validate_plan_gdd_shape(value)?; + typed_serde_fingerprint( + PLAN_GDD_FINGERPRINT_DOMAIN, + &PlanGddFingerprintValue::from(value), + ) +} + +pub(crate) fn validate_plan_gdd(value: &PlanGddV1) -> Result<(), PlanningStorageError> { + validate_plan_gdd_shape(value)?; + let expected = plan_gdd_fingerprint(value)?; + if value.fingerprint != expected { + return Err(PlanningStorageError::new( + "PLAN_FINGERPRINT_MISMATCH", + "GDD fingerprint 与 canonical payload 不一致", + )); + } + Ok(()) +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanGddIndexV1 { + pub(crate) schema_version: String, + pub(crate) project_id: String, + pub(crate) gdd_id: String, + pub(crate) entries: Vec, + pub(crate) status_cache: PlanGddIndexStatusCache, + pub(crate) rebuilt_at_utc: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanGddIndexEntry { + pub(crate) version: u32, + pub(crate) submission_id: String, + pub(crate) approval_request_id: String, + pub(crate) action_fingerprint: String, + pub(crate) fingerprint: String, + pub(crate) file: String, + pub(crate) agent_id: String, + pub(crate) source: String, + pub(crate) run_profile: String, + pub(crate) run_profile_binding_fingerprint: String, + pub(crate) root_run_id: String, + pub(crate) delegation_id: String, + pub(crate) session_id: String, + pub(crate) source_session_revision: u32, + pub(crate) source_session_fingerprint: String, + pub(crate) created_by_run_id: String, + pub(crate) created_at_utc: String, + pub(crate) submitted_at_utc: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanGddIndexStatusCache { + pub(crate) latest_version: u32, + pub(crate) pending_version: Option, + pub(crate) approved_version: Option, + pub(crate) versions: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanGddIndexVersionStatus { + pub(crate) version: u32, + pub(crate) status: String, +} + +fn validate_plan_index_entry( + entry: &PlanGddIndexEntry, + expected_version: u32, + gdd_id: &str, +) -> Result<(), PlanningStorageError> { + if entry.version != expected_version { + return Err(invalid("index entries 必须从 1 连续递增")); + } + validate_action_id(&entry.submission_id, "index.submissionId")?; + validate_uuid_prefixed( + &entry.approval_request_id, + "gdd-approval-", + "index.approvalRequestId", + )?; + if !is_bare_fingerprint(&entry.action_fingerprint) + || !is_typed_fingerprint(&entry.fingerprint) + || !is_bare_fingerprint(&entry.run_profile_binding_fingerprint) + || !is_typed_fingerprint(&entry.source_session_fingerprint) + { + return Err(invalid("index entry 的 fingerprint 格式非法")); + } + let expected_file = format!("gdd.v{expected_version}.json"); + if entry.file != expected_file { + return Err(invalid("index entry.file 与 version 不一致")); + } + if entry.agent_id != "project-planning" + || entry.source != "agent-delegate" + || entry.run_profile != "standard" + { + return Err(invalid("index entry 的 plan identity 常量不匹配")); + } + validate_opaque_id(&entry.root_run_id, "index.rootRunId", false)?; + validate_opaque_id(&entry.delegation_id, "index.delegationId", false)?; + validate_opaque_id(&entry.session_id, "index.sessionId", false)?; + validate_opaque_id(&entry.created_by_run_id, "index.createdByRunId", false)?; + if entry.source_session_revision == 0 { + return Err(invalid("index.sourceSessionRevision 必须大于 0")); + } + validate_timestamp(&entry.created_at_utc, "index.createdAtUtc")?; + validate_timestamp(&entry.submitted_at_utc, "index.submittedAtUtc")?; + if entry.created_at_utc != entry.submitted_at_utc { + return Err(invalid("v1 index.submittedAtUtc 必须等于 createdAtUtc")); + } + if gdd_id.is_empty() { + return Err(invalid("index 缺少 gddId")); + } + Ok(()) +} + +pub(crate) fn validate_plan_gdd_index(value: &PlanGddIndexV1) -> Result<(), PlanningStorageError> { + if value.schema_version != PLAN_GDD_INDEX_SCHEMA_VERSION { + return Err(invalid("未知 GDD index schemaVersion")); + } + validate_opaque_id(&value.project_id, "index.projectId", false)?; + validate_uuid_prefixed(&value.gdd_id, "gdd-", "index.gddId")?; + if value.entries.len() > PLAN_MAX_VERSIONS as usize { + return Err(invalid("index entries 超过 lineage 版本上限")); + } + for (index, entry) in value.entries.iter().enumerate() { + validate_plan_index_entry(entry, index as u32 + 1, &value.gdd_id)?; + } + if value.entries.is_empty() { + if value.status_cache.latest_version != 0 + || value.status_cache.pending_version.is_some() + || value.status_cache.approved_version.is_some() + || !value.status_cache.versions.is_empty() + { + return Err(invalid("空 index 的 statusCache 必须为空")); + } + } else { + let latest = value.entries.len() as u32; + if value.status_cache.latest_version != latest + || value.status_cache.versions.len() != value.entries.len() + { + return Err(invalid("index statusCache 与 entries 长度不一致")); + } + for (index, status) in value.status_cache.versions.iter().enumerate() { + if status.version != index as u32 + 1 + || !matches!( + status.status.as_str(), + "ready_for_approval" + | "revision_requested" + | "rejected" + | "approved" + | "superseded" + ) + { + return Err(invalid("index statusCache.versions 非法")); + } + } + for optional in [ + value.status_cache.pending_version, + value.status_cache.approved_version, + ] + .into_iter() + .flatten() + { + if optional == 0 || optional > latest { + return Err(invalid("index statusCache 版本引用越界")); + } + } + if let Some(pending) = value.status_cache.pending_version { + let status = &value.status_cache.versions[pending as usize - 1].status; + if status != "ready_for_approval" { + return Err(invalid( + "index pendingVersion 必须指向 ready_for_approval 版本", + )); + } + } + if let Some(approved) = value.status_cache.approved_version { + let status = &value.status_cache.versions[approved as usize - 1].status; + if status != "approved" { + return Err(invalid("index approvedVersion 必须指向 approved 版本")); + } + } + } + validate_timestamp(&value.rebuilt_at_utc, "index.rebuiltAtUtc")?; + Ok(()) +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanGddRef { + pub(crate) gdd_id: String, + pub(crate) version: u32, + pub(crate) fingerprint: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanDecisionRef { + pub(crate) version: u32, + pub(crate) response_id: String, + pub(crate) action: String, + pub(crate) receipt_fingerprint: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanAppliedAnswer { + pub(crate) delegation_id: String, + pub(crate) continuation_delegation_id: String, + pub(crate) request_id: String, + pub(crate) question_id: String, + pub(crate) response_id: String, + pub(crate) questions_sha256: String, + pub(crate) answers_sha256: String, + pub(crate) decision_id: String, + pub(crate) round: u32, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanSessionV1 { + pub(crate) schema_version: String, + pub(crate) project_id: String, + pub(crate) gdd_id: String, + pub(crate) session_revision: u32, + pub(crate) previous_fingerprint: Option, + pub(crate) session_fingerprint: String, + pub(crate) agent_id: String, + pub(crate) source: String, + pub(crate) run_profile: String, + pub(crate) run_profile_binding_fingerprint: String, + pub(crate) root_agent_id: String, + pub(crate) root_run_id: String, + pub(crate) latest_delegation_id: String, + pub(crate) session_id: String, + pub(crate) active_run_id: Option, + pub(crate) last_run_id: String, + pub(crate) phase: String, + pub(crate) accumulated_agent_millis: u64, + pub(crate) applied_steer_cursor: u64, + pub(crate) decisions_summary: Vec, + pub(crate) prototype_validation_items: Vec, + pub(crate) applied_answers: Vec, + pub(crate) latest_submitted_ref: Option, + pub(crate) last_decision_ref: Option, + pub(crate) updated_at_utc: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanDecisionSummary { + pub(crate) id: String, + pub(crate) topic: String, + pub(crate) state: String, + pub(crate) answer_source: String, + pub(crate) round: u32, + pub(crate) answer_summary: String, +} + +fn validate_plan_gdd_ref(value: &PlanGddRef, label: &str) -> Result<(), PlanningStorageError> { + validate_uuid_prefixed(&value.gdd_id, "gdd-", &format!("{label}.gddId"))?; + if !(1..=PLAN_MAX_VERSIONS).contains(&value.version) { + return Err(invalid(format!("{label}.version 越界"))); + } + if !is_typed_fingerprint(&value.fingerprint) { + return Err(invalid(format!("{label}.fingerprint 非法"))); + } + Ok(()) +} + +fn validate_plan_session_shape(value: &PlanSessionV1) -> Result<(), PlanningStorageError> { + if value.schema_version != PLAN_SESSION_SCHEMA_VERSION { + return Err(invalid("未知 plan session schemaVersion")); + } + validate_opaque_id(&value.project_id, "session.projectId", false)?; + validate_uuid_prefixed(&value.gdd_id, "gdd-", "session.gddId")?; + if value.session_revision == 0 { + return Err(invalid("sessionRevision 必须从 1 开始")); + } + if let Some(previous) = &value.previous_fingerprint { + if !is_typed_fingerprint(previous) { + return Err(invalid("previousFingerprint 非法")); + } + } + if !is_typed_fingerprint(&value.session_fingerprint) { + return Err(invalid("sessionFingerprint 非法")); + } + if value.agent_id != "project-planning" + || value.source != "agent-delegate" + || value.run_profile != "standard" + || value.root_agent_id != "project-supervisor" + { + return Err(invalid("session 的 plan identity 常量不匹配")); + } + if !is_bare_fingerprint(&value.run_profile_binding_fingerprint) { + return Err(invalid("session.runProfileBindingFingerprint 非法")); + } + validate_opaque_id(&value.root_run_id, "session.rootRunId", false)?; + validate_opaque_id( + &value.latest_delegation_id, + "session.latestDelegationId", + true, + )?; + validate_opaque_id(&value.session_id, "session.sessionId", false)?; + if let Some(active) = &value.active_run_id { + validate_opaque_id(active, "session.activeRunId", false)?; + } + validate_opaque_id(&value.last_run_id, "session.lastRunId", false)?; + if !matches!( + value.phase.as_str(), + "collecting" + | "awaiting_user_input" + | "awaiting_gdd_approval" + | "revision_requested" + | "approved" + | "rejected" + | "recovery_required" + ) { + return Err(invalid("未知 session phase")); + } + if value.decisions_summary.is_empty() || value.decisions_summary.len() > 32 { + return Err(invalid("session.decisionsSummary 必须有 1~32 项")); + } + validate_unique( + value.decisions_summary.iter().map(|item| item.id.as_str()), + "session.decisionsSummary.id", + )?; + for (index, decision) in value.decisions_summary.iter().enumerate() { + validate_text( + &decision.id, + &format!("session.decisionsSummary[{index}].id"), + 1, + 32, + )?; + validate_text( + &decision.topic, + &format!("session.decisionsSummary[{index}].topic"), + 1, + 80, + )?; + validate_decision_state(&decision.state)?; + validate_answer_source(&decision.answer_source)?; + if decision.round > 3 { + return Err(invalid("session decision round 不能超过 3")); + } + validate_text( + &decision.answer_summary, + &format!("session.decisionsSummary[{index}].answerSummary"), + 1, + 400, + )?; + } + if value.decisions_summary[0].id != "initial-request" { + return Err(invalid( + "session.decisionsSummary 首项必须是 initial-request", + )); + } + validate_decisions( + &value + .decisions_summary + .iter() + .map(|decision| PlanDecision { + id: decision.id.clone(), + topic: decision.topic.clone(), + state: decision.state.clone(), + answer_source: decision.answer_source.clone(), + round: decision.round, + answer_summary: decision.answer_summary.clone(), + basis: None, + }) + .collect::>(), + &value.prototype_validation_items, + )?; + if value.applied_answers.len() > 3 { + return Err(invalid("session.appliedAnswers 最多 3 项")); + } + let mut previous_round = 0; + let mut answer_keys = std::collections::BTreeSet::new(); + let mut answer_decisions = std::collections::BTreeSet::new(); + for (index, answer) in value.applied_answers.iter().enumerate() { + validate_opaque_id(&answer.delegation_id, "appliedAnswers.delegationId", false)?; + validate_opaque_id( + &answer.continuation_delegation_id, + "appliedAnswers.continuationDelegationId", + false, + )?; + validate_opaque_id(&answer.request_id, "appliedAnswers.requestId", false)?; + validate_opaque_id(&answer.question_id, "appliedAnswers.questionId", false)?; + validate_text(&answer.response_id, "appliedAnswers.responseId", 1, 160)?; + if !is_lower_hex(&answer.questions_sha256, 64) || !is_lower_hex(&answer.answers_sha256, 64) + { + return Err(invalid("appliedAnswers 的裸 sha256 非法")); + } + validate_opaque_id(&answer.decision_id, "appliedAnswers.decisionId", false)?; + if !(1..=3).contains(&answer.round) || (index > 0 && answer.round <= previous_round) { + return Err(invalid("appliedAnswers.round 必须在 1..=3 且递增")); + } + if !answer_keys.insert((&answer.request_id, &answer.response_id)) { + return Err(invalid( + "appliedAnswers 的 (requestId,responseId) 组合不能重复", + )); + } + if !answer_decisions.insert(answer.decision_id.as_str()) { + return Err(invalid("appliedAnswers.decisionId 不能重复")); + } + let Some(decision) = value + .decisions_summary + .iter() + .find(|decision| decision.id == answer.decision_id) + else { + return Err(invalid( + "appliedAnswers.decisionId 必须引用 decisionsSummary", + )); + }; + if decision.round != answer.round { + return Err(invalid( + "appliedAnswers.decisionId 必须引用同一 round 的 decisionsSummary", + )); + } + let expected_continuation = derive_plan_continuation_delegation_id( + &value.root_run_id, + &answer.delegation_id, + &answer.questions_sha256, + &answer.answers_sha256, + )?; + if answer.continuation_delegation_id != expected_continuation { + return Err(conflict( + "appliedAnswers.continuationDelegationId 与确定性委派派生值不一致", + )); + } + previous_round = answer.round; + } + if let Some(reference) = &value.latest_submitted_ref { + validate_plan_gdd_ref(reference, "latestSubmittedRef")?; + if reference.gdd_id != value.gdd_id { + return Err(invalid("latestSubmittedRef.gddId 与 session 不一致")); + } + } + if let Some(reference) = &value.last_decision_ref { + validate_plan_gdd_ref( + &PlanGddRef { + gdd_id: value.gdd_id.clone(), + version: reference.version, + fingerprint: reference.receipt_fingerprint.clone(), + }, + "lastDecisionRef", + )?; + if !matches!(reference.action.as_str(), "approve" | "revise" | "reject") { + return Err(invalid("lastDecisionRef.action 非法")); + } + validate_uuid_prefixed( + &reference.response_id, + "gdd-response-", + "lastDecisionRef.responseId", + )?; + if value + .latest_submitted_ref + .as_ref() + .is_some_and(|submitted| reference.version > submitted.version) + { + return Err(invalid( + "lastDecisionRef.version 不能晚于 latestSubmittedRef.version", + )); + } + } + match value.phase.as_str() { + "awaiting_gdd_approval" if value.latest_submitted_ref.is_none() => { + return Err(invalid("awaiting_gdd_approval 必须有 latestSubmittedRef")); + } + "awaiting_gdd_approval" if value.last_decision_ref.is_some() => { + return Err(invalid( + "awaiting_gdd_approval 不得已经存在 lastDecisionRef", + )); + } + "revision_requested" | "approved" | "rejected" if value.last_decision_ref.is_none() => { + return Err(invalid("终态 session 必须有 lastDecisionRef")); + } + "approved" + if value + .last_decision_ref + .as_ref() + .is_some_and(|reference| reference.action != "approve") => + { + return Err(invalid( + "approved session 的 lastDecisionRef.action 必须是 approve", + )); + } + "revision_requested" + if value + .last_decision_ref + .as_ref() + .is_some_and(|reference| reference.action != "revise") => + { + return Err(invalid( + "revision_requested session 的 lastDecisionRef.action 必须是 revise", + )); + } + "rejected" + if value + .last_decision_ref + .as_ref() + .is_some_and(|reference| reference.action != "reject") => + { + return Err(invalid( + "rejected session 的 lastDecisionRef.action 必须是 reject", + )); + } + _ => {} + } + if value.phase == "awaiting_user_input" && value.latest_delegation_id.is_empty() { + return Err(invalid( + "awaiting_user_input 必须有 latestDelegationId 以定位问题 delivery", + )); + } + if let Some(last_answer) = value.applied_answers.last() { + if value.latest_delegation_id != last_answer.continuation_delegation_id { + return Err(conflict( + "latestDelegationId 必须等于最后一个 appliedAnswers 的 continuationDelegationId", + )); + } + } + if matches!( + value.phase.as_str(), + "awaiting_gdd_approval" + | "revision_requested" + | "approved" + | "rejected" + | "recovery_required" + ) && value.active_run_id.is_some() + { + return Err(invalid( + "session 进入审批/终态/recovery_required 后不得保留 activeRunId", + )); + } + if value.phase == "awaiting_user_input" && value.active_run_id.is_some() { + return Err(invalid( + "awaiting_user_input 的策划子 run 已终态,不得保留 activeRunId", + )); + } + if value.session_revision == 1 && value.previous_fingerprint.is_some() { + return Err(invalid( + "revision=1 的 session previousFingerprint 必须为 null", + )); + } + if value.session_revision > 1 && value.previous_fingerprint.is_none() { + return Err(invalid( + "revision>1 的 session previousFingerprint 不能为空", + )); + } + validate_timestamp(&value.updated_at_utc, "session.updatedAtUtc")?; + Ok(()) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PlanSessionFingerprintValue<'a> { + schema_version: &'a str, + project_id: &'a str, + gdd_id: &'a str, + session_revision: u32, + previous_fingerprint: &'a Option, + agent_id: &'a str, + source: &'a str, + run_profile: &'a str, + run_profile_binding_fingerprint: &'a str, + root_agent_id: &'a str, + root_run_id: &'a str, + latest_delegation_id: &'a str, + session_id: &'a str, + active_run_id: &'a Option, + last_run_id: &'a str, + phase: &'a str, + accumulated_agent_millis: u64, + applied_steer_cursor: u64, + decisions_summary: &'a Vec, + prototype_validation_items: &'a Vec, + applied_answers: &'a Vec, + latest_submitted_ref: &'a Option, + last_decision_ref: &'a Option, + updated_at_utc: &'a str, +} + +impl<'a> From<&'a PlanSessionV1> for PlanSessionFingerprintValue<'a> { + fn from(value: &'a PlanSessionV1) -> Self { + Self { + schema_version: &value.schema_version, + project_id: &value.project_id, + gdd_id: &value.gdd_id, + session_revision: value.session_revision, + previous_fingerprint: &value.previous_fingerprint, + agent_id: &value.agent_id, + source: &value.source, + run_profile: &value.run_profile, + run_profile_binding_fingerprint: &value.run_profile_binding_fingerprint, + root_agent_id: &value.root_agent_id, + root_run_id: &value.root_run_id, + latest_delegation_id: &value.latest_delegation_id, + session_id: &value.session_id, + active_run_id: &value.active_run_id, + last_run_id: &value.last_run_id, + phase: &value.phase, + accumulated_agent_millis: value.accumulated_agent_millis, + applied_steer_cursor: value.applied_steer_cursor, + decisions_summary: &value.decisions_summary, + prototype_validation_items: &value.prototype_validation_items, + applied_answers: &value.applied_answers, + latest_submitted_ref: &value.latest_submitted_ref, + last_decision_ref: &value.last_decision_ref, + updated_at_utc: &value.updated_at_utc, + } + } +} + +pub(crate) fn plan_session_fingerprint( + value: &PlanSessionV1, +) -> Result { + validate_plan_session_shape(value)?; + typed_serde_fingerprint( + PLAN_SESSION_FINGERPRINT_DOMAIN, + &PlanSessionFingerprintValue::from(value), + ) +} + +pub(crate) fn validate_plan_session(value: &PlanSessionV1) -> Result<(), PlanningStorageError> { + validate_plan_session_shape(value)?; + let expected = plan_session_fingerprint(value)?; + if value.session_fingerprint != expected { + return Err(PlanningStorageError::new( + "PLAN_FINGERPRINT_MISMATCH", + "sessionFingerprint 与 canonical payload 不一致", + )); + } + Ok(()) +} + +/// Validate the session projection against the clarification round derived +/// from the static-delegate lineage. The lineage counter intentionally stays +/// outside this storage module; callers must supply the independently read +/// value rather than letting a durable session become a second source of +/// truth. +pub(crate) fn validate_plan_session_for_clarification_round( + value: &PlanSessionV1, + clarification_round: u32, +) -> Result<(), PlanningStorageError> { + validate_plan_session(value)?; + if clarification_round > 3 || value.applied_answers.len() as u32 != clarification_round { + return Err(PlanningStorageError::new( + "PLAN_NEEDS_RECONCILIATION", + "session appliedAnswers 数量与委派链 clarification_round 不一致", + )); + } + Ok(()) +} + +pub(crate) fn validate_plan_session_successor( + previous: &PlanSessionV1, + next: &PlanSessionV1, +) -> Result<(), PlanningStorageError> { + validate_plan_session(previous)?; + validate_plan_session(next)?; + if previous.phase == "recovery_required" { + return Err(conflict("recovery_required session 不能继续推进 successor")); + } + if next.project_id != previous.project_id + || next.gdd_id != previous.gdd_id + || next.session_id != previous.session_id + || next.agent_id != previous.agent_id + || next.source != previous.source + || next.run_profile != previous.run_profile + || next.run_profile_binding_fingerprint != previous.run_profile_binding_fingerprint + || next.root_agent_id != previous.root_agent_id + || next.root_run_id != previous.root_run_id + { + return Err(conflict( + "session successor 跨越了 project/session identity", + )); + } + let expected_revision = previous + .session_revision + .checked_add(1) + .ok_or_else(|| conflict("sessionRevision 溢出,不能创建 successor"))?; + if next.session_revision != expected_revision + || next.previous_fingerprint.as_deref() != Some(previous.session_fingerprint.as_str()) + { + return Err(conflict( + "session successor 必须是 revision+1 且 previousFingerprint 精确回链", + )); + } + if next.accumulated_agent_millis < previous.accumulated_agent_millis + || next.applied_steer_cursor < previous.applied_steer_cursor + { + return Err(conflict( + "session successor 的累计运行时间和 steer cursor 只能单调增加", + )); + } + Ok(()) +} + +/// Derive the deterministic continuation identity used by the static delegate +/// clarification contract. Keeping the derivation here lets the durable +/// session projection reject a forged continuation id without importing the +/// delegation writer or trusting a caller-provided string. +pub(crate) fn derive_plan_continuation_delegation_id( + parent_run_id: &str, + repair_of_delegation_id: &str, + questions_sha256: &str, + answers_sha256: &str, +) -> Result { + validate_opaque_id(parent_run_id, "continuation.parentRunId", false)?; + validate_opaque_id( + repair_of_delegation_id, + "continuation.repairOfDelegationId", + false, + )?; + if !is_lower_hex(questions_sha256, 64) || !is_lower_hex(answers_sha256, 64) { + return Err(invalid( + "continuation questionsSha256/answersSha256 必须是裸 64 位小写 digest", + )); + } + Ok(format!( + "clarification-continuation-{:x}", + Sha256::digest(format!( + "{parent_run_id}\n{repair_of_delegation_id}\n{questions_sha256}\n{answers_sha256}" + )) + )) +} + +fn reject_noncanonical_storage_bytes( + bytes: &[u8], + label: &str, +) -> Result<(), PlanningStorageError> { + if bytes.starts_with(&[0xef, 0xbb, 0xbf]) { + return Err(PlanningStorageError::new( + "PLAN_NON_CANONICAL_BYTES", + format!("{label} 不得包含 UTF-8 BOM"), + )); + } + if bytes + .last() + .is_some_and(|byte| *byte == b'\n' || *byte == b'\r' || *byte == b' ' || *byte == b'\t') + { + return Err(PlanningStorageError::new( + "PLAN_NON_CANONICAL_BYTES", + format!("{label} 不得以换行或尾部空白结束"), + )); + } + Ok(()) +} + +fn parse_strict_canonical( + bytes: &[u8], + label: &str, + max_bytes: usize, +) -> Result +where + T: DeserializeOwned + Serialize, +{ + if bytes.len() > max_bytes { + return Err(PlanningStorageError::new( + "PLAN_SIZE_LIMIT", + format!("{label} 超过 {max_bytes} 字节上限"), + )); + } + reject_noncanonical_storage_bytes(bytes, label)?; + let mut duplicate_checker = serde_json::Deserializer::from_slice(bytes); + duplicate_checker + .deserialize_any(DuplicateKeyVisitor) + .map_err(|error| { + PlanningStorageError::new( + "PLAN_INVALID_JSON", + format!("{label} JSON 重复键或结构无效:{error}"), + ) + })?; + duplicate_checker.end().map_err(|error| { + PlanningStorageError::new( + "PLAN_INVALID_JSON", + format!("{label} JSON 尾部存在额外内容:{error}"), + ) + })?; + let value = serde_json::from_slice::(bytes).map_err(|error| { + PlanningStorageError::new("PLAN_INVALID_JSON", format!("{label} JSON 无效:{error}")) + })?; + let canonical = canonical_bytes(&value)?; + if canonical != bytes { + return Err(PlanningStorageError::new( + "PLAN_NON_CANONICAL_BYTES", + format!("{label} 必须是字段声明顺序的 compact canonical JSON"), + )); + } + Ok(value) +} + +pub(crate) fn canonical_plan_gdd_bytes(value: &PlanGddV1) -> Result, PlanningStorageError> { + validate_plan_gdd(value)?; + let bytes = canonical_bytes(value)?; + if bytes.len() > PLAN_GDD_MAX_BYTES { + return Err(PlanningStorageError::new( + "PLAN_SIZE_LIMIT", + "GDD 超过 64 KiB 字节上限", + )); + } + Ok(bytes) +} + +pub(crate) fn parse_plan_gdd_bytes(bytes: &[u8]) -> Result { + let value = parse_strict_canonical::(bytes, "GDD", PLAN_GDD_MAX_BYTES)?; + validate_plan_gdd(&value)?; + Ok(value) +} + +pub(crate) fn canonical_plan_session_bytes( + value: &PlanSessionV1, +) -> Result, PlanningStorageError> { + validate_plan_session(value)?; + let bytes = canonical_bytes(value)?; + if bytes.len() > PLAN_SESSION_MAX_BYTES { + return Err(PlanningStorageError::new( + "PLAN_SIZE_LIMIT", + "session 超过 64 KiB 字节上限", + )); + } + Ok(bytes) +} + +pub(crate) fn parse_plan_session_bytes( + bytes: &[u8], +) -> Result { + let value = + parse_strict_canonical::(bytes, "plan session", PLAN_SESSION_MAX_BYTES)?; + validate_plan_session(&value)?; + Ok(value) +} + +pub(crate) fn canonical_plan_index_bytes( + value: &PlanGddIndexV1, +) -> Result, PlanningStorageError> { + validate_plan_gdd_index(value)?; + let bytes = canonical_bytes(value)?; + if bytes.len() > PLAN_INDEX_MAX_BYTES { + return Err(PlanningStorageError::new( + "PLAN_SIZE_LIMIT", + "GDD index 超过 256 KiB 字节上限", + )); + } + Ok(bytes) +} + +pub(crate) fn parse_plan_index_bytes(bytes: &[u8]) -> Result { + let value = parse_strict_canonical::(bytes, "GDD index", PLAN_INDEX_MAX_BYTES)?; + validate_plan_gdd_index(&value)?; + Ok(value) +} + +pub(crate) fn validate_plan_submit_gdd_input( + value: &PlanSubmitGddInputV1, +) -> Result<(), PlanningStorageError> { + if value.schema_version != PLAN_SUBMIT_GDD_INPUT_SCHEMA_VERSION { + return Err(invalid("未知 plan.submit_gdd input schemaVersion")); + } + // Provider input deliberately omits Runtime-injected platform facts and + // all durable identity/fingerprint fields. Reuse the same business + // limits by projecting it to the durable shape with null bases. + let game = PlanGddGame { + title: value.game.title.clone(), + genre: value.game.genre.clone(), + art_style: value.game.art_style.clone(), + one_liner: value.game.one_liner.clone(), + pillars: value + .game + .pillars + .iter() + .map(|pillar| PlanPillar { + name: pillar.name.clone(), + player_feel: pillar.player_feel.clone(), + mechanism: pillar.mechanism.clone(), + decision_state: pillar.decision_state.clone(), + basis: None, + }) + .collect(), + core_loop: value.game.core_loop.clone(), + target_users: value.game.target_users.clone(), + platform_facts: PlanPlatformFacts { + runtime: "self-contained-web".to_string(), + viewports: vec!["desktop".to_string(), "mobile".to_string()], + inputs: vec!["keyboard".to_string(), "touch".to_string()], + preview: "local-http".to_string(), + }, + mvp_systems: value + .game + .mvp_systems + .iter() + .map(|system| PlanMvpSystem { + system: system.system.clone(), + minimal_function: system.minimal_function.clone(), + why_required: system.why_required.clone(), + verify_method: system.verify_method.clone(), + decision_state: system.decision_state.clone(), + basis: None, + }) + .collect(), + out_of_scope: value.game.out_of_scope.clone(), + creator_tips: value.game.creator_tips.clone(), + }; + validate_plan_game(&game)?; + let decisions = value + .decisions + .iter() + .map(|decision| PlanDecision { + id: decision.id.clone(), + topic: decision.topic.clone(), + state: decision.state.clone(), + answer_source: decision.answer_source.clone(), + round: decision.round, + answer_summary: decision.answer_summary.clone(), + basis: None, + }) + .collect::>(); + validate_decisions(&decisions, &value.prototype_validation_items) +} + +pub(crate) fn canonical_plan_submit_gdd_input_bytes( + value: &PlanSubmitGddInputV1, +) -> Result, PlanningStorageError> { + validate_plan_submit_gdd_input(value)?; + let bytes = canonical_bytes(value)?; + if bytes.len() > PLAN_GDD_MAX_BYTES { + return Err(PlanningStorageError::new( + "PLAN_SIZE_LIMIT", + "plan.submit_gdd input 超过 64 KiB 字节上限", + )); + } + Ok(bytes) +} + +pub(crate) fn parse_plan_submit_gdd_input_bytes( + bytes: &[u8], +) -> Result { + let value = parse_strict_canonical::( + bytes, + "plan.submit_gdd input", + PLAN_GDD_MAX_BYTES, + )?; + validate_plan_submit_gdd_input(&value)?; + Ok(value) +} + +pub(crate) fn validate_plan_gdd_chain(values: &[PlanGddV1]) -> Result<(), PlanningStorageError> { + if values.len() > PLAN_MAX_VERSIONS as usize { + return Err(PlanningStorageError::new( + "PLAN_VERSION_LIMIT_REACHED", + "单一 GDD lineage 不能超过 128 个版本", + )); + } + let Some(first) = values.first() else { + return Ok(()); + }; + if first.version != 1 { + return Err(conflict("GDD 版本链必须从 version=1 开始")); + } + validate_plan_gdd(first)?; + let mut submission_ids = std::collections::BTreeSet::new(); + let mut approval_request_ids = std::collections::BTreeSet::new(); + let mut action_fingerprints = std::collections::BTreeSet::new(); + submission_ids.insert(first.submission_id.as_str()); + approval_request_ids.insert(first.approval_request_id.as_str()); + action_fingerprints.insert(first.action_fingerprint.as_str()); + for (index, value) in values.iter().enumerate().skip(1) { + validate_plan_gdd(value)?; + let expected_version = index as u32 + 1; + if value.version != expected_version + || value.gdd_id != first.gdd_id + || value.project_id != first.project_id + { + return Err(conflict("GDD 版本链必须连续且绑定同一 projectId/gddId")); + } + if !submission_ids.insert(value.submission_id.as_str()) + || !approval_request_ids.insert(value.approval_request_id.as_str()) + || !action_fingerprints.insert(value.action_fingerprint.as_str()) + { + return Err(conflict( + "GDD 版本链的 submissionId/approvalRequestId/actionFingerprint 必须唯一", + )); + } + } + Ok(()) +} + +pub(crate) fn validate_next_plan_gdd_version( + existing: &[PlanGddV1], + candidate: &PlanGddV1, +) -> Result<(), PlanningStorageError> { + validate_plan_gdd(candidate)?; + validate_plan_gdd_chain(existing)?; + let expected_version = existing.len() as u32 + 1; + if expected_version > PLAN_MAX_VERSIONS { + return Err(PlanningStorageError::new( + "PLAN_VERSION_LIMIT_REACHED", + "不能继续创建第 129 个 GDD 版本", + )); + } + if candidate.version != expected_version + || existing.first().is_some_and(|first| { + first.project_id != candidate.project_id || first.gdd_id != candidate.gdd_id + }) + { + return Err(conflict("candidate GDD 不是版本链的唯一 next version")); + } + Ok(()) +} + +pub(crate) fn build_plan_gdd_index( + gdds: &[PlanGddV1], + rebuilt_at_utc: &str, +) -> Result { + validate_plan_gdd_chain(gdds)?; + validate_timestamp(rebuilt_at_utc, "rebuiltAtUtc")?; + let Some(first) = gdds.first() else { + return Err(invalid("没有 GDD 权威事实时不能构造 plan-gdd-index.v1")); + }; + let entries = gdds + .iter() + .map(plan_gdd_index_entry_from_gdd) + .collect::>(); + let latest_version = gdds.last().expect("non-empty GDD chain").version; + // M1B-1 does not yet own approval receipts. A later GDD candidate + // supersedes the older candidate in this pre-receipt projection, while + // only the latest candidate can be awaiting approval. M1C-1 must rebuild + // these statuses from the authoritative receipt facts once that schema is + // available; the index itself never becomes a source of truth. + let statuses = gdds + .iter() + .map(|gdd| PlanGddIndexVersionStatus { + version: gdd.version, + status: if gdd.version == latest_version { + "ready_for_approval".to_string() + } else { + "superseded".to_string() + }, + }) + .collect::>(); + Ok(PlanGddIndexV1 { + schema_version: PLAN_GDD_INDEX_SCHEMA_VERSION.to_string(), + project_id: first.project_id.clone(), + gdd_id: first.gdd_id.clone(), + entries, + status_cache: PlanGddIndexStatusCache { + latest_version: gdds.len() as u32, + pending_version: Some(latest_version), + approved_version: None, + versions: statuses, + }, + rebuilt_at_utc: rebuilt_at_utc.to_string(), + }) +} + +fn plan_gdd_index_entry_from_gdd(gdd: &PlanGddV1) -> PlanGddIndexEntry { + PlanGddIndexEntry { + version: gdd.version, + submission_id: gdd.submission_id.clone(), + approval_request_id: gdd.approval_request_id.clone(), + action_fingerprint: gdd.action_fingerprint.clone(), + fingerprint: gdd.fingerprint.clone(), + file: format!("gdd.v{}.json", gdd.version), + agent_id: gdd.agent_id.clone(), + source: gdd.source.clone(), + run_profile: gdd.run_profile.clone(), + run_profile_binding_fingerprint: gdd.run_profile_binding_fingerprint.clone(), + root_run_id: gdd.root_run_id.clone(), + delegation_id: gdd.delegation_id.clone(), + session_id: gdd.session_id.clone(), + source_session_revision: gdd.source_session_revision, + source_session_fingerprint: gdd.source_session_fingerprint.clone(), + created_by_run_id: gdd.created_by_run_id.clone(), + created_at_utc: gdd.created_at_utc.clone(), + submitted_at_utc: gdd.created_at_utc.clone(), + } +} + +pub(crate) fn validate_plan_gdd_index_against_gdds( + index: &PlanGddIndexV1, + gdds: &[PlanGddV1], +) -> Result<(), PlanningStorageError> { + validate_plan_gdd_chain(gdds)?; + validate_plan_gdd_index(index)?; + let Some(first) = gdds.first() else { + return Err(invalid("index 没有可对应的 GDD 权威事实")); + }; + if index.project_id != first.project_id || index.gdd_id != first.gdd_id { + return Err(conflict("index projectId/gddId 与 GDD 权威事实不一致")); + } + if index.entries.len() != gdds.len() + || index + .entries + .iter() + .zip(gdds) + .any(|(entry, gdd)| entry != &plan_gdd_index_entry_from_gdd(gdd)) + { + return Err(conflict("index entries 必须逐项等于对应 GDD 的权威字段")); + } + let expected_status_cache = build_plan_gdd_index(gdds, &index.rebuilt_at_utc)?.status_cache; + if index.status_cache != expected_status_cache { + return Err(conflict( + "index statusCache 必须由当前 M1B-1 GDD lineage 确定性重建", + )); + } + Ok(()) +} + +fn is_recoverable_index_projection_error(error: &PlanningStorageError) -> bool { + matches!( + error.code(), + "PLAN_INVALID_JSON" + | "PLAN_NON_CANONICAL_BYTES" + | "PLAN_INVALID_SCHEMA" + | "PLAN_FINGERPRINT_MISMATCH" + | "PLAN_IDENTITY_CONFLICT" + | "PLAN_SIZE_LIMIT" + ) +} + +/// Read the derived index and repair a missing, malformed, or stale primary +/// from the validated GDD lineage while holding the project lock. The index +/// is never used to invent authority: if no GDD fact exists, an absent index +/// is returned as `None`, while an index left behind without a GDD is a hard +/// reconciliation failure. +pub(crate) fn read_plan_gdd_index_with_recovery( + root: &Path, + rebuilt_at_utc: &str, +) -> Result, PlanningStorageError> { + let _lock = acquire_project_write_lock(root, "planning.index.read") + .map_err(|error| io_error("读取 planning index 时取得项目锁失败", error))?; + read_plan_gdd_index_with_recovery_locked(root, rebuilt_at_utc) +} + +pub(crate) fn read_plan_gdd_index_with_recovery_locked( + root: &Path, + rebuilt_at_utc: &str, +) -> Result, PlanningStorageError> { + validate_timestamp(rebuilt_at_utc, "rebuiltAtUtc")?; + let gdds = read_plan_gdd_chain_locked(root)?; + let target = resolve_local_project_path(root, PLAN_GDD_INDEX_PATH) + .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; + let target_exists = match fs::symlink_metadata(&target) { + Ok(_) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => return Err(io_error("探测 planning index 失败", error)), + }; + + if gdds.is_empty() { + if target_exists { + return Err(conflict("planning index 存在但没有对应的 GDD 权威事实")); + } + return Ok(None); + } + + let rebuild = || { + let rebuilt = build_plan_gdd_index(&gdds, rebuilt_at_utc)?; + write_plan_gdd_index_atomic_locked(root, &rebuilt)?; + Ok(Some(rebuilt)) + }; + + if !target_exists { + return rebuild(); + } + + let bytes = match read_regular_planning_file(&target, "planning GDD index") { + Ok(bytes) => bytes, + Err(error) if is_recoverable_index_projection_error(&error) => return rebuild(), + Err(error) => return Err(error), + }; + let parsed = match parse_plan_index_bytes(&bytes) { + Ok(value) => value, + Err(error) if is_recoverable_index_projection_error(&error) => return rebuild(), + Err(error) => return Err(error), + }; + match validate_plan_gdd_index_against_gdds(&parsed, &gdds) { + Ok(()) => Ok(Some(parsed)), + Err(error) if is_recoverable_index_projection_error(&error) => rebuild(), + Err(error) => Err(error), + } +} + +fn gdd_version_from_file_name(name: &str) -> Result, PlanningStorageError> { + if !name.starts_with("gdd.v") { + return Ok(None); + } + let Some(number) = name + .strip_prefix("gdd.v") + .and_then(|value| value.strip_suffix(".json")) + else { + return Err(PlanningStorageError::new( + "PLAN_INVALID_PATH", + format!("孤儿 GDD 文件名不符合 gdd.vN.json:{name}"), + )); + }; + if number.is_empty() + || number.starts_with('0') + || !number.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(PlanningStorageError::new( + "PLAN_INVALID_PATH", + format!("孤儿 GDD 文件名版本非法:{name}"), + )); + } + let version = number.parse::().map_err(|_| { + PlanningStorageError::new("PLAN_INVALID_PATH", format!("GDD 文件版本溢出:{name}")) + })?; + if !(1..=PLAN_MAX_VERSIONS).contains(&version) { + return Err(PlanningStorageError::new( + "PLAN_VERSION_LIMIT_REACHED", + format!("GDD 文件版本超出 1..=128:{name}"), + )); + } + Ok(Some(version)) +} + +/// Enumerate only exact `gdd.vN.json` facts and validate the complete +/// continuous lineage. Unrelated planning projections are ignored; a +/// malformed file that claims to be a GDD is rejected instead of guessed. +pub(crate) fn read_plan_gdd_chain(root: &Path) -> Result, PlanningStorageError> { + let _lock = acquire_project_write_lock(root, "planning.read-gdd-chain") + .map_err(|error| io_error("读取 planning GDD 链时取得项目锁失败", error))?; + read_plan_gdd_chain_locked(root) +} + +pub(crate) fn read_plan_gdd_chain_locked( + root: &Path, +) -> Result, PlanningStorageError> { + let planning_root = resolve_local_project_path(root, PLAN_STORAGE_ROOT) + .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; + let metadata = match fs::symlink_metadata(&planning_root) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(io_error("读取 planning 根目录失败", error)), + }; + if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() { + return Err(PlanningStorageError::new( + "PLAN_UNTRUSTED_PATH", + "planning 根路径必须是可信普通目录", + )); + } + let mut versions = Vec::<(u32, PlanGddV1)>::new(); + for entry in + fs::read_dir(&planning_root).map_err(|error| io_error("枚举 planning 根目录失败", error))? + { + let entry = entry.map_err(|error| io_error("读取 planning 目录项失败", error))?; + let file_name = entry.file_name(); + let name = file_name.to_str().ok_or_else(|| { + PlanningStorageError::new( + "PLAN_INVALID_PATH", + "planning 目录包含非 UTF-8 文件名,拒绝静默忽略", + ) + })?; + let Some(version) = gdd_version_from_file_name(name)? else { + continue; + }; + let path = entry.path(); + let bytes = read_regular_planning_file(&path, &format!("GDD v{version}"))?; + let value = parse_plan_gdd_bytes(&bytes)?; + if value.version != version { + return Err(conflict(format!( + "GDD 文件名版本 v{version} 与 payload version={} 不一致", + value.version + ))); + } + versions.push((version, value)); + } + versions.sort_by_key(|(version, _)| *version); + let values = versions + .into_iter() + .map(|(_, value)| value) + .collect::>(); + validate_plan_gdd_chain(&values)?; + Ok(values) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum PlanningCreateOutcome { + Created, + Replayed, +} + +fn validate_planning_relative_path(relative_path: &str) -> Result { + let normalized = normalize_relative_path(relative_path) + .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; + if !is_agent_planning_storage_path(&normalized) { + return Err(PlanningStorageError::new( + "PLAN_INVALID_PATH", + "规划存储路径必须位于 .agent/planning/**", + )); + } + if normalized != normalized.to_ascii_lowercase() { + return Err(PlanningStorageError::new( + "PLAN_INVALID_PATH", + "planning 路径必须使用固定小写文件名", + )); + } + let allowed = normalized == PLAN_GDD_INDEX_PATH + || normalized == PLAN_SESSION_PATH + || normalized == PLAN_SESSION_PREVIOUS_PATH + || normalized == ".agent/planning/pending.json" + || normalized.starts_with(".agent/planning/gdd.v") + || normalized.starts_with(".agent/planning/approvals/v"); + if !allowed + || (normalized.starts_with(".agent/planning/gdd.v") + && !is_version_file_name(&normalized, ".agent/planning/gdd.v", false)) + || (normalized.starts_with(".agent/planning/approvals/v") + && !is_version_file_name(&normalized, ".agent/planning/approvals/v", true)) + { + return Err(PlanningStorageError::new( + "PLAN_INVALID_PATH", + format!("不允许的 planning 文件名:{normalized}"), + )); + } + Ok(normalized) +} + +fn validate_planning_payload_bytes( + relative_path: &str, + bytes: &[u8], + label: &str, +) -> Result<(), PlanningStorageError> { + if relative_path == PLAN_GDD_INDEX_PATH { + parse_plan_index_bytes(bytes)?; + return Ok(()); + } + if relative_path == PLAN_SESSION_PATH || relative_path == PLAN_SESSION_PREVIOUS_PATH { + parse_plan_session_bytes(bytes)?; + return Ok(()); + } + if relative_path.starts_with(".agent/planning/gdd.v") { + let value = parse_plan_gdd_bytes(bytes)?; + let expected = relative_path + .strip_prefix(".agent/planning/") + .ok_or_else(|| invalid("GDD 路径前缀非法"))?; + let Some(version) = gdd_version_from_file_name(expected)? else { + return Err(invalid("GDD 路径文件名非法")); + }; + if value.version != version { + return Err(conflict(format!( + "{label} 文件名 version={version} 与 payload version={} 不一致", + value.version + ))); + } + return Ok(()); + } + // Approval/pending schemas are deliberately owned by M1B-2. Do not let + // the generic writer smuggle arbitrary JSON into their reserved paths. + Err(PlanningStorageError::new( + "PLAN_UNSUPPORTED_SCHEMA", + format!("{relative_path} 的 durable schema 尚未由 M1B-1 实现"), + )) +} + +fn validate_planning_payload_at_root( + root: &Path, + relative_path: &str, + bytes: &[u8], + label: &str, +) -> Result<(), PlanningStorageError> { + validate_planning_payload_bytes(relative_path, bytes, label)?; + if relative_path == PLAN_GDD_INDEX_PATH { + let index = parse_plan_index_bytes(bytes)?; + let gdds = read_plan_gdd_chain_locked(root)?; + validate_plan_gdd_index_against_gdds(&index, &gdds)?; + } + Ok(()) +} + +fn is_version_file_name(path: &str, prefix: &str, approval: bool) -> bool { + let Some(rest) = path.strip_prefix(prefix) else { + return false; + }; + let expected_suffix = ".json"; + let Some(number) = rest.strip_suffix(expected_suffix) else { + return false; + }; + if number.is_empty() || !number.bytes().all(|byte| byte.is_ascii_digit()) { + return false; + } + if number.starts_with('0') { + return false; + } + if number + .parse::() + .ok() + .is_none_or(|value| !(1..=PLAN_MAX_VERSIONS).contains(&value)) + { + return false; + } + if approval { + path.starts_with(".agent/planning/approvals/v") + } else { + path.starts_with(".agent/planning/gdd.v") + } +} + +fn planning_metadata_is_link_or_reparse(metadata: &fs::Metadata) -> bool { + if metadata.file_type().is_symlink() { + return true; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + return metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0; + } + #[cfg(not(windows))] + { + false + } +} + +fn ensure_planning_parent(path: &Path) -> Result<&Path, PlanningStorageError> { + let parent = path + .parent() + .ok_or_else(|| PlanningStorageError::new("PLAN_INVALID_PATH", "规划文件缺少父目录"))?; + // Create missing components one at a time. `create_dir_all` can follow a + // directory symlink inserted between its internal component checks; the + // explicit loop lets us reject every component immediately after creation. + let mut missing = Vec::::new(); + let mut cursor = parent.to_path_buf(); + loop { + match fs::symlink_metadata(&cursor) { + Ok(metadata) => { + if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() { + return Err(PlanningStorageError::new( + "PLAN_UNTRUSTED_PATH", + format!("planning 路径父级不是可信目录:{}", cursor.display()), + )); + } + break; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let component = cursor.file_name().ok_or_else(|| { + PlanningStorageError::new("PLAN_INVALID_PATH", "规划父目录组件无效") + })?; + missing.push(component.to_os_string()); + if !cursor.pop() { + return Err(PlanningStorageError::new( + "PLAN_INVALID_PATH", + "规划父目录无法回溯到已存在项目根", + )); + } + } + Err(error) => return Err(io_error("读取 planning 父目录失败", error)), + } + } + while let Some(component) = missing.pop() { + cursor.push(component); + match fs::create_dir(&cursor) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(io_error("创建 planning 父目录失败", error)), + } + let metadata = fs::symlink_metadata(&cursor) + .map_err(|error| io_error("复核 planning 父目录失败", error))?; + if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() { + return Err(PlanningStorageError::new( + "PLAN_UNTRUSTED_PATH", + format!("planning 新建父级不是可信目录:{}", cursor.display()), + )); + } + } + Ok(parent) +} + +/// Open the directory that owns a planning target without following a final +/// symlink/reparse point. Publishing relative to this held directory keeps +/// the no-replace operation anchored to the directory we validated while the +/// project lock is held. +#[cfg(unix)] +fn open_planning_parent_directory(parent: &Path) -> Result { + use std::os::unix::fs::OpenOptionsExt; + OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(parent) + .map_err(|error| io_error("打开 planning 父目录失败", error)) +} + +#[cfg(windows)] +fn open_planning_parent_directory(parent: &Path) -> Result { + use std::os::windows::fs::OpenOptionsExt; + let directory = OpenOptions::new() + .read(true) + // FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS; the + // latter is required for opening a directory handle on Windows. + .custom_flags(0x0020_0000 | 0x0200_0000) + .open(parent) + .map_err(|error| io_error("打开 planning 父目录失败", error))?; + let metadata = directory + .metadata() + .map_err(|error| io_error("读取 planning 父目录句柄失败", error))?; + if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() { + return Err(PlanningStorageError::new( + "PLAN_UNTRUSTED_PATH", + format!("planning 父目录句柄不是可信普通目录:{}", parent.display()), + )); + } + Ok(directory) +} + +#[cfg(not(any(unix, windows)))] +fn open_planning_parent_directory(parent: &Path) -> Result { + File::open(parent).map_err(|error| io_error("打开 planning 父目录失败", error)) +} + +fn verify_regular_planning_file( + path: &Path, + label: &str, +) -> Result { + let metadata = fs::symlink_metadata(path) + .map_err(|error| io_error(&format!("读取 {label} 元数据失败"), error))?; + if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_file() { + return Err(PlanningStorageError::new( + "PLAN_UNTRUSTED_PATH", + format!("{label} 必须是可信普通文件:{}", path.display()), + )); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if metadata.nlink() != 1 { + return Err(PlanningStorageError::new( + "PLAN_UNTRUSTED_PATH", + format!("{label} 不能是硬链接文件:{}", path.display()), + )); + } + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + let file = OpenOptions::new() + .read(true) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(path) + .map_err(|error| io_error(&format!("打开 {label} 句柄失败"), error))?; + crate::runner::validate_windows_regular_file_handle(&file, label) + .map_err(|error| PlanningStorageError::new("PLAN_UNTRUSTED_PATH", error))?; + } + Ok(metadata) +} + +fn read_regular_planning_file(path: &Path, label: &str) -> Result, PlanningStorageError> { + let metadata = verify_regular_planning_file(path, label)?; + if metadata.len() > PLAN_INDEX_MAX_BYTES as u64 { + return Err(PlanningStorageError::new( + "PLAN_SIZE_LIMIT", + format!("{label} 超过 {} 字节读取上限", PLAN_INDEX_MAX_BYTES), + )); + } + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + // The final open must inspect the directory entry itself. Without + // OPEN_REPARSE_POINT a junction/symlink can be followed before the + // handle validator sees the target's (apparently regular) attributes. + options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut file = options + .open(path) + .map_err(|error| io_error(&format!("打开 {label} 失败"), error))?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let opened_metadata = file + .metadata() + .map_err(|error| io_error(&format!("复核 {label} 句柄元数据失败"), error))?; + if opened_metadata.dev() != metadata.dev() + || opened_metadata.ino() != metadata.ino() + || opened_metadata.nlink() != 1 + { + return Err(PlanningStorageError::new( + "PLAN_RECONCILIATION_REQUIRED", + format!("打开 {label} 时文件身份发生漂移"), + )); + } + } + #[cfg(windows)] + crate::runner::validate_windows_regular_file_handle(&file, label) + .map_err(|error| PlanningStorageError::new("PLAN_UNTRUSTED_PATH", error))?; + let mut bytes = Vec::with_capacity(metadata.len().min(PLAN_INDEX_MAX_BYTES as u64) as usize); + (&mut file) + .take((PLAN_INDEX_MAX_BYTES as u64).saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|error| io_error(&format!("读取 {label} 失败"), error))?; + if bytes.len() > PLAN_INDEX_MAX_BYTES { + return Err(PlanningStorageError::new( + "PLAN_SIZE_LIMIT", + format!("{label} 超过 {} 字节读取上限", PLAN_INDEX_MAX_BYTES), + )); + } + let final_metadata = file + .metadata() + .map_err(|error| io_error(&format!("复核 {label} 元数据失败"), error))?; + if final_metadata.len() != bytes.len() as u64 || final_metadata.len() != metadata.len() { + return Err(PlanningStorageError::new( + "PLAN_RECONCILIATION_REQUIRED", + format!("读取 {label} 时文件发生漂移"), + )); + } + Ok(bytes) +} + +fn sync_planning_parent(parent: &Path) -> Result<(), PlanningStorageError> { + #[cfg(unix)] + File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|error| io_error("同步 planning 父目录失败", error))?; + #[cfg(windows)] + { + let directory = open_planning_parent_directory(parent)?; + if let Err(error) = directory.sync_all() { + // MoveFileExW(MOVEFILE_WRITE_THROUGH) already flushes the file + // publication on Windows. Some Windows filesystems reject + // FlushFileBuffers on a directory handle with ACCESS_DENIED or + // INVALID_FUNCTION; keep the stronger native flush as the + // fallback rather than making every durable write unusable there. + if !matches!(error.raw_os_error(), Some(1 | 5 | 6)) { + return Err(io_error("同步 planning 父目录失败", error)); + } + } + } + #[cfg(not(any(unix, windows)))] + let _ = parent; + Ok(()) +} + +fn temp_planning_path(parent: &Path, target: &Path) -> PathBuf { + let nonce = PLANNING_TEMP_NONCE.fetch_add(1, Ordering::Relaxed); + let random = Uuid::new_v4(); + let file_name = target + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("planning.json"); + parent.join(format!( + ".{file_name}.tmp-{}-{nonce}-{random}", + std::process::id() + )) +} + +fn write_sync_new_file(path: &Path, bytes: &[u8], label: &str) -> Result<(), PlanningStorageError> { + if bytes.len() > PLAN_INDEX_MAX_BYTES { + return Err(PlanningStorageError::new( + "PLAN_SIZE_LIMIT", + format!("{label} 临时内容超过 {} 字节上限", PLAN_INDEX_MAX_BYTES), + )); + } + let result = (|| { + let mut options = OpenOptions::new(); + options.read(true).write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); + } + let mut file = options + .open(path) + .map_err(|error| io_error(&format!("创建 {label} 临时文件失败"), error))?; + #[cfg(windows)] + crate::runner::validate_windows_regular_file_handle(&file, label) + .map_err(|error| PlanningStorageError::new("PLAN_UNTRUSTED_PATH", error))?; + file.write_all(bytes) + .and_then(|_| file.sync_all()) + .map_err(|error| io_error(&format!("写入 {label} 临时文件失败"), error))?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let metadata = file + .metadata() + .map_err(|error| io_error(&format!("复核 {label} 临时文件句柄失败"), error))?; + if !metadata.is_file() || metadata.nlink() != 1 { + return Err(PlanningStorageError::new( + "PLAN_UNTRUSTED_PATH", + format!("{label} 临时文件句柄不是唯一普通文件"), + )); + } + } + file.seek(SeekFrom::Start(0)) + .map_err(|error| io_error(&format!("定位 {label} 临时文件回读位置失败"), error))?; + let mut check = Vec::new(); + file.read_to_end(&mut check) + .map_err(|error| io_error(&format!("回读 {label} 临时文件失败"), error))?; + if check != bytes { + return Err(PlanningStorageError::new( + "PLAN_RECONCILIATION_REQUIRED", + format!("{label} 临时文件回读不一致"), + )); + } + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(path); + } + result +} + +#[cfg(unix)] +fn planning_component(path: &Path, label: &str) -> Result { + use std::ffi::CString; + let component = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| { + PlanningStorageError::new("PLAN_INVALID_PATH", format!("{label} 文件名无效")) + })?; + CString::new(component.as_bytes()).map_err(|_| { + PlanningStorageError::new("PLAN_INVALID_PATH", format!("{label} 文件名包含 NUL")) + }) +} + +/// Install a prepared file under a target name without replacing an existing +/// directory entry. Native no-replace rename is preferred; a hard-link +/// fallback is used only when the platform explicitly reports that the native +/// primitive is unavailable. +#[allow(unreachable_code)] +#[allow(unused_variables)] +fn publish_planning_noreplace( + parent: &Path, + temporary: &Path, + target: &Path, + label: &str, +) -> Result<(), std::io::Error> { + #[cfg(target_os = "linux")] + { + use std::os::unix::io::AsRawFd; + let directory = open_planning_parent_directory(parent) + .map_err(|error| std::io::Error::other(error.to_string()))?; + let source = planning_component(temporary, label) + .map_err(|error| std::io::Error::other(error.to_string()))?; + let destination = planning_component(target, label) + .map_err(|error| std::io::Error::other(error.to_string()))?; + // SAFETY: both names are validated single components relative to the + // held, no-follow directory descriptor. + let result = unsafe { + libc::renameat2( + directory.as_raw_fd(), + source.as_ptr(), + directory.as_raw_fd(), + destination.as_ptr(), + libc::RENAME_NOREPLACE, + ) + }; + if result == 0 { + return Ok(()); + } + let error = std::io::Error::last_os_error(); + if !matches!( + error.raw_os_error(), + Some(libc::ENOSYS | libc::EINVAL | libc::ENOTSUP | libc::EOPNOTSUPP) + ) { + return Err(error); + } + } + #[cfg(target_vendor = "apple")] + { + use std::os::unix::io::AsRawFd; + let directory = open_planning_parent_directory(parent) + .map_err(|error| std::io::Error::other(error.to_string()))?; + let source = planning_component(temporary, label) + .map_err(|error| std::io::Error::other(error.to_string()))?; + let destination = planning_component(target, label) + .map_err(|error| std::io::Error::other(error.to_string()))?; + // SAFETY: both names are validated single components relative to the + // held, no-follow directory descriptor. + let result = unsafe { + libc::renameatx_np( + directory.as_raw_fd(), + source.as_ptr(), + directory.as_raw_fd(), + destination.as_ptr(), + libc::RENAME_EXCL, + ) + }; + if result == 0 { + return Ok(()); + } + let error = std::io::Error::last_os_error(); + if !matches!( + error.raw_os_error(), + Some(libc::ENOSYS | libc::EINVAL | libc::ENOTSUP | libc::EOPNOTSUPP) + ) { + return Err(error); + } + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_WRITE_THROUGH}; + let from = temporary + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let to = target + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + // Omitting MOVEFILE_REPLACE_EXISTING is the Windows no-replace mode. + let result = unsafe { MoveFileExW(from.as_ptr(), to.as_ptr(), MOVEFILE_WRITE_THROUGH) }; + if result != 0 { + return Ok(()); + } + return Err(std::io::Error::last_os_error()); + } + // Filesystems/platforms without a native no-replace rename get the + // documented, create-only hard-link fallback. The caller removes the + // temporary link before validating the published inode. + #[cfg(not(windows))] + { + fs::hard_link(temporary, target) + } + #[cfg(windows)] + { + unreachable!("Windows uses MoveFileExW no-replace above") + } +} + +fn verify_planning_hardlink_publish_identity( + temporary: &Path, + target: &Path, + label: &str, +) -> Result<(), PlanningStorageError> { + let temporary_metadata = fs::symlink_metadata(temporary) + .map_err(|error| io_error(&format!("读取 {label} fallback 临时文件身份失败"), error))?; + let target_metadata = fs::symlink_metadata(target) + .map_err(|error| io_error(&format!("读取 {label} fallback 目标身份失败"), error))?; + if planning_metadata_is_link_or_reparse(&temporary_metadata) + || planning_metadata_is_link_or_reparse(&target_metadata) + || !temporary_metadata.is_file() + || !target_metadata.is_file() + { + return Err(PlanningStorageError::new( + "PLAN_UNTRUSTED_PATH", + format!("{label} fallback 发布身份不是可信普通文件"), + )); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if temporary_metadata.dev() != target_metadata.dev() + || temporary_metadata.ino() != target_metadata.ino() + || temporary_metadata.nlink() < 2 + { + return Err(PlanningStorageError::new( + "PLAN_RECONCILIATION_REQUIRED", + format!("{label} fallback 发布前 temp/target 文件身份不一致"), + )); + } + } + #[cfg(not(unix))] + { + return Err(PlanningStorageError::new( + "PLAN_UNSUPPORTED_PLATFORM", + format!("{label} fallback 无法证明 temp/target 文件身份"), + )); + } + Ok(()) +} + +/// Publish an immutable planning JSON file without replacing an existing +/// target. A byte-identical target is an idempotent replay; every other +/// existing target is an identity conflict. +pub(crate) fn durable_create_json_no_replace( + root: &Path, + relative_path: &str, + bytes: &[u8], + label: &str, +) -> Result { + let _lock = acquire_project_write_lock(root, "planning.create") + .map_err(|error| io_error("取得 planning create 项目锁失败", error))?; + durable_create_json_no_replace_locked(root, relative_path, bytes, label) +} + +pub(crate) fn durable_create_json_no_replace_locked( + root: &Path, + relative_path: &str, + bytes: &[u8], + label: &str, +) -> Result { + let relative_path = validate_planning_relative_path(relative_path)?; + if matches!( + relative_path.as_str(), + PLAN_GDD_INDEX_PATH | PLAN_SESSION_PATH | PLAN_SESSION_PREVIOUS_PATH + ) { + return Err(PlanningStorageError::new( + "PLAN_DEDICATED_WRITER_REQUIRED", + format!("{relative_path} 只能由对应的原子/CAS writer 写入"), + )); + } + reject_noncanonical_storage_bytes(bytes, label)?; + validate_planning_payload_at_root(root, &relative_path, bytes, label)?; + let target = resolve_local_project_path(root, &relative_path) + .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; + let parent = ensure_planning_parent(&target)?; + if relative_path.starts_with(".agent/planning/gdd.v") { + // Validate the complete on-disk lineage before even considering a + // same-byte replay; an orphaned vN must not become authoritative just + // because its individual payload is well formed. + read_plan_gdd_chain_locked(root)?; + } + + match fs::symlink_metadata(&target) { + Ok(_) => { + let existing = read_regular_planning_file(&target, label)?; + validate_planning_payload_at_root(root, &relative_path, &existing, label)?; + if existing == bytes { + return Ok(PlanningCreateOutcome::Replayed); + } + return Err(conflict(format!( + "{label} 已存在且 canonical bytes 不同:{}", + target.display() + ))); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(io_error(&format!("读取 {label} 目标失败"), error)), + } + + if relative_path.starts_with(".agent/planning/gdd.v") { + let candidate = parse_plan_gdd_bytes(bytes)?; + let existing = read_plan_gdd_chain_locked(root)?; + validate_next_plan_gdd_version(&existing, &candidate)?; + } + + let temporary = temp_planning_path(parent, &target); + write_sync_new_file(&temporary, bytes, label)?; + let publish_result = publish_planning_noreplace(parent, &temporary, &target, label); + let outcome = match publish_result { + Ok(()) => { + // Native rename consumes the temporary name. The hard-link + // fallback leaves two names for the same inode, so unlink the + // temporary name before regular-file identity validation (nlink + // must be exactly one for an authoritative planning fact). + match fs::symlink_metadata(&temporary) { + Ok(_) => { + verify_planning_hardlink_publish_identity(&temporary, &target, label)?; + fs::remove_file(&temporary) + .map_err(|error| io_error(&format!("清理 {label} 临时文件失败"), error))?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(io_error(&format!("读取 {label} 临时文件失败"), error)), + } + let published = read_regular_planning_file(&target, label)?; + validate_planning_payload_at_root(root, &relative_path, &published, label)?; + if published != bytes { + return Err(PlanningStorageError::new( + "PLAN_RECONCILIATION_REQUIRED", + format!("{label} 发布后回读不一致"), + )); + } + PlanningCreateOutcome::Created + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + let existing = read_regular_planning_file(&target, label)?; + validate_planning_payload_at_root(root, &relative_path, &existing, label)?; + let outcome = if existing == bytes { + PlanningCreateOutcome::Replayed + } else { + return Err(conflict(format!( + "{label} 并发发布产生 identity conflict:{}", + target.display() + ))); + }; + fs::remove_file(&temporary) + .map_err(|cleanup| io_error(&format!("清理 {label} 临时文件失败"), cleanup))?; + outcome + } + Err(error) => { + let _ = fs::remove_file(&temporary); + return Err(io_error(&format!("发布 {label} 失败"), error)); + } + }; + sync_planning_parent(parent)?; + Ok(outcome) +} + +/// Rebuild and atomically replace the derived GDD index. The index is not an +/// immutable fact: a new GDD version must replace it, while a corrupt or +/// missing index can always be rebuilt from the authoritative GDD chain. +pub(crate) fn write_plan_gdd_index_atomic( + root: &Path, + value: &PlanGddIndexV1, +) -> Result<(), PlanningStorageError> { + let _lock = acquire_project_write_lock(root, "planning.index") + .map_err(|error| io_error("取得 planning index 项目锁失败", error))?; + write_plan_gdd_index_atomic_locked(root, value) +} + +pub(crate) fn write_plan_gdd_index_atomic_locked( + root: &Path, + value: &PlanGddIndexV1, +) -> Result<(), PlanningStorageError> { + let bytes = canonical_plan_index_bytes(value)?; + let gdds = read_plan_gdd_chain_locked(root)?; + validate_plan_gdd_index_against_gdds(value, &gdds)?; + let target = resolve_local_project_path(root, PLAN_GDD_INDEX_PATH) + .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; + let parent = ensure_planning_parent(&target)?; + match fs::symlink_metadata(&target) { + Ok(_) => { + verify_regular_planning_file(&target, "现有 GDD index")?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(io_error("读取现有 GDD index 失败", error)), + } + + let temporary = temp_planning_path(parent, &target); + let result = (|| { + write_sync_new_file(&temporary, &bytes, "GDD index")?; + verify_replace_target_is_safe(&target, "GDD index")?; + replace_planning_file_atomically(&temporary, &target, "GDD index")?; + let published = read_regular_planning_file(&target, "已发布 GDD index")?; + let parsed = parse_plan_index_bytes(&published)?; + let current_gdds = read_plan_gdd_chain_locked(root)?; + validate_plan_gdd_index_against_gdds(&parsed, ¤t_gdds)?; + if published != bytes { + return Err(PlanningStorageError::new( + "PLAN_RECONCILIATION_REQUIRED", + "GDD index 发布后 canonical bytes 不一致", + )); + } + sync_planning_parent(parent) + })(); + if temporary.exists() { + let cleanup = fs::remove_file(&temporary) + .map_err(|error| io_error("清理 GDD index 临时文件失败", error)); + if result.is_ok() { + cleanup?; + } + } + result +} + +fn replace_planning_file_atomically( + temporary: &Path, + target: &Path, + label: &str, +) -> Result<(), PlanningStorageError> { + #[cfg(not(windows))] + { + fs::rename(temporary, target) + .map_err(|error| io_error(&format!("原子替换 {label} 失败"), error))?; + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + let from = temporary + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let to = target + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let result = unsafe { + MoveFileExW( + from.as_ptr(), + to.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if result == 0 { + return Err(io_error( + &format!("原子替换 {label} 失败"), + std::io::Error::last_os_error(), + )); + } + } + Ok(()) +} + +fn verify_replace_target_is_safe(target: &Path, label: &str) -> Result<(), PlanningStorageError> { + match fs::symlink_metadata(target) { + Ok(_) => { + verify_regular_planning_file(target, &format!("现有 {label}"))?; + Ok(()) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(io_error(&format!("读取现有 {label} 目标失败"), error)), + } +} + +/// Atomically advance `session.json`, retaining exactly one `.session.previous` +/// recovery copy. Callers should hold the project write lock; the public +/// wrapper acquires it for standalone/tests and the `_locked` variant is used +/// by Runtime code that already owns the lock. +pub(crate) fn write_plan_session_atomic( + root: &Path, + value: &PlanSessionV1, +) -> Result<(), PlanningStorageError> { + let _lock = acquire_project_write_lock(root, "planning.session") + .map_err(|error| io_error("取得 planning session 项目锁失败", error))?; + write_plan_session_atomic_locked(root, value) +} + +pub(crate) fn write_plan_session_atomic_locked( + root: &Path, + value: &PlanSessionV1, +) -> Result<(), PlanningStorageError> { + let bytes = canonical_plan_session_bytes(value)?; + let target = resolve_local_project_path(root, PLAN_SESSION_PATH) + .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; + let previous = resolve_local_project_path(root, PLAN_SESSION_PREVIOUS_PATH) + .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; + let parent = ensure_planning_parent(&target)?; + let target_state = match fs::symlink_metadata(&target) { + Ok(_) => { + verify_regular_planning_file(&target, "现有 plan session")?; + let old_bytes = read_regular_planning_file(&target, "现有 plan session")?; + let old = parse_plan_session_bytes(&old_bytes)?; + Some((old, old_bytes)) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(io_error("读取现有 plan session 失败", error)), + }; + let previous_state = match fs::symlink_metadata(&previous) { + Ok(_) => { + verify_regular_planning_file(&previous, "现有 plan session previous")?; + let old_bytes = read_regular_planning_file(&previous, "现有 plan session previous")?; + Some(parse_plan_session_bytes(&old_bytes)?) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(io_error("读取现有 plan session previous 失败", error)), + }; + + let same_current = target_state + .as_ref() + .is_some_and(|(_, current_bytes)| current_bytes == &bytes); + if let Some((current, _)) = target_state.as_ref() { + if !same_current { + validate_plan_session_successor(current, value)?; + } + } else if let Some(previous_value) = previous_state.as_ref() { + if previous_value != value { + validate_plan_session_successor(previous_value, value)?; + } + } else if value.session_revision != 1 || value.previous_fingerprint.is_some() { + return Err(conflict( + "首个 plan session 必须是 revision=1 且 previousFingerprint=null", + )); + } + if let (Some((current, _)), Some(previous_value)) = + (target_state.as_ref(), previous_state.as_ref()) + { + if current != previous_value { + validate_plan_session_successor(previous_value, current)?; + } + } + if same_current { + return Ok(()); + } + + let temporary = temp_planning_path(parent, &target); + let previous_temp = temp_planning_path(parent, &previous); + let result = (|| { + write_sync_new_file(&temporary, &bytes, "plan session")?; + + if let Some((_, old_bytes)) = target_state.as_ref() { + // The recovery copy is written only after the successor is durable + // in a sibling temp file. A crash therefore leaves old primary or + // a valid previous copy, never a half-written JSON document. + write_sync_new_file(&previous_temp, old_bytes, "plan session previous")?; + replace_planning_file_atomically(&previous_temp, &previous, "plan session previous")?; + sync_planning_parent(parent)?; + } + verify_replace_target_is_safe(&target, "plan session")?; + replace_planning_file_atomically(&temporary, &target, "plan session")?; + let published = read_regular_planning_file(&target, "已发布 plan session")?; + if published != bytes { + return Err(PlanningStorageError::new( + "PLAN_RECONCILIATION_REQUIRED", + "plan session 发布后 canonical bytes 不一致", + )); + } + parse_plan_session_bytes(&published)?; + sync_planning_parent(parent) + })(); + let _ = fs::remove_file(&temporary); + let _ = fs::remove_file(&previous_temp); + result +} + +/// Read the session primary and its single recovery copy according to the +/// revision/hash-chain rules in §10.2. A corrupt primary is never silently +/// replaced by a valid previous copy. +fn read_optional_plan_session_file( + path: &Path, + label: &str, +) -> Result, PlanningStorageError> { + match fs::symlink_metadata(path) { + Ok(_) => { + let bytes = read_regular_planning_file(path, label)?; + Ok(Some(parse_plan_session_bytes(&bytes)?)) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(io_error(&format!("读取 {label} 失败"), error)), + } +} + +pub(crate) fn read_plan_session_with_recovery( + root: &Path, +) -> Result, PlanningStorageError> { + let primary_path = resolve_local_project_path(root, PLAN_SESSION_PATH) + .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; + let previous_path = resolve_local_project_path(root, PLAN_SESSION_PREVIOUS_PATH) + .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; + let primary_exists = fs::symlink_metadata(&primary_path) + .map(|_| true) + .or_else(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + Ok(false) + } else { + Err(error) + } + }) + .map_err(|error| io_error("探测 plan session primary 失败", error))?; + let previous_exists = fs::symlink_metadata(&previous_path) + .map(|_| true) + .or_else(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + Ok(false) + } else { + Err(error) + } + }) + .map_err(|error| io_error("探测 plan session previous 失败", error))?; + if !primary_exists && !previous_exists { + return Ok(None); + } + let _lock = acquire_project_write_lock(root, "planning.session.read") + .map_err(|error| io_error("读取 plan session 时取得项目锁失败", error))?; + read_plan_session_with_recovery_locked(root) +} + +pub(crate) fn read_plan_session_with_recovery_locked( + root: &Path, +) -> Result, PlanningStorageError> { + let primary_path = resolve_local_project_path(root, PLAN_SESSION_PATH) + .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; + let previous_path = resolve_local_project_path(root, PLAN_SESSION_PREVIOUS_PATH) + .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; + let primary_state = read_optional_plan_session_file(&primary_path, "plan session primary")?; + let previous_state = read_optional_plan_session_file(&previous_path, "plan session previous")?; + match (primary_state, previous_state) { + (None, None) => Ok(None), + (Some(primary), None) => Ok(Some(primary)), + (None, Some(previous)) => { + let primary_path = resolve_local_project_path(root, PLAN_SESSION_PATH) + .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; + let previous_path = resolve_local_project_path(root, PLAN_SESSION_PREVIOUS_PATH) + .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; + // Re-read while holding the project lock. A writer may have + // published a new primary between the optimistic read and lock + // acquisition; never overwrite that newer fact. + if let Some(current_primary) = + read_optional_plan_session_file(&primary_path, "锁内 plan session primary")? + { + let current_previous = + read_optional_plan_session_file(&previous_path, "锁内 plan session previous")?; + return match current_previous { + Some(current_previous) => { + if current_primary != current_previous { + validate_plan_session_successor(¤t_previous, ¤t_primary)?; + } + Ok(Some(current_primary)) + } + None => Ok(Some(current_primary)), + }; + } + let current_previous = + read_optional_plan_session_file(&previous_path, "锁内 plan session previous")? + .ok_or_else(|| { + PlanningStorageError::new( + "PLAN_RECONCILIATION_REQUIRED", + "提升 session previous 时 recovery 文件已消失", + ) + })?; + if current_previous != previous { + return Err(conflict( + "提升 session previous 前 recovery 文件发生身份漂移", + )); + } + verify_replace_target_is_safe(&primary_path, "plan session previous 提升")?; + replace_planning_file_atomically( + &previous_path, + &primary_path, + "plan session previous 提升", + )?; + sync_planning_parent(primary_path.parent().expect("session has parent"))?; + let promoted = + read_optional_plan_session_file(&primary_path, "提升后的 plan session primary")? + .ok_or_else(|| { + PlanningStorageError::new( + "PLAN_RECONCILIATION_REQUIRED", + "plan session previous 提升后 primary 缺失", + ) + })?; + if promoted != previous { + return Err(PlanningStorageError::new( + "PLAN_RECONCILIATION_REQUIRED", + "plan session previous 提升后内容不一致", + )); + } + Ok(Some(promoted)) + } + (Some(primary_value), Some(previous_value)) => { + if primary_value == previous_value { + let current_primary = + read_optional_plan_session_file(&primary_path, "锁内 plan session primary")? + .ok_or_else(|| { + PlanningStorageError::new( + "PLAN_RECONCILIATION_REQUIRED", + "清理 session previous 时 primary 缺失", + ) + })?; + let current_previous = + read_optional_plan_session_file(&previous_path, "锁内 plan session previous")? + .ok_or_else(|| { + PlanningStorageError::new( + "PLAN_RECONCILIATION_REQUIRED", + "清理 session previous 时 recovery 文件缺失", + ) + })?; + if current_primary != primary_value || current_previous != previous_value { + return Err(conflict("清理 session previous 前文件发生身份漂移")); + } + fs::remove_file(&previous_path) + .map_err(|error| io_error("清理 plan session previous 失败", error))?; + sync_planning_parent(primary_path.parent().expect("session has parent"))?; + return Ok(Some(current_primary)); + } + validate_plan_session_successor(&previous_value, &primary_value)?; + let current_primary = + read_optional_plan_session_file(&primary_path, "锁内 plan session primary")? + .ok_or_else(|| { + PlanningStorageError::new( + "PLAN_RECONCILIATION_REQUIRED", + "清理 session previous 时 primary 缺失", + ) + })?; + let current_previous = + read_optional_plan_session_file(&previous_path, "锁内 plan session previous")? + .ok_or_else(|| { + PlanningStorageError::new( + "PLAN_RECONCILIATION_REQUIRED", + "清理 session previous 时 recovery 文件缺失", + ) + })?; + validate_plan_session_successor(¤t_previous, ¤t_primary)?; + fs::remove_file(&previous_path) + .map_err(|error| io_error("清理 plan session previous 失败", error))?; + sync_planning_parent(primary_path.parent().expect("session has parent"))?; + Ok(Some(current_primary)) + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PlanningRuntimeWriteIdentity<'a> { + pub(crate) agent_id: &'a str, + pub(crate) source: &'a str, + pub(crate) run_profile: &'a str, + pub(crate) parent_agent_id: Option<&'a str>, +} + +pub(crate) fn validate_planning_runtime_write_identity( + identity: &PlanningRuntimeWriteIdentity<'_>, +) -> Result<(), PlanningStorageError> { + if identity.agent_id != "project-planning" + || identity.source != "agent-delegate" + || identity.run_profile != "standard" + || identity.parent_agent_id != Some("project-supervisor") + { + return Err(PlanningStorageError::new( + "PLAN_WRITE_AUTHORIZATION_DENIED", + "只有 project-planning/agent-delegate/standard/Supervisor 子 Run 可以申请规划 Runtime 写入", + )); + } + Ok(()) +} + +pub(crate) fn durable_create_json_no_replace_authorized( + root: &Path, + identity: &PlanningRuntimeWriteIdentity<'_>, + relative_path: &str, + bytes: &[u8], + label: &str, +) -> Result { + validate_planning_runtime_write_identity(identity)?; + durable_create_json_no_replace(root, relative_path, bytes, label) +} + +pub(crate) fn write_plan_session_atomic_authorized( + root: &Path, + identity: &PlanningRuntimeWriteIdentity<'_>, + value: &PlanSessionV1, +) -> Result<(), PlanningStorageError> { + validate_planning_runtime_write_identity(identity)?; + write_plan_session_atomic(root, value) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn golden_gdd() -> PlanGddV1 { + let mut value = PlanGddV1 { + schema_version: PLAN_GDD_SCHEMA_VERSION.to_string(), + project_id: "project-golden-001".to_string(), + gdd_id: "gdd-00000000-0000-4000-8000-000000000001".to_string(), + version: 1, + submission_id: "action-0123456789abcdef01234567".to_string(), + approval_request_id: "gdd-approval-00000000-0000-4000-8000-000000000002".to_string(), + action_fingerprint: "1".repeat(64), + agent_id: "project-planning".to_string(), + source: "agent-delegate".to_string(), + run_profile: "standard".to_string(), + run_profile_binding_fingerprint: "2".repeat(64), + root_agent_id: "project-supervisor".to_string(), + root_run_id: "run-golden-root-001".to_string(), + delegation_id: "clarification-continuation-4444444444444444".to_string(), + session_id: "session-golden-001".to_string(), + source_session_revision: 3, + source_session_fingerprint: format!("sha256-serde-json-v2:{}", "3".repeat(64)), + created_by_run_id: "run-golden-plan-001".to_string(), + created_at_utc: "2026-08-10T00:00:00.000Z".to_string(), + game: PlanGddGame { + title: "萤火守夜人".to_string(), + genre: PlanGenre { + primary: "轻量动作解谜".to_string(), + fusion: None, + }, + art_style: PlanArtStyle { + visual_type: "低多边形剪影".to_string(), + keywords: vec!["萤火".to_string(), "深蓝".to_string(), "暖金".to_string()], + mood_and_color: "深蓝夜色配暖金反馈".to_string(), + mvp_art_boundary: "仅玩家、灯塔、三类障碍与HUD".to_string(), + }, + one_liner: "玩家扮演守夜人,在会熄灭的群岛间收集萤火、点亮灯塔并规划安全返回路线,每局用有限光源换取更远探索。".to_string(), + pillars: vec![ + PlanPillar { + name: "光源抉择".to_string(), + player_feel: "每一步都在安全与收益间权衡".to_string(), + mechanism: "光量同时承担生命、视野与开门消耗".to_string(), + decision_state: "confirmed".to_string(), + basis: None, + }, + PlanPillar { + name: "短局探索".to_string(), + player_feel: "十分钟内完成一次清晰冒险".to_string(), + mechanism: "岛屿分支和撤离时机形成重玩差异".to_string(), + decision_state: "prototype_pending".to_string(), + basis: None, + }, + ], + core_loop: vec![ + "观察剩余光量与岛屿分支".to_string(), + "选择路线和光源投入".to_string(), + "移动、收集并处理障碍".to_string(), + "点亮灯塔或及时撤离".to_string(), + ], + target_users: PlanTargetUsers { + core_users: "喜欢短局策略与轻量探索的玩家".to_string(), + preferences: "清晰反馈、低操作压力、可复盘选择".to_string(), + session_length: "10至15分钟".to_string(), + reference_games: Vec::new(), + }, + platform_facts: PlanPlatformFacts { + runtime: "self-contained-web".to_string(), + viewports: vec!["desktop".to_string(), "mobile".to_string()], + inputs: vec!["keyboard".to_string(), "touch".to_string()], + preview: "local-http".to_string(), + }, + mvp_systems: vec![ + PlanMvpSystem { + system: "光量资源".to_string(), + minimal_function: "移动和交互消耗光量".to_string(), + why_required: "承载核心取舍".to_string(), + verify_method: "观察玩家是否因光量改变路线".to_string(), + decision_state: "confirmed".to_string(), + basis: None, + }, + PlanMvpSystem { + system: "分支岛屿".to_string(), + minimal_function: "每局提供两次二选一路线".to_string(), + why_required: "形成重玩差异".to_string(), + verify_method: "记录第二局路线变化".to_string(), + decision_state: "prototype_pending".to_string(), + basis: None, + }, + PlanMvpSystem { + system: "灯塔结算".to_string(), + minimal_function: "点亮终点或撤离时结算".to_string(), + why_required: "闭合本局目标".to_string(), + verify_method: "玩家能理解三类结算".to_string(), + decision_state: "default_pending".to_string(), + basis: None, + }, + ], + out_of_scope: vec!["多人".to_string()], + creator_tips: PlanCreatorTips { + do_first: "先验证光量与路线取舍".to_string(), + defer_for_now: "完整剧情和大量岛屿".to_string(), + how_to_verify: "让三名玩家各试玩两局并说明路线理由".to_string(), + expand_when: "多数玩家会主动改变第二局路线".to_string(), + }, + }, + decisions: vec![ + PlanDecision { + id: "initial-request".to_string(), + topic: "初始需求".to_string(), + state: "confirmed".to_string(), + answer_source: "user_freeform".to_string(), + round: 0, + answer_summary: "做一款围绕有限光源探索群岛的短局动作解谜游戏".to_string(), + basis: None, + }, + PlanDecision { + id: "route-replay".to_string(), + topic: "路线重玩".to_string(), + state: "prototype_pending".to_string(), + answer_source: "user_option".to_string(), + round: 1, + answer_summary: "用微型原型验证分支是否驱动重玩".to_string(), + basis: None, + }, + ], + prototype_validation_items: vec![PlanPrototypeValidationItem { + id: "route-replay".to_string(), + question: "分支路线是否驱动第二局选择变化".to_string(), + micro_prototype: "制作两次二选一路线和光量结算".to_string(), + observation: "记录第二局是否主动改变分支并说明原因".to_string(), + pass_criterion: "三名测试者中至少两名主动改变路线且能说出取舍".to_string(), + }], + fingerprint: format!("sha256-serde-json-v2:{}", "0".repeat(64)), + }; + value.fingerprint = plan_gdd_fingerprint(&value).expect("golden GDD fingerprint"); + value + } + + fn golden_session() -> PlanSessionV1 { + let mut value = PlanSessionV1 { + schema_version: PLAN_SESSION_SCHEMA_VERSION.to_string(), + project_id: "project-golden-001".to_string(), + gdd_id: "gdd-00000000-0000-4000-8000-000000000001".to_string(), + session_revision: 1, + previous_fingerprint: None, + session_fingerprint: format!("sha256-serde-json-v2:{}", "0".repeat(64)), + agent_id: "project-planning".to_string(), + source: "agent-delegate".to_string(), + run_profile: "standard".to_string(), + run_profile_binding_fingerprint: "2".repeat(64), + root_agent_id: "project-supervisor".to_string(), + root_run_id: "run-golden-root-001".to_string(), + latest_delegation_id: "delegation-golden-001".to_string(), + session_id: "session-golden-001".to_string(), + active_run_id: Some("run-golden-plan-001".to_string()), + last_run_id: "run-golden-plan-001".to_string(), + phase: "collecting".to_string(), + accumulated_agent_millis: 10, + applied_steer_cursor: 0, + decisions_summary: vec![PlanDecisionSummary { + id: "initial-request".to_string(), + topic: "初始需求".to_string(), + state: "confirmed".to_string(), + answer_source: "user_freeform".to_string(), + round: 0, + answer_summary: "做一款围绕有限光源探索群岛的短局动作解谜游戏".to_string(), + }], + prototype_validation_items: Vec::new(), + applied_answers: Vec::new(), + latest_submitted_ref: None, + last_decision_ref: None, + updated_at_utc: "2026-08-10T00:00:00.000Z".to_string(), + }; + value.session_fingerprint = plan_session_fingerprint(&value).expect("session fingerprint"); + value + } + + fn golden_submit_input() -> PlanSubmitGddInputV1 { + let gdd = golden_gdd(); + PlanSubmitGddInputV1 { + schema_version: PLAN_SUBMIT_GDD_INPUT_SCHEMA_VERSION.to_string(), + game: PlanSubmitGame { + title: gdd.game.title, + genre: gdd.game.genre, + art_style: gdd.game.art_style, + one_liner: gdd.game.one_liner, + pillars: gdd + .game + .pillars + .into_iter() + .map(|pillar| PlanSubmitPillar { + name: pillar.name, + player_feel: pillar.player_feel, + mechanism: pillar.mechanism, + decision_state: pillar.decision_state, + }) + .collect(), + core_loop: gdd.game.core_loop, + target_users: gdd.game.target_users, + mvp_systems: gdd + .game + .mvp_systems + .into_iter() + .map(|system| PlanSubmitMvpSystem { + system: system.system, + minimal_function: system.minimal_function, + why_required: system.why_required, + verify_method: system.verify_method, + decision_state: system.decision_state, + }) + .collect(), + out_of_scope: gdd.game.out_of_scope, + creator_tips: gdd.game.creator_tips, + }, + decisions: gdd + .decisions + .into_iter() + .map(|decision| PlanSubmitDecision { + id: decision.id, + topic: decision.topic, + state: decision.state, + answer_source: decision.answer_source, + round: decision.round, + answer_summary: decision.answer_summary, + }) + .collect(), + prototype_validation_items: gdd.prototype_validation_items, + } + } + + #[test] + fn typed_serde_gdd_golden_vector_matches_spec() { + let value = golden_gdd(); + let canonical = PlanGddFingerprintValue::from(&value); + let bytes = typed_serde_canonical_bytes(PLAN_GDD_FINGERPRINT_DOMAIN, &canonical) + .expect("golden canonical bytes"); + assert_eq!(bytes.len(), 3857); + assert_eq!( + value.fingerprint, + "sha256-serde-json-v2:a59856de7ef134cf2f49c4dedd2ba10ae4ab2340a9634d402eb792b6ee5458f0" + ); + assert_eq!( + typed_serde_fingerprint(PLAN_GDD_FINGERPRINT_DOMAIN, &canonical) + .expect("golden fingerprint"), + value.fingerprint + ); + } + + #[test] + fn typed_fingerprint_changes_for_protected_bytes() { + let value = golden_gdd(); + let first = plan_gdd_fingerprint(&value).expect("fingerprint"); + let mut changed = value.clone(); + changed.game.title = "萤火守夜者".to_string(); + changed.fingerprint = first.clone(); + assert_ne!( + plan_gdd_fingerprint(&changed).expect("changed fingerprint"), + first + ); + let mut reordered = value.clone(); + reordered.game.art_style.keywords.reverse(); + reordered.fingerprint = first.clone(); + assert_ne!( + plan_gdd_fingerprint(&reordered).expect("reordered fingerprint"), + first + ); + let mut identity = value; + identity.root_run_id = "run-golden-root-002".to_string(); + identity.fingerprint = first.clone(); + assert_ne!( + plan_gdd_fingerprint(&identity).expect("identity fingerprint"), + first + ); + } + + #[test] + fn timestamp_validation_rejects_invalid_calendar_and_non_ascii_values() { + let mut value = golden_gdd(); + value.created_at_utc = "2026-99-99T99:99:99.999Z".to_string(); + assert_eq!( + plan_gdd_fingerprint(&value).unwrap_err().code(), + "PLAN_INVALID_SCHEMA" + ); + value.created_at_utc = "😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀".to_string(); + assert_eq!( + plan_gdd_fingerprint(&value).unwrap_err().code(), + "PLAN_INVALID_SCHEMA" + ); + } + + #[test] + fn strict_parser_rejects_duplicate_keys_and_noncanonical_suffix() { + let value = golden_gdd(); + let bytes = canonical_plan_gdd_bytes(&value).expect("canonical GDD"); + assert!(parse_plan_gdd_bytes(&bytes).is_ok()); + let mut newline = bytes.clone(); + newline.push(b'\n'); + assert_eq!( + parse_plan_gdd_bytes(&newline).unwrap_err().code(), + "PLAN_NON_CANONICAL_BYTES" + ); + let duplicate = br#"{"schemaVersion":"plan-gdd.v1","schemaVersion":"plan-gdd.v1"}"#; + assert_eq!( + parse_plan_gdd_bytes(duplicate).unwrap_err().code(), + "PLAN_INVALID_JSON" + ); + } + + #[test] + fn immutable_writer_is_create_only_and_generic_gate_is_write_only() { + let directory = tempfile::tempdir().expect("temp root"); + let root = directory.path(); + let value = golden_gdd(); + let bytes = canonical_plan_gdd_bytes(&value).expect("canonical GDD"); + let path = ".agent/planning/gdd.v1.json"; + let malformed_root = tempfile::tempdir().expect("malformed root"); + assert_eq!( + durable_create_json_no_replace(malformed_root.path(), path, br"{}", "GDD",) + .unwrap_err() + .code(), + "PLAN_INVALID_JSON" + ); + assert_eq!( + durable_create_json_no_replace(root, path, &bytes, "GDD").expect("first create"), + PlanningCreateOutcome::Created + ); + assert_eq!( + durable_create_json_no_replace(root, path, &bytes, "GDD").expect("replay"), + PlanningCreateOutcome::Replayed + ); + let mut changed = bytes.clone(); + let changed_index = changed.len() - 2; + changed[changed_index] ^= 1; + assert_eq!( + durable_create_json_no_replace(root, path, &changed, "GDD") + .unwrap_err() + .code(), + "PLAN_INVALID_JSON" + ); + assert!(write_local_project_file_at(root, path, "tamper").is_err()); + assert!(delete_local_project_file_at(root, path).is_err()); + assert!(write_local_project_file_at(root, "game/fast_gdd.md", "tamper").is_err()); + assert!(delete_local_project_file_at(root, "game/fast_gdd.md").is_err()); + } + + #[test] + fn planning_write_identity_is_fail_closed() { + let valid = PlanningRuntimeWriteIdentity { + agent_id: "project-planning", + source: "agent-delegate", + run_profile: "standard", + parent_agent_id: Some("project-supervisor"), + }; + assert!(validate_planning_runtime_write_identity(&valid).is_ok()); + let invalid_identity = PlanningRuntimeWriteIdentity { + agent_id: "project-supervisor", + ..valid + }; + assert_eq!( + validate_planning_runtime_write_identity(&invalid_identity) + .unwrap_err() + .code(), + "PLAN_WRITE_AUTHORIZATION_DENIED" + ); + } + + #[test] + fn submit_input_has_strict_canonical_parser_and_runtime_field_boundary() { + let value = golden_submit_input(); + let bytes = canonical_plan_submit_gdd_input_bytes(&value).expect("submit input bytes"); + assert_eq!( + parse_plan_submit_gdd_input_bytes(&bytes).expect("parse input"), + value + ); + let mut newline = bytes.clone(); + newline.push(b'\n'); + assert_eq!( + parse_plan_submit_gdd_input_bytes(&newline) + .unwrap_err() + .code(), + "PLAN_NON_CANONICAL_BYTES" + ); + let mut object = serde_json::from_slice::(&bytes).expect("input json"); + object["projectId"] = serde_json::Value::String("forged-project".to_string()); + let forged = serde_json::to_vec(&object).expect("forged input"); + assert!(parse_plan_submit_gdd_input_bytes(&forged).is_err()); + } + + #[test] + fn gdd_chain_and_index_are_authority_checked() { + let directory = tempfile::tempdir().expect("temp root"); + let root = directory.path(); + let first = golden_gdd(); + let first_bytes = canonical_plan_gdd_bytes(&first).expect("first bytes"); + durable_create_json_no_replace(root, ".agent/planning/gdd.v1.json", &first_bytes, "GDD") + .expect("write first GDD"); + let mut second = first.clone(); + second.version = 2; + second.submission_id = "action-abcdefabcdefabcdefabcdef".to_string(); + second.approval_request_id = + "gdd-approval-00000000-0000-4000-8000-000000000003".to_string(); + second.action_fingerprint = "4".repeat(64); + second.fingerprint = plan_gdd_fingerprint(&second).expect("second fingerprint"); + let second_bytes = canonical_plan_gdd_bytes(&second).expect("second bytes"); + durable_create_json_no_replace(root, ".agent/planning/gdd.v2.json", &second_bytes, "GDD") + .expect("write second GDD"); + let chain = read_plan_gdd_chain(root).expect("read chain"); + assert_eq!(chain, vec![first.clone(), second.clone()]); + let index = build_plan_gdd_index(&chain, "2026-08-10T00:00:00.000Z").expect("index"); + assert_eq!( + index + .status_cache + .versions + .iter() + .map(|item| item.status.as_str()) + .collect::>(), + vec!["superseded", "ready_for_approval"] + ); + validate_plan_gdd_index_against_gdds(&index, &chain).expect("index authority"); + let mut tampered = index.clone(); + tampered.entries[1].root_run_id = "run-tampered".to_string(); + assert_eq!( + validate_plan_gdd_index_against_gdds(&tampered, &chain) + .unwrap_err() + .code(), + "PLAN_IDENTITY_CONFLICT" + ); + let mut status_tampered = index.clone(); + status_tampered.status_cache.versions[0].status = "ready_for_approval".to_string(); + assert_eq!( + validate_plan_gdd_index_against_gdds(&status_tampered, &chain) + .unwrap_err() + .code(), + "PLAN_IDENTITY_CONFLICT" + ); + let index_bytes = canonical_plan_index_bytes(&index).expect("index bytes"); + assert_eq!( + durable_create_json_no_replace(root, PLAN_GDD_INDEX_PATH, &index_bytes, "GDD index") + .unwrap_err() + .code(), + "PLAN_DEDICATED_WRITER_REQUIRED" + ); + write_plan_gdd_index_atomic(root, &index).expect("write index"); + let mut rebuilt = index.clone(); + rebuilt.rebuilt_at_utc = "2026-08-11T00:00:00.000Z".to_string(); + write_plan_gdd_index_atomic(root, &rebuilt).expect("replace index"); + assert_eq!( + parse_plan_index_bytes( + &fs::read( + root.join(PLAN_GDD_INDEX_PATH.replace('/', std::path::MAIN_SEPARATOR_STR)) + ) + .expect("read index") + ) + .expect("parse replaced index"), + rebuilt + ); + fs::remove_file(root.join(PLAN_GDD_INDEX_PATH.replace('/', std::path::MAIN_SEPARATOR_STR))) + .expect("remove index for recovery"); + let recovered = read_plan_gdd_index_with_recovery(root, "2026-08-12T00:00:00.000Z") + .expect("recover missing index") + .expect("recovered index"); + assert_eq!(recovered.entries, index.entries); + assert_eq!(recovered.rebuilt_at_utc, "2026-08-12T00:00:00.000Z"); + + fs::write( + root.join(PLAN_GDD_INDEX_PATH.replace('/', std::path::MAIN_SEPARATOR_STR)), + br"{}", + ) + .expect("corrupt index"); + let recovered_corrupt = read_plan_gdd_index_with_recovery(root, "2026-08-13T00:00:00.000Z") + .expect("recover corrupt index") + .expect("recovered corrupt index"); + assert_eq!(recovered_corrupt.entries, index.entries); + assert_eq!(recovered_corrupt.rebuilt_at_utc, "2026-08-13T00:00:00.000Z"); + assert!(build_plan_gdd_index(&[], "2026-08-10T00:00:00.000Z").is_err()); + } + + #[test] + fn session_successor_and_recovery_are_cas_checked() { + let directory = tempfile::tempdir().expect("temp root"); + let root = directory.path(); + let first = golden_session(); + let first_bytes = canonical_plan_session_bytes(&first).expect("session bytes"); + assert_eq!( + durable_create_json_no_replace(root, PLAN_SESSION_PATH, &first_bytes, "plan session") + .unwrap_err() + .code(), + "PLAN_DEDICATED_WRITER_REQUIRED" + ); + write_plan_session_atomic(root, &first).expect("write session v1"); + let mut second = first.clone(); + second.session_revision = 2; + second.previous_fingerprint = Some(first.session_fingerprint.clone()); + second.accumulated_agent_millis += 10; + second.active_run_id = None; + second.phase = "awaiting_user_input".to_string(); + second.session_fingerprint = plan_session_fingerprint(&second).expect("v2 fingerprint"); + write_plan_session_atomic(root, &second).expect("write session v2"); + assert!(root.join(PLAN_SESSION_PREVIOUS_PATH).exists()); + assert_eq!( + read_plan_session_with_recovery(root).expect("recover session"), + Some(second.clone()) + ); + assert!(!root.join(PLAN_SESSION_PREVIOUS_PATH).exists()); + let mut invalid_next = second.clone(); + invalid_next.session_revision = 4; + invalid_next.previous_fingerprint = Some(second.session_fingerprint.clone()); + invalid_next.session_fingerprint = plan_session_fingerprint(&invalid_next).expect("bad fp"); + assert_eq!( + write_plan_session_atomic(root, &invalid_next) + .unwrap_err() + .code(), + "PLAN_IDENTITY_CONFLICT" + ); + } + + #[test] + fn session_applied_answers_bind_unique_round_and_continuation() { + let mut session = golden_session(); + session.phase = "awaiting_user_input".to_string(); + session.active_run_id = None; + session.decisions_summary.push(PlanDecisionSummary { + id: "route-replay".to_string(), + topic: "路线重玩".to_string(), + state: "confirmed".to_string(), + answer_source: "user_option".to_string(), + round: 1, + answer_summary: "验证分支是否驱动重玩".to_string(), + }); + let questions_sha256 = "a".repeat(64); + let answers_sha256 = "b".repeat(64); + let delegation_id = "delegation-question-001".to_string(); + let continuation = derive_plan_continuation_delegation_id( + &session.root_run_id, + &delegation_id, + &questions_sha256, + &answers_sha256, + ) + .expect("continuation id"); + session.applied_answers.push(PlanAppliedAnswer { + delegation_id, + continuation_delegation_id: continuation, + request_id: "request-question-001".to_string(), + question_id: "route_replay".to_string(), + response_id: "app-user-input-001".to_string(), + questions_sha256, + answers_sha256, + decision_id: "route-replay".to_string(), + round: 1, + }); + session.latest_delegation_id = session + .applied_answers + .last() + .expect("answer") + .continuation_delegation_id + .clone(); + session.session_fingerprint = plan_session_fingerprint(&session).expect("answer fp"); + validate_plan_session(&session).expect("valid applied answer"); + let mut duplicate = session.clone(); + duplicate + .applied_answers + .push(duplicate.applied_answers[0].clone()); + duplicate.session_fingerprint = session.session_fingerprint.clone(); + assert!(validate_plan_session(&duplicate).is_err()); + } + + #[test] + fn session_recovery_rejects_corrupt_primary_and_forked_previous() { + let directory = tempfile::tempdir().expect("temp root"); + let root = directory.path(); + let first = golden_session(); + write_plan_session_atomic(root, &first).expect("write session"); + let primary_path = root.join(PLAN_SESSION_PATH.replace('/', std::path::MAIN_SEPARATOR_STR)); + let previous_path = + root.join(PLAN_SESSION_PREVIOUS_PATH.replace('/', std::path::MAIN_SEPARATOR_STR)); + fs::write(&primary_path, b"{}").expect("corrupt primary"); + let error = read_plan_session_with_recovery(root).expect_err("corrupt primary rejected"); + assert_eq!(error.code(), "PLAN_INVALID_JSON"); + assert!(!previous_path.exists()); + + // Missing primary may be promoted only when the recovery copy is the + // sole valid fact. + fs::write( + &primary_path, + canonical_plan_session_bytes(&first).expect("restore primary"), + ) + .expect("restore primary"); + fs::rename(&primary_path, &previous_path).expect("move to previous"); + assert_eq!( + read_plan_session_with_recovery(root).expect("promote previous"), + Some(first.clone()) + ); + assert!(primary_path.exists()); + assert!(!previous_path.exists()); + + let mut second = first.clone(); + second.session_revision = 2; + second.previous_fingerprint = Some(first.session_fingerprint.clone()); + second.accumulated_agent_millis += 1; + second.active_run_id = None; + second.phase = "awaiting_user_input".to_string(); + second.session_fingerprint = plan_session_fingerprint(&second).expect("second fp"); + write_plan_session_atomic(root, &second).expect("write successor"); + let mut forked_previous = first.clone(); + forked_previous.updated_at_utc = "2026-08-11T00:00:00.000Z".to_string(); + forked_previous.session_fingerprint = + plan_session_fingerprint(&forked_previous).expect("fork fp"); + fs::write( + &previous_path, + canonical_plan_session_bytes(&forked_previous).expect("fork bytes"), + ) + .expect("write fork"); + let error = read_plan_session_with_recovery(root).expect_err("fork rejected"); + assert_eq!(error.code(), "PLAN_IDENTITY_CONFLICT"); + } + + #[cfg(unix)] + #[test] + fn immutable_writer_rejects_symlink_and_hardlink_targets() { + use std::os::unix::fs::symlink; + + let directory = tempfile::tempdir().expect("temp root"); + let root = directory.path(); + let value = golden_gdd(); + let bytes = canonical_plan_gdd_bytes(&value).expect("GDD bytes"); + let outside = directory.path().join("outside.json"); + fs::write(&outside, &bytes).expect("outside bytes"); + let symlink_path = root.join(".agent/planning/gdd.v1.json"); + ensure_planning_parent(&symlink_path).expect("planning dir"); + symlink(&outside, &symlink_path).expect("symlink"); + assert_eq!( + durable_create_json_no_replace(root, ".agent/planning/gdd.v1.json", &bytes, "GDD") + .unwrap_err() + .code(), + "PLAN_UNTRUSTED_PATH" + ); + + let hardlink_directory = tempfile::tempdir().expect("hardlink root"); + let hardlink_root = hardlink_directory.path(); + let hardlink_outside = hardlink_root.join("outside.json"); + fs::write(&hardlink_outside, &bytes).expect("hardlink outside"); + let hardlink_path = hardlink_root.join(".agent/planning/gdd.v1.json"); + ensure_planning_parent(&hardlink_path).expect("hardlink planning dir"); + fs::hard_link(&hardlink_outside, &hardlink_path).expect("hardlink"); + assert_eq!( + durable_create_json_no_replace( + hardlink_root, + ".agent/planning/gdd.v1.json", + &bytes, + "GDD", + ) + .unwrap_err() + .code(), + "PLAN_UNTRUSTED_PATH" + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs index 90dcdce36..8d35de0f7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs @@ -329,6 +329,18 @@ pub(in crate::agent) fn agent_role_project_path_mutation_block( tool: &str, path: &str, ) -> Option { + if is_agent_planning_storage_path(path) || is_plan_fast_gdd_projection_path(path) { + return Some(AgentRuntimeToolObservation { + tool: tool.to_string(), + status: "blocked".to_string(), + summary: if is_agent_planning_storage_path(path) { + "`.agent/planning/**` 只能由立项策划 Runtime 专用存储层写入".to_string() + } else { + "`game/fast_gdd.md` 只能由立项策划 Runtime renderer 写入".to_string() + }, + detail: Some(format!("agentId={agent_id} · runId={run_id} · path={path}")), + }); + } match autonomous_owner_artifact_validation_available_for_run_at(root, agent_id, run_id) { Ok(true) => { let allowed = autonomous_manifest_owner_artifact_paths(agent_id); diff --git a/apps/ai-game-creator-shell/src-tauri/src/patchset.rs b/apps/ai-game-creator-shell/src-tauri/src/patchset.rs index dbe032e3e..ca3125e28 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/patchset.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/patchset.rs @@ -7,7 +7,9 @@ use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use unicode_normalization::UnicodeNormalization; -use crate::project::{normalize_relative_path, validate_project_root}; +use crate::project::{ + is_plan_fast_gdd_projection_path, normalize_relative_path, validate_project_root, +}; const PROJECT_PATCHSET_MAX_CHANGES: usize = 12; const PROJECT_PATCHSET_MAX_TOTAL_BODY_BYTES: usize = 256 * 1024; @@ -464,6 +466,11 @@ fn normalize_and_validate_patchset_inputs( let path = normalize_relative_path(change.path())?; reject_sensitive_patchset_path(&path)?; + if is_plan_fast_gdd_projection_path(&path) { + return Err( + "project.patchset 不得直接修改 Runtime-owned game/fast_gdd.md 投影".to_string(), + ); + } let change = match change { ParsedProjectPatchsetChange::Create { content, .. } => { validate_input_text(&content, &path)?; @@ -1365,6 +1372,11 @@ fn metadata_is_link_or_reparse(metadata: &Metadata) -> bool { } fn reject_sensitive_patchset_path(relative_path: &str) -> Result<(), String> { + if is_plan_fast_gdd_projection_path(relative_path) { + return Err( + "project.patchset 不得直接修改 Runtime-owned game/fast_gdd.md 投影".to_string(), + ); + } let components = relative_path .split('/') .map(str::to_ascii_lowercase) @@ -1694,6 +1706,8 @@ mod tests { for path in [ ".agent/runtime/state.json", + ".agent/planning/gdd.v1.json", + "game/fast_gdd.md", ".env.local", "config/private.pem", "data/runtime.sqlite", diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs index 571257861..66b6b142b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs @@ -566,6 +566,7 @@ pub(crate) fn restore_local_project_checkpoint_at( pub(crate) fn should_skip_project_restore_path(relative_path: &str) -> bool { should_skip_project_snapshot_path(relative_path) + || is_plan_fast_gdd_projection_path(relative_path) || relative_path == ".agent/agent.db" || relative_path == PROJECT_PERMISSION_POLICY_PATH || relative_path == PROJECT_WRITE_LOCK_PATH diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index a49f17b04..e4561938a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -281,6 +281,54 @@ pub(crate) fn reject_agent_runtime_private_control_path( Ok(()) } +/// `.agent/planning/**` is a Runtime-owned sidecar. It remains readable by +/// the narrow planning read tools, but generic project mutation helpers must +/// never be able to create, replace, patch, or delete it. Keeping this gate +/// separate from `reject_agent_runtime_private_control_path` is deliberate: +/// the planning Agent needs `file.read`/`file.list` observations while its +/// durable writer is still the only component allowed to mutate the sidecar. +pub(crate) fn is_agent_planning_storage_path(normalized_path: &str) -> bool { + normalized_path.eq_ignore_ascii_case(".agent/planning") + || normalized_path + .to_ascii_lowercase() + .starts_with(".agent/planning/") +} + +pub(crate) fn is_agent_planning_managed_write_path(normalized_path: &str) -> bool { + is_agent_planning_storage_path(normalized_path) || normalized_path == "game/fast_gdd.md" +} + +pub(crate) fn reject_agent_planning_storage_write_path( + normalized_path: &str, +) -> Result<(), String> { + if is_agent_planning_managed_write_path(normalized_path) { + return Err( + "`.agent/planning/**` 与 `game/fast_gdd.md` 只能由立项策划 Runtime 专用存储层写入,通用文件写入被拒绝" + .to_string(), + ); + } + Ok(()) +} + +/// `game/fast_gdd.md` is the human-readable planning projection. It lives +/// outside `.agent/planning`, but it is still Runtime-owned and must not be +/// mutated by generic file tools. Keep this predicate write-only so planning +/// observations can continue to read the projection. +pub(crate) fn is_plan_fast_gdd_projection_path(normalized_path: &str) -> bool { + normalized_path.eq_ignore_ascii_case("game/fast_gdd.md") +} + +pub(crate) fn reject_plan_projection_write_path(normalized_path: &str) -> Result<(), String> { + reject_agent_planning_storage_write_path(normalized_path)?; + if is_plan_fast_gdd_projection_path(normalized_path) { + return Err( + "`game/fast_gdd.md` 只能由立项策划 Runtime renderer 写入,通用文件写入被拒绝" + .to_string(), + ); + } + Ok(()) +} + fn reject_agent_control_path_delete(normalized_path: &str) -> Result<(), String> { if matches!( normalized_path.split('/').next(), @@ -317,6 +365,7 @@ pub(crate) fn write_local_project_file_at( ) -> Result { let normalized_path = normalize_relative_path(relative_path)?; reject_agent_runtime_private_control_path(&normalized_path)?; + reject_plan_projection_write_path(&normalized_path)?; let path = resolve_local_project_path(root, &normalized_path)?; if path.exists() && !path.is_file() { return Err("只能写入文件".to_string()); @@ -341,6 +390,7 @@ pub(crate) fn delete_local_project_file_at( ) -> Result { let normalized_path = normalize_relative_path(relative_path)?; reject_agent_runtime_private_control_path(&normalized_path)?; + reject_plan_projection_write_path(&normalized_path)?; reject_agent_control_path_delete(&normalized_path)?; let path = resolve_local_project_path(root, &normalized_path)?; if !path.exists() { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 847b563ca..3a85ae871 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -30,6 +30,16 @@ 2. **plan 之外的 source 多了一个失败面。** 两个 observe 函数新增的 task journal 读取,其 `Err` 分支位于弱判据之前,对 `project-supervisor-gui/cli/game-chat` 同样生效。正常状态不触发(仅 journal I/O 损坏等异常)。保留的依据是:读不到 task 就无法判断这条 run 是不是 plan 根,此时 fail closed 是正确取向;改成「读失败就放行」会引入一个新的 fail-open。**这是对第 19 节「做游戏链路逐字不变」的一处有意例外,仅限异常路径。** 3. **上下文层回归是弱断言。** `plan_root_supervisor_prompt_drops_intro_and_visual_contract_sections` 用「不含某几条独有短句」断言,不是对照组那种字节级 `assert_eq`。已实测非永真。已知漏报场景:若将来 `$visualContract` / `supervisorIntro` 被替换成措辞不同但仍暗示专业组扇出的新文本,这条不会报警。**执行层的 `A1`/`A2` 是该场景的唯一保障**——这也是本包把硬门排在裁段之前的原因。 - 关联文档:`docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md` 第 4.3、22、23.8、24 节。 +## 2026-08-14 M1B-1:planning storage 基础与只挡写隔离完成(待合入) + +- 范围:在 `M1A-2` 的 planning 子 Agent 工具边界之上,先落地 Runtime-owned `.agent/planning/**` 的 typed storage 基础,不注册、不广告、不执行 `plan.submit_gdd`(该工具仍属于 `M1B-2`)。当前实现位于隔离 worktree 的 `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs`,由 `runtime_protocol.rs` 注册。 +- strict 合同:实现 `plan-gdd.v1`、`plan-gdd-index.v1`、`plan-session.v1` 与 `plan.submit_gdd` input 的 `deny_unknown_fields` 结构校验,并复用统一的文本、ID、时间、枚举、数量/字节上限和 `basis=null` 约束。canonical storage bytes 固定为 Rust struct 声明顺序的 compact UTF-8 JSON;BOM、尾换行/空白、重复键、字段重排和语义等价但非 canonical 的 bytes 均拒绝。typed 指纹固定使用 `sha256-serde-json-v2:<64 位小写 hex>` 与 domain separation,GDD fingerprint 排除外层自身字段。 +- durable 边界:GDD/index 等不可变事实使用项目锁 + 同目录临时文件 + `sync_all` + 回读 + OS no-replace 发布;相同 canonical bytes 只返回 replay,其它同路径内容返回 identity conflict。session 使用原子 replace 与单份 `.session.json.previous`,按 `revision + 1` / `previousFingerprint` 链校验;primary 损坏不得静默被 previous 覆盖,只有 primary 缺失且 previous 唯一有效时才允许持锁提升。 +- 写入隔离:`.agent/planning/**` 保持 `file.read` / `file.list` 可读,但通用 `file.write`、`file.patch`、`file.delete`、`project.patchset` 和 checkpoint restore 只挡写;`game/fast_gdd.md` 作为 Runtime renderer 的人读投影同样禁止通用写入。专用 writer 另做 `project-planning + agent-delegate + standard + project-supervisor` identity 校验,失败关闭。 +- 当前验证:第 9.1 节 golden vector 已逐字节复核(3857 bytes,指纹 `sha256-serde-json-v2:a59856de7ef134cf2f49c4dedd2ba10ae4ab2340a9634d402eb792b6ee5458f0`),planning storage 定向测试 11/11 通过,writer 已按目标 schema 重解析并核对文件名/版本,index 与权威 GDD 逐项对账,新增 index recovery API 在锁内从严格 GDD 链重建缺失、损坏或陈旧 index,GDD 文件枚举/连续链读取、session request/decision/phase 约束及 recovery 分叉矩阵均有回归覆盖;`cargo check --offline`、`npm run check:encoding` 与 `git diff --check` 均通过。当前实现仍在隔离 worktree,待提交并合回原分支。 +- index 的 `statusCache` 在 M1B-1 仍是无 approval receipt 的预审批投影:多版本只把最新版本标为 `ready_for_approval`,旧版本标为 `superseded`。M1B-1 尚无 approval receipt schema;M1C-1 接入 receipt 后必须重建真实的 `approved` / `revise` / `reject` / `superseded` 状态。`clarification_round` 与完整 root/session identity 绑定留给后续 `M1B-2` / `M1C-2b` 接线。 +- 边界:本条不代表 GDD 提交点、approval pending/receipt、审批 UI、`game/fast_gdd.md` renderer 或构建 `approvedGddRef` 已可用;这些仍按 `M1B-2` 及后续 `M1C~M1E` 交付。上述边界不影响 M1B-1 存储层本身已完成。 +- 关联:`docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md` 第 8.3~10.2、23.6、23.8 节;`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs`;`apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs`。 ## 2026-08-13 M1A-2:planning 子 Agent 两层工具面与角色 brief 注入 @@ -37,7 +47,7 @@ - planning 子 Agent 的当前 native action exact allowlist 只有 `file.read`、`file.list`;`update_agent_plan` / `respond_to_user` 是协议控制函数,不计入 action capability。MCP catalog 强制为空,`webSearchEnabled=false`,`collaborationPolicy=null`。`plan.submit_gdd` 刻意未注册、未广告、未执行,留给后续 `M1B-2`,因此本条不代表 GDD 提交、版本存储或审批闭环已完成。 - Prompt:Prompt Bundle 新增并登记 `project-planning` role brief,standard planning child 的初始请求与 repair/rebuild 请求均注入同一 brief;Supervisor 和其它 Agent 不注入该 section。brief 只描述 Fast GDD 澄清、终态 `AGC_NEEDS_USER_INPUT_V1`、三轮边界、平台事实与低幻觉约束,不授予任何写入、命令、MCP、预览、生成或审批能力。 - 纵深拒绝:广告层不再向 planning child 暴露 `user.input_request`;Provider parser、action batch/pending、并行只读、执行层和状态恢复均按原始 tool identity 再校验。伪造写入/命令/MCP、`project.search` 等映射为 `file.read` 的 alias、`user.input_request` 都 fail-closed;恢复旧快照不得把 planning 工具面扩回全量目录。委派子 Agent 原有 `validate_user_input_action_owner` 执行层拒绝继续保留。 -- 回归与边界:覆盖 planning 函数目录精确集合、brief 只注入 planning、MCP/web search/collaboration 收窄、原始工具身份拒绝及状态归一化不扩权;Supervisor 根 run 的 standard 工具面保持既有行为。M1A-3 的 source 保留、强判据与 retry 语义不改;`M1B-1`/`M1B-2` 的 planning 存储、strict schema、typed 指纹和 `plan.submit_gdd` 提交仍未实现。 +- 回归与边界:覆盖 planning 函数目录精确集合、brief 只注入 planning、MCP/web search/collaboration 收窄、原始工具身份拒绝及状态归一化不扩权;Supervisor 根 run 的 standard 工具面保持既有行为。M1A-3 的 source 保留、强判据与 retry 语义不改;`M1B-1` 的 planning 存储、strict schema、typed 指纹和写入隔离已完成,`plan.submit_gdd` 提交仍留给 `M1B-2`。 - 关联文档:`docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md` 第 4.3、6、19.2、23.6、23.8 节。 ## 2026-08-13 M1A-3:plan 根 run 强判据与 retry 保源 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index aee2d8e35..c691bab4f 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,13 @@ # 踩坑与排障记录 +## 2026-08-14 planning sidecar 必须区分“可读”与“可写”,canonical bytes 也不等于 typed 指纹 + +- 现象:如果为了保护 Runtime-owned 事实,直接把 `.agent/planning/**` 加进现有 private-control **读**门,planning 子 Agent 的 `file.read` / `file.list` 会一起失败;反过来若只依赖工具面约束,通用 `file.write`、`file.patch`、`file.delete`、`project.patchset` 或 checkpoint restore 仍可能覆盖 GDD/session。另一个常见误判是把“能反序列化且语义相同”的 JSON 当成已提交文件,导致尾换行、字段重排或重复键绕过不可变事实的字节身份。 +- 原因:`.agent/planning/**` 是 Runtime 专用 durable sidecar,但 planning Agent 需要只读观察;`game/fast_gdd.md` 又是 Runtime renderer 的人读投影,二者都不能复用“读写一体”的旧 private path 判据。存储 bytes 与 typed fingerprint 是两个门:前者约束磁盘 canonical serialization(Rust struct 字段顺序、compact UTF-8、无 BOM/尾空白、无重复键),后者约束 domain-separated 业务 payload 的完整性;只过其中一门都不能视为 authoritative。 +- 处理:保持现有 private-control **读**门不含 planning,新增只挡写 predicate;所有通用 mutation 与 restore 路径在推进 revision 前先拒绝 `.agent/planning/**` / `game/fast_gdd.md`,专用 writer 再校验 `project-planning + agent-delegate + standard + project-supervisor` 身份。GDD/index 等不可变文件走项目锁、同目录临时文件、`sync_all`、回读与 no-replace 发布;相同 canonical bytes 才是 replay,任何其它内容都是 identity conflict。session 只保留一个 `.session.json.previous`,primary 损坏时 fail closed,不得拿 previous 猜测新旧。 +- 验证:先用 planning Agent 的只读 action 验证 sidecar 可列出/读取,再逐项证明 `file.write`、`file.patch`、`file.delete`、`project.patchset` 与 checkpoint restore 均拒绝;storage 测试应覆盖 duplicate key、BOM/尾空白、字段顺序、symlink/目录/硬链接、create-only replay/conflict、session 缺 primary 提升、primary 损坏和合法 successor。第 9.1 节 golden vector 当前为 3857 bytes / `sha256-serde-json-v2:a59856de7ef134cf2f49c4dedd2ba10ae4ab2340a9634d402eb792b6ee5458f0`。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs`、`apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs`、`apps/ai-game-creator-shell/src-tauri/src/patchset.rs`、`docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md` 第 8.3~10.2 节。 + ## 2026-08-12 在 autonomous-game-build 下试图向用户提问,会让整条工作流永久瘫痪 - 现象:给自主构建链路加「问用户一句」的需求时,最自然的两个想法——让 DAG 节点自己问、或让父 Supervisor 代问——**都不成立**,而且第二个的失败方式是灾难性的。 diff --git a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md index b4cbd476a..fbaa53213 100644 --- a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md +++ b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md @@ -1,9 +1,9 @@ # 立项策划 Agent(Fast GDD)技术方案 - 日期:2026-08-10 -- 状态:2026-08-12 **M0 代码工作包全部完成**(`M0A-2`、`M0B-1`、`M0B-2` 已合入 M0 集成分支并通过各自门禁,见第 23.4 节);同日 **D6 作废、拓扑改变**,`M0A-1` 交付的文档基线随之失效,需以工作包 `M0A-3` 修订,**修订完成前 M0 不计完整完成**(见第 1.1 节)。2026-08-13 **D9 二次作废、D10 作废,由 D11 取代**:立项策划节点改为 Project Supervisor 通过 `agent.delegate` 发起的静态委派子 Agent,问询复用 PR #165 中转链路(见第 1.1 节「D11 新拓扑」);D11 依赖 WP1(静态委派澄清轮次与返工深度拆分)为强制前置,**该前置已于 2026-08-13 落地并合入**(`WP1` 生产代码 + `WP2` 回归,完成状态与门禁见第 23.5 节),澄清轮次上限现为 3(game-chat source 仍为 1)。随后 `M1A-1`、`M1A-2`、`M1A-3` 已分别落地:`M1A-2` 仅收口两层工具面、`project-planning` role brief 注入和 fail-closed 拒绝边界;`plan.submit_gdd`、`.agent/planning` 存储、提交/审批闭环仍未实现,见第 23.6、23.8 节。后续执行计划见第 23.6 节。 +- 状态:2026-08-12 **M0 代码工作包全部完成**(`M0A-2`、`M0B-1`、`M0B-2` 已合入 M0 集成分支并通过各自门禁,见第 23.4 节);同日 **D6 作废、拓扑改变**,`M0A-1` 交付的文档基线随之失效,需以工作包 `M0A-3` 修订,**修订完成前 M0 不计完整完成**(见第 1.1 节)。2026-08-13 **D9 二次作废、D10 作废,由 D11 取代**:立项策划节点改为 Project Supervisor 通过 `agent.delegate` 发起的静态委派子 Agent,问询复用 PR #165 中转链路(见第 1.1 节「D11 新拓扑」);D11 依赖 WP1(静态委派澄清轮次与返工深度拆分)为强制前置,**该前置已于 2026-08-13 落地并合入**(`WP1` 生产代码 + `WP2` 回归,完成状态与门禁见第 23.5 节),澄清轮次上限现为 3(game-chat source 仍为 1)。随后 `M1A-1`、`M1A-2`、`M1A-3` 已分别落地:`M1A-2` 仅收口两层工具面、`project-planning` role brief 注入和 fail-closed 拒绝边界。**2026-08-14 M1B-1 已在隔离 worktree 完成并通过本包验收门禁,当前待提交、合回原分支**:已落地 `.agent/planning` storage module、strict schema/typed 指纹/canonical parser、GDD 版本链、session 原子恢复、Runtime 写入身份及只挡写门禁;golden vector 与 11 个定向 storage 测试通过,writer/index/recovery 及全仓库门禁已完成。`plan.submit_gdd`、审批闭环和 UI 仍未实现,见第 23.6、23.8 节。后续执行计划见第 23.6 节。 - 适用范围:AI 游戏创作独立 App、Project Supervisor、Agent Runtime、本地项目策划 sidecar 与后续完整构建准入 -- 当前实现边界:本文件是后续详细设计与实现的仓库内阶段基线;M0 工作包冻结 Fast GDD 合同并修复现有 owner 验证、game-chat retry 与前端投影边界,`M1A-1`~`M1A-3` 已提供 plan source、两层工具面和角色 brief 的 Runtime 基础,但不代表立项策划入口、GDD/`.agent/planning` 持久化、`plan.submit_gdd` 提交、审批 UI 或构建绑定已经可用 +- 当前实现边界:本文件是后续详细设计与实现的仓库内阶段基线;M0 工作包冻结 Fast GDD 合同并修复现有 owner 验证、game-chat retry 与前端投影边界,`M1A-1`~`M1A-3` 已提供 plan source、两层工具面和角色 brief 的 Runtime 基础,`M1B-1` 当前 worktree 已提供 storage 基础与写入隔离,但尚未宣称最终合入完成;立项策划入口、`plan.submit_gdd` 提交、审批 UI 或构建绑定仍不可用 ## 1. 背景与目标 @@ -1883,7 +1883,7 @@ M0 完成不表示完整策划闭环已经上线。`M1A-1`、`M1A-2`、`M1A-3` | 三 | `project-planning` 的 agentCatalog 登记 | **已完成**(机制冻结见第 3.1 节;**代码亦已落地**,2026-08-13:manifest、prompt bundle、runtime adapter、`prompt.rs` 角色合成分支及四处 needs_change 全部合入) | | 四 | `M0A-3` 批二:拓扑与工具面部分 | **已完成**(2026-08-13),拆解见下 | | 四之余 | schema 与 golden vector 收口 | **已完成**(2026-08-13),拆解见下 | -| 五 | M1 本体:策划闭环功能实现 | **`M1A-1`、`M1A-2`、`M1A-3` 已落地**;`M1B-1` 及之后仍未开始。M1A-2 只交付工具面、brief 注入和拒绝边界,不包含 GDD 提交/审批。合入门见第 23.8 节 | +| 五 | M1 本体:策划闭环功能实现 | **`M1A-1`、`M1A-2`、`M1A-3` 已落地**;`M1B-1` 已于 2026-08-14 在隔离 worktree 完成并通过本包验收门禁,当前待提交、合回原分支:storage 基础、strict schema、typed 指纹、版本链、session 原子恢复、只挡写门禁及 writer/index/recovery 验证均已完成,golden vector 与 11 个定向 storage 测试通过。`M1A-2` 只交付工具面、brief 注入和拒绝边界,不包含 GDD 提交/审批;`M1B-2` 及之后未开始。合入门见第 23.8 节 | 批二在 2026-08-13 拆成两半,因为其中一半在 M1 代码存在之前**做不完**: @@ -1963,7 +1963,7 @@ M0 完成不表示完整策划闭环已经上线。`M1A-1`、`M1A-2`、`M1A-3` | `M1A-2` | 两层工具面 + `project-planning` 角色 brief | `M1A-1` | **已落地**:Supervisor 根 run 保持 standard 工具面;planning 子 Agent 按 `agent-delegate`/`standard`/Supervisor parent 身份构建 exact native allowlist(`file.read`、`file.list` 与两个协议控制函数),MCP 为空、web search 关闭、collaboration policy 为 `null`;Prompt Bundle brief 只注入 `project-planning`,repair/rebuild 与初始请求一致;伪造写入、命令、MCP、`project.search` alias、`user.input_request` 等原始工具在广告/解析/执行/恢复路径均 fail-closed。**不包含 `plan.submit_gdd` 注册或 GDD 提交/审批。** | | `M1A-3` | plan 根 run 强判据函数 + retry 保源(本节下方「`M1A-3` 的由来」,规格见第 4.1 节第 5、7 段) | `M1A-1` | **已落地**(source 保源与强判据)。`kind=plan-root-retry-identity-unsupported`;gui/cli 兜底不变。第 4.1 节第 7 段里依赖 plan-session / `gddId` / `gdd-approval` kind 的部分仍后置。必须早于 `M1C-2a` | | `M1A-4` | plan 根 run 的子 Agent 创建面收窄:`agent.delegate` 目标必须是 `project-planning`、`agent.spawn_isolated` 一律拒;plan source 下不拼 `supervisorIntro` 与 `$visualContract` | `M1A-2` | 执行层与上下文层**都要做且不可互相替代**(同第 19 节第 2 条纪律);`agent.spawn_isolated` 是第二条造子 Agent 的通道,只堵 `agent.delegate` 视为未完成;强判据 `Err` 必须落进拒绝分支而非当作「不是 plan 根」;`project-supervisor-gui/cli` 的委派与 spawn 行为正常路径不变。**必须早于 `M1D-2` 与 `M1E`** | -| `M1B-1` | `.agent/planning/` 存储层、strict schema、typed 指纹、版本链;含 `.agent/planning/**` 只挡写判据 | `M1A-2` | **第 9.1 节 golden vector 逐字节相等且指纹相等**(先于其它测试);create-only 与等前缀不可变 | +| `M1B-1` | `.agent/planning/` 存储层、strict schema、typed 指纹、版本链;含 `.agent/planning/**` 只挡写判据 | `M1A-2` | **2026-08-14 已在隔离 worktree 完成并通过本包验收门禁,当前待提交、合回原分支**:GDD / index / session strict DTO、canonical bytes、duplicate-key 与后缀门、typed 指纹、连续版本链、session 原子写入/恢复、Runtime writer identity 及 `game/fast_gdd.md` / `.agent/planning/**` 通用写入拒绝;第 9.1 节 golden vector(3857 bytes)与 11 个定向 storage 测试通过,writer 目标 schema 重验、index 权威对账与锁内 index recovery(缺失/损坏/陈旧从严格 GDD 链重建)、恢复故障矩阵、`cargo check --offline`、`npm run check:encoding` 与 `git diff --check` 均通过。当前无 approval receipt schema,多版本 `statusCache` 只是无 receipt 的预审批投影(最新版本 `ready_for_approval`、旧版本 `superseded`);M1C-1 接入 receipt 后必须重建 `approved/revise/reject/superseded` 状态。`plan.submit_gdd`、审批闭环仍留给 `M1B-2` 及后续包;create-only 与等前缀不可变 | | `M1B-2` | `plan.submit_gdd` 原生工具与提交点 | `M1B-1` | 全部拒绝分支;提交点前后强杀恢复;同 submissionId replay 不产生 vN+1 | | `M1C-0` | 新增 `StaticDelegateContractStatus::UserRevisionRequested` 与分类分支 | `M1A-1` | **本 PR 无写入方、是惰性路径,行为零变化**;做游戏链路返工仍在 `depth=1` 被拒;无该变体的历史记录分类不变 | | `M1C-1` | `gdd-approval` pending、审批命令、receipt;receipt 写入上述 status | `M1B-2`、`M1C-0` | 三动作全通;版本+指纹竞态防护;两窗口并发;**连续多次修订均可通过且 `repair_depth` 不变** | @@ -1973,6 +1973,10 @@ M0 完成不表示完整策划闭环已经上线。`M1A-1`、`M1A-2`、`M1A-3` | `M1D-2` | 入口分流与阶段进度 | `M1D-1` | 「直接开建」跳过路径与现状零差异 | | `M1E` | 端到端与故障注入收口 | `M1D-2` | 第 21 节测试矩阵中跨层场景 | +**2026-08-14 `M1B-1` 实现验收快照(待提交、合回原分支)**:当前隔离 worktree 的实现集中在 `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs`,并由 `runtime_protocol.rs` 注册。已具备 `plan-gdd.v1`、`plan-gdd-index.v1`、`plan-session.v1` 及 `plan.submit_gdd` input 的 strict serde 形状校验、文本/ID/时间/枚举边界、typed serde fingerprint、canonical JSON(重复键、BOM、尾空白、字段顺序)解析、GDD 连续版本链、session `revision + 1` / `previousFingerprint` 链、create-only durable writer、session 原子替换与受限 recovery,以及 `project-planning / agent-delegate / standard / project-supervisor` writer identity。通用 `file.write`、`file.patch`、`file.delete`、`project.patchset` 与 checkpoint restore 对 `.agent/planning/**` 和 `game/fast_gdd.md` 只挡写,planning 的 `file.read` / `file.list` 仍可读;本包没有注册或执行 `plan.submit_gdd`,也没有实现 approval pending、receipt、UI 或构建准入。 + +当前已验证第 9.1 节 golden vector(canonical envelope 3857 bytes 与指纹 `sha256-serde-json-v2:a59856de7ef134cf2f49c4dedd2ba10ae4ab2340a9634d402eb792b6ee5458f0`)及 11 个定向 storage 测试;durable writer 的目标 schema 重解析与文件名/版本核对、index 与权威 GDD 对账、缺失/损坏/陈旧 index 的锁内链重建、`(requestId,responseId)` / decision/ref / active run/phase 等 session 约束、GDD 文件枚举/链读取及 primary 损坏/previous 提升/分叉 recovery 矩阵均已覆盖。M1B-1 尚无 approval receipt schema,因此多版本 `statusCache` 只表达无 receipt 的预审批投影;M1C-1 接入 receipt 后重建真实 approved/revise/reject/superseded 状态。`clarification_round` 与完整 root/session identity 绑定留给后续 `M1B-2` / `M1C-2b` 接线。`cargo check --offline`、`npm run check:encoding`、`git diff --check` 均通过;本包仅待提交并合回原分支,不把 `plan.submit_gdd`、审批闭环、UI 或 renderer 误报为已实现。 + **拆包纪律**: - `M1C-0` 与 `M1C-1` 拆开的理由是**风险类别不同**:前者改的是 master 已发布的静态委派机制,后者是 M1 新增功能。合并成一个 PR 会让「做游戏链路返工额度未被误放宽」这条最关键的回归淹没在审批闭环的 diff 里。拆开后 `M1C-0` 全程不产生半状态——它没有写入方,`UserRevisionRequested` 要到 `M1C-1` 才被写出。