diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index a02e69b6f..d07d47b94 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -1751,7 +1751,7 @@ mod tests { assert!(feature.subjektiv.profile.enabled); assert!(feature.subjektiv.profile.chat.enabled); assert!(!feature.subjektiv.profile.consolidation_tools); - assert!(!feature.subjektiv.profile.extraction.enabled); + assert!(feature.subjektiv.profile.extraction.enabled); assert!(feature.subjektiv.workspace_settings.is_none()); assert!(!feature.subjektiv.execution_enabled()); assert!(!feature.memory.profile.enabled); @@ -1823,6 +1823,8 @@ mod tests { "SubjektivMemoryQuery", "SubjektivMemoryRead", "SubjektivMemoryListChanges", + "SubjektivMemoryRemember", + "SubjektivMemoryProposeChange", "QueryObjective", "ShowObjective", "QueryTicket", diff --git a/crates/server-api/src/authorization.rs b/crates/server-api/src/authorization.rs index d5ac418be..6e0ca042e 100644 --- a/crates/server-api/src/authorization.rs +++ b/crates/server-api/src/authorization.rs @@ -55,6 +55,8 @@ use serde::{Deserialize, Serialize}; mod catalog; pub use catalog::*; +mod subject_defaults; +pub use subject_defaults::*; pub const MAX_SCOPED_PERMISSIONS: usize = 256; diff --git a/crates/server-api/src/authorization/subject_defaults.rs b/crates/server-api/src/authorization/subject_defaults.rs new file mode 100644 index 000000000..e18c9f458 --- /dev/null +++ b/crates/server-api/src/authorization/subject_defaults.rs @@ -0,0 +1,85 @@ +//! Initial conversation authority, not a runtime fallback or a Profile capability. +//! +//! Hosts persist this set only while creating a new Subject. Existing Subjects opt in +//! with the normal administrator CAS replacement: read the current source, merge only +//! the desired entries into `execution`, preserve `delegation`, and submit that observed +//! change ID with a stable operation ID. Never apply it on launch, restore or migration; +//! an unset or explicitly emptied existing policy must remain denied. The Subject detail +//! permissions editor and `GET`/`POST /api/w/{workspace}/subjektiv/subjects/{subject}/permissions` +//! expose the same saved source and CAS. Replacing a source also invalidates previously +//! delegated children; an administrator must deliberately review that effect before applying. +//! No automatic opt-in or overwrite of individual exceptions is provided. +//! +//! `builtin:async-chat` requests the conversation composition and an explicit Subject +//! connection selects the body. Neither action writes policy. All entries below target +//! exactly that Subject in its storage scope; none covers Workspace, files, Workers or +//! other Subjects. Background Worker delegation is deliberately empty. Host-owned +//! Memory/Journal Jobs additionally require their kind/attempt/input/writer grants; +//! these source permissions alone do not create or authorize an arbitrary Job. +use super::{PermissionResource, ScopedPermission}; + +pub const SUBJECT_CONVERSATION_PROFILE: &str = "builtin:async-chat"; + +/// Basic body operations and the current-policy ceiling for standard memory Jobs. +/// Confirmed Memory mutation remains behind consolidation; explicit remembering stages +/// evidence, never directly edits confirmed Memory. Journal recording is a separate Job. +pub const SUBJECT_CONVERSATION_OPERATIONS: &[&str] = &[ + "session.record", + "session.list", + "session.search", + "session.read", + "chat.read", + "chat.send", + "memory.query", + "memory.read", + "memory.changes.list", + "memory.resident.read", + "memory.resident.supply", + "memory.staging.write", + "memory.staging.list", + "memory.staging.read", + "memory.receipt.read", + "memory.proposal.validate", + "memory.candidate.decide", + "memory.extract.read", + "memory.extract.supply", + "memory.extract.commit", + "memory.consolidation.request", + "memory.consolidation.supply", + "memory.surface.prepare", + "memory.surface.supply", + "memory.surface.publish", + "memory.surface.fail", + "journal.startup.read", + "journal.startup.supply", + "journal.compaction.read", + "journal.compaction.supply", + "journal.recording.request", + "journal.job.input.read", + "journal.job.input.supply", + "journal.state.read", + "journal.text.read", + "journal.operation.query", + "journal.operation.list", + "journal.operation.coverage.read", + "journal.entry.create", + "journal.entry.fix", + "journal.entry.rewrite", + "journal.position.update", + "journal.unchanged.confirm", + "journal.checkpoint", + "journal.finish", +]; + +pub fn subject_conversation_permissions(scope: &str, subject: &str) -> Vec { + SUBJECT_CONVERSATION_OPERATIONS + .iter() + .map(|operation| ScopedPermission { + operation: (*operation).into(), + resource: PermissionResource::Subject { + workspace_id: scope.into(), + subject_id: subject.into(), + }, + }) + .collect() +} diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index aa151c016..fbe0d94cb 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -5640,6 +5640,8 @@ pub struct SubjektivSubjectDeleteResponse { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct SubjektivSubjectCreateRequest { + /// Stable across exact retries; changed input with the same ID conflicts. + pub operation_id: String, pub role: String, #[serde(default)] pub behavior_md: String, @@ -11253,7 +11255,7 @@ pub fn memory_api_typescript() -> String { // `ts-rs` solely for this read-only projection. let subjektiv_browser_declarations = r#"export type SubjektivSubjectState = "active" | "retired"; -export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, }; +export type SubjektivSubjectCreateRequest = { operation_id: string, role: string, behavior_md?: string, }; export type SubjektivSubjectDeleteRequest = { operation_id: string, }; diff --git a/crates/subjektiv/src/chat/tests.rs b/crates/subjektiv/src/chat/tests.rs index c060f2ee2..2910f6d61 100644 --- a/crates/subjektiv/src/chat/tests.rs +++ b/crates/subjektiv/src/chat/tests.rs @@ -640,12 +640,21 @@ fn policy( operation: &str, expected: Option<&str>, ) -> String { + // Initial test replacement observes the source installed by normal Subject creation. + // Keep explicit expectations unchanged so replacement races still use their pinned CAS. + let expected = expected.map(str::to_owned).unwrap_or_else(|| { + store + .subject_permissions(subject) + .unwrap() + .unwrap() + .change_id + }); store .set_subject_permissions( subject, &server_api::authorization::SubjectPermissionsUpdate { operation_id: operation.into(), - expected_change_id: expected.map(str::to_owned), + expected_change_id: Some(expected), execution: Vec::new(), delegation: Vec::new(), }, diff --git a/crates/subjektiv/src/deletion/tests.rs b/crates/subjektiv/src/deletion/tests.rs index 8f49014e6..1662cd989 100644 --- a/crates/subjektiv/src/deletion/tests.rs +++ b/crates/subjektiv/src/deletion/tests.rs @@ -226,7 +226,12 @@ fn deletion_identity_conflicts_are_scope_wide_but_do_not_cross_workspaces() { assert_eq!(store.list_memories(&b).unwrap().len(), 1); let fresh = store.create_subject(role()).unwrap().id; assert!(store.list_memories(&fresh).unwrap().is_empty()); - assert!(store.subject_permissions(&fresh).unwrap().is_none()); + let fresh_source = store.subject_permissions(&fresh).unwrap().unwrap(); + assert_eq!( + fresh_source.execution, + server_api::authorization::subject_conversation_permissions(store.scope_id(), &fresh) + ); + assert!(fresh_source.delegation.is_empty()); } #[test] @@ -259,8 +264,14 @@ fn concurrent_begin_and_write_serialize_and_exact_finish_is_replayable() { fn every_subject_owned_table_has_old_and_new_sql_fences_and_retention_is_scoped() { let temp = tempfile::tempdir().unwrap(); let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); - let a = store.create_subject(role()).unwrap().id; - let b = store.create_subject(role()).unwrap().id; + let a = store + .create_subject_idempotent(role(), "private behavior a".into(), "create-a") + .unwrap() + .id; + let b = store + .create_subject_idempotent(role(), "private behavior b".into(), "create-b") + .unwrap() + .id; store .stage_candidate(candidate(&a, "a", store.workspace_id())) .unwrap(); @@ -268,11 +279,18 @@ fn every_subject_owned_table_has_old_and_new_sql_fences_and_retention_is_scoped( .stage_candidate(candidate(&b, "b", store.workspace_id())) .unwrap(); store.begin_subject_deletion(&a, "delete").unwrap(); - store.database.try_with_connection::<_, SubjektivError>(|c| { + let creation_receipts = store.database.try_with_connection::<_, SubjektivError>(|c| { let tables = c.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")?.query_map([], |r| r.get::<_,String>(0))?.collect::,_>>()?; for table in tables { let columns = c.prepare(&format!("PRAGMA table_info({table})"))?.query_map([], |r| r.get::<_,String>(1))?.collect::,_>>()?; if !columns.iter().any(|v| v == "subject_id") || table.starts_with("subject_delet") { continue; } + if table == "subject_creation_receipts" { + // Retained non-content identity tombstones prevent duplicate recreation after erase. + // They have no Subject FK or content to purge, and intentionally need no deletion fence. + assert_eq!(columns, ["operation_id", "subject_id", "request_fingerprint"]); + assert!(c.prepare("PRAGMA foreign_key_list(subject_creation_receipts)")?.query([])?.next()?.is_none()); + continue; + } assert!(DIRECT.contains(&table.as_str()) || table == "journal_erased_sessions" || table == "chat_conversations", "unclassified Subject table: {table}"); } for table in DIRECT.iter().copied().chain(INDIRECT.iter().map(|v| v.0)).chain(["journal_erased_sessions"]) { @@ -289,9 +307,22 @@ fn every_subject_owned_table_has_old_and_new_sql_fences_and_retention_is_scoped( assert!(c.execute("DELETE FROM staging_records WHERE subject_id=?1", [&b]).is_err()); assert!(c.execute("DELETE FROM subject_deletions WHERE subject_id=?1", [&a]).is_err()); assert!(c.execute("UPDATE subject_deletions SET operation_id='changed' WHERE subject_id=?1", [&a]).is_err()); - Ok(()) + Ok(c.prepare("SELECT operation_id,subject_id,request_fingerprint FROM subject_creation_receipts ORDER BY operation_id")? + .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)))? + .collect::, _>>()?) }).unwrap(); + assert_eq!(creation_receipts.len(), 2); + assert_eq!(creation_receipts[0].1, a); + assert_eq!(creation_receipts[1].1, b); store.finish_subject_deletion(&a, "delete").unwrap(); + let retained = store.database.try_with_connection::<_, SubjektivError>(|c| { + Ok(c.prepare("SELECT operation_id,subject_id,request_fingerprint FROM subject_creation_receipts ORDER BY operation_id")? + .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)))? + .collect::, _>>()?) + }).unwrap(); + assert_eq!(retained, creation_receipts); + assert!(store.subject(&a).unwrap().is_none()); + assert!(store.subject(&b).unwrap().is_some()); assert!(store.staging_candidate(&b, "b").unwrap().is_some()); } diff --git a/crates/subjektiv/src/journal/memory_links/tests.rs b/crates/subjektiv/src/journal/memory_links/tests.rs index 66605e943..246fd230e 100644 --- a/crates/subjektiv/src/journal/memory_links/tests.rs +++ b/crates/subjektiv/src/journal/memory_links/tests.rs @@ -35,9 +35,13 @@ impl Fixture { )) .unwrap(); let store = SubjektivStore::open(&workspace, ®istration).unwrap(); - let subject = store - .create_subject(SubjectRole::new("companion").unwrap()) - .unwrap(); + let subject = if migrations.last().unwrap().version() < 12 { + crate::journal::tests::pre_permissions_subject_fixture(&store, "companion", "") + } else { + store + .create_subject(SubjectRole::new("companion").unwrap()) + .unwrap() + }; let attr = SubjectSessionAttribution::new(&subject.id, "runtime", "worker", "session").unwrap(); store.record_session_attribution(attr.clone()).unwrap(); diff --git a/crates/subjektiv/src/journal/tests.rs b/crates/subjektiv/src/journal/tests.rs index c20707123..d246552bc 100644 --- a/crates/subjektiv/src/journal/tests.rs +++ b/crates/subjektiv/src/journal/tests.rs @@ -429,12 +429,16 @@ mod input_delivery { )) .unwrap(); let store = SubjektivStore::open(&scope, ®istration).unwrap(); - let subject = store - .create_subject_with_behavior( - SubjectRole::new("preserved").unwrap(), - "Existing behavior".into(), - ) - .unwrap(); + let subject = if baseline.last().unwrap().version() < 12 { + pre_permissions_subject_fixture(&store, "preserved", "Existing behavior") + } else { + store + .create_subject_with_behavior( + SubjectRole::new("preserved").unwrap(), + "Existing behavior".into(), + ) + .unwrap() + }; drop(store); drop(scope); manager.shutdown().unwrap(); @@ -1591,6 +1595,42 @@ use sha2::{Digest, Sha256}; use std::sync::{Arc, Barrier}; use std::thread; +pub(super) fn pre_permissions_subject_fixture( + store: &SubjektivStore, + role: &str, + behavior: &str, +) -> crate::SubjectRecord { + // Freeze the schema 7–11 row/record shape; today's create API also writes policy tables. + assert!((7..=11).contains(&store.database.schema_version().unwrap())); + let record = serde_json::json!({ + "schema_version": 1, + "id": "legacy-subject", + "role": role, + "behavior_md": behavior, + "state": "active", + "created_at": "2026-09-28T10:00:00.000Z", + "updated_at": "2026-09-28T10:00:00.000Z" + }); + store + .database + .try_transaction::<_, crate::SubjektivError>(|tx| { + tx.execute( + "INSERT INTO subjects(subject_id,role,behavior_md,state,record_json,created_at,updated_at) + VALUES(?1,?2,?3,'active',?4,?5,?5)", + rusqlite::params![ + "legacy-subject", + role, + behavior, + record.to_string(), + "2026-09-28T10:00:00.000Z", + ], + )?; + Ok(()) + }) + .unwrap(); + serde_json::from_value(record).unwrap() +} + #[test] fn explicit_erasure_releases_only_after_all_body_sessions_including_unadmitted_history_are_erased() { @@ -3133,16 +3173,17 @@ impl Fixture { SubjektivStore::register(&workspace).unwrap() }; let store = SubjektivStore::open(&workspace, ®istration).unwrap(); - let subject = store - .create_subject_with_behavior( - SubjectRole::new("companion").unwrap(), - if upgrade { - "Preserve existing Subject behavior".into() - } else { - String::new() - }, + let subject = if upgrade { + pre_permissions_subject_fixture( + &store, + "companion", + "Preserve existing Subject behavior", ) - .unwrap(); + } else { + store + .create_subject(SubjectRole::new("companion").unwrap()) + .unwrap() + }; let attribution = SubjectSessionAttribution::new(&subject.id, "runtime", "worker", "source-session") .unwrap(); @@ -5508,12 +5549,7 @@ fn journal_migration_failure_rolls_back_ddl_and_retries_without_changing_existin )) .unwrap(); let store = SubjektivStore::open(&scope, ®istration).unwrap(); - let subject = store - .create_subject_with_behavior( - SubjectRole::new("preserved").unwrap(), - "Original behavior".into(), - ) - .unwrap(); + let subject = pre_permissions_subject_fixture(&store, "preserved", "Original behavior"); store .database .try_transaction::<_, JournalError>(|tx| { diff --git a/crates/subjektiv/src/lib.rs b/crates/subjektiv/src/lib.rs index 645a98a13..dd7d22ec8 100644 --- a/crates/subjektiv/src/lib.rs +++ b/crates/subjektiv/src/lib.rs @@ -9,6 +9,7 @@ pub mod journal; mod legacy_migrations; pub mod permissions; mod schema; +mod subject_creation; use legacy_migrations::{ add_candidate_decision_receipts, add_subject_behavior, add_subject_session_attribution, add_surface_generation_state, add_surface_job_provenance, create_schema, @@ -54,6 +55,8 @@ pub enum SubjektivError { InvalidRecord(String), #[error("subject `{0}` was not found in this Workspace")] SubjectNotFound(String), + #[error("Subject creation request `{0}` was reused with different input")] + SubjectCreationConflict(String), #[error("Subject deletion request `{0}` conflicts with a saved deletion")] SubjectDeletionConflict(String), #[error("subject `{0}` is legacy-retired")] @@ -658,6 +661,11 @@ static MIGRATIONS: &[FeatureMigration] = &[ deletion::migrate, ), FeatureMigration::new(15, "add retained text-only conversations", chat::migrate), + FeatureMigration::new( + 16, + "retain replayable Subject creation receipts", + subject_creation::migrate, + ), ]; pub const REGISTRATION: FeatureRegistration = @@ -754,34 +762,21 @@ impl SubjektivStore { role: SubjectRole, behavior_md: String, ) -> Result { - validate_subject_behavior(&behavior_md)?; - let timestamp = now(); - let record = SubjectRecord { - schema_version: SUBJEKTIV_SCHEMA_VERSION, - id: issued_id("subject"), - role, - behavior_md, - state: SubjectState::Active, - created_at: timestamp.clone(), - updated_at: timestamp, - }; - let raw = serde_json::to_string(&record)?; - self.database.try_transaction(|transaction| { - transaction.execute( - "INSERT INTO subjects ( - subject_id, role, behavior_md, state, record_json, created_at, updated_at - ) VALUES (?1, ?2, ?3, 'active', ?4, ?5, ?6)", - params![ - record.id, - record.role.as_str(), - record.behavior_md, - raw, - record.created_at, - record.updated_at - ], - )?; - Ok(record) - }) + self.create_subject_request(role, behavior_md, None) + } + + /// Replays the original creation without reissuing permissions or changing the Subject. + /// The Host authenticates the administrator before calling. Keep the same operation ID + /// and full input after an unknown outcome; different input is a conflict. + pub fn create_subject_idempotent( + &self, + role: SubjectRole, + behavior_md: String, + operation_id: &str, + ) -> Result { + server_api::authorization::validate_authorization_identifier(operation_id) + .map_err(SubjektivError::InvalidRecord)?; + self.create_subject_request(role, behavior_md, Some(operation_id)) } pub fn subject(&self, subject_id: &str) -> Result> { diff --git a/crates/subjektiv/src/permissions/tests.rs b/crates/subjektiv/src/permissions/tests.rs index af859b04f..674b81599 100644 --- a/crates/subjektiv/src/permissions/tests.rs +++ b/crates/subjektiv/src/permissions/tests.rs @@ -68,16 +68,71 @@ fn open_version( (manager, scope, store) } +fn legacy_unset_source_fixture(store: &SubjektivStore, subject: &str) { + // Model an unset legacy source without deleting immutable permission history or receipts. + store + .database + .try_transaction::<_, SubjektivError>(|tx| { + assert_eq!( + tx.execute( + "DELETE FROM subject_permissions WHERE subject_id=?1", + [subject] + )?, + 1 + ); + Ok(()) + }) + .unwrap(); + assert!(store.subject_permissions(subject).unwrap().is_none()); +} + +fn pre_permissions_subject_fixture(store: &SubjektivStore, behavior: &str) -> SubjectRecord { + // Freeze the version-11 row/record shape rather than invoking today's initializing create API. + assert_eq!(store.database.schema_version().unwrap(), 11); + let record = serde_json::json!({ + "schema_version": 1, + "id": "legacy-subject", + "role": role().as_str(), + "behavior_md": behavior, + "state": "active", + "created_at": "2026-09-28T10:00:00.000Z", + "updated_at": "2026-09-28T10:00:00.000Z" + }); + store + .database + .try_transaction::<_, SubjektivError>(|tx| { + tx.execute( + "INSERT INTO subjects(subject_id,role,behavior_md,state,record_json,created_at,updated_at) + VALUES(?1,?2,?3,'active',?4,?5,?5)", + params![ + "legacy-subject", + role().as_str(), + behavior, + record.to_string(), + "2026-09-28T10:00:00.000Z", + ], + )?; + Ok(()) + }) + .unwrap(); + serde_json::from_value(record).unwrap() +} + #[test] fn replacements_are_cas_fenced_replayable_and_never_restore_old_permissions() { let temp = tempfile::tempdir().unwrap(); let subject; let first; - let first_update = update("allow", None, vec![permission("file.read", ".", true)]); + let first_update; { let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); subject = store.create_subject(role()).unwrap().id; - assert!(store.subject_permissions(&subject).unwrap().is_none()); + let initial = store.subject_permissions(&subject).unwrap().unwrap(); + first_update = update( + "allow", + Some(&initial.change_id), + vec![permission("file.read", ".", true)], + ); first = store .set_subject_permissions(&subject, &first_update) .unwrap(); @@ -144,8 +199,12 @@ fn failed_receipt_insert_rolls_back_source_history_and_projection() { let temp = tempfile::tempdir().unwrap(); let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); let subject = store.create_subject(role()).unwrap().id; + let initial = store.subject_permissions(&subject).unwrap().unwrap(); let first = store - .set_subject_permissions(&subject, &update("first", None, Vec::new())) + .set_subject_permissions( + &subject, + &update("first", Some(&initial.change_id), Vec::new()), + ) .unwrap(); store.database.try_transaction::<_, SubjektivError>(|tx| { tx.execute_batch("CREATE TRIGGER fail_permission_receipt BEFORE INSERT ON subject_permission_receipts BEGIN SELECT RAISE(ABORT,'injected receipt failure'); END;")?; @@ -164,7 +223,7 @@ fn failed_receipt_insert_rolls_back_source_history_and_projection() { assert_eq!(store.subject_permissions(&subject).unwrap(), Some(first)); store.database.try_transaction::<_, SubjektivError>(|tx| { let counts: (u32, u32) = tx.query_row("SELECT (SELECT count(*) FROM subject_permission_changes),(SELECT count(*) FROM subject_permission_receipts)", [], |r| Ok((r.get(0)?, r.get(1)?)))?; - assert_eq!(counts, (1, 1)); + assert_eq!(counts, (2, 1)); tx.execute_batch("DROP TRIGGER fail_permission_receipt")?; Ok(()) }).unwrap(); @@ -181,6 +240,7 @@ fn scope_and_validation_rejections_do_not_issue_permission_sources() { let (_a_manager, _a_scope, a) = open_store(&temp.path().join("a"), "workspace-a"); let (_b_manager, _b_scope, b) = open_store(&temp.path().join("b"), "workspace-b"); let subject = a.create_subject(role()).unwrap().id; + legacy_unset_source_fixture(&a, &subject); assert!( b.set_subject_permissions(&subject, &update("cross-subject", None, Vec::new())) .is_err() @@ -220,10 +280,15 @@ fn child_grants_are_immutable_bounded_and_do_not_create_subject_ownership() { let temp = tempfile::tempdir().unwrap(); let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); let subject = store.create_subject(role()).unwrap().id; + let initial = store.subject_permissions(&subject).unwrap().unwrap(); let source = store .set_subject_permissions( &subject, - &update("allow", None, vec![permission("file.read", "src", true)]), + &update( + "allow", + Some(&initial.change_id), + vec![permission("file.read", "src", true)], + ), ) .unwrap(); let child = grant(&subject, &source.change_id, "child"); @@ -278,10 +343,15 @@ fn redelegation_requires_current_source_and_explicit_retained_parent_ceiling() { let temp = tempfile::tempdir().unwrap(); let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); let subject = store.create_subject(role()).unwrap().id; + let initial = store.subject_permissions(&subject).unwrap().unwrap(); let source = store .set_subject_permissions( &subject, - &update("allow", None, vec![permission("file.read", ".", true)]), + &update( + "allow", + Some(&initial.change_id), + vec![permission("file.read", ".", true)], + ), ) .unwrap(); let mut child = grant(&subject, &source.change_id, "child"); @@ -325,10 +395,15 @@ fn journal_state_neither_authenticates_roots_nor_bypasses_retained_child_ceiling let temp = tempfile::tempdir().unwrap(); let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); let subject = store.create_subject(role()).unwrap().id; + let initial = store.subject_permissions(&subject).unwrap().unwrap(); let source = store .set_subject_permissions( &subject, - &update("allow", None, vec![permission("file.read", ".", true)]), + &update( + "allow", + Some(&initial.change_id), + vec![permission("file.read", ".", true)], + ), ) .unwrap(); // The Host has authenticated root ownership outside this store. No Journal record is @@ -369,10 +444,15 @@ fn retained_origins_reject_cycles_foreign_subjects_and_stale_parent_sources() { let temp = tempfile::tempdir().unwrap(); let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); let subject = store.create_subject(role()).unwrap().id; + let initial = store.subject_permissions(&subject).unwrap().unwrap(); let source = store .set_subject_permissions( &subject, - &update("allow", None, vec![permission("file.read", ".", true)]), + &update( + "allow", + Some(&initial.change_id), + vec![permission("file.read", ".", true)], + ), ) .unwrap(); let mut child = grant(&subject, &source.change_id, "child"); @@ -386,8 +466,16 @@ fn retained_origins_reject_cycles_foreign_subjects_and_stale_parent_sources() { Err(SubjektivError::AuthorizationDenied(_)) )); let other = store.create_subject(role()).unwrap().id; + let other_initial = store.subject_permissions(&other).unwrap().unwrap(); let other_source = store - .set_subject_permissions(&other, &update("other", None, source.delegation.clone())) + .set_subject_permissions( + &other, + &update( + "other", + Some(&other_initial.change_id), + source.delegation.clone(), + ), + ) .unwrap(); let mut foreign = grant(&other, &other_source.change_id, "foreign-child"); foreign.origin_worker_id = "child".into(); @@ -414,10 +502,15 @@ fn failed_grant_insert_leaves_no_binding_and_can_retry_same_grant() { let temp = tempfile::tempdir().unwrap(); let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); let subject = store.create_subject(role()).unwrap().id; + let initial = store.subject_permissions(&subject).unwrap().unwrap(); let source = store .set_subject_permissions( &subject, - &update("allow", None, vec![permission("file.read", ".", true)]), + &update( + "allow", + Some(&initial.change_id), + vec![permission("file.read", ".", true)], + ), ) .unwrap(); let child = grant(&subject, &source.change_id, "child"); @@ -470,9 +563,7 @@ fn current_schema_migration_preserves_subject_and_matches_new_database() { let original; { let (_old_manager, _old_scope, old) = open_version(&root, 11); - original = old - .create_subject_with_behavior(role(), "Keep behavior separate".into()) - .unwrap(); + original = pre_permissions_subject_fixture(&old, "Keep behavior separate"); } let (_migrated_manager, _migrated_scope, migrated) = open_store(&root, "workspace-a"); assert_eq!( @@ -502,6 +593,7 @@ fn unset_or_execution_only_source_cannot_issue_child_grants_and_saved_grants_sur { let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); let subject = store.create_subject(role()).unwrap().id; + legacy_unset_source_fixture(&store, &subject); assert!( store .bind_worker_execution_grant(&grant(&subject, "unset", "child")) @@ -556,14 +648,27 @@ fn subject_grant_listing_is_scoped_retained_ordered_and_fails_on_overflow() { .unwrap() .is_empty() ); + let initial = store.subject_permissions(&subject).unwrap().unwrap(); let source = store .set_subject_permissions( &subject, - &update("allow", None, vec![permission("file.read", ".", true)]), + &update( + "allow", + Some(&initial.change_id), + vec![permission("file.read", ".", true)], + ), ) .unwrap(); + let other_initial = store.subject_permissions(&other).unwrap().unwrap(); let other_source = store - .set_subject_permissions(&other, &update("other", None, source.delegation.clone())) + .set_subject_permissions( + &other, + &update( + "other", + Some(&other_initial.change_id), + source.delegation.clone(), + ), + ) .unwrap(); let other_grant = grant(&other, &other_source.change_id, "other-child"); store.bind_worker_execution_grant(&other_grant).unwrap(); @@ -631,7 +736,7 @@ fn failed_schema_migration_rolls_back_new_tables_and_preserves_old_data() { let original; { let (_old_manager, _old_scope, old) = open_version(temp.path(), 11); - original = old.create_subject(role()).unwrap(); + original = pre_permissions_subject_fixture(&old, ""); old.database .try_transaction::<_, SubjektivError>(|tx| { tx.execute_batch("CREATE TABLE subject_permissions (collision TEXT)")?; @@ -660,7 +765,12 @@ fn deletion_fences_source_and_child_grant_replays_and_never_falls_back_after_pur let temp = tempfile::tempdir().unwrap(); let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); let subject = store.create_subject(role()).unwrap().id; - let replacement = update("allow", None, vec![permission("file.read", ".", true)]); + let initial = store.subject_permissions(&subject).unwrap().unwrap(); + let replacement = update( + "allow", + Some(&initial.change_id), + vec![permission("file.read", ".", true)], + ); let source = store .set_subject_permissions(&subject, &replacement) .unwrap(); @@ -688,7 +798,12 @@ fn deletion_fences_source_and_child_grant_replays_and_never_falls_back_after_pur .is_none() ); let fresh = store.create_subject(role()).unwrap().id; - assert!(store.subject_permissions(&fresh).unwrap().is_none()); + let fresh_source = store.subject_permissions(&fresh).unwrap().unwrap(); + assert_eq!( + fresh_source.execution, + server_api::authorization::subject_conversation_permissions("workspace-a", &fresh) + ); + assert!(fresh_source.delegation.is_empty()); assert!( store .record_session_attribution( diff --git a/crates/subjektiv/src/subject_creation.rs b/crates/subjektiv/src/subject_creation.rs new file mode 100644 index 000000000..38f169a7b --- /dev/null +++ b/crates/subjektiv/src/subject_creation.rs @@ -0,0 +1,75 @@ +//! New Subjects receive their self-scoped conversation source in the creation transaction. +//! This path is never called by migration, launch, restore or permission reads. Receipts +//! retain only the issued identity and input fingerprint across erasure, preventing replay +//! from recreating an erased Subject or restoring a revoked policy. +use super::*; +use server_api::authorization::{SubjectPermissions, subject_conversation_permissions}; +use sha2::{Digest, Sha256}; + +pub(super) fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> { + tx.execute_batch( + "CREATE TABLE subject_creation_receipts ( + operation_id TEXT PRIMARY KEY CHECK(length(operation_id)>0), + subject_id TEXT NOT NULL UNIQUE, + request_fingerprint TEXT NOT NULL + );", + )?; + Ok(()) +} + +impl SubjektivStore { + pub(super) fn create_subject_request( + &self, + role: SubjectRole, + behavior_md: String, + operation_id: Option<&str>, + ) -> Result { + validate_subject_behavior(&behavior_md)?; + let fingerprint: String = Sha256::digest(serde_json::to_vec(&(&role, &behavior_md))?) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + self.database.try_transaction(|tx| { + if let Some(operation) = operation_id { + let receipt: Option<(String, String)> = tx.query_row( + "SELECT subject_id,request_fingerprint FROM subject_creation_receipts WHERE operation_id=?1", + [operation], |row| Ok((row.get(0)?, row.get(1)?)), + ).optional()?; + if let Some((subject, saved)) = receipt { + if saved != fingerprint { + return Err(SubjektivError::SubjectCreationConflict(operation.into())); + } + // Current identity/state, never a stale success that can recreate deleted data. + return require_active_subject(tx, &subject); + } + } + let timestamp = now(); + let record = SubjectRecord { + schema_version: SUBJEKTIV_SCHEMA_VERSION, + id: issued_id("subject"), role, behavior_md, + state: SubjectState::Active, + created_at: timestamp.clone(), updated_at: timestamp, + }; + tx.execute( + "INSERT INTO subjects(subject_id,role,behavior_md,state,record_json,created_at,updated_at) + VALUES(?1,?2,?3,'active',?4,?5,?6)", + params![record.id,record.role.as_str(),record.behavior_md,serde_json::to_string(&record)?,record.created_at,record.updated_at], + )?; + let source = SubjectPermissions { + change_id: issued_id("permissions"), + execution: subject_conversation_permissions(self.scope_id(), &record.id), + delegation: vec![], + }; + tx.execute("INSERT INTO subject_permission_changes(subject_id,change_id,record_json) VALUES(?1,?2,?3)", + params![record.id,source.change_id,serde_json::to_string(&source)?])?; + tx.execute("INSERT INTO subject_permissions(subject_id,change_id) VALUES(?1,?2)",params![record.id,source.change_id])?; + if let Some(operation) = operation_id { + tx.execute("INSERT INTO subject_creation_receipts(operation_id,subject_id,request_fingerprint) VALUES(?1,?2,?3)",params![operation,record.id,fingerprint])?; + } + Ok(record) + }) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/subjektiv/src/subject_creation/tests.rs b/crates/subjektiv/src/subject_creation/tests.rs new file mode 100644 index 000000000..f5ee5664e --- /dev/null +++ b/crates/subjektiv/src/subject_creation/tests.rs @@ -0,0 +1,221 @@ +//! SQLite component coverage: normal store creation, persisted source/CAS and exact retries. +//! Authentication/Runtime connections are covered by the Backend connected chat suite. +use super::*; +use crate::tests::{open_store, role}; +use server_api::authorization::{SubjectPermissionsUpdate, validate_managed_subject_permissions}; + +#[test] +fn creation_persists_self_scoped_execution_and_no_delegation() { + let dir = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(dir.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let source = store.subject_permissions(&subject.id).unwrap().unwrap(); + assert_eq!( + source.execution, + subject_conversation_permissions("workspace-a", &subject.id) + ); + assert!(source.delegation.is_empty()); + validate_managed_subject_permissions( + None, + &SubjectPermissionsUpdate { + operation_id: "validate-initial".into(), + expected_change_id: None, + execution: source.execution.clone(), + delegation: vec![], + }, + ) + .unwrap(); + for permission in source.execution { + let mut foreign = permission.clone(); + foreign.resource = server_api::authorization::PermissionResource::Subject { + workspace_id: "workspace-a".into(), + subject_id: "other".into(), + }; + assert!(!permission.covers(&foreign)); + } +} + +#[test] +fn creation_replay_after_reopen_never_duplicates_or_restores_revoked_source() { + let dir = tempfile::tempdir().unwrap(); + let id; + let revoked; + { + let (_manager, _scope, store) = open_store(dir.path(), "workspace-a"); + let first = store + .create_subject_idempotent(role(), "original".into(), "create") + .unwrap(); + assert_eq!( + store + .create_subject_idempotent(role(), "original".into(), "create") + .unwrap(), + first + ); + assert!(matches!( + store.create_subject_idempotent(role(), "changed".into(), "create"), + Err(SubjektivError::SubjectCreationConflict(_)) + )); + id = first.id; + let source = store.subject_permissions(&id).unwrap().unwrap(); + revoked = store + .set_subject_permissions( + &id, + &SubjectPermissionsUpdate { + operation_id: "revoke".into(), + expected_change_id: Some(source.change_id), + execution: vec![], + delegation: vec![], + }, + ) + .unwrap(); + } + let (_manager, _scope, store) = open_store(dir.path(), "workspace-a"); + assert_eq!( + store + .create_subject_idempotent(role(), "original".into(), "create") + .unwrap() + .id, + id + ); + assert_eq!(store.subject_permissions(&id).unwrap(), Some(revoked)); + assert_eq!(store.list_subjects_after(100, None).unwrap().items.len(), 1); +} + +#[test] +fn permission_write_failure_rolls_back_subject_and_creation_receipt() { + let dir = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(dir.path(), "workspace-a"); + store.database.with_connection(|c| { + Ok(c.execute_batch("CREATE TRIGGER fail_initial_policy BEFORE INSERT ON subject_permissions BEGIN SELECT RAISE(ABORT,'injected policy failure'); END;")?) + }).unwrap(); + assert!( + store + .create_subject_idempotent(role(), String::new(), "atomic") + .is_err() + ); + assert!( + store + .list_subjects_after(100, None) + .unwrap() + .items + .is_empty() + ); + store + .database + .with_connection(|c| Ok(c.execute_batch("DROP TRIGGER fail_initial_policy;")?)) + .unwrap(); + let subject = store + .create_subject_idempotent(role(), String::new(), "atomic") + .unwrap(); + assert!(store.subject_permissions(&subject.id).unwrap().is_some()); +} + +#[test] +fn creation_replay_after_erasure_does_not_recreate_subject_or_policy() { + let dir = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(dir.path(), "workspace-a"); + let created = store + .create_subject_idempotent(role(), "private".into(), "create-erased") + .unwrap(); + store.begin_subject_deletion(&created.id, "erase").unwrap(); + assert!( + store + .create_subject_idempotent(role(), "private".into(), "create-erased") + .is_err() + ); + store.finish_subject_deletion(&created.id, "erase").unwrap(); + assert!( + store + .create_subject_idempotent(role(), "private".into(), "create-erased") + .is_err() + ); + assert!( + store + .list_subjects_after(100, None) + .unwrap() + .items + .is_empty() + ); +} + +#[test] +fn receipt_migration_preserves_existing_unset_and_revoked_policies_and_rolls_back_failure() { + let dir = tempfile::tempdir().unwrap(); + let manager = feature_storage::FeatureStorage::new(dir.path()); + let scope = manager.scope("workspace-a").unwrap(); + let old_registration = scope + .register(feature_storage::FeatureRegistration::new( + SUBJEKTIV_FEATURE_ID, + &MIGRATIONS[..15], + )) + .unwrap(); + let old = SubjektivStore::open(&scope, &old_registration).unwrap(); + let unset = old.create_subject(role()).unwrap(); + let revoked = old.create_subject(role()).unwrap(); + let source = old.subject_permissions(&revoked.id).unwrap().unwrap(); + let empty = old + .set_subject_permissions( + &revoked.id, + &SubjectPermissionsUpdate { + operation_id: "revoke-before-upgrade".into(), + expected_change_id: Some(source.change_id), + execution: vec![], + delegation: vec![], + }, + ) + .unwrap(); + old.database + .with_connection(|c| { + c.execute( + "DELETE FROM subject_permissions WHERE subject_id=?1", + [&unset.id], + )?; + c.execute_batch("CREATE TABLE subject_creation_receipts(conflicting_column TEXT);")?; + Ok(()) + }) + .unwrap(); + drop(old); + drop(scope); + drop(manager); + { + let manager = feature_storage::FeatureStorage::new(dir.path()); + let scope = manager.scope("workspace-a").unwrap(); + let registration = SubjektivStore::register(&scope).unwrap(); + assert!(SubjektivStore::open(&scope, ®istration).is_err()); + } + let manager = feature_storage::FeatureStorage::new(dir.path()); + let scope = manager.scope("workspace-a").unwrap(); + let registration = scope + .register(feature_storage::FeatureRegistration::new( + SUBJEKTIV_FEATURE_ID, + &MIGRATIONS[..15], + )) + .unwrap(); + let old = SubjektivStore::open(&scope, ®istration).unwrap(); + assert_eq!(old.database.schema_version().unwrap(), 15); + assert_eq!(old.subject(&unset.id).unwrap(), Some(unset.clone())); + assert_eq!( + old.subject_permissions(&revoked.id).unwrap(), + Some(empty.clone()) + ); + old.database + .with_connection(|c| { + c.execute_batch("DROP TABLE subject_creation_receipts;")?; + Ok(()) + }) + .unwrap(); + drop(old); + drop(scope); + drop(manager); + let manager = feature_storage::FeatureStorage::new(dir.path()); + let scope = manager.scope("workspace-a").unwrap(); + let registration = SubjektivStore::register(&scope).unwrap(); + let upgraded = SubjektivStore::open(&scope, ®istration).unwrap(); + assert_eq!(upgraded.subject_permissions(&unset.id).unwrap(), None); + assert_eq!( + upgraded.subject_permissions(&revoked.id).unwrap(), + Some(empty) + ); + let fresh = upgraded.create_subject(role()).unwrap(); + assert!(upgraded.subject_permissions(&fresh.id).unwrap().is_some()); +} diff --git a/crates/workspace-server/src/profile_settings.rs b/crates/workspace-server/src/profile_settings.rs index 3141de655..c515affa9 100644 --- a/crates/workspace-server/src/profile_settings.rs +++ b/crates/workspace-server/src/profile_settings.rs @@ -495,6 +495,11 @@ pub fn sanitize_workspace_display_name(value: &str) -> Result { fn builtin_profile_summaries(default_profile: Option<&str>) -> Vec { let labels = [ + ( + server_api::authorization::SUBJECT_CONVERSATION_PROFILE, + "Subject conversation", + "Default Subject conversation and memory profile; requires an explicit Subject connection", + ), ( "builtin:companion", "Companion", @@ -541,7 +546,8 @@ pub fn selector_for_builtin_candidate( id: &str, ) -> Option { match id { - "builtin:companion" + "builtin:async-chat" + | "builtin:companion" | "builtin:intake" | "builtin:orchestrator" | "builtin:coder" diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 98a32276d..c36a06b77 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -23856,7 +23856,7 @@ async fn scoped_create_subjektiv_subject( crate::subjektiv::validate_subject_behavior(&request.behavior_md) .map_err(|error| Error::InvalidInput(error.to_string()))?; let subject = open_subjektiv_store(&api)? - .create_subject_with_behavior(role, request.behavior_md) + .create_subject_idempotent(role, request.behavior_md, &request.operation_id) .map_err(subjektiv_store_error)?; Ok(( StatusCode::CREATED, @@ -25752,6 +25752,7 @@ fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error { Error::WorkspacePermissionDenied(error.to_string()) } crate::subjektiv::SubjektivError::SubjectPermissionsConflict(_) + | crate::subjektiv::SubjektivError::SubjectCreationConflict(_) | crate::subjektiv::SubjektivError::SubjectDeletionConflict(_) | crate::subjektiv::SubjektivError::PermissionOperationConflict(_) | crate::subjektiv::SubjektivError::WorkerExecutionGrantConflict(_) => { @@ -45318,7 +45319,10 @@ mod tests { &subject_id, &SubjectPermissionsUpdate { operation_id: "completion-grant".into(), - expected_change_id: None, + expected_change_id: store + .subject_permissions(&subject_id) + .unwrap() + .map(|p| p.change_id), execution: vec![ScopedPermission { operation: "merge_request.complete".into(), resource: PermissionResource::MergeRequest { @@ -47388,7 +47392,7 @@ mod tests { app.clone(), "POST", &format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"), - Some(serde_json::json!({ "role": "Browser author" })), + Some(serde_json::json!({ "operation_id":"browser-unauthorized", "role": "Browser author" })), StatusCode::UNAUTHORIZED, ) .await; @@ -47397,6 +47401,7 @@ mod tests { "POST", &format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"), Some(serde_json::json!({ + "operation_id":"browser-create", "role": " Browser author ", "behavior_md": "Prefer explicit evidence.\nAsk when uncertain." })), @@ -47483,7 +47488,7 @@ mod tests { app.clone(), "POST", &format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"), - Some(serde_json::json!({ "role": " " })), + Some(serde_json::json!({ "operation_id":"invalid-role", "role": " " })), &owner_token, StatusCode::BAD_REQUEST, ) @@ -47496,7 +47501,9 @@ mod tests { app.clone(), "POST", "/api/w/another-workspace/subjektiv/subjects", - Some(serde_json::json!({ "role": "Cross Workspace" })), + Some( + serde_json::json!({ "operation_id":"cross-workspace", "role": "Cross Workspace" }), + ), &owner_token, StatusCode::NOT_FOUND, ) @@ -47581,6 +47588,7 @@ mod tests { workspace_id: TEST_WORKSPACE_ID.to_string(), }), Json(server_api::SubjektivSubjectCreateRequest { + operation_id: format!("create-subject-{}", uuid::Uuid::now_v7()), role: "companion".to_string(), behavior_md: "Prefer explicit evidence.".to_string(), }), @@ -47644,6 +47652,7 @@ mod tests { workspace_id: TEST_WORKSPACE_ID.to_string(), }), Json(server_api::SubjektivSubjectCreateRequest { + operation_id: format!("create-subject-{}", uuid::Uuid::now_v7()), role: "reviewer".to_string(), behavior_md: "x".repeat(server_api::SUBJEKTIV_MAX_BEHAVIOR_BYTES + 1), }), @@ -48139,6 +48148,18 @@ mod tests { serde_json::to_vec(&body).unwrap(), ) }; + let initial = store.subject_permissions(&subject.id).unwrap().unwrap(); + store + .set_subject_permissions( + &subject.id, + &SubjectPermissionsUpdate { + operation_id: "bound-authorization-revoke".into(), + expected_change_id: Some(initial.change_id), + execution: vec![], + delegation: vec![], + }, + ) + .unwrap(); let denied = build_router(api.clone()) .oneshot(check(subject_request.clone())) .await @@ -48158,7 +48179,10 @@ mod tests { &subject.id, &SubjectPermissionsUpdate { operation_id: Uuid::now_v7().to_string(), - expected_change_id: None, + expected_change_id: store + .subject_permissions(&subject.id) + .unwrap() + .map(|p| p.change_id), execution: vec![ ScopedPermission { operation: "memory.resident.supply".into(), @@ -48287,7 +48311,11 @@ mod tests { &subject.id, &SubjectPermissionsUpdate { operation_id: "staging-scope-fixture".into(), - expected_change_id: None, + expected_change_id: open_subjektiv_store(&api) + .unwrap() + .subject_permissions(&subject.id) + .unwrap() + .map(|p| p.change_id), execution, delegation: vec![], }, diff --git a/crates/workspace-server/src/server/tests/chat_delegation_connected_tests.rs b/crates/workspace-server/src/server/tests/chat_delegation_connected_tests.rs index e47a7a0f5..d2add80d7 100644 --- a/crates/workspace-server/src/server/tests/chat_delegation_connected_tests.rs +++ b/crates/workspace-server/src/server/tests/chat_delegation_connected_tests.rs @@ -775,7 +775,7 @@ async fn connected_chat_delegation(scenario: Scenario) { ]; foreign = Some((other_subject, other_ticket)); } - account_request(&h,"POST",&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),Some(json!({"operation_id":"chat-policy","expected_change_id":null,"execution":execution,"delegation":delegation}))).await; + account_request(&h,"POST",&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),Some(json!({"operation_id":"chat-policy","expected_change_id":store.subject_permissions(&subject).unwrap().unwrap().change_id,"execution":execution,"delegation":delegation}))).await; let original_policy = store.subject_permissions(&subject).unwrap().unwrap(); for (name, target) in [("conversation-a", true), ("conversation-b", false)] { let c = account_request( diff --git a/crates/workspace-server/src/server/tests/chat_tests.rs b/crates/workspace-server/src/server/tests/chat_tests.rs index 971573dac..b975329dc 100644 --- a/crates/workspace-server/src/server/tests/chat_tests.rs +++ b/crates/workspace-server/src/server/tests/chat_tests.rs @@ -670,7 +670,10 @@ impl agen::llm_client::LlmClient for ChatModel { is_error, content, .. } = item { - assert!(!is_error, "real connected chat Tool failed: {content:?}"); + assert!( + !is_error, + "real connected chat Tool failed: {item:?}; detail={content:?}" + ); } } let events = match phase { @@ -712,11 +715,33 @@ impl agen::llm_client::LlmClient for ChatModel { request.tools.iter().any(|tool| tool.name == name), "builtin:async-chat must expose {name}" ); - vec![ + let mut events = vec![ Event::tool_use_start(0, format!("chat-tool-{phase}"), name), Event::tool_input_delta(0, input.to_string()), Event::tool_use_stop(0), - ] + ]; + if phase == 3 { + for (index, name, input) in [ + (1, "SubjektivMemoryQuery", json!({})), + ( + 2, + "SubjektivMemoryRemember", + json!({"kind":"lesson","claim":"The connected chat path supports explicit remembering.","why_useful":"Retain tested conversation behavior."}), + ), + (3, "SubjektivSessionList", json!({})), + ] { + assert!( + request.tools.iter().any(|tool| tool.name == name), + "default profile must expose {name}" + ); + events.extend([ + Event::tool_use_start(index, format!("default-memory-{index}"), name), + Event::tool_input_delta(index, input.to_string()), + Event::tool_use_stop(index), + ]); + } + } + events } 4..=6 => vec![ Event::text_block_start(0), @@ -743,38 +768,11 @@ struct ConnectedChat { owner: String, url: String, lose_next_input_ack: Arc, + identity: RuntimeIdentityMaterial, _backend: AuthorizationCallbackServer, _runtime_server: ConnectedRuntimeServer, _dir: tempfile::TempDir, } -fn publish_chat_fixture_profile(api: &WorkspaceApi) { - // A bundled Profile is not automatically a Backend-published candidate. - // Publish the actual built-in without copying or widening its capabilities. - let state = api - .config_store - .load_workspace_config(TEST_WORKSPACE_ID) - .unwrap() - .unwrap(); - let main = state - .snapshot - .get(&config_source::VirtualPath::parse("main.dcdl").unwrap()) - .unwrap(); - let content = r#"{ profile = { default_profile = "project:connected-chat"; entries = [{ selector = "project:connected-chat"; label = "Connected chat"; description = "Real built-in async chat fixture"; profile = import "$builtin/profiles/async-chat.dcdl"; }]; }; }"#; - commit_workspace_config_tree( - api, - TEST_WORKSPACE_ID, - &ConfigCommitRequest { - base_digest: state.snapshot.digest.clone(), - entrypoints: state.contract.entrypoints.clone(), - changes: vec![config_source::ConfigTreeChange::Update { - path: main.path.clone(), - expected_digest: main.content_digest.clone(), - content: content.into(), - }], - }, - ) - .unwrap(); -} impl ConnectedChat { async fn new( @@ -943,7 +941,6 @@ impl ConnectedChat { ) .unwrap(), ); - publish_chat_fixture_profile(&api); seed_test_delivery_binding(&api); let owner = seed_test_workspace_owner_token(api.store.as_ref(), "connected-chat-owner").await; @@ -957,6 +954,7 @@ impl ConnectedChat { owner, url, lose_next_input_ack, + identity, _backend: backend, _runtime_server: runtime_server, _dir: dir, @@ -980,6 +978,54 @@ impl ConnectedChat { } } +fn connected_source_request( + f: &ConnectedChat, + worker: &str, + path: &str, + body: Vec, +) -> Request { + let proof = worker_runtime::auth::RuntimeRequestSourceSigner::from_identity(&f.identity) + .issue( + &f.url, + TEST_WORKSPACE_ID, + Some(worker), + worker_runtime::auth::WORKSPACE_REQUEST_PERMISSION, + "POST", + path, + &body, + i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap(), + 30, + ) + .unwrap(); + Request::builder() + .method("POST") + .uri(path) + .header(CONTENT_TYPE, "application/json") + .header( + worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER, + proof, + ) + .body(Body::from(body)) + .unwrap() +} + +async fn worker_chat_connected( + f: &ConnectedChat, + worker: &str, + operation: Value, +) -> (StatusCode, Value) { + response( + &f.api, + connected_source_request( + f, + worker, + &chat_worker_path(), + serde_json::to_vec(&json!({"operation":operation})).unwrap(), + ), + ) + .await +} + async fn next_chat_input( inputs: &mut tokio::sync::mpsc::Receiver<(usize, String)>, expected: usize, @@ -1049,22 +1095,44 @@ async fn chat_public_receive_busy_messages_commit_before_next_model_input_and_id }) .await; let store = open_subjektiv_store(&f.api).unwrap(); - let subject = store - .create_subject(crate::subjektiv::SubjectRole::new("connected-chat").unwrap()) + let create_wire = + json!({"operation_id":"connected-subject-create","role":"connected-chat","behavior_md":""}); + let subject = f + .post( + &format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"), + create_wire.clone(), + ) + .await["id"] + .as_str() .unwrap() - .id; - let operations = [ - "session.record", - "memory.resident.read", - "memory.resident.supply", - "journal.startup.read", - "journal.startup.supply", - // Restore uses the ordinary fail-closed recording hook as well. - "journal.recording.request", - "chat.read", - "chat.send", - ]; - f.post(&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),json!({"operation_id":"connected-chat-policy","expected_change_id":null,"execution":operations.into_iter().map(|op|subject_permission(&subject,op)).collect::>(),"delegation":[]})).await; + .to_owned(); + assert_eq!( + f.post( + &format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"), + create_wire.clone() + ) + .await["id"], + subject + ); + let (status, managed) = account( + &f.api, + &f.owner, + "GET", + &format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"), + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "{managed}"); + let initial = store.subject_permissions(&subject).unwrap().unwrap(); + assert_eq!( + managed["permissions"], + serde_json::to_value(&initial).unwrap() + ); + assert_eq!( + initial.execution, + server_api::authorization::subject_conversation_permissions(TEST_WORKSPACE_ID, &subject) + ); + assert!(initial.delegation.is_empty()); let conversation = f .post( &conversations_path(), @@ -1073,7 +1141,7 @@ async fn chat_public_receive_busy_messages_commit_before_next_model_input_and_id .await; let c = conversation["id"].as_str().unwrap(); *conversation_id.lock().unwrap() = c.into(); - let created = f.post(&format!("/api/w/{TEST_WORKSPACE_ID}/workers"),json!({"runtime_id":"connected-chat","display_name":"Connected async chat","profile":"project:connected-chat","initial_submit":[],"workdir_attachments":[],"feature_connections":{"subjektiv":{"subject_id":subject}}})).await; + let created = f.post(&format!("/api/w/{TEST_WORKSPACE_ID}/workers"),json!({"runtime_id":"connected-chat","display_name":"Connected async chat","profile":"builtin:async-chat","initial_submit":[],"workdir_attachments":[],"feature_connections":{"subjektiv":{"subject_id":subject}}})).await; let worker = created["worker_id"].as_str().unwrap(); let mut events = f.runtime.subscribe_worker_observation().unwrap(); let first_wire = json!({"operation_id":"connected-first","text":"CHAT_FIRST_USER_CONTENT"}); @@ -1170,6 +1238,19 @@ async fn chat_public_receive_busy_messages_commit_before_next_model_input_and_id "three user messages and two explicit Sends, not internal output" ); assert!(!public.to_string().contains("INTERNAL_ASSISTANT")); + let staged = store.pending_staging_candidates(&subject, 20).unwrap(); + assert!( + staged + .iter() + .any(|candidate| candidate.claim + == "The connected chat path supports explicit remembering."), + "Remember must reach real authorized SQLite staging, not just return a fixture receipt: {staged:?}" + ); + assert_eq!( + store.subject_permissions(&subject).unwrap(), + Some(initial.clone()), + "composition and memory operations never reissue policy" + ); let idle_wire = json!({"operation_id":"connected-idle","text":"CHAT_IDLE_USER_CONTENT"}); let idle = f.post(&messages_path(c), idle_wire.clone()).await; assert_eq!(idle["delivery"]["state"], "accepted"); @@ -1272,6 +1353,88 @@ async fn chat_public_receive_busy_messages_commit_before_next_model_input_and_id "explicit retry after restore preserves one public User message" ); assert!(!public.to_string().contains("INTERNAL_ASSISTANT")); + let other = f + .post( + &format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"), + json!({"operation_id":"foreign-default-subject","role":"foreign"}), + ) + .await["id"] + .as_str() + .unwrap() + .to_owned(); + for permission in [ + subject_permission(&other, "chat.read"), + ScopedPermission { + operation: "filesystem.read".into(), + resource: PermissionResource::Workdir { + working_directory_id: "outside".into(), + path: ".".into(), + recursive: true, + }, + }, + ] { + let (status, body) = response( + &f.api, + connected_source_request( + &f, + worker, + &authorization_path(), + serde_json::to_vec(&permission).unwrap(), + ), + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "initial self source must not cover foreign resources: {body}" + ); + } + let revoked = f.post(&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"), json!({"operation_id":"revoke-chat-defaults","expected_change_id":initial.change_id,"execution":[],"delegation":[]})).await; + assert_eq!( + f.post( + &format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"), + create_wire + ) + .await["id"], + subject + ); + let (status, body) = worker_chat_connected(&f, worker, read(c)).await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "revocation cannot be repaired by profile or create replay: {body}" + ); + f.post(&format!("{lifecycle_path}/stop"), json!({})).await; + let token = f + .api + .runtime + .worker(&worker_ref) + .unwrap() + .restore_observation_token + .unwrap(); + let restored = f + .post( + &format!("{lifecycle_path}/restore"), + json!({"request_id":"restore-revoked-defaults","expected_observation_token":token}), + ) + .await; + assert_ne!( + restored["result"]["state"], "accepted", + "required startup authorization must refuse revoked source: {restored}" + ); + let (status, managed) = account( + &f.api, + &f.owner, + "GET", + &format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"), + None, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + managed["permissions"], revoked["permissions"], + "restore never reissues the initial policy" + ); } #[derive(Clone)] @@ -1302,21 +1465,15 @@ impl agen::llm_client::LlmClient for NotifyObservationModel { async fn browser_notify_preserves_ack_id_limits_retry_and_console_next_draft() { let (tx, mut inputs) = tokio::sync::mpsc::channel(32); let f = ConnectedChat::new(NotifyObservationModel(tx), |router| router).await; - let subject = open_subjektiv_store(&f.api) + let subject = f + .post( + &format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"), + json!({"operation_id":"notify-console-subject","role":"notify-console"}), + ) + .await["id"] + .as_str() .unwrap() - .create_subject(crate::subjektiv::SubjectRole::new("notify-console").unwrap()) - .unwrap() - .id; - let operations = [ - "session.record", - "memory.resident.read", - "memory.resident.supply", - "journal.startup.read", - "journal.startup.supply", - "chat.read", - "chat.send", - ]; - f.post(&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),json!({"operation_id":"notify-console-policy","expected_change_id":null,"execution":operations.into_iter().map(|op|subject_permission(&subject,op)).collect::>(),"delegation":[]})).await; + .to_owned(); let conversation = f .post( &conversations_path(), @@ -1324,7 +1481,7 @@ async fn browser_notify_preserves_ack_id_limits_retry_and_console_next_draft() { ) .await; let c = conversation["id"].as_str().unwrap(); - let created = f.post(&format!("/api/w/{TEST_WORKSPACE_ID}/workers"),json!({"runtime_id":"connected-chat","display_name":"Notify Console","profile":"project:connected-chat","initial_submit":[],"workdir_attachments":[],"feature_connections":{"subjektiv":{"subject_id":subject}}})).await; + let created = f.post(&format!("/api/w/{TEST_WORKSPACE_ID}/workers"),json!({"runtime_id":"connected-chat","display_name":"Notify Console","profile":"builtin:async-chat","initial_submit":[],"workdir_attachments":[],"feature_connections":{"subjektiv":{"subject_id":subject}}})).await; let worker = created["worker_id"].as_str().unwrap(); let chat = f .post( diff --git a/crates/workspace-server/src/server/tests/subject_authorization_tests.rs b/crates/workspace-server/src/server/tests/subject_authorization_tests.rs index f1abbda75..d2a877f62 100644 --- a/crates/workspace-server/src/server/tests/subject_authorization_tests.rs +++ b/crates/workspace-server/src/server/tests/subject_authorization_tests.rs @@ -143,7 +143,14 @@ impl Fixture { &subject, &SubjectPermissionsUpdate { operation_id: "fixture-session-setup".into(), - expected_change_id: None, + expected_change_id: Some( + open_subjektiv_store(&api) + .unwrap() + .subject_permissions(&subject) + .unwrap() + .unwrap() + .change_id, + ), execution: vec![subject_permission(&subject, "session.record")], delegation: vec![], }, @@ -3085,7 +3092,7 @@ async fn connected_read_only_subject_delegates_editing_profile_to_real_runtime_r // not an unset body or a registry/singleton row manufactured by the test. let (status, body) = response(&api, human_request(&owner, "POST", &format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"), - Some(json!({"operation_id":"connected-authorization-policy", "expected_change_id":null, + Some(json!({"operation_id":"connected-authorization-policy", "expected_change_id":subject_store.subject_permissions(&subject).unwrap().unwrap().change_id, "execution":(["session.record", "memory.resident.read", "memory.resident.supply", "journal.startup.read", "journal.startup.supply"].into_iter().map(|operation| subject_permission(&subject, operation)).collect::>()), "delegation":child_permissions.clone()})))).await; assert_eq!(status, StatusCode::OK, "{body}"); @@ -3543,7 +3550,7 @@ async fn connected_two_subjects_delegate_distinct_tickets_concurrently_on_separa .human( reqwest::Method::POST, &format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"), - json!({"operation_id":format!("parallel-{label}-policy"), "expected_change_id":null, + json!({"operation_id":format!("parallel-{label}-policy"), "expected_change_id":subjects.subject_permissions(&subject).unwrap().unwrap().change_id, "execution":execution, "delegation":permissions}), ) .await; diff --git a/crates/workspace-server/src/server/tests/subject_deletion_tests.rs b/crates/workspace-server/src/server/tests/subject_deletion_tests.rs index 5cc349ee1..705ae64a5 100644 --- a/crates/workspace-server/src/server/tests/subject_deletion_tests.rs +++ b/crates/workspace-server/src/server/tests/subject_deletion_tests.rs @@ -132,7 +132,12 @@ async fn deletion_owner_scope_exact_retry_and_subject_isolation() { .create_subject(crate::subjektiv::SubjectRole::new("fresh").unwrap()) .unwrap(); assert_ne!(fresh.id, subject.id); - assert!(store.subject_permissions(&fresh.id).unwrap().is_none()); + let source = store.subject_permissions(&fresh.id).unwrap().unwrap(); + assert_eq!( + source.execution, + server_api::authorization::subject_conversation_permissions(TEST_WORKSPACE_ID, &fresh.id) + ); + assert!(source.delegation.is_empty()); assert_eq!( store .pending_staging_candidates(&fresh.id, 100) diff --git a/crates/workspace-server/src/server/tests/subject_permission_management_tests.rs b/crates/workspace-server/src/server/tests/subject_permission_management_tests.rs index b93b58d5c..efde68717 100644 --- a/crates/workspace-server/src/server/tests/subject_permission_management_tests.rs +++ b/crates/workspace-server/src/server/tests/subject_permission_management_tests.rs @@ -74,7 +74,9 @@ fn update(screen: &Value, operation: &str, execution: Value, delegation: Value) async fn management_read_exposes_operation_use_without_duplicate_worker_target_grants() { let f = ManagementFixture::new().await; let screen = f.read().await; - assert!(screen["permissions"].is_null()); + assert!(screen["permissions"]["change_id"].is_string()); + assert_eq!(screen["permissions"]["delegation"], json!([])); + f.assert_current(&screen["permissions"]); assert_eq!(screen["can_manage"], true); let operations = screen["operations"].as_array().expect("management catalog"); let create = operations @@ -348,7 +350,7 @@ async fn read_only_account_cannot_save_or_replay_an_owner_permission_change() { ) .await; assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); - f.assert_current(&Value::Null); + f.assert_current(&initial["permissions"]); let saved = f.save(input.clone()).await; let (status, body) = response( &f.api, @@ -398,8 +400,8 @@ async fn new_unknown_legacy_unavailable_and_operation_resource_mismatch_are_reje .await; assert_eq!(status, StatusCode::BAD_REQUEST, "{name}/{lane}: {body}"); assert!(body["message"].is_string(), "{body}"); - f.assert_current(&Value::Null); - assert!(f.read().await["permissions"].is_null()); + f.assert_current(&screen["permissions"]); + assert_eq!(f.read().await["permissions"], screen["permissions"]); } } } @@ -417,7 +419,14 @@ async fn unsupported_saved_entries_can_only_be_retained_in_the_same_lane_or_remo &f.subject, &SubjectPermissionsUpdate { operation_id: "seed-pre-catalog-policy".into(), - expected_change_id: None, + expected_change_id: Some( + open_subjektiv_store(&f.api) + .unwrap() + .subject_permissions(&f.subject) + .unwrap() + .unwrap() + .change_id, + ), execution: execution.clone(), delegation: delegation.clone(), }, diff --git a/crates/workspace-server/src/server/tests/subject_spawn_tests.rs b/crates/workspace-server/src/server/tests/subject_spawn_tests.rs index 95e7ba25f..fa00dc258 100644 --- a/crates/workspace-server/src/server/tests/subject_spawn_tests.rs +++ b/crates/workspace-server/src/server/tests/subject_spawn_tests.rs @@ -23,7 +23,13 @@ async fn subject_callbacks_finish_before_waiting_host_parents_release_capacity() &subject.id, &server_api::authorization::SubjectPermissionsUpdate { operation_id: format!("subject-callback-grant-{index}"), - expected_change_id: None, + expected_change_id: Some( + subjektiv + .subject_permissions(&subject.id) + .unwrap() + .unwrap() + .change_id, + ), execution: [ "memory.resident.read", "session.record", diff --git a/crates/workspace-server/src/server/tests/subjektiv_jobs_tests.rs b/crates/workspace-server/src/server/tests/subjektiv_jobs_tests.rs index 92110be5e..5fd6aad22 100644 --- a/crates/workspace-server/src/server/tests/subjektiv_jobs_tests.rs +++ b/crates/workspace-server/src/server/tests/subjektiv_jobs_tests.rs @@ -1011,6 +1011,18 @@ async fn unset_subject_consolidation_denies_before_job_dispatch() { .create_subject(SubjectRole::new("unset-job-policy").unwrap()) .unwrap(); let source = attach_memory_job_source(&api, &subject.id).await; + // A legacy unset policy has no source pointer; retain the initial history fixture. + api.feature_storage + .open(&api.subjektiv_registration) + .unwrap() + .with_connection(|c| { + c.execute( + "DELETE FROM subject_permissions WHERE subject_id=?1", + [&subject.id], + )?; + Ok(()) + }) + .unwrap(); assert!(store.subject_permissions(&subject.id).unwrap().is_none()); stage(&store, &subject.id, "untouched"); let error = start_subjektiv_staging_consolidation( diff --git a/crates/workspace-server/src/server/tests/subjektiv_journal_connected_tests.rs b/crates/workspace-server/src/server/tests/subjektiv_journal_connected_tests.rs index 4315e464c..7c2172d49 100644 --- a/crates/workspace-server/src/server/tests/subjektiv_journal_connected_tests.rs +++ b/crates/workspace-server/src/server/tests/subjektiv_journal_connected_tests.rs @@ -432,37 +432,10 @@ async fn backend_real_runtime_journal_release_and_next_body_persisted_startup() let subject = store .create_subject(crate::subjektiv::SubjectRole::new("connected-handoff").unwrap()) .unwrap(); - let owner = - seed_test_workspace_owner_token(api.store.as_ref(), "connected-journal-owner").await; - // This fixture exercises Journal continuity, not unset-policy refusal. Assign - // the companion's required Subject operations through authenticated administration; - // no filesystem, shell or delegation permission is granted to its body. - let update = server_api::authorization::SubjectPermissionsUpdate { - operation_id: "connected-journal-activation".into(), - expected_change_id: None, - execution: connected_subject_permissions(&subject.id), - delegation: Vec::new(), - }; - let response = build_router(api.clone()) - .oneshot( - axum::http::Request::builder() - .method("POST") - .uri(format!( - "/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{}/permissions", - subject.id - )) - .header("authorization", format!("Bearer {owner}")) - .header(CONTENT_TYPE, "application/json") - .body(Body::from(serde_json::to_vec(&update).unwrap())) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = to_bytes(response.into_body(), 2 * 1024 * 1024) - .await - .unwrap(); - assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); + // Exercise creation-time authority through real recording and startup callbacks, + // without a management grant or an execution/delegation policy replacement. + let initial_policy = store.subject_permissions(&subject.id).unwrap().unwrap(); + assert!(initial_policy.delegation.is_empty()); let old = connected_create(&api, &subject.id, "connected-old-body").await; let old_attr = store .subject_session_attributions(&subject.id) @@ -766,6 +739,11 @@ async fn backend_real_runtime_journal_release_and_next_body_persisted_startup() .unwrap(), before ); + assert_eq!( + store.subject_permissions(&subject.id).unwrap(), + Some(initial_policy), + "recording and startup must not rewrite the initial policy" + ); for (worker, session) in [ (&old, old_attr.session_id.as_str()), (&next, old_attr.session_id.as_str()), @@ -1622,7 +1600,13 @@ async fn connected_restore_fixture_with_pending_head( seed_test_workspace_owner_token(api.store.as_ref(), "restore-host-auth-owner").await; let update = server_api::authorization::SubjectPermissionsUpdate { operation_id: "restore-host-auth-permissions".into(), - expected_change_id: None, + expected_change_id: Some( + store + .subject_permissions(&subject.id) + .unwrap() + .unwrap() + .change_id, + ), execution: connected_subject_permissions(&subject.id), delegation: vec![], }; diff --git a/crates/workspace-server/src/server/tests/subjektiv_memory_journal_tests.rs b/crates/workspace-server/src/server/tests/subjektiv_memory_journal_tests.rs index bbea4d558..8722e5286 100644 --- a/crates/workspace-server/src/server/tests/subjektiv_memory_journal_tests.rs +++ b/crates/workspace-server/src/server/tests/subjektiv_memory_journal_tests.rs @@ -45,7 +45,13 @@ impl Fixture { subject_id, &server_api::authorization::SubjectPermissionsUpdate { operation_id: format!("memory-journal-fixture-grant-{subject_id}"), - expected_change_id: None, + expected_change_id: Some( + store + .subject_permissions(subject_id) + .unwrap() + .unwrap() + .change_id, + ), execution: [ "journal.memory.links.reconcile", "journal.memory.links.read", @@ -83,6 +89,19 @@ impl Fixture { .unwrap(); if granted { Self::grant_permissions(&store, &subject.id); + } else { + // Model an existing Subject with unset policy, retaining creation history. + api.feature_storage + .open(&api.subjektiv_registration) + .unwrap() + .with_connection(|connection| { + connection.execute( + "DELETE FROM subject_permissions WHERE subject_id = ?1", + [&subject.id], + )?; + Ok(()) + }) + .unwrap(); } let worker = Self::body_worker(&api, &subject.id); let source_worker = RuntimeWorkerRef::new(RUNTIME, Uuid::new_v4().to_string()); @@ -802,6 +821,8 @@ async fn memory_journal_unset_or_revoked_permissions_deny_every_route_without_li }, ) .unwrap(); + } else { + assert!(f.store.subject_permissions(&subject).unwrap().is_none()); } let count_links = || { f.api diff --git a/crates/workspace-server/src/server/tests/ticket_worker_messaging_connected_tests.rs b/crates/workspace-server/src/server/tests/ticket_worker_messaging_connected_tests.rs index afa37bac5..4b1d61410 100644 --- a/crates/workspace-server/src/server/tests/ticket_worker_messaging_connected_tests.rs +++ b/crates/workspace-server/src/server/tests/ticket_worker_messaging_connected_tests.rs @@ -551,7 +551,7 @@ async fn public_two_subject_ticket_spawn_notify_edit_result_retry_and_fences() { ]); let (status, policy) = harness.human(reqwest::Method::POST, &format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"), - json!({"operation_id":format!("public-{label}-policy"),"expected_change_id":null,"execution":execution,"delegation":delegation})).await; + json!({"operation_id":format!("public-{label}-policy"),"expected_change_id":subjects.subject_permissions(&subject).unwrap().unwrap().change_id,"execution":execution,"delegation":delegation})).await; assert_eq!(status, StatusCode::OK, "{policy}"); let change = subjects .subject_permissions(&subject) diff --git a/crates/workspace-server/src/server/tests/worker_messaging_connected_tests.rs b/crates/workspace-server/src/server/tests/worker_messaging_connected_tests.rs index dc701fbf2..b2b6b3577 100644 --- a/crates/workspace-server/src/server/tests/worker_messaging_connected_tests.rs +++ b/crates/workspace-server/src/server/tests/worker_messaging_connected_tests.rs @@ -385,7 +385,7 @@ async fn connected_roundtrip(explicit_workdir: bool, repair: bool) { operation("worker.stop"), operation("worker.observe"), ]); - let (status, assigned) = response(api, human_request(&harness.owner,"POST",&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),Some(json!({"operation_id":"public-messaging-policy","expected_change_id":null,"execution":execution,"delegation":permissions.lock().unwrap().clone()})))).await; + let (status, assigned) = response(api, human_request(&harness.owner,"POST",&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),Some(json!({"operation_id":"public-messaging-policy","expected_change_id":store.subject_permissions(&subject).unwrap().unwrap().change_id,"execution":execution,"delegation":permissions.lock().unwrap().clone()})))).await; assert_eq!(status, StatusCode::OK, "{assigned}"); let before = store.subject_permissions(&subject).unwrap().unwrap(); if repair { diff --git a/crates/workspace-server/src/server/tests/workspace_admission_tests.rs b/crates/workspace-server/src/server/tests/workspace_admission_tests.rs index f5737f558..da2378be7 100644 --- a/crates/workspace-server/src/server/tests/workspace_admission_tests.rs +++ b/crates/workspace-server/src/server/tests/workspace_admission_tests.rs @@ -826,7 +826,13 @@ async fn create_and_restore_session_callbacks_finish_while_deletion_start_waits( &subject.id, &server_api::authorization::SubjectPermissionsUpdate { operation_id: "session-callback-grant".into(), - expected_change_id: None, + expected_change_id: Some( + subjektiv + .subject_permissions(&subject.id) + .unwrap() + .unwrap() + .change_id, + ), execution: vec![server_api::authorization::ScopedPermission { operation: "session.record".into(), resource: server_api::authorization::PermissionResource::Subject { diff --git a/crates/workspace-server/src/server_db_execution_tests.rs b/crates/workspace-server/src/server_db_execution_tests.rs index ecf74eb06..35cc40781 100644 --- a/crates/workspace-server/src/server_db_execution_tests.rs +++ b/crates/workspace-server/src/server_db_execution_tests.rs @@ -178,7 +178,11 @@ impl Fixture { Self::request( "POST", SUBJECTS, - serde_json::json!({ "role": role, "behavior_md": "" }), + serde_json::json!({ + "operation_id": format!("create-{role}"), + "role": role, + "behavior_md": "" + }), ) } @@ -1118,6 +1122,7 @@ async fn subjektiv_runtime_waits_leave_db_and_control_free_and_cancel_retains_th workspace_id: WORKSPACE.into(), }), Json(server_api::SubjektivSubjectCreateRequest { + operation_id: format!("create-network-subject-{kind}"), role: "network subject".into(), behavior_md: String::new(), }), @@ -1136,7 +1141,14 @@ async fn subjektiv_runtime_waits_leave_db_and_control_free_and_cancel_retains_th &subject.id, &server_api::authorization::SubjectPermissionsUpdate { operation_id: format!("session-wait-grant-{kind}"), - expected_change_id: None, + expected_change_id: Some( + open_subjektiv_store(&fixture.api) + .unwrap() + .subject_permissions(&subject.id) + .unwrap() + .unwrap() + .change_id, + ), execution: vec![server_api::authorization::ScopedPermission { operation: operation.into(), resource: server_api::authorization::PermissionResource::Subject { diff --git a/openapi/server-api.json b/openapi/server-api.json index f1139939d..1cb14c69c 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -15451,11 +15451,16 @@ "default": "", "type": "string" }, + "operation_id": { + "description": "Stable across exact retries; changed input with the same ID conflicts.", + "type": "string" + }, "role": { "type": "string" } }, "required": [ + "operation_id", "role" ], "type": "object" @@ -22720,7 +22725,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:2ac605f7ea70d0c725f9993a5c59c4a8c4266cf69184902db7b3ea7fd5932224" + "x-yoi-source-digest": "sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c" }, "openapi": "3.1.0", "paths": { diff --git a/resources/profiles/async-chat.dcdl b/resources/profiles/async-chat.dcdl index 26dfc4966..db943e4ad 100644 --- a/resources/profiles/async-chat.dcdl +++ b/resources/profiles/async-chat.dcdl @@ -14,7 +14,7 @@ enabled = true; chat = { enabled = true; }; consolidation_tools = false; - extraction = { enabled = false; }; + extraction = { enabled = true; threshold = 50000; }; }; web = { enabled = false; }; image = { enabled = false; }; @@ -54,6 +54,8 @@ { tool = "SubjektivMemoryQuery"; pattern = "*"; action = "allow"; }, { tool = "SubjektivMemoryRead"; pattern = "*"; action = "allow"; }, { tool = "SubjektivMemoryListChanges"; pattern = "*"; action = "allow"; }, + { tool = "SubjektivMemoryRemember"; pattern = "*"; action = "allow"; }, + { tool = "SubjektivMemoryProposeChange"; pattern = "*"; action = "allow"; }, { tool = "QueryObjective"; pattern = "*"; action = "allow"; }, { tool = "ShowObjective"; pattern = "*"; action = "allow"; }, { tool = "QueryTicket"; pattern = "*"; action = "allow"; }, diff --git a/tools/web-ux/browser-tests/memory_document_fixture_server.ts b/tools/web-ux/browser-tests/memory_document_fixture_server.ts index 7bf6b63ab..85456e973 100644 --- a/tools/web-ux/browser-tests/memory_document_fixture_server.ts +++ b/tools/web-ux/browser-tests/memory_document_fixture_server.ts @@ -43,6 +43,12 @@ const memoryPageCursor = "fixture-memory-page-2"; const changePageCursor = "fixture-change-page-2"; const createdSubjectIds = new Set(); const subjectCreateRequests: unknown[] = []; +type SubjectCreateInput = { operation_id: string; role: string; behavior_md: string }; +const subjectCreateReceipts = new Map< + string, + { request: SubjectCreateInput; subject: ReturnType } +>(); +const subjectCreateOutcomes: { outcome: string; subject_id?: string }[] = []; let createdSubjectCount = 0; const paragraphs = Array.from( @@ -390,6 +396,21 @@ async function staticResponse(pathname: string): Promise { Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { const url = new URL(request.url); if (url.pathname === "/health") return new Response("ok"); + // 合成HTTPの閲覧主体。実Backendの認証や権限付与の証拠にはしない。 + if (url.pathname === "/api/auth/whoami") { + return json({ + authentication_reference: "memory-review-fixture-authentication", + session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, + actor: { + user_id: "fixture-user", + account_id: "owner", + handle: "fixture", + display_name: "Synthetic Memory owner", + auth_method: "browser_session", + }, + }); + } + if (url.pathname === "/api/hosting/access") return json({ is_admin: false }); if (permissionsMode) { const response = await permissionsFixture(request); if (response) return response; @@ -397,6 +418,14 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { if (url.pathname === "/fixture/subject-create-requests") { return json({ count: subjectCreateRequests.length, requests: subjectCreateRequests }); } + if (url.pathname === "/fixture/subject-create-evidence") { + return json({ + authority: "synthetic-http-NOT-real-Backend", + requests: subjectCreateRequests, + outcomes: subjectCreateOutcomes, + created: subjects.filter((item) => createdSubjectIds.has(item.id)), + }); + } if (url.pathname === "/api/workspaces") { return json([{ workspace_id: workspaceId, @@ -488,22 +517,47 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { if (url.pathname === subjectsPath) { if (request.method === "POST") { const payload = await request.json().catch(() => null) as { + operation_id?: unknown; role?: unknown; behavior_md?: unknown; } | null; subjectCreateRequests.push(payload); - if (!payload || typeof payload.role !== "string" || payload.role.trim().length === 0) { - return json({ error: "Bad Request", message: "subject role must not be empty" }, 400); + const reject = (message: string, status = 400) => { + subjectCreateOutcomes.push({ outcome: `rejected-${status}` }); + return json({ error: status === 409 ? "Conflict" : "Bad Request", message }, status); + }; + if ( + !payload || typeof payload.operation_id !== "string" || + payload.operation_id.trim().length === 0 + ) return reject("subject create operation_id is required"); + if (typeof payload.role !== "string" || payload.role.trim().length === 0) { + return reject("subject role must not be empty"); } if (payload.role === "Permission denied") { + subjectCreateOutcomes.push({ outcome: "rejected-403" }); return json({ error: "Forbidden", message: "workspace permission denied" }, 403); } if (typeof payload.behavior_md !== "string") { - return json({ error: "Bad Request", message: "subject behavior must be a string" }, 400); + return reject("subject behavior must be a string"); + } + const input: SubjectCreateInput = { + operation_id: payload.operation_id, + role: payload.role, + behavior_md: payload.behavior_md, + }; + const receipt = subjectCreateReceipts.get(input.operation_id); + if (receipt) { + if ( + receipt.request.role !== input.role || receipt.request.behavior_md !== input.behavior_md + ) return reject("operation_id already used with different input", 409); + subjectCreateOutcomes.push({ outcome: "replay", subject_id: receipt.subject.id }); + return json(receipt.subject, 201); } createdSubjectCount += 1; const id = `created-subject-${String(createdSubjectCount).padStart(4, "0")}`; - const created = subject(id, payload.role, "active", "fingerprint-empty", payload.behavior_md); + const created = subject(id, input.role, "active", "fingerprint-empty", input.behavior_md); + subjectCreateReceipts.set(input.operation_id, { request: input, subject: created }); + subjectCreateOutcomes.push({ outcome: "created", subject_id: id }); createdSubjectIds.add(id); subjects.unshift(created); return json(created, 201); diff --git a/tools/web-ux/browser-tests/memory_document_shell_test.ts b/tools/web-ux/browser-tests/memory_document_shell_test.ts index c5ac54311..79535ed31 100644 --- a/tools/web-ux/browser-tests/memory_document_shell_test.ts +++ b/tools/web-ux/browser-tests/memory_document_shell_test.ts @@ -482,11 +482,25 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco ]); const createEvidence = await (await fetch(`${baseUrl}/fixture/subject-create-requests`)) .json(); + const operationIds = createEvidence.requests.map((request: { operation_id: string }) => + request.operation_id + ); + for (const operationId of operationIds) { + assert( + typeof operationId === "string" && + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + operationId, + ), + "Each new creation attempt must carry a UUID operation_id", + ); + } + assert(operationIds[0] !== operationIds[1], "A resolved rejection must allow a new attempt"); assertEquals(createEvidence, { count: 2, requests: [ - { role: "Permission denied", behavior_md: "" }, + { operation_id: operationIds[0], role: "Permission denied", behavior_md: "" }, { + operation_id: operationIds[1], role: " Release steward ", behavior_md: "Challenge unsupported assumptions before release.", }, diff --git a/tools/web-ux/browser-tests/subject_creation_review.ts b/tools/web-ux/browser-tests/subject_creation_review.ts new file mode 100644 index 000000000..ed1b26334 --- /dev/null +++ b/tools/web-ux/browser-tests/subject_creation_review.ts @@ -0,0 +1,444 @@ +#!/usr/bin/env -S deno run -A +/** 実Webの作成・再送を合成HTTP境界で検証する。実Backendの保存・認可は証明しない。 */ +import { assert, assertEquals } from "@std/assert"; +import { dirname, fromFileUrl, join, relative, resolve } from "@std/path"; +import { chromium, type Page } from "playwright"; +import { sha256File } from "../src/artifacts.ts"; +import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts"; +import { assertNoPageWideOverflow, freePort } from "./memory_document_harness.ts"; + +const root = resolve(dirname(fromFileUrl(import.meta.url)), "../../.."); +const [buildArg, runId, modeArg] = Deno.args; +const mode = modeArg ?? (runId?.startsWith("before") ? "before" : "after"); +if ( + !buildArg || !runId || !/^[a-zA-Z0-9._-]+$/.test(runId) || !["before", "after"].includes(mode) +) { + throw new Error( + "usage: subject_creation_review.ts [before|after]", + ); +} +const buildRoot = resolve(buildArg); +for (const name of ["index.html", "_app/immutable"]) { + await Deno.stat(join(buildRoot, name)).catch(() => { + throw new Error(`Actual Web build missing: ${join(buildRoot, name)}; parent must supply it.`); + }); +} +const output = join(root, "target/web-ux/t806", runId); +await Deno.mkdir(dirname(output), { recursive: true }); +await Deno.mkdir(output); // 既存runの画像や判断を上書きしない。 + +async function git(args: string[]) { + const result = await new Deno.Command("git", { args, cwd: root, stdout: "piped" }).output(); + return result.success ? new TextDecoder().decode(result.stdout).trim() : null; +} +async function inventory(path: string): Promise<{ path: string; sha256: string }[]> { + const files: { path: string; sha256: string }[] = []; + for await (const entry of Deno.readDir(path)) { + const child = join(path, entry.name); + if (entry.isDirectory) files.push(...await inventory(child)); + else if (entry.isFile) { + files.push({ path: relative(buildRoot, child), sha256: await sha256File(child) }); + } + } + return files.sort((a, b) => a.path.localeCompare(b.path)); +} +const buildFiles = await inventory(buildRoot); +const sourceStatus = await git(["status", "--porcelain"]); +const manifest = { + evidence: "actual-Web-with-synthetic-HTTP-NOT-real-Backend", + mode, + buildRoot, + buildFiles, + source: { + commitSha: await git(["rev-parse", "HEAD"]), + dirty: sourceStatus === null ? null : sourceStatus.length > 0, + status: sourceStatus, + captureSources: await Promise.all([ + "tools/web-ux/browser-tests/subject_creation_review.ts", + "tools/web-ux/browser-tests/memory_document_fixture_server.ts", + "tools/web-ux/browser-tests/memory_document_harness.ts", + ].map(async (path) => ({ path, sha256: await sha256File(join(root, path)) }))), + buildProvenance: + "parent-supplied build; checkout metadata is capture-time, not a build attestation", + }, + visualVerdict: "UNREVIEWED — parent must ViewImage and inspect review-context.json", + status: "running", + browser: { name: "chromium", version: "" }, + expectedFaults: [ + "Per width/theme: first POST reply aborted only after synthetic persistence (HTTP 201)", + ...(mode === "after" + ? ["Per width/theme: second POST intentionally returns HTTP 403 before fixture"] + : []), + ], + baselineLimit: mode === "before" + ? "Capture only. Missing operation_id is added ONLY to fixture transport for synthetic persistence; original browser body is recorded. No replay or disabled-input success asserted." + : null, + captures: [] as unknown[], + scenarios: [] as unknown[], + failures: [] as string[], +}; +async function saveManifest() { + await Deno.writeTextFile(join(output, "review-context.json"), JSON.stringify(manifest, null, 2)); +} +type Fault = { + kind: string; + message: string; + url?: string; + method?: string; + expected: boolean; +}; +function observe(page: Page, createUrl: string) { + const faults: Fault[] = []; + let documentStatus: number | null = null; + let allowAbort = false; + let allow403 = false; + page.on( + "pageerror", + (e) => faults.push({ kind: "pageerror", message: e.stack ?? e.message, expected: false }), + ); + page.on("console", (e) => { + if (!["warning", "error"].includes(e.type())) return; + const url = e.location().url; + // 意図した同一endpointのネットワーク診断だけを許容し、pageerrorは常に失敗にする。 + const expected = url === createUrl && ( + (allowAbort && /Failed to load resource: net::ERR_FAILED/.test(e.text())) || + (allow403 && /Failed to load resource:.*403/.test(e.text())) + ); + faults.push({ kind: `console-${e.type()}`, message: e.text(), url, expected }); + }); + page.on("requestfailed", (r) => + faults.push({ + kind: "requestfailed", + message: r.failure()?.errorText ?? "request failed", + url: r.url(), + method: r.method(), + expected: allowAbort && r.url() === createUrl && r.method() === "POST" && + r.failure()?.errorText === "net::ERR_FAILED", + })); + page.on("response", (r) => { + if (r.request().isNavigationRequest()) documentStatus = r.status(); + if (r.status() >= 400) { + faults.push({ + kind: "http", + message: `HTTP ${r.status()}`, + url: r.url(), + method: r.request().method(), + expected: allow403 && r.url() === createUrl && r.request().method() === "POST" && + r.status() === 403, + }); + } + }); + return { + faults, + documentStatus: () => documentStatus, + expectAbort: () => { + allowAbort = true; + }, + expect403: () => { + allow403 = true; + }, + }; +} +async function controls(page: Page) { + const role = page.getByRole("textbox", { name: "Role", exact: true }); + const behavior = page.getByRole("textbox", { name: /Behavior/ }); + if (!await role.count()) return null; + return { + role: await role.inputValue(), + behavior: await behavior.inputValue(), + roleDisabled: await role.isDisabled(), + behaviorDisabled: await behavior.isDisabled(), + submitting: await page.locator("#subject-create-panel").getAttribute("aria-busy"), + }; +} +async function capture(page: Page, observation: ReturnType, id: string) { + const image = join(output, `${id}.png`); + const snapshot = join(output, `${id}.aria.txt`); + await page.screenshot({ path: image }); + await Deno.writeTextFile(snapshot, await page.locator("body").ariaSnapshot()); + manifest.captures.push({ + id, + route: new URL(page.url()).pathname, + viewport: page.viewportSize(), + theme: await page.evaluate(() => + (globalThis as any).matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" + ), + documentStatus: observation.documentStatus(), + visibleAlerts: await page.getByRole("alert").allTextContents(), + controls: await controls(page), + focus: await page.locator(":focus").evaluateAll((elements) => + elements.map((e) => ({ tag: e.tagName, text: e.textContent, id: e.id })) + ), + mainBounds: await page.locator("main").evaluate((e) => ({ + width: e.clientWidth, + scrollTop: e.scrollTop, + })), + image, + imageSha256: await sha256File(image), + accessibilitySnapshot: snapshot, + accessibilitySha256: await sha256File(snapshot), + faults: [...observation.faults], + unexpectedFaults: observation.faults.filter((f) => !f.expected), + reviewed: false, + }); + await saveManifest(); + await assertNoPageWideOverflow(page); + assertEquals(observation.documentStatus(), 200, `${id}: document must render successfully`); +} +const roleText = " Release steward — evidence and uncertainty "; +const behaviorText = + "Challenge unsupported assumptions before release.\nAsk before irreversible actions; preserve the exact pending request.\nEvidence: https://example.com/review/subject-creation"; +const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +async function assertPending(page: Page) { + assertEquals(await controls(page), { + role: roleText, + behavior: behaviorText, + roleDisabled: true, + behaviorDisabled: true, + submitting: "false", + }); + assert(await page.getByRole("button", { name: "Create Subject", exact: true }).isEnabled()); +} +async function reopen(page: Page) { + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + await page.locator("#subject-create-panel").waitFor({ state: "detached" }); + assert( + await page.getByRole("button", { name: "New Subject", exact: true }).evaluate((e) => + e === e.ownerDocument.activeElement + ), + ); + await page.getByRole("button", { name: "New Subject", exact: true }).click(); + await page.locator('#subject-create-panel[aria-busy="false"]').waitFor(); +} +const browser = await chromium.launch({ headless: true }); +manifest.browser.version = browser.version(); +try { + for (const theme of ["light", "dark"] as const) { + for (const width of [1440, 768, 390]) { + const prefix = `${theme}-${width}`; + const port = await freePort(); + const base = `http://127.0.0.1:${port}`; + const createUrl = `${base}/api/w/memory-review/subjektiv/subjects`; + const owned = await startOwnedProcesses( + [{ + id: `subject-create-${prefix}`, + command: Deno.execPath(), + cwd: join(root, "tools/web-ux"), + args: [ + "run", + "-A", + join(root, "tools/web-ux/browser-tests/memory_document_fixture_server.ts"), + String(port), + buildRoot, + "3", + ], + readyUrl: `${base}/health`, + }], + join(output, "scenario.json"), + join(output, `logs-${prefix}`), + [], + ); + const context = await browser.newContext({ + viewport: { width, height: width === 390 ? 844 : 900 }, + colorScheme: theme, + reducedMotion: "reduce", + }); + const page = await context.newPage(); + page.setDefaultTimeout(15_000); + const observation = observe(page, createUrl); + const requests: unknown[] = []; + const transportOverrides: unknown[] = []; + const scenario = { + prefix, + requests, + transportOverrides, + syntheticEvidence: null as unknown, + faults: observation.faults, + }; + manifest.scenarios.push(scenario); + try { + await page.goto(`${base}/w/memory-review/memory`); + await page.getByRole("heading", { name: "Subjects", level: 1, exact: true }).waitFor(); + await page.getByRole("button", { name: "New Subject", exact: true }).click(); + const role = page.getByRole("textbox", { name: "Role", exact: true }); + const behavior = page.getByRole("textbox", { name: /Behavior/ }); + await role.fill(roleText); + await behavior.fill(behaviorText); + await role.focus(); + await capture(page, observation, `${prefix}-normal-form`); + await page.keyboard.press("Tab"); + assert(await behavior.evaluate((e) => e === e.ownerDocument.activeElement)); + await page.keyboard.press("Tab"); + assert( + await page.getByRole("button", { name: "Create Subject", exact: true }).evaluate((e) => + e === e.ownerDocument.activeElement + ), + ); + await capture(page, observation, `${prefix}-normal-actions-keyboard`); + + let attempt = 0; + await page.route(createUrl, async (route) => { + if (route.request().method() !== "POST") return await route.continue(); + attempt += 1; + const body = route.request().postDataJSON(); + requests.push(body); + if (attempt === 1) { + observation.expectAbort(); + // 旧buildの比較撮影だけ、fixtureへ送るコピーにoperation_idを足す。 + // 実browser要求はrequestsに未変更で保存し、旧UIの契約成功とは扱わない。 + const bridged = mode === "before" && !body.operation_id; + const transport = bridged ? { ...body, operation_id: crypto.randomUUID() } : body; + if (bridged) { + transportOverrides.push({ + attempt, + reason: "baseline-only fixture bridge", + body: transport, + }); + } + const persisted = await route.fetch({ postData: JSON.stringify(transport) }); + assertEquals( + persisted.status(), + 201, + "Fault must follow synthetic persistence, not validation rejection", + ); + await persisted.dispose(); + await route.abort("failed"); + } else if (attempt === 2 && mode === "after") { + observation.expect403(); + await route.fulfill({ + status: 403, + contentType: "application/json", + body: JSON.stringify({ + error: "Forbidden", + message: "synthetic later permission rejection", + }), + }); + } else await route.continue(); + }); + await page.getByRole("button", { name: "Create Subject", exact: true }).click(); + await page.getByRole("alert").filter({ hasText: /unknown|may have been created/i }) + .waitFor(); + await page.locator('#subject-create-panel[aria-busy="false"]').waitFor(); + await page.getByRole("alert").scrollIntoViewIfNeeded(); + await capture(page, observation, `${prefix}-unknown-after-persisted-response-loss`); + await role.scrollIntoViewIfNeeded(); + await capture(page, observation, `${prefix}-unknown-fields`); + if (mode === "after") { + await assertPending(page); + const first = requests[0] as { operation_id: string; role: string; behavior_md: string }; + assert(uuid.test(first.operation_id), "UI must generate a UUID per creation attempt"); + assertEquals(first, { + operation_id: first.operation_id, + role: roleText, + behavior_md: behaviorText, + }); + await reopen(page); + await assertPending(page); + await page.getByRole("alert").scrollIntoViewIfNeeded(); + await capture(page, observation, `${prefix}-cancel-reopen-pending`); + await page.getByRole("button", { name: "Create Subject", exact: true }).click(); + await page.getByRole("alert").filter({ hasText: /do not have permission/ }).waitFor(); + await page.locator('#subject-create-panel[aria-busy="false"]').waitFor(); + assertEquals( + requests[1], + first, + "Retry after cancel/reopen must retain the complete request", + ); + await assertPending(page); + await page.getByRole("alert").scrollIntoViewIfNeeded(); + await capture(page, observation, `${prefix}-later-403-still-pending`); + await role.scrollIntoViewIfNeeded(); + await capture(page, observation, `${prefix}-later-403-disabled-fields`); + await reopen(page); + await assertPending(page); + await page.getByRole("alert").scrollIntoViewIfNeeded(); + await capture(page, observation, `${prefix}-later-403-reopened-pending`); + await page.getByRole("button", { name: "Create Subject", exact: true }).click(); + await page.waitForURL("**/memory/created-subject-0001"); + await page.getByRole("heading", { name: roleText.trim(), level: 1, exact: true }) + .waitFor(); + assertEquals( + requests, + [first, first, first], + "All retries must use the same operation and full input", + ); + const article = page.getByRole("article", { name: "User-managed Subject behavior" }); + await article.waitFor(); + assert((await article.textContent())?.includes("preserve the exact pending request.")); + await page.getByText("No committed Memories yet.").waitFor(); + await capture(page, observation, `${prefix}-recovered-actual-detail`); + } + scenario.syntheticEvidence = await (await fetch(`${base}/fixture/subject-create-evidence`)) + .json(); + const evidence = scenario.syntheticEvidence as { + created: { id: string; role: string; behavior_md: string }[]; + requests: unknown[]; + outcomes: unknown[]; + }; + assertEquals(evidence.created.length, 1, "Only one synthetic Subject may be created"); + assertEquals(evidence.created[0].role, roleText); + assertEquals(evidence.created[0].behavior_md, behaviorText); + if (mode === "after") { + assertEquals(evidence.requests, [requests[0], requests[0]]); + assertEquals(evidence.outcomes, [ + { outcome: "created", subject_id: "created-subject-0001" }, + { outcome: "replay", subject_id: "created-subject-0001" }, + ]); + assertEquals(observation.faults.filter((f) => f.kind === "http" && f.expected).length, 1); + } + assertEquals( + observation.faults.filter((f) => f.kind === "requestfailed" && f.expected).length, + 1, + ); + assertEquals( + observation.faults.filter((f) => !f.expected), + [], + `${prefix}: unexpected browser faults`, + ); + await Deno.writeTextFile( + join(output, `${prefix}-synthetic-http-evidence.json`), + JSON.stringify(scenario, null, 2), + ); + } catch (e) { + manifest.failures.push( + `${prefix}: ${e instanceof Error ? e.stack ?? e.message : String(e)}`, + ); + await capture(page, observation, `${prefix}-failure`).catch((captureError) => + manifest.failures.push(`Failure capture: ${captureError}`) + ); + throw e; + } finally { + try { + scenario.syntheticEvidence = await fetch(`${base}/fixture/subject-create-evidence`) + .then((response) => response.json()).catch(() => scenario.syntheticEvidence); + await Deno.writeTextFile( + join(output, `${prefix}-synthetic-http-evidence.json`), + JSON.stringify(scenario, null, 2), + ); + } finally { + try { + await context.close(); + } finally { + await stopOwnedProcesses(owned); + await saveManifest(); + } + } + } + } + } + assertEquals( + await inventory(buildRoot), + buildFiles, + "Parent build changed during the capture matrix", + ); + manifest.status = mode === "before" + ? "baseline-capture-only-visual-unreviewed" + : "browser-assertions-passed-visual-unreviewed"; +} catch (e) { + manifest.status = "failed"; + throw e; +} finally { + await browser.close(); + await saveManifest(); + console.log(join(output, "review-context.json")); +} diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index ff89b5de4..766492c8d 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:2ac605f7ea70d0c725f9993a5c59c4a8c4266cf69184902db7b3ea7fd5932224) +// Input contract: 0.1.0 (sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/memory-api.ts b/web/workspace/src/lib/generated/memory-api.ts index b1bbc3881..3daa53463 100644 --- a/web/workspace/src/lib/generated/memory-api.ts +++ b/web/workspace/src/lib/generated/memory-api.ts @@ -68,7 +68,7 @@ export type SubjectPermissionInterruption = { runtime_id: string, worker_id: str export type SubjektivSubjectState = "active" | "retired"; -export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, }; +export type SubjektivSubjectCreateRequest = { operation_id: string, role: string, behavior_md?: string, }; export type SubjektivSubjectDeleteRequest = { operation_id: string, }; diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 7a4a4bee8..81f19707e 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:2ac605f7ea70d0c725f9993a5c59c4a8c4266cf69184902db7b3ea7fd5932224) +// Input contract: 0.1.0 (sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 25cf9167f..5d91a36bf 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:2ac605f7ea70d0c725f9993a5c59c4a8c4266cf69184902db7b3ea7fd5932224) +// Input contract: 0.1.0 (sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 119fde58e..e643dbc5b 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:2ac605f7ea70d0c725f9993a5c59c4a8c4266cf69184902db7b3ea7fd5932224) +// Input contract: 0.1.0 (sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 9ff1cfd42..7fc5de5f0 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:2ac605f7ea70d0c725f9993a5c59c4a8c4266cf69184902db7b3ea7fd5932224) +// Input contract: 0.1.0 (sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check diff --git a/web/workspace/src/lib/workspace/memory/api.ts b/web/workspace/src/lib/workspace/memory/api.ts index 8bb095fd1..027004335 100644 --- a/web/workspace/src/lib/workspace/memory/api.ts +++ b/web/workspace/src/lib/workspace/memory/api.ts @@ -117,6 +117,18 @@ export class SubjektivSubjectCreateError extends Error { export function validateSubjektivSubjectCreateRequest( request: SubjektivSubjectCreateRequest, ): SubjektivSubjectCreateRequest { + if ( + typeof request.operation_id !== "string" || + request.operation_id.length === 0 || + new TextEncoder().encode(request.operation_id).byteLength > 256 || + request.operation_id.trim() !== request.operation_id || + /[\p{Cc}*]/u.test(request.operation_id) + ) { + throw new SubjektivSubjectCreateError( + "validation", + "Subject operation_id must be nonempty, at most 256 bytes, and contain no surrounding whitespace, control characters, or '*'.", + ); + } if (request.role.trim().length === 0) { throw new SubjektivSubjectCreateError( "validation", @@ -135,7 +147,11 @@ export function validateSubjektivSubjectCreateRequest( } const behaviorMd = request.behavior_md ?? ""; validateSubjectBehavior(behaviorMd); - return { role: request.role, behavior_md: behaviorMd }; + return { + operation_id: request.operation_id, + role: request.role, + behavior_md: behaviorMd, + }; } function validateSubjectBehavior(behaviorMd: string): void { @@ -224,6 +240,12 @@ export async function deleteSubjektivSubject( } } +/** + * 呼出元は意図した作成ごとにoperation_idを一度だけ採番し、結果不明なら + * 同じID・role・behavior_mdの要求全体を保持する。この関数は自動再試行しない。 + * 厳密な再送は同じSubjectを返し、同じIDで入力を変えると409になる。 + * 再送は権限の再付与や削除済みSubjectの復活ではない。 + */ export async function createSubjektivSubject( fetchFn: typeof fetch, workspaceId: string, @@ -241,7 +263,7 @@ export async function createSubjektivSubject( } catch { throw new SubjektivSubjectCreateError( "unknown_outcome", - "The request outcome is unknown. A Subject may have been created.", + "The request outcome is unknown. A Subject may have been created. Retry only the same operation_id and full request.", ); } @@ -256,7 +278,7 @@ export async function createSubjektivSubject( } throw new SubjektivSubjectCreateError( "unknown_outcome", - "The request outcome is unknown. A Subject may have been created.", + "The request outcome is unknown. A Subject may have been created. Retry only the same operation_id and full request.", response.status, ); } @@ -275,7 +297,7 @@ export async function createSubjektivSubject( } catch { throw new SubjektivSubjectCreateError( "unknown_outcome", - "The Subject may have been created, but the response could not be confirmed.", + "The Subject may have been created, but the response could not be confirmed. Retry only the same operation_id and full request.", ); } } diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/PageContent.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/PageContent.svelte index dd672b917..0a27469d8 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/PageContent.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/memory/PageContent.svelte @@ -6,7 +6,7 @@ createSubjektivSubject, SubjektivSubjectCreateError, } from '#lib/workspace/memory/api.ts'; - import type { SubjektivSubjectResponse } from '#lib/generated/memory-api.ts'; + import type { SubjektivSubjectCreateRequest, SubjektivSubjectResponse } from '#lib/generated/memory-api.ts'; import type { ResolvedPageData } from '#lib/workspace/navigation/deferred.ts'; import type { PageData as RouteData } from './$types'; @@ -23,6 +23,7 @@ let roleInvalid = $state(false); let behaviorInvalid = $state(false); let createOutcomeUnknown = $state(false); + let createRequest = $state(null); let createdSubject = $state(null); let createButton = $state(); let roleInput = $state(); @@ -42,12 +43,14 @@ } async function openCreateForm(): Promise { + if (submitting || createExpanded) return; createExpanded = true; - createError = null; - roleInvalid = false; - behaviorInvalid = false; - createOutcomeUnknown = false; - createdSubject = null; + if (!createOutcomeUnknown) { + createError = null; + roleInvalid = false; + behaviorInvalid = false; + createdSubject = null; + } await tick(); roleInput?.focus(); } @@ -55,13 +58,16 @@ async function cancelCreate(): Promise { if (submitting) return; createExpanded = false; - role = ''; - behaviorMd = ''; - createError = null; - roleInvalid = false; - behaviorInvalid = false; - createOutcomeUnknown = false; - createdSubject = null; + // 結果不明の要求は閉じても破棄せず、再度開いたときも厳密に再送する。 + if (!createOutcomeUnknown) { + role = ''; + behaviorMd = ''; + createRequest = null; + createError = null; + roleInvalid = false; + behaviorInvalid = false; + createdSubject = null; + } await tick(); createButton?.focus(); } @@ -72,13 +78,15 @@ createError = null; roleInvalid = false; behaviorInvalid = false; - createOutcomeUnknown = false; try { - const subject = await createSubjektivSubject(fetch, data.workspaceId, { + createRequest ??= { + operation_id: crypto.randomUUID(), role, behavior_md: behaviorMd, - }); + }; + const subject = await createSubjektivSubject(fetch, data.workspaceId, createRequest); createdSubject = subject; + createOutcomeUnknown = false; try { await goto(subjectHref(subject.id)); } catch { @@ -88,7 +96,8 @@ if (error instanceof SubjektivSubjectCreateError) { roleInvalid = error.kind === 'validation' && error.message.startsWith('Subject role'); behaviorInvalid = error.kind === 'validation' && error.message.startsWith('Subject behavior'); - createOutcomeUnknown = error.kind === 'unknown_outcome'; + // 再送の拒否だけでは、先の結果不明な作成が未実行だったとは確認できない。 + createOutcomeUnknown ||= error.kind === 'unknown_outcome'; createError = error.status === 401 || error.status === 403 ? 'You do not have permission to create Subjects in this Workspace.' : error.message; @@ -102,6 +111,7 @@ createError = 'The request outcome is unknown. A Subject may have been created.'; } } finally { + if (!createOutcomeUnknown && !createdSubject) createRequest = null; submitting = false; } } @@ -161,7 +171,7 @@ type="text" autocomplete="off" required - disabled={submitting || createdSubject !== null} + disabled={submitting || createOutcomeUnknown || createdSubject !== null} onkeydown={createFormKeydown} aria-invalid={roleInvalid ? 'true' : undefined} aria-describedby={createError && roleInvalid ? 'subject-role-help subject-create-error' : 'subject-role-help'} @@ -173,7 +183,7 @@ bind:this={behaviorInput} bind:value={behaviorMd} rows="7" - disabled={submitting || createdSubject !== null} + disabled={submitting || createOutcomeUnknown || createdSubject !== null} onkeydown={createFormKeydown} aria-invalid={behaviorInvalid ? 'true' : undefined} aria-describedby={createError && behaviorInvalid ? 'subject-behavior-help subject-create-error' : 'subject-behavior-help'} diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts index 3e54b167c..e94b2342b 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts +++ b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts @@ -175,9 +175,13 @@ test("creates one Subject with the exact role and navigates to its generated det ]; expect(path).toBe("/api/w/workspace-1/subjektiv/subjects"); expect(init.method).toBe("POST"); - expect(init.body).toBe( - '{"role":" Review lead ","behavior_md":"Challenge unsupported assumptions."}', - ); + const request = JSON.parse(init.body as string); + expect(request).toEqual({ + operation_id: expect.any(String), + role: " Review lead ", + behavior_md: "Challenge unsupported assumptions.", + }); + expect(request.operation_id).not.toBe(""); }); test("keeps creation available for an empty list and restores focus on cancel", async () => { diff --git a/web/workspace/tests/memory-api.test.ts b/web/workspace/tests/memory-api.test.ts index dae4e6f6b..b114e79ce 100644 --- a/web/workspace/tests/memory-api.test.ts +++ b/web/workspace/tests/memory-api.test.ts @@ -271,24 +271,25 @@ Deno.test("Subject list parser preserves the exact bounded response", () => { Deno.test("Subject create request mirrors Backend role validation without rewriting input", () => { assertEquals( - validateSubjektivSubjectCreateRequest({ role: " Release coordinator " }), - { role: " Release coordinator ", behavior_md: "" }, + validateSubjektivSubjectCreateRequest({ operation_id: "create-attempt", role: " Release coordinator " }), + { operation_id: "create-attempt", role: " Release coordinator ", behavior_md: "" }, ); assertThrows( - () => validateSubjektivSubjectCreateRequest({ role: " " }), + () => validateSubjektivSubjectCreateRequest({ operation_id: "create-attempt", role: " " }), "must not be empty", ); assertThrows( - () => validateSubjektivSubjectCreateRequest({ role: "line\nbreak" }), + () => validateSubjektivSubjectCreateRequest({ operation_id: "create-attempt", role: "line\nbreak" }), "control characters", ); assertThrows( - () => validateSubjektivSubjectCreateRequest({ role: "界".repeat(86) }), + () => validateSubjektivSubjectCreateRequest({ operation_id: "create-attempt", role: "界".repeat(86) }), "at most 256 bytes", ); assertThrows( () => validateSubjektivSubjectCreateRequest({ + operation_id: "create-attempt", role: "Reviewer", behavior_md: " ".repeat(3), }), @@ -297,6 +298,7 @@ Deno.test("Subject create request mirrors Backend role validation without rewrit assertThrows( () => validateSubjektivSubjectCreateRequest({ + operation_id: "create-attempt", role: "Reviewer", behavior_md: "x".repeat(16 * 1024 + 1), }), @@ -304,6 +306,35 @@ Deno.test("Subject create request mirrors Backend role validation without rewrit ); }); +Deno.test("Subject create rejects invalid operation IDs before sending without rewriting them", async () => { + let calls = 0; + const fetchFn = (async () => { + calls += 1; + return Response.json(subject()); + }) as typeof fetch; + for (const operationId of [ + undefined, null, 12, "", " ", " leading", "trailing ", "create*", + "line\nbreak", "control\u007F", "x".repeat(257), "界".repeat(86), + ]) { + const request = JSON.parse(JSON.stringify({ + operation_id: operationId, + role: "Reviewer", + })); + await assertSubjectCreateRejects( + () => createSubjektivSubject(fetchFn, "workspace", request), + "validation", + "Subject operation_id", + ); + } + assertEquals(calls, 0); + for (const operationId of [crypto.randomUUID(), "x".repeat(256), "界".repeat(85)]) { + assertEquals( + validateSubjektivSubjectCreateRequest({ operation_id: operationId, role: "Reviewer" }), + { operation_id: operationId, role: "Reviewer", behavior_md: "" }, + ); + } +}); + Deno.test("Subject behavior update uses CAS and preserves exact text", async () => { const requests: Array<{ path: string; init?: RequestInit }> = []; const fetchFn = (async (path: string | URL | Request, init?: RequestInit) => { @@ -363,6 +394,7 @@ Deno.test("Subject create client sends the exact typed body once and parses the }) as typeof fetch; const created = await createSubjektivSubject(fetchFn, "workspace one", { + operation_id: "create-attempt", role: " Release coordinator ", behavior_md: "Stay concise.", }); @@ -376,7 +408,7 @@ Deno.test("Subject create client sends the exact typed body once and parses the assertEquals(requests[0].init?.method, "POST"); assertEquals( requests[0].init?.body, - '{"role":" Release coordinator ","behavior_md":"Stay concise."}', + '{"operation_id":"create-attempt","role":" Release coordinator ","behavior_md":"Stay concise."}', ); }); @@ -396,6 +428,7 @@ Deno.test("Subject create client distinguishes rejected and unknown outcomes wit await assertSubjectCreateRejects( () => createSubjektivSubject(rejectedFetch, "workspace-1", { + operation_id: "create-attempt", role: "Reviewer", }), "rejected", @@ -412,6 +445,7 @@ Deno.test("Subject create client distinguishes rejected and unknown outcomes wit await assertSubjectCreateRejects( () => createSubjektivSubject(serverErrorFetch, "workspace-1", { + operation_id: "create-attempt", role: "Reviewer", }), "unknown_outcome", @@ -428,6 +462,7 @@ Deno.test("Subject create client distinguishes rejected and unknown outcomes wit await assertSubjectCreateRejects( () => createSubjektivSubject(mismatchFetch, "workspace-1", { + operation_id: "create-attempt", role: "Reviewer", }), "unknown_outcome", @@ -441,13 +476,65 @@ Deno.test("Subject create client distinguishes rejected and unknown outcomes wit throw new TypeError("connection reset"); }) as typeof fetch; await assertSubjectCreateRejects( - () => createSubjektivSubject(unknownFetch, "workspace-1", { role: "Reviewer" }), + () => createSubjektivSubject(unknownFetch, "workspace-1", { operation_id: "create-attempt", role: "Reviewer" }), "unknown_outcome", "may have been created", ); assertEquals(unknownCalls, 1); }); +Deno.test("Subject create exact retry retains serialized operation ID and full input after unknown outcomes", async () => { + for (const failure of ["transport", "server", "malformed", "mismatched"] as const) { + const request = Object.freeze({ + operation_id: crypto.randomUUID(), + role: " Release coordinator ", + behavior_md: "Stay concise.\nPreserve exact text.\n", + }); + const bodies: unknown[] = []; + const created = { ...subject(), role: request.role, behavior_md: request.behavior_md }; + const fetchFn = (async (_path: string | URL | Request, init?: RequestInit) => { + bodies.push(init?.body); + if (bodies.length === 1) { + if (failure === "transport") throw new TypeError("connection reset"); + if (failure === "server") return Response.json({ error: "Internal Server Error" }, { status: 500 }); + if (failure === "malformed") return new Response("not JSON", { status: 201 }); + return Response.json({ ...created, behavior_md: "Different input" }, { status: 201 }); + } + return Response.json(created, { status: 201 }); + }) as typeof fetch; + await assertSubjectCreateRejects( + () => createSubjektivSubject(fetchFn, "workspace", request), + "unknown_outcome", + "Retry only the same operation_id and full request", + failure === "server" ? 500 : null, + ); + assertEquals(bodies, [JSON.stringify(request)]); + assertEquals(await createSubjektivSubject(fetchFn, "workspace", request), created); + assertEquals(bodies, [JSON.stringify(request), JSON.stringify(request)]); + } +}); + +Deno.test("Subject create conflict and deleted-Subject rejection never trigger a fresh operation", async () => { + for (const [status, message] of [ + [409, "operation_id already used with different input"], + [404, "Subject was deleted"], + ] as const) { + const request = { operation_id: crypto.randomUUID(), role: "Reviewer", behavior_md: "Changed input" }; + const bodies: unknown[] = []; + const fetchFn = (async (_path: string | URL | Request, init?: RequestInit) => { + bodies.push(init?.body); + return Response.json({ message }, { status }); + }) as typeof fetch; + await assertSubjectCreateRejects( + () => createSubjektivSubject(fetchFn, "workspace", request), + "rejected", + message, + status, + ); + assertEquals(bodies, [JSON.stringify(request)]); + } +}); + Deno.test("Resident surface parser accepts ready-empty and enforces snapshot invariants", () => { const readyEmpty = { subject_id: "subject-1",