diff --git a/Cargo.lock b/Cargo.lock index 68c2991..a5a514b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -25,7 +25,16 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq", - "cpufeatures", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", ] [[package]] @@ -63,11 +72,21 @@ dependencies = [ "blake3", "serde", "serde_json", + "sha2", "thiserror", "time", "uuid", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -77,6 +96,16 @@ dependencies = [ "libc", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "deranged" version = "0.5.8" @@ -86,6 +115,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -116,6 +155,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -253,6 +302,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -326,6 +386,12 @@ dependencies = [ "time-core", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -344,6 +410,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasm-bindgen" version = "0.2.126" diff --git a/Cargo.toml b/Cargo.toml index 40692d4..e1c41ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ serde_json = "1" thiserror = "2" uuid = { version = "1", features = ["v4", "serde"] } blake3 = "1" +sha2 = "0.10" time = { version = "0.3", features = ["serde", "macros"] } [workspace.lints.rust] diff --git a/crates/coven-threads-core/Cargo.toml b/crates/coven-threads-core/Cargo.toml index e51915c..345d0e5 100644 --- a/crates/coven-threads-core/Cargo.toml +++ b/crates/coven-threads-core/Cargo.toml @@ -17,4 +17,5 @@ serde_json.workspace = true thiserror.workspace = true uuid.workspace = true blake3.workspace = true +sha2.workspace = true time.workspace = true diff --git a/crates/coven-threads-core/src/lib.rs b/crates/coven-threads-core/src/lib.rs index f7295b6..235a840 100644 --- a/crates/coven-threads-core/src/lib.rs +++ b/crates/coven-threads-core/src/lib.rs @@ -79,8 +79,10 @@ pub use pattern::{ WeaveCoherence, }; pub use portability::{ - export_weave, from_json_bytes, import_weave, to_json_bytes, PortabilityError, PortableWeave, - SerializationContract, PORTABILITY_FORMAT_VERSION, + export_af, export_weave, export_weave_with_surfaces, from_json_bytes, import_weave, + to_json_bytes, AfCoreMemoryBlock, AfNonRoundTrippableMarker, AfTag, LossyAfExport, + PortabilityError, PortableSurfaceContent, PortableWeave, SerializationContract, + PORTABILITY_FORMAT_VERSION, }; pub use staging::{PendingProposal, StagedContents, StagedEdit}; pub use strand::{HashAlgo, SigKind, Strand, StrandKind}; diff --git a/crates/coven-threads-core/src/portability.rs b/crates/coven-threads-core/src/portability.rs index c3798fd..042012a 100644 --- a/crates/coven-threads-core/src/portability.rs +++ b/crates/coven-threads-core/src/portability.rs @@ -27,13 +27,17 @@ //! *identical authority topology* — never wider (RFC-0001 §5.1: import is //! not a write path around the gates). +use std::collections::BTreeMap; + use serde::{Deserialize, Serialize}; +use serde_json::json; +use sha2::{Digest, Sha256}; use thiserror::Error; use crate::channel::Channel; use crate::ids::{SurfaceId, ThreadId}; use crate::pattern::PatternPredicate; -use crate::strand::{Strand, StrandKind}; +use crate::strand::{HashAlgo, Strand, StrandKind}; use crate::weave::{Weave, WeaveError, WeaveRecord}; /// The portability format version this crate exports and accepts. @@ -102,6 +106,127 @@ pub struct PortableWeave { pub contract_hash: Vec, /// The weave record (threads, tension, weave hash, derived descriptor). pub record: WeaveRecord, + /// Optional protected-surface content payloads, keyed by typed surface. + /// + /// Legacy `.weave` artifacts without this field still import after envelope + /// verification. When present, every payload is checked against the + /// `ContentHash` strands for its surface after the weave itself verifies. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub surfaces: BTreeMap, +} + +/// A surface payload carried inside a Shape B `.weave` envelope. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PortableSurfaceContent { + /// Payload encoding. This crate currently exports and verifies `utf8`. + pub encoding: String, + /// The encoded content bytes as text for `utf8` payloads. + pub data: String, +} + +impl PortableSurfaceContent { + /// Construct a UTF-8 surface payload. + pub fn utf8>(data: S) -> Self { + Self { + encoding: "utf8".to_string(), + data: data.into(), + } + } +} + +/// Lossy one-way Letta `.af` export for handoff. +/// +/// This type intentionally implements `Serialize` only. There is no `.af` import +/// path: Coven re-entry is via the original `.weave` artifact only. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct LossyAfExport { + /// Letta-facing agent type marker. + pub agent_type: String, + /// Explicitly false: the protection layer does not survive `.af`. + pub round_trippable: bool, + /// Letta core-memory blocks, deterministically sorted by surface id. + pub core_memory: Vec, + /// Deterministic creation timestamp for this lossy artifact. + pub created_at: String, + /// Handoff description warning that this is not a Coven re-entry artifact. + pub description: Option, + /// Minimal Letta embedding config for schema-compatible handoff. + pub embedding_config: serde_json::Value, + /// Minimal Letta LLM config for schema-compatible handoff. + pub llm_config: serde_json::Value, + /// Letta message-buffer setting. + pub message_buffer_autoclear: bool, + /// In-context message indices; empty for a content-only handoff. + pub in_context_message_indices: Vec, + /// Letta messages; empty for a content-only handoff. + pub messages: Vec, + /// Letta metadata field. + pub metadata_: Option, + /// Letta multi-agent group field. + pub multi_agent_group: Option, + /// Deterministic agent name derived from the weave's familiar id. + pub name: String, + /// System prompt for the lossy handoff. + pub system: String, + /// Letta tags. + pub tags: Vec, + /// Letta tool environment variables; empty for a content-only handoff. + pub tool_exec_environment_variables: Vec, + /// Letta tool rules; empty for a content-only handoff. + pub tool_rules: Vec, + /// Letta tools; empty for a content-only handoff. + pub tools: Vec, + /// Deterministic update timestamp for this lossy artifact. + pub updated_at: String, + /// Letta `.af` schema version marker. + pub version: String, + /// Coven warning metadata, not an import surface. + pub x_coven_threads: AfNonRoundTrippableMarker, +} + +/// Letta core-memory block in the lossy `.af` handoff. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AfCoreMemoryBlock { + /// Deterministic creation timestamp for this memory block. + pub created_at: String, + /// Optional Letta block description. + pub description: Option, + /// Letta template marker; false for concrete exported surfaces. + pub is_template: bool, + /// Surface id rendered as Letta's block label. + pub label: String, + /// Letta block character budget. + pub limit: usize, + /// Letta block metadata. + pub metadata_: Option, + /// Optional Letta template name. + pub template_name: Option, + /// Deterministic update timestamp for this memory block. + pub updated_at: String, + /// Surface content rendered as Letta's block value. + pub value: String, +} + +/// Letta tag object. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AfTag { + /// Tag text. + pub tag: String, +} + +/// Explicit warning that `.af` is not a Coven round-trip format. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AfNonRoundTrippableMarker { + /// Always true for this exporter. + pub non_round_trippable: bool, + /// Human-readable loss reason. + pub loss_reason: String, + /// Re-entry rule for operators and tools. + pub reentry: String, + /// Canonical source format. + pub source_format: String, + /// Source weave hash for operator correlation only; not an import verifier. + pub source_weave_hash: Vec, } /// Why an export or import failed — every arm is a *visible* refusal (C7). @@ -159,6 +284,48 @@ pub enum PortabilityError { /// duplicate `(surface, writer)` pair). #[error("weave verification failed on import: {0}")] Weave(#[from] WeaveError), + + /// A surface payload uses an encoding this runtime cannot verify. + #[error("surface {surface} uses unsupported content encoding {encoding:?}")] + UnsupportedSurfaceEncoding { + /// The surface whose payload could not be decoded. + surface: SurfaceId, + /// The unsupported encoding string. + encoding: String, + }, + + /// A surface payload was present but no thread `ContentHash` strand commits + /// to that surface. + #[error("surface {surface} content has no matching thread ContentHash strand")] + SurfaceContentUnbound { + /// The unbound surface. + surface: SurfaceId, + }, + + /// A non-empty surfaces map omitted a content payload needed to verify a + /// thread `ContentHash` strand. + #[error("surface {surface} has a ContentHash strand but no content payload")] + SurfaceContentMissing { + /// The missing surface. + surface: SurfaceId, + }, + + /// A surface payload does not match a thread's `ContentHash` strand. + #[error("surface {surface} content hash mismatch for {algorithm:?}")] + SurfaceContentHashMismatch { + /// The surface whose content failed verification. + surface: SurfaceId, + /// Hash algorithm named by the strand. + algorithm: HashAlgo, + /// Hash bytes recorded in the strand. + expected: Vec, + /// Hash bytes computed from the payload. + actual: Vec, + }, + + /// A Letta `.af` handoff requires actual surface content. + #[error("cannot export lossy .af: no surface content is present in the .weave artifact")] + AfExportMissingSurfaces, } /// Export a weave as a portable artifact. @@ -185,6 +352,148 @@ pub fn export_weave(weave: &Weave) -> Result { format_version: contract.format_version.clone(), contract_hash: contract.contract_hash(), record: weave.to_record(), + surfaces: BTreeMap::new(), + }) +} + +/// Export a weave with UTF-8 surface content payloads. +/// +/// The resulting Shape B `.weave` envelope carries surface content keyed by +/// [`SurfaceId`]. The map is verified against the weave's `ContentHash` strands +/// before it leaves this runtime, so a mismatched payload fails visibly at export +/// instead of producing an invalid artifact. +pub fn export_weave_with_surfaces( + weave: &Weave, + surfaces: I, +) -> Result +where + I: IntoIterator, +{ + let mut portable = export_weave(weave)?; + portable.surfaces = surfaces + .into_iter() + .map(|(surface, data)| (surface, PortableSurfaceContent::utf8(data))) + .collect(); + verify_surface_content(weave.threads(), &portable.surfaces)?; + Ok(portable) +} + +/// Export a lossy, one-way Letta `.af` handoff. +/// +/// The output is deliberately marked non-round-trippable. It contains surface +/// text for Letta's `core_memory`, but not the Coven protection layer; no `.af` +/// import path exists in this crate. +pub fn export_af(portable: &PortableWeave) -> Result { + if portable.surfaces.is_empty() { + return Err(PortabilityError::AfExportMissingSurfaces); + } + verify_surface_content(&portable.record.threads, &portable.surfaces)?; + + let timestamp = "1970-01-01T00:00:00Z"; + let core_memory = portable + .surfaces + .iter() + .map(|(surface, content)| { + if content.encoding != "utf8" { + return Err(PortabilityError::UnsupportedSurfaceEncoding { + surface: surface.clone(), + encoding: content.encoding.clone(), + }); + } + Ok(AfCoreMemoryBlock { + created_at: timestamp.to_string(), + description: Some( + "Lossy Coven surface handoff; not a protection boundary".to_string(), + ), + is_template: false, + label: surface.as_str().to_string(), + limit: content.data.len(), + metadata_: None, + template_name: None, + updated_at: timestamp.to_string(), + value: content.data.clone(), + }) + }) + .collect::, _>>()?; + + Ok(LossyAfExport { + agent_type: "coven_familiar_lossy_handoff".to_string(), + round_trippable: false, + core_memory, + created_at: timestamp.to_string(), + description: Some( + "Lossy one-way Letta .af handoff exported from Coven .weave; not round-trippable" + .to_string(), + ), + embedding_config: json!({ + "embedding_endpoint_type": "openai", + "embedding_endpoint": "https://api.openai.com/v1", + "embedding_model": "text-embedding-3-small", + "embedding_dim": 1536, + "embedding_chunk_size": 300, + "handle": null, + "batch_size": 32, + "azure_endpoint": null, + "azure_version": null, + "azure_deployment": null + }), + llm_config: json!({ + "model": "gpt-4o-mini", + "display_name": null, + "model_endpoint_type": "openai", + "model_endpoint": "https://api.openai.com/v1", + "provider_name": null, + "provider_category": null, + "model_wrapper": null, + "context_window": 128000, + "put_inner_thoughts_in_kwargs": false, + "handle": null, + "temperature": 1.0, + "max_tokens": null, + "enable_reasoner": true, + "reasoning_effort": null, + "max_reasoning_tokens": 0, + "effort": null, + "frequency_penalty": null, + "compatibility_type": null, + "verbosity": null, + "tier": null, + "parallel_tool_calls": false, + "response_format": null, + "strict": false, + "return_logprobs": false, + "top_logprobs": null, + "return_token_ids": false, + "tool_call_parser": null + }), + message_buffer_autoclear: false, + in_context_message_indices: Vec::new(), + messages: Vec::new(), + metadata_: None, + multi_agent_group: None, + name: format!("coven-familiar-{}", portable.record.familiar_id), + system: "Lossy Coven familiar handoff. Use the original .weave for Coven re-entry." + .to_string(), + tags: vec![ + AfTag { + tag: "coven".to_string(), + }, + AfTag { + tag: "lossy-af-export".to_string(), + }, + ], + tool_exec_environment_variables: Vec::new(), + tool_rules: Vec::new(), + tools: Vec::new(), + updated_at: timestamp.to_string(), + version: "0.1.0".to_string(), + x_coven_threads: AfNonRoundTrippableMarker { + non_round_trippable: true, + loss_reason: "Coven protection layer is stripped in Letta .af handoff".to_string(), + reentry: "re-entry requires the original .weave; .af import is unsupported".to_string(), + source_format: "coven-threads PortableWeave .weave".to_string(), + source_weave_hash: portable.record.weave_hash.clone(), + }, }) } @@ -251,7 +560,88 @@ pub fn import_weave( // Structural verification: recomputed weave hash must equal the recorded // one; duplicate (surface, writer) pairs are refused (§2.1). - Ok(Weave::from_record(portable.record, pattern)?) + let surfaces = portable.surfaces; + let weave = Weave::from_record(portable.record, pattern)?; + + // Shape B content-map verification happens only after the envelope itself + // verifies. Legacy artifacts without a surfaces map remain valid C7 + // round-trip artifacts; non-empty maps are fail-closed. + verify_surface_content(weave.threads(), &surfaces)?; + + Ok(weave) +} + +fn verify_surface_content( + threads: &[crate::thread::Thread], + surfaces: &BTreeMap, +) -> Result<(), PortabilityError> { + if surfaces.is_empty() { + return Ok(()); + } + + for (surface, content) in surfaces { + let bytes = surface_content_bytes(surface, content)?; + let mut matched = false; + for thread in threads.iter().filter(|thread| &thread.surface == surface) { + for strand in &thread.strands { + if let Strand::ContentHash { + algorithm, value, .. + } = strand + { + matched = true; + let actual = hash_surface_bytes(*algorithm, bytes); + if &actual != value { + return Err(PortabilityError::SurfaceContentHashMismatch { + surface: surface.clone(), + algorithm: *algorithm, + expected: value.clone(), + actual, + }); + } + } + } + } + if !matched { + return Err(PortabilityError::SurfaceContentUnbound { + surface: surface.clone(), + }); + } + } + + for thread in threads { + let has_content_hash = thread + .strands + .iter() + .any(|strand| matches!(strand, Strand::ContentHash { .. })); + if has_content_hash && !surfaces.contains_key(&thread.surface) { + return Err(PortabilityError::SurfaceContentMissing { + surface: thread.surface.clone(), + }); + } + } + + Ok(()) +} + +fn surface_content_bytes<'a>( + surface: &SurfaceId, + content: &'a PortableSurfaceContent, +) -> Result<&'a [u8], PortabilityError> { + if content.encoding == "utf8" { + Ok(content.data.as_bytes()) + } else { + Err(PortabilityError::UnsupportedSurfaceEncoding { + surface: surface.clone(), + encoding: content.encoding.clone(), + }) + } +} + +fn hash_surface_bytes(algorithm: HashAlgo, bytes: &[u8]) -> Vec { + match algorithm { + HashAlgo::Blake3 => blake3::hash(bytes).as_bytes().to_vec(), + HashAlgo::Sha256 => Sha256::digest(bytes).to_vec(), + } } #[cfg(test)] diff --git a/crates/coven-threads-core/tests/c7_roundtrip.rs b/crates/coven-threads-core/tests/c7_roundtrip.rs index 6c48d6c..85ae18c 100644 --- a/crates/coven-threads-core/tests/c7_roundtrip.rs +++ b/crates/coven-threads-core/tests/c7_roundtrip.rs @@ -12,8 +12,8 @@ //! the exported ones — an artifact cannot smuggle wider authority in. use coven_threads_core::portability::{ - export_weave, from_json_bytes, import_weave, to_json_bytes, PortabilityError, - SerializationContract, PORTABILITY_FORMAT_VERSION, + export_af, export_weave, export_weave_with_surfaces, from_json_bytes, import_weave, + to_json_bytes, PortabilityError, SerializationContract, PORTABILITY_FORMAT_VERSION, }; use coven_threads_core::{ AllSurfacesHoldOnChannels, Channel, EventRef, FamiliarId, FrayReason, HashAlgo, ManifestId, @@ -53,6 +53,17 @@ fn all_five_strands() -> Vec { ] } +fn all_five_strands_for_content(content: &str) -> Vec { + let mut strands = all_five_strands(); + let content_hash = blake3::hash(content.as_bytes()).as_bytes().to_vec(); + if let Strand::ContentHash { value, .. } = &mut strands[0] { + *value = content_hash; + } else { + panic!("fixture: first strand should be ContentHash"); + } + strands +} + fn thread_on(surface: &str, writer: &str) -> Thread { Thread { id: ThreadId::new(), @@ -65,6 +76,18 @@ fn thread_on(surface: &str, writer: &str) -> Thread { } } +fn thread_on_with_content(surface: &str, writer: &str, content: &str) -> Thread { + Thread { + id: ThreadId::new(), + surface: SurfaceId::new(surface), + writer: WriterId::new(writer), + strands: all_five_strands_for_content(content), + holds_under: vec![Channel::Forced, Channel::Serialization, Channel::Mutation], + created_at: OffsetDateTime::UNIX_EPOCH, + tension: coven_threads_core::TensionState::Holds, + } +} + fn floor_pattern() -> Box { Box::new(AllSurfacesHoldOnChannels::rfc0001_floor()) } @@ -83,6 +106,29 @@ fn floor_weave() -> Weave { .unwrap() } +fn surfaced_weave() -> (Weave, Vec<(SurfaceId, String)>) { + let surfaces = vec![ + (SurfaceId::new("SOUL.md"), "soul-body\n".to_string()), + (SurfaceId::new("IDENTITY.md"), "identity-body\n".to_string()), + (SurfaceId::new("MEMORY.md"), "memory-body\n".to_string()), + (SurfaceId::new("ward.toml"), "ward-body\n".to_string()), + ]; + let weave = Weave::new( + coven_threads_core::WeaveId::new(), + FamiliarId::new(), + surfaces + .iter() + .map(|(surface, content)| { + thread_on_with_content(surface.as_str(), "principal:val", content) + }) + .collect(), + floor_pattern(), + None, + ) + .unwrap(); + (weave, surfaces) +} + // ── Positive arm ───────────────────────────────────────────────────────────── #[test] @@ -149,6 +195,91 @@ fn import_never_widens_authority() { assert_eq!(exported_topology, imported_topology); } +#[test] +fn surfaces_map_round_trips_and_verifies_content_hash() { + let (weave, surfaces) = surfaced_weave(); + let artifact = export_weave_with_surfaces(&weave, surfaces.clone()).expect("export"); + let bytes = to_json_bytes(&artifact).expect("encode"); + let decoded = from_json_bytes(&bytes).expect("decode"); + let imported = import_weave(decoded, floor_pattern()).expect("import verifies surfaces"); + + assert_eq!(imported.threads(), weave.threads()); + assert_eq!(imported.weave_hash(), weave.weave_hash()); +} + +#[test] +fn tampered_surface_content_fails_closed_after_envelope_verification() { + let (weave, surfaces) = surfaced_weave(); + let mut artifact = export_weave_with_surfaces(&weave, surfaces).expect("export"); + artifact + .surfaces + .get_mut(&SurfaceId::new("SOUL.md")) + .expect("surface") + .data + .push_str("tamper"); + + let err = import_weave(artifact, floor_pattern()).unwrap_err(); + assert!( + matches!(err, PortabilityError::SurfaceContentHashMismatch { .. }), + "got {err:?}" + ); +} + +#[test] +fn af_export_marks_non_round_trippable() { + let (weave, surfaces) = surfaced_weave(); + let artifact = export_weave_with_surfaces(&weave, surfaces).expect("export"); + let af = export_af(&artifact).expect("af export"); + let json = serde_json::to_value(&af).expect("af json"); + + assert_eq!(json["round_trippable"], false); + assert_eq!(json["agent_type"], "coven_familiar_lossy_handoff"); + assert_eq!(json["x_coven_threads"]["non_round_trippable"], true); + assert_eq!( + json["x_coven_threads"]["reentry"], + "re-entry requires the original .weave; .af import is unsupported" + ); + assert!(json["embedding_config"].is_object()); + assert!(json["llm_config"].is_object()); + assert_eq!(json["messages"].as_array().unwrap().len(), 0); + assert_eq!(json["tools"].as_array().unwrap().len(), 0); + assert_eq!(json["core_memory"][0]["is_template"], false); + assert!(json["core_memory"][0]["limit"].as_u64().unwrap() > 0); +} + +#[test] +fn af_export_is_deterministic_and_contains_all_surfaces() { + let (weave, mut surfaces) = surfaced_weave(); + surfaces.reverse(); + let artifact = export_weave_with_surfaces(&weave, surfaces).expect("export"); + + let first = serde_json::to_vec(&export_af(&artifact).expect("af export")).unwrap(); + let second = serde_json::to_vec(&export_af(&artifact).expect("af export")).unwrap(); + assert_eq!(first, second); + + let json: serde_json::Value = serde_json::from_slice(&first).unwrap(); + let labels: Vec<&str> = json["core_memory"] + .as_array() + .unwrap() + .iter() + .map(|entry| entry["label"].as_str().unwrap()) + .collect(); + assert_eq!( + labels, + vec!["IDENTITY.md", "MEMORY.md", "SOUL.md", "ward.toml"] + ); +} + +#[test] +fn legacy_weave_without_surfaces_still_imports() { + let weave = floor_weave(); + let artifact = export_weave(&weave).expect("legacy export"); + assert!(artifact.surfaces.is_empty()); + + let imported = import_weave(artifact, floor_pattern()).expect("legacy import"); + assert_eq!(imported.threads(), weave.threads()); +} + // ── Negative arms: every failure is visible ────────────────────────────────── #[test]