diff --git a/Cargo.lock b/Cargo.lock index 4bb517eb5..2e6537d58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2754,6 +2754,7 @@ dependencies = [ "serde_json", "tempfile", "thiserror 2.0.18", + "ticket", "uuid", ] @@ -4788,6 +4789,7 @@ dependencies = [ "serde", "serde_json", "server-api", + "sha2 0.11.0", "tempfile", "thiserror 2.0.18", "uuid", diff --git a/Cargo.toml b/Cargo.toml index 2b2715ea7..552252a61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -172,5 +172,5 @@ webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation [workspace.metadata.wip-upstream] repository = "https://gitea.hareworks.net/Hare/wip-rs" version = "0.2.0" -reference-revision = "6086f3c5ef10aa1464ce9c824750d7f667217bfd" +reference-commit = "6086f3c5ef10aa1464ce9c824750d7f667217bfd" source = "crates.io" diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index 2d58cb741..32411e020 100644 --- a/crates/client/src/workspace_product.rs +++ b/crates/client/src/workspace_product.rs @@ -3,11 +3,11 @@ use server_api::{ CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse, ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest, - RuntimeTrustKeyRevealResponse, SubjektivMemoryDetailQuery, SubjektivMemoryListQuery, - SubjektivMemoryListRevisionsResponse, SubjektivMemoryQueryResponse, - SubjektivMemoryReadResponse, SubjektivMemoryRevisionsQuery, SubjektivResidentSurfaceResponse, - SubjektivSubjectListQuery, SubjektivSubjectListResponse, SubjektivSubjectResponse, - WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource, + RuntimeTrustKeyRevealResponse, SubjektivMemoryChangesQuery, SubjektivMemoryDetailQuery, + SubjektivMemoryListChangesResponse, SubjektivMemoryListQuery, SubjektivMemoryQueryResponse, + SubjektivMemoryReadResponse, SubjektivResidentSurfaceResponse, SubjektivSubjectListQuery, + SubjektivSubjectListResponse, SubjektivSubjectResponse, WorkerLaunchOptionsResponse, + WorkspaceRuntimeDetail, WorkspaceRuntimeResource, }; use ticket::{ MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation, @@ -168,7 +168,7 @@ impl BackendWorkspaceProductClient { }; Ok(ticket::TicketStateUpdate { operation_key: uuid::Uuid::now_v7().to_string(), - expected_item_revision: ticket::ticket_item_revision(¤t), + expected_content_digest: ticket::ticket_content_digest(¤t), expected_state: parse(&change.from)?, state: parse(&change.to)?, reason: if change.body.as_str().is_empty() { @@ -208,7 +208,7 @@ impl BackendWorkspaceProductClient { let current = self.show_ticket(id)?; let request = ticket::TicketCompletion { operation_key: uuid::Uuid::now_v7().to_string(), - expected_item_revision: ticket::ticket_item_revision(¤t), + expected_content_digest: ticket::ticket_content_digest(¤t), expected_state: current.meta.workflow_state, reason: resolution.as_str().to_string(), references: Vec::new(), @@ -521,19 +521,19 @@ impl BackendWorkspaceProductClient { }) } - pub fn list_subjektiv_memory_revisions( + pub fn list_subjektiv_memory_changes( &self, subject_id: &str, memory_id: &str, - query: &SubjektivMemoryRevisionsQuery, - ) -> Result { + query: &SubjektivMemoryChangesQuery, + ) -> Result { let workspace_id = self.workspace_id.clone(); let subject_id = subject_id.to_string(); let memory_id = memory_id.to_string(); let query = query.clone(); self.generated(move |client| async move { client - .subjektiv_memory_revisions(workspace_id, subject_id, memory_id, query) + .subjektiv_memory_changes(workspace_id, subject_id, memory_id, query) .await }) } @@ -997,7 +997,7 @@ mod tests { } #[test] - fn legacy_state_adapter_uses_snapshot_revision_without_overriding_stale_expected_state() { + fn legacy_state_adapter_uses_snapshot_content_without_overriding_stale_expected_state() { let temp = tempfile::tempdir().unwrap(); let backend = ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a") @@ -1021,8 +1021,8 @@ mod tests { let body: serde_json::Value = serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap(); assert_eq!( - body["expected_item_revision"], - ticket::ticket_item_revision(&snapshot) + body["expected_content_digest"], + ticket::ticket_content_digest(&snapshot) ); assert_eq!(body["expected_state"], "ready"); assert_eq!(body["state"], "done"); @@ -1057,8 +1057,8 @@ mod tests { let body: serde_json::Value = serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap(); assert_eq!( - body["expected_item_revision"], - ticket::ticket_item_revision(&snapshot) + body["expected_content_digest"], + ticket::ticket_content_digest(&snapshot) ); assert_eq!(body["expected_state"], "planning"); assert_eq!(body["reason"], "The request was withdrawn"); @@ -1070,7 +1070,7 @@ mod tests { fn ticket_backend_completion_forwards_cas_judgment_and_backend_conflicts() { let (base_url, request, handle) = one_response_server( "409 Conflict", - r#"{"error":"ticket_conflict","message":"stale revision","diagnostics":[]}"#, + r#"{"error":"ticket_conflict","message":"stale content","diagnostics":[]}"#, ); let client = BackendWorkspaceProductClient::new_with_access_token( base_url, @@ -1083,7 +1083,7 @@ mod tests { "T-718", ticket::TicketCompletion { operation_key: "research-done".into(), - expected_item_revision: "ticket:1".into(), + expected_content_digest: "ticket:1".into(), expected_state: ticket::TicketWorkflowState::Planning, reason: "Research findings recorded".into(), references: Vec::new(), @@ -1100,7 +1100,7 @@ mod tests { serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap(); assert_eq!(body["state"], "done"); assert_eq!(body["operation_key"], "research-done"); - assert_eq!(body["expected_item_revision"], "ticket:1"); + assert_eq!(body["expected_content_digest"], "ticket:1"); assert_eq!(body["expected_state"], "planning"); assert_eq!(body["reason"], "Research findings recorded"); assert!(body.get("merge_request_ids").is_none()); diff --git a/crates/config-source-wasm/src/lib.rs b/crates/config-source-wasm/src/lib.rs index 2196469f4..df335ab00 100644 --- a/crates/config-source-wasm/src/lib.rs +++ b/crates/config-source-wasm/src/lib.rs @@ -230,27 +230,51 @@ mod tests { use config_source::{ConfigContentType, ConfigEntry}; fn snapshot() -> ConfigTreeSnapshot { - ConfigTreeSnapshot::from_entries( - 7, - [ - ConfigEntry::new( - VirtualPath::parse("main.dcdl").unwrap(), - ConfigContentType::Decodal, - "{}", - ) - .unwrap(), - // A same-suffix workspace file must not be an unknown builtin fallback. - ConfigEntry::new( - VirtualPath::parse("profiles/missing.dcdl").unwrap(), - ConfigContentType::Decodal, - "{}", - ) - .unwrap(), - ], - ) + ConfigTreeSnapshot::from_entries([ + ConfigEntry::new( + VirtualPath::parse("main.dcdl").unwrap(), + ConfigContentType::Decodal, + "{}", + ) + .unwrap(), + // A same-suffix workspace file must not be an unknown builtin fallback. + ConfigEntry::new( + VirtualPath::parse("profiles/missing.dcdl").unwrap(), + ConfigContentType::Decodal, + "{}", + ) + .unwrap(), + ]) .unwrap() } + #[test] + fn wasm_analysis_uses_applied_tree_content_identity() { + let base = snapshot(); + let path = VirtualPath::parse("main.dcdl").unwrap(); + let change = ConfigTreeChange::Update { + path: path.clone(), + expected_digest: base.entries[&path].content_digest.clone(), + content: "{ broken = ; }".into(), + }; + let changed = base.apply(&[change]).unwrap(); + let diagnostics = session_environment(changed.clone()).analyze(&path, None); + assert!(!diagnostics.is_empty()); + assert!( + diagnostics + .iter() + .all(|diagnostic| diagnostic.tree_digest == changed.digest) + ); + assert_ne!(changed.digest, base.digest); + let restored = changed.apply(&changed.changes_to(&base)).unwrap(); + assert_eq!(restored.digest, base.digest); + assert!( + session_environment(restored) + .analyze(&path, None) + .is_empty() + ); + } + #[test] fn wasm_completion_boundary_preserves_read_only_builtin_detail() { let snapshot = snapshot(); @@ -301,7 +325,6 @@ mod tests { }) .unwrap(); assert_eq!(diagnostic.path, path); - assert_eq!(diagnostic.revision, 7); assert_eq!(diagnostic.tree_digest, snapshot.digest); assert!( diagnostic diff --git a/crates/config-source/bindings/ConfigProjectionValidator.ts b/crates/config-source/bindings/ConfigProjectionValidator.ts new file mode 100644 index 000000000..04cd5395e --- /dev/null +++ b/crates/config-source/bindings/ConfigProjectionValidator.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ConfigProjectionValidator = { "kind": "static_template_catalog", namespace: string, key_aliases?: { [key in string]: string }, }; diff --git a/crates/config-source/bindings/ConfigSchemaContribution.ts b/crates/config-source/bindings/ConfigSchemaContribution.ts new file mode 100644 index 000000000..957a1a06a --- /dev/null +++ b/crates/config-source/bindings/ConfigSchemaContribution.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ConfigProjectionValidator } from "./ConfigProjectionValidator"; + +export type ConfigSchemaContribution = { provider_id: string, namespace: string, version: string, source: string, authoring_source?: string | null, projection_validator?: ConfigProjectionValidator | null, source_digest: string, }; diff --git a/crates/config-source/bindings/EvaluatedProjection.ts b/crates/config-source/bindings/EvaluatedProjection.ts new file mode 100644 index 000000000..86f4d2e1a --- /dev/null +++ b/crates/config-source/bindings/EvaluatedProjection.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { VirtualPath } from "./VirtualPath"; + +export type EvaluatedProjection = { entrypoint: VirtualPath, data_json: unknown, projection_digest: string, }; diff --git a/crates/config-source/bindings/VirtualPath.ts b/crates/config-source/bindings/VirtualPath.ts new file mode 100644 index 000000000..12c7d5eca --- /dev/null +++ b/crates/config-source/bindings/VirtualPath.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type VirtualPath = string; diff --git a/crates/config-source/bindings/WorkspaceConfigSchemaBundle.ts b/crates/config-source/bindings/WorkspaceConfigSchemaBundle.ts new file mode 100644 index 000000000..48b2e3d88 --- /dev/null +++ b/crates/config-source/bindings/WorkspaceConfigSchemaBundle.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ConfigSchemaContribution } from "./ConfigSchemaContribution"; + +export type WorkspaceConfigSchemaBundle = { contributions: Array, source: string, fingerprint: string, }; diff --git a/crates/config-source/src/lib.rs b/crates/config-source/src/lib.rs index 0e377d0a9..b65c2971d 100644 --- a/crates/config-source/src/lib.rs +++ b/crates/config-source/src/lib.rs @@ -27,9 +27,16 @@ pub const MAX_TOTAL_BYTES: usize = 4 * 1024 * 1024; pub const MAX_PATH_BYTES: usize = 512; pub const MAX_IMPORT_DEPTH: usize = 32; -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, ts_rs::TS)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, ts_rs::TS)] pub struct VirtualPath(String); +impl<'de> Deserialize<'de> for VirtualPath { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + Self::parse(value).map_err(serde::de::Error::custom) + } +} + impl VirtualPath { pub fn parse(value: impl AsRef) -> Result { let value = value.as_ref(); @@ -267,27 +274,46 @@ impl ConfigEntry { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, ts_rs::TS)] pub struct ConfigTreeSnapshot { - #[ts(type = "number")] - pub revision: u64, + /// SHA-256 of ordered virtual paths, content types and complete source bytes. + /// Identifies tree content, not an edit sequence or freshness counter. pub digest: String, pub entries: BTreeMap, } +impl<'de> Deserialize<'de> for ConfigTreeSnapshot { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + struct SnapshotContent { + digest: String, + entries: BTreeMap, + } + let content = SnapshotContent::deserialize(deserializer)?; + let snapshot = Self { + digest: content.digest, + entries: content.entries, + }; + snapshot.validate().map_err(serde::de::Error::custom)?; + Ok(snapshot) + } +} + impl ConfigTreeSnapshot { pub fn empty() -> Self { - Self::from_entries(0, Vec::new()).expect("empty config snapshot is valid") + Self::from_entries(Vec::new()).expect("empty config snapshot is valid") } pub fn from_entries( - revision: u64, entries: impl IntoIterator, ) -> Result { let mut ordered = BTreeMap::new(); let mut total = 0usize; for entry in entries { validate_workspace_path(&entry.path)?; + if digest_bytes(entry.content.as_bytes()) != entry.content_digest { + return Err(ConfigTreeError::EntryDigestMismatch(entry.path)); + } if entry.content.len() > MAX_ENTRY_BYTES { return Err(ConfigTreeError::LimitExceeded("entry bytes")); } @@ -306,12 +332,25 @@ impl ConfigTreeSnapshot { } let digest = snapshot_digest(&ordered); Ok(Self { - revision, digest, entries: ordered, }) } + /// Verify external snapshot metadata against the complete source content. + pub fn validate(&self) -> Result<(), ConfigTreeError> { + for (path, entry) in &self.entries { + if path != &entry.path { + return Err(ConfigTreeError::EntryPathMismatch(path.clone())); + } + } + let rebuilt = Self::from_entries(self.entries.values().cloned())?; + if self.digest != rebuilt.digest { + return Err(ConfigTreeError::TreeDigestMismatch); + } + Ok(()) + } + pub fn list_prefix(&self, prefix: Option<&VirtualPath>) -> Vec<&ConfigEntry> { self.entries .values() @@ -364,7 +403,11 @@ impl ConfigTreeSnapshot { changes } + /// Apply an atomic batch. Each expected_digest compares the target's complete + /// source bytes only; it is not a tree precondition. Callers needing whole-tree + /// CAS must also compare the authoritative tree digest under their write lock. pub fn apply(&self, changes: &[ConfigTreeChange]) -> Result { + self.validate()?; if changes.len() > MAX_CHANGE_COUNT { return Err(ConfigTreeError::LimitExceeded("change count")); } @@ -440,7 +483,7 @@ impl ConfigTreeSnapshot { } } } - Self::from_entries(self.revision, entries.into_values()) + Self::from_entries(entries.into_values()) } } @@ -788,8 +831,6 @@ pub struct ConfigDiagnosticLabel { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] pub struct ConfigDiagnostic { pub path: VirtualPath, - #[ts(type = "number")] - pub revision: u64, pub tree_digest: String, pub kind: String, pub span: ConfigSpan, @@ -1436,7 +1477,6 @@ impl SnapshotEnvironment { ) -> ConfigDiagnostic { ConfigDiagnostic { path, - revision: self.snapshot.revision, tree_digest: self.snapshot.digest.clone(), kind: kind.into(), span: ConfigSpan { @@ -1572,13 +1612,39 @@ impl SnapshotImportLoader { import_edges: BTreeMap::new(), } } + /// Translate an Engine cache identity back to its actual virtual source path. + /// Only exact keys produced by this loader are recognized; ordinary source + /// paths (including paths containing '@') are not split heuristically. + fn source_path(&self, key: &str) -> Result { + if let Some(entry) = self + .snapshot + .entries + .values() + .find(|entry| snapshot_import_cache_key(entry) == key) + { + return Ok(entry.path.clone()); + } + for resource in BUILTIN_PROFILE_RESOURCES { + let path = format!("$builtin/{}", resource.path); + if key + == format!( + "config-source://{path}@{}", + digest_bytes(resource.source.as_bytes()) + ) + { + return VirtualPath::parse(path); + } + } + VirtualPath::parse(key) + } + pub fn resolve( &self, current_key: Option<&str>, specifier: &str, ) -> Result { let current = current_key - .map(VirtualPath::parse) + .map(|key| self.source_path(key)) .transpose()? .ok_or_else(|| ConfigTreeError::InvalidImport(specifier.into()))?; resolve_import(¤t, specifier) @@ -1594,11 +1660,10 @@ impl ImportLoader for SnapshotImportLoader { let path = self.resolve(current_key, specifier).map_err(import_error)?; // Guard the import graph before returning a module to Decodal. In particular, // cycles hidden inside lazy object fields must not recurse during materialization. - let current = current_key - .expect("resolve requires a source context") - .split("@sha256:") - .next() - .unwrap(); + let current = self + .source_path(current_key.expect("resolve requires a source context")) + .map_err(import_error)? + .to_string(); if self .import_edges .entry(current.to_string()) @@ -1614,7 +1679,7 @@ impl ImportLoader for SnapshotImportLoader { // A language service can keep using its loader after a diagnostic. // Only successful edges may be cached as already validated. self.import_edges - .get_mut(current) + .get_mut(¤t) .unwrap() .remove(path.as_str()); return Err(diagnostic); @@ -1651,7 +1716,10 @@ impl ImportLoader for SnapshotImportLoader { return Err(import_error(ConfigTreeError::LimitExceeded("total bytes"))); } return Ok(LoadedImport::source( - format!("{path}@{}", digest_bytes(resource.source.as_bytes())), + format!( + "config-source://{path}@{}", + digest_bytes(resource.source.as_bytes()) + ), path.as_str(), resource.source, )); @@ -1747,9 +1815,16 @@ fn validate_import_graph( fn snapshot_import_cache_key(entry: &ConfigEntry) -> String { // The source id remains the virtual path for diagnostics and relative-import - // resolution. The cache identity also includes immutable source content so - // equal paths from different revisions cannot alias in an Engine cache. - format!("{}@{}", entry.path, entry.content_digest) + // resolution. Cache identity binds path, import interpretation and source bytes, + // so changed content or content type cannot alias in an Engine cache. + // The URI-like prefix is rejected by VirtualPath, keeping engine identities + // disjoint from literal paths even when a path spells another entry's digest. + format!( + "config-source://{}@{}@{}", + entry.path, + entry.content_type.media_type(), + entry.content_digest + ) } fn is_builtin_path(path: &VirtualPath) -> bool { @@ -1881,7 +1956,6 @@ fn project_engine_diagnostic( .unwrap_or(fallback_path); ConfigDiagnostic { path, - revision: snapshot.revision, tree_digest: snapshot.digest.clone(), span: ConfigSpan { start_byte: diagnostic.span.start, @@ -1911,7 +1985,6 @@ fn project_diagnostic( ) -> ConfigDiagnostic { ConfigDiagnostic { path: fallback_path, - revision: snapshot.revision, tree_digest: snapshot.digest.clone(), kind: diagnostic_kind(diagnostic.kind).to_string(), span: ConfigSpan { @@ -2174,6 +2247,12 @@ pub enum ConfigTreeError { NotFound(VirtualPath), #[error("virtual config entry changed: {0}")] EntryConflict(VirtualPath), + #[error("virtual config entry content digest mismatch: {0}")] + EntryDigestMismatch(VirtualPath), + #[error("virtual config entry path does not match its tree key: {0}")] + EntryPathMismatch(VirtualPath), + #[error("virtual config tree content digest mismatch")] + TreeDigestMismatch, #[error("virtual config path changed more than once in one candidate: {0}")] PathChangedMoreThanOnce(VirtualPath), #[error("duplicate virtual config path")] @@ -2254,13 +2333,10 @@ mod tests { #[test] fn candidate_changes_are_atomic_ordered_and_conflict_checked() { - let base = ConfigTreeSnapshot::from_entries( - 7, - [ - entry("profiles/a.dcdl", "{ a = 1; }"), - entry("shared.dcdl", "{}"), - ], - ) + let base = ConfigTreeSnapshot::from_entries([ + entry("profiles/a.dcdl", "{ a = 1; }"), + entry("shared.dcdl", "{}"), + ]) .unwrap(); let updated = base .apply(&[ @@ -2296,30 +2372,96 @@ mod tests { )); } + #[test] + fn source_edits_use_content_preconditions_and_preserve_equal_tree_identity() { + let base = ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{ x = 1; }")]).unwrap(); + let expected_digest = base.entries[&path("main.dcdl")].content_digest.clone(); + let update = |source: &str| ConfigTreeChange::Update { + path: path("main.dcdl"), + expected_digest: expected_digest.clone(), + content: source.into(), + }; + assert_eq!(base.apply(&[]).unwrap(), base); + assert_eq!(base.apply(&[update("{ x = 1; }")]).unwrap(), base); + let changed = base.apply(&[update("{ x = 2; }")]).unwrap(); + assert!(matches!( + changed.apply(&[update("{ x = 3; }")]), + Err(ConfigTreeError::EntryConflict(_)) + )); + let restored = changed.apply(&changed.changes_to(&base)).unwrap(); + assert_eq!(restored, base); + assert!(restored.apply(&[update("{ x = 3; }")]).is_ok()); + + // Equal evaluated values are not equal source trees. + let whitespace_edit = base.apply(&[update("{ x = 1; }\n")]).unwrap(); + assert_ne!(whitespace_edit.digest, base.digest); + let contract = ToolchainContract::new(1, vec![path("main.dcdl")], 1); + assert_eq!( + SnapshotEnvironment::new(base) + .evaluate_contract(&contract) + .unwrap(), + SnapshotEnvironment::new(whitespace_edit) + .evaluate_contract(&contract) + .unwrap() + ); + } + + #[test] + fn snapshot_json_rejects_forged_content_identity() { + let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{}")]).unwrap(); + let original = serde_json::to_value(&snapshot).unwrap(); + assert_eq!( + original + .as_object() + .unwrap() + .keys() + .cloned() + .collect::>(), + ["digest", "entries"] + ); + assert_eq!( + serde_json::from_value::(original.clone()).unwrap(), + snapshot + ); + for mutation in ["body", "entry_digest", "tree_digest", "entry_path"] { + let mut wire = original.clone(); + match mutation { + "body" => wire["entries"]["main.dcdl"]["content"] = "{ changed = true; }".into(), + "entry_digest" => { + wire["entries"]["main.dcdl"]["content_digest"] = "sha256:forged".into() + } + "tree_digest" => wire["digest"] = "sha256:forged".into(), + "entry_path" => wire["entries"]["main.dcdl"]["path"] = "other.dcdl".into(), + _ => unreachable!(), + } + assert!( + serde_json::from_value::(wire).is_err(), + "{mutation}" + ); + } + } + #[test] fn snapshot_digest_is_deterministic() { - let left = ConfigTreeSnapshot::from_entries( - 1, - [entry("z.dcdl", "{}"), entry("a.dcdl", "{ x = 1; }")], - ) + let left = ConfigTreeSnapshot::from_entries([ + entry("z.dcdl", "{}"), + entry("a.dcdl", "{ x = 1; }"), + ]) .unwrap(); - let right = ConfigTreeSnapshot::from_entries( - 99, - [entry("a.dcdl", "{ x = 1; }"), entry("z.dcdl", "{}")], - ) + let right = ConfigTreeSnapshot::from_entries([ + entry("a.dcdl", "{ x = 1; }"), + entry("z.dcdl", "{}"), + ]) .unwrap(); assert_eq!(left.digest, right.digest); } #[test] fn entrypoint_can_assert_empty_workspace_schema_global() { - let snapshot = ConfigTreeSnapshot::from_entries( - 1, - [entry( - "main.dcdl", - "{ answer = 42; } as WorkspaceConfigSchema", - )], - ) + let snapshot = ConfigTreeSnapshot::from_entries([entry( + "main.dcdl", + "{ answer = 42; } as WorkspaceConfigSchema", + )]) .unwrap(); let contract = ToolchainContract::new(1, vec![path("main.dcdl")], 1); SnapshotEnvironment::new(snapshot) @@ -2373,7 +2515,7 @@ mod tests { #[test] fn completion_projects_workspace_schema_fields_into_config_objects() { - let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap(); + let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{}")]).unwrap(); let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( "builtin:profile", "profile", @@ -2458,7 +2600,7 @@ mod tests { #[test] fn completion_projects_array_element_and_nested_profile_fields() { - let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap(); + let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{}")]).unwrap(); let environment = SnapshotEnvironment::new(snapshot) .with_schema_bundle(profile_entries_completion_schema()); for (marked_source, expected, prefix_len) in [ @@ -2544,7 +2686,7 @@ mod tests { #[test] fn completion_tracks_fixed_array_positions_and_nested_array_ranges() { - let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap(); + let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{}")]).unwrap(); let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( "builtin:profile", "profile", @@ -2617,7 +2759,7 @@ mod tests { #[test] fn completion_preserves_general_schema_member_behavior() { - let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap(); + let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{}")]).unwrap(); let environment = SnapshotEnvironment::new(snapshot) .with_schema_bundle(profile_entries_completion_schema()); for source in [ @@ -2642,7 +2784,7 @@ mod tests { #[test] fn completion_array_schema_retains_nested_profile_diagnostics() { let valid = "{ profile = { default_profile = \"default\"; entries = [{ selector = \"default\"; profile = { slug = \"default\"; worker = { mode = \"interactive\"; }; }; }]; }; } as WorkspaceConfigSchema"; - let snapshot = ConfigTreeSnapshot::from_entries(7, [entry("main.dcdl", valid)]).unwrap(); + let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", valid)]).unwrap(); let environment = SnapshotEnvironment::new(snapshot) .with_schema_bundle(profile_entries_completion_schema()); assert!(environment.analyze(&path("main.dcdl"), None).is_empty()); @@ -2663,7 +2805,7 @@ mod tests { diagnostics.iter().any(|diagnostic| { diagnostic.kind == "constraint_violation" && diagnostic.path == path("main.dcdl") - && diagnostic.revision == 7 + && diagnostic.tree_digest == environment.snapshot().digest && diagnostic.span.end_byte > diagnostic.span.start_byte && (diagnostic.message.contains(field) || diagnostic @@ -2690,7 +2832,7 @@ mod tests { )]) .unwrap(); let source = "{ profile = { defaults = {}; entries = [{ selector = \"default\"; profile = {}; }]; }; } as WorkspaceConfigSchema"; - let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", source)]).unwrap(); + let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", source)]).unwrap(); let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema.clone()); assert!(environment.analyze(&path("main.dcdl"), None).is_empty()); let contract = ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, schema); @@ -2760,13 +2902,10 @@ mod tests { fn actual_profile_schema_diagnoses_nested_values_without_materializing_omissions() { let schema = actual_profile_schema(); let source = "{ profile = { entries = [{ selector = \"project:alpha\"; profile = {}; }]; }; } as WorkspaceConfigSchema"; - let snapshot = ConfigTreeSnapshot::from_entries( - 8, - [ - entry("main.dcdl", source), - entry("recipe.dcdl", "{ worker = { mode = 42; }; }"), - ], - ) + let snapshot = ConfigTreeSnapshot::from_entries([ + entry("main.dcdl", source), + entry("recipe.dcdl", "{ worker = { mode = 42; }; }"), + ]) .unwrap(); let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema.clone()); let contract = ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, schema); @@ -2814,7 +2953,7 @@ mod tests { "{diagnostic:?}" ); assert_eq!(diagnostic.path, path(expected_path), "{diagnostic:?}"); - assert_eq!(diagnostic.revision, 8); + assert_eq!(diagnostic.tree_digest, environment.snapshot().digest); let diagnostic_source = if expected_path == "main.dcdl" { &source } else { @@ -2874,10 +3013,10 @@ mod tests { let source = format!( r#"{{ profile.entries = [{{ selector = "project:scoped"; profile = {form}; }}]; }} as WorkspaceConfigSchema"# ); - let snapshot = ConfigTreeSnapshot::from_entries( - 9, - [entry("main.dcdl", &source), entry("scoped.dcdl", recipe)], - ) + let snapshot = ConfigTreeSnapshot::from_entries([ + entry("main.dcdl", &source), + entry("scoped.dcdl", recipe), + ]) .unwrap(); let schema = actual_profile_schema(); let environment = @@ -2967,10 +3106,10 @@ mod tests { let source = format!( r#"{{ profile.entries = [{{ selector = "project:corpus"; profile = {form}; }}]; }} as WorkspaceConfigSchema"# ); - let snapshot = ConfigTreeSnapshot::from_entries( - 10, - [entry("main.dcdl", &source), entry("recipe.dcdl", &recipe)], - ) + let snapshot = ConfigTreeSnapshot::from_entries([ + entry("main.dcdl", &source), + entry("recipe.dcdl", &recipe), + ]) .unwrap(); let schema = actual_profile_schema(); let environment = @@ -3001,7 +3140,7 @@ mod tests { } let source = "{} as WorkspaceConfigSchema"; let environment = SnapshotEnvironment::new( - ConfigTreeSnapshot::from_entries(10, [entry("main.dcdl", source)]).unwrap(), + ConfigTreeSnapshot::from_entries([entry("main.dcdl", source)]).unwrap(), ) .with_schema_bundle(actual_profile_schema()); for (profile_path, prefix, label) in [ @@ -3051,7 +3190,7 @@ mod tests { .unwrap(), ]) .unwrap(); - let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap(); + let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{}")]).unwrap(); let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema); for (marked_source, expected) in [ ("{ ed| } as WorkspaceConfigSchema", "editor"), @@ -3148,14 +3287,11 @@ mod tests { #[test] fn relative_imports_and_completion_share_the_snapshot_namespace() { - let snapshot = ConfigTreeSnapshot::from_entries( - 1, - [ - entry("profiles/main.dcdl", r#"import "../shared/value.dcdl""#), - entry("shared/value.dcdl", "{ answer = 42; }"), - entry("other.dcdl", "{}"), - ], - ) + let snapshot = ConfigTreeSnapshot::from_entries([ + entry("profiles/main.dcdl", r#"import "../shared/value.dcdl""#), + entry("shared/value.dcdl", "{ answer = 42; }"), + entry("other.dcdl", "{}"), + ]) .unwrap(); assert_eq!( resolve_import(&path("profiles/main.dcdl"), "../shared/value.dcdl").unwrap(), @@ -3207,7 +3343,7 @@ mod tests { #[test] fn workspace_schema_applies_defaults_with_asymmetric_decodal_validation() { let snapshot = - ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{ web = {}; }")]).unwrap(); + ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{ web = {}; }")]).unwrap(); let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( "builtin:web", "web", @@ -3239,8 +3375,7 @@ mod tests { ("{ web = {}; custom = 42; }", "custom"), ("{ web = { typo = true; }; }", "typo"), ] { - let snapshot = - ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", source)]).unwrap(); + let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", source)]).unwrap(); let diagnostics = SnapshotEnvironment::new(snapshot) .evaluate_contract(&ToolchainContract::with_schema_bundle( 1, @@ -3258,13 +3393,10 @@ mod tests { #[test] fn workspace_schema_supports_typed_associative_collections() { - let snapshot = ConfigTreeSnapshot::from_entries( - 1, - [entry( - "main.dcdl", - "{ features = { web = { enabled = true; }; tickets = { enabled = false; }; }; }", - )], - ) + let snapshot = ConfigTreeSnapshot::from_entries([entry( + "main.dcdl", + "{ features = { web = { enabled = true; }; tickets = { enabled = false; }; }; }", + )]) .unwrap(); let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( "builtin:features", @@ -3315,8 +3447,7 @@ mod tests { "constraintviolation", ), ] { - let snapshot = - ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", source)]).unwrap(); + let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", source)]).unwrap(); let diagnostics = SnapshotEnvironment::new(snapshot) .evaluate_contract(&ToolchainContract::with_schema_bundle( 1, @@ -3335,7 +3466,7 @@ mod tests { fn language_service_and_formatter_accept_decodal_0_4_schema_syntax() { let source = "{} as { features = {...{ enabled = Bool; }}; web = { enabled = Bool; ...Unknown }; }"; - let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("schema.dcdl", source)]).unwrap(); + let snapshot = ConfigTreeSnapshot::from_entries([entry("schema.dcdl", source)]).unwrap(); let environment = SnapshotEnvironment::new(snapshot); let diagnostics = environment.analyze(&path("schema.dcdl"), None); assert!( @@ -3370,13 +3501,10 @@ mod tests { #[test] fn workspace_schema_preserves_fields_only_where_rest_is_explicit() { - let snapshot = ConfigTreeSnapshot::from_entries( - 1, - [entry( - "main.dcdl", - "{ web = { enabled = true; extension_value = 42; }; }", - )], - ) + let snapshot = ConfigTreeSnapshot::from_entries([entry( + "main.dcdl", + "{ web = { enabled = true; extension_value = 42; }; }", + )]) .unwrap(); let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( "builtin:web", @@ -3402,8 +3530,7 @@ mod tests { #[test] fn unresolved_unknown_cannot_be_materialized() { - let snapshot = - ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "Unknown")]).unwrap(); + let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", "Unknown")]).unwrap(); let diagnostics = SnapshotEnvironment::new(snapshot) .evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1)) .unwrap_err(); @@ -3413,11 +3540,9 @@ mod tests { #[test] fn workspace_schema_type_mismatch_is_a_decodal_diagnostic() { - let snapshot = ConfigTreeSnapshot::from_entries( - 1, - [entry("main.dcdl", "{ web = { enabled = 1; }; }")], - ) - .unwrap(); + let snapshot = + ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{ web = { enabled = 1; }; }")]) + .unwrap(); let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( "builtin:web", "web", @@ -3459,9 +3584,82 @@ mod tests { let first = text_entry("skills/debug-rust/SKILL.md", "first"); let second = text_entry("skills/debug-rust/SKILL.md", "second"); let first_key = snapshot_import_cache_key(&first); - assert!(first_key.starts_with("skills/debug-rust/SKILL.md@sha256:")); + assert!( + first_key.starts_with("config-source://skills/debug-rust/SKILL.md@text/plain@sha256:") + ); assert!(first_key.ends_with(&first.content_digest)); assert_ne!(first_key, snapshot_import_cache_key(&second)); + let different_type = entry("skills/debug-rust/SKILL.md", "first"); + assert_ne!(first_key, snapshot_import_cache_key(&different_type)); + } + + #[test] + fn cached_import_identity_resolves_relative_paths_and_keeps_literal_at_paths() { + let source = entry("profiles/name@sha256:literal.dcdl", "{}"); + let loader = + SnapshotImportLoader::new(ConfigTreeSnapshot::from_entries([source.clone()]).unwrap()); + for key in [ + source.path.as_str().to_owned(), + snapshot_import_cache_key(&source), + ] { + assert_eq!( + loader.resolve(Some(&key), "./shared.dcdl").unwrap(), + path("profiles/shared.dcdl") + ); + assert_eq!(loader.source_path(&key).unwrap(), source.path); + } + } + + #[test] + fn snapshot_json_rejects_cache_identity_paths_even_with_valid_content_digests() { + let source = entry("a.dcdl", "{}"); + // Build adversarial JSON with a correct tree digest, bypassing the private + // constructor only to describe the untrusted input at this boundary. + let forged = ConfigEntry::new( + VirtualPath(snapshot_import_cache_key(&source)), + ConfigContentType::Decodal, + r#"import "./shared.dcdl""#, + ) + .unwrap(); + let snapshot = ConfigTreeSnapshot::from_entries([source, forged]).unwrap(); + let json = serde_json::to_string(&snapshot).unwrap(); + let error = serde_json::from_str::(&json).unwrap_err(); + assert!(error.to_string().contains("config-source://"), "{error}"); + for invalid in [ + "../escape", + "/absolute", + "a//b", + "a\\b", + "config-source://entry", + ] { + assert!(serde_json::from_value::(serde_json::json!(invalid)).is_err()); + } + let path: VirtualPath = serde_json::from_str(r#""profiles/name@digest.dcdl""#).unwrap(); + assert_eq!(path.as_str(), "profiles/name@digest.dcdl"); + } + + #[test] + fn literal_path_matching_another_entries_digest_keeps_its_relative_import_base() { + let source = entry("a.dcdl", "{}"); + let literal_path = format!("a.dcdl@text/x-decodal@{}", source.content_digest); + let snapshot = ConfigTreeSnapshot::from_entries([ + entry("main.dcdl", "{}"), + source.clone(), + entry(&literal_path, r#"import "./shared.dcdl""#), + entry("a.dcdl@text/shared.dcdl", "{ answer = 42; }"), + ]) + .unwrap(); + let loader = SnapshotImportLoader::new(snapshot.clone()); + assert_eq!( + loader + .resolve(Some(&literal_path), "./shared.dcdl") + .unwrap(), + path("a.dcdl@text/shared.dcdl") + ); + assert!(VirtualPath::parse(snapshot_import_cache_key(&source)).is_err()); + SnapshotEnvironment::new(snapshot) + .evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1)) + .expect("literal paths must not alias engine cache identities"); } #[test] @@ -3476,9 +3674,7 @@ mod tests { "---\n", "# Debug Rust\n", ); - let snapshot = ConfigTreeSnapshot::from_entries( - 3, - [ + let snapshot = ConfigTreeSnapshot::from_entries([ entry( "main.dcdl", r#"{ skill = import "./skills/debug-rust/SKILL.md" as { frontmatter = { name = String; description = String; ...Unknown }; content = String; }; }"#, @@ -3512,13 +3708,10 @@ mod tests { content: source.to_string(), } ); - let snapshot = ConfigTreeSnapshot::from_entries( - 1, - [ - entry("main.dcdl", r#"import "./skills/plain/SKILL.md""#), - text_entry("skills/plain/SKILL.md", source), - ], - ) + let snapshot = ConfigTreeSnapshot::from_entries([ + entry("main.dcdl", r#"import "./skills/plain/SKILL.md""#), + text_entry("skills/plain/SKILL.md", source), + ]) .unwrap(); let result = SnapshotEnvironment::new(snapshot) .evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1)) @@ -3536,16 +3729,13 @@ mod tests { project_markdown_document("---\nname: missing-close\nbody\n").unwrap_err(), "opening YAML frontmatter delimiter has no closing delimiter" ); - let snapshot = ConfigTreeSnapshot::from_entries( - 1, - [ - entry("main.dcdl", r#"import "./skills/broken/SKILL.md""#), - text_entry( - "skills/broken/SKILL.md", - "---\nname: [unterminated\n---\nbody\n", - ), - ], - ) + let snapshot = ConfigTreeSnapshot::from_entries([ + entry("main.dcdl", r#"import "./skills/broken/SKILL.md""#), + text_entry( + "skills/broken/SKILL.md", + "---\nname: [unterminated\n---\nbody\n", + ), + ]) .unwrap(); let diagnostics = SnapshotEnvironment::new(snapshot) .evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1)) @@ -3557,7 +3747,7 @@ mod tests { #[test] fn builtin_imports_are_ordinary_values_with_confined_transitive_sources() { - let snapshot = ConfigTreeSnapshot::from_entries(1, [ + let snapshot = ConfigTreeSnapshot::from_entries([ entry("main.dcdl", r#"import "$builtin/profiles/companion.dcdl" // { worker = { mode = "wip"; }; feature = { workspace_config = { enabled = true; }; }; }"#), entry("profiles/base.dcdl", "{ slug = \"workspace-shadow\"; }"), ]).unwrap(); @@ -3583,13 +3773,10 @@ mod tests { ); // Intersections conflict on explicit builtin values, exactly like any other import. - let snapshot = ConfigTreeSnapshot::from_entries( - 1, - [entry( - "main.dcdl", - r#"import "$builtin/profiles/companion.dcdl" & { slug = "different"; }"#, - )], - ) + let snapshot = ConfigTreeSnapshot::from_entries([entry( + "main.dcdl", + r#"import "$builtin/profiles/companion.dcdl" & { slug = "different"; }"#, + )]) .unwrap(); assert!( SnapshotEnvironment::new(snapshot) @@ -3612,13 +3799,10 @@ mod tests { "builtin:companion", ] { let source = format!("import {specifier:?}"); - let snapshot = ConfigTreeSnapshot::from_entries( - 1, - [ - entry("main.dcdl", &source), - entry("profiles/base.dcdl", "{ spoof = true; }"), - ], - ) + let snapshot = ConfigTreeSnapshot::from_entries([ + entry("main.dcdl", &source), + entry("profiles/base.dcdl", "{ spoof = true; }"), + ]) .unwrap(); let diagnostics = SnapshotEnvironment::new(snapshot) .evaluate_contract(&contract) @@ -3684,7 +3868,7 @@ mod tests { #[test] fn builtin_values_do_not_bypass_cycle_or_path_limits() { - let snapshot = ConfigTreeSnapshot::from_entries(1, [ + let snapshot = ConfigTreeSnapshot::from_entries([ entry("main.dcdl", r#"{ builtin = import "$builtin/profiles/companion.dcdl"; cycle = import "./cycle.dcdl"; }"#), entry("cycle.dcdl", r#"import "./main.dcdl""#), ]).unwrap(); @@ -3718,7 +3902,7 @@ mod tests { #[test] fn rejected_import_graph_edges_stay_rejected_after_diagnostics() { let mut loader = SnapshotImportLoader::new( - ConfigTreeSnapshot::from_entries(1, [entry("a.dcdl", "{}"), entry("b.dcdl", "{}")]) + ConfigTreeSnapshot::from_entries([entry("a.dcdl", "{}"), entry("b.dcdl", "{}")]) .unwrap(), ); loader.load(Some("a.dcdl"), "./b.dcdl").unwrap(); @@ -3760,7 +3944,7 @@ mod tests { entries.push(entry(&format!("{index}.dcdl"), &source)); } let diagnostics = - SnapshotEnvironment::new(ConfigTreeSnapshot::from_entries(1, entries).unwrap()) + SnapshotEnvironment::new(ConfigTreeSnapshot::from_entries(entries).unwrap()) .evaluate_contract(&ToolchainContract::new(1, vec![path("0.dcdl")], 1)) .unwrap_err(); assert!( @@ -3781,7 +3965,7 @@ mod tests { entries.push(text_entry(&format!("unused/{i}.txt"), "")); } let environment = - SnapshotEnvironment::new(ConfigTreeSnapshot::from_entries(1, entries).unwrap()); + SnapshotEnvironment::new(ConfigTreeSnapshot::from_entries(entries).unwrap()); let diagnostics = environment.evaluate_contract(&contract).unwrap_err(); assert!(diagnostics[0].message.contains("entry count")); let mut entries = vec![entry( @@ -3798,20 +3982,17 @@ mod tests { entries.push(text_entry(&format!("unused/{i}.txt"), &"a".repeat(bytes))); } let environment = - SnapshotEnvironment::new(ConfigTreeSnapshot::from_entries(1, entries).unwrap()); + SnapshotEnvironment::new(ConfigTreeSnapshot::from_entries(entries).unwrap()); let diagnostics = environment.evaluate_contract(&contract).unwrap_err(); assert!(diagnostics[0].message.contains("total bytes")); } #[test] fn host_environment_evaluation_uses_only_snapshot_imports() { - let snapshot = ConfigTreeSnapshot::from_entries( - 3, - [ - entry("profiles/main.dcdl", r#"import "./shared.dcdl""#), - entry("profiles/shared.dcdl", "{ answer = 42; }"), - ], - ) + let snapshot = ConfigTreeSnapshot::from_entries([ + entry("profiles/main.dcdl", r#"import "./shared.dcdl""#), + entry("profiles/shared.dcdl", "{ answer = 42; }"), + ]) .unwrap(); let contract = ToolchainContract::new( DEFAULT_SCHEMA_VERSION, @@ -3826,13 +4007,10 @@ mod tests { #[test] fn candidate_evaluation_rejects_invalid_unreferenced_decodal_source() { - let snapshot = ConfigTreeSnapshot::from_entries( - 1, - [ - entry("workspace.dcdl", "{ answer = 42; }"), - entry("unused.dcdl", "{ broken = ; }"), - ], - ) + let snapshot = ConfigTreeSnapshot::from_entries([ + entry("workspace.dcdl", "{ answer = 42; }"), + entry("unused.dcdl", "{ broken = ; }"), + ]) .unwrap(); let diagnostics = SnapshotEnvironment::new(snapshot) .evaluate_contract(&ToolchainContract::new(1, vec![path("workspace.dcdl")], 1)) @@ -3844,7 +4022,7 @@ mod tests { #[test] fn missing_import_and_cycles_are_structured_failures() { let missing = - ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", r#"import "./missing.dcdl""#)]) + ConfigTreeSnapshot::from_entries([entry("main.dcdl", r#"import "./missing.dcdl""#)]) .unwrap(); let contract = ToolchainContract::new(1, vec![path("main.dcdl")], 1); let diagnostics = SnapshotEnvironment::new(missing) @@ -3852,13 +4030,10 @@ mod tests { .unwrap_err(); assert_eq!(diagnostics[0].kind, "import"); - let cycle = ConfigTreeSnapshot::from_entries( - 1, - [ - entry("a.dcdl", r#"import "./b.dcdl""#), - entry("b.dcdl", r#"import "./a.dcdl""#), - ], - ) + let cycle = ConfigTreeSnapshot::from_entries([ + entry("a.dcdl", r#"import "./b.dcdl""#), + entry("b.dcdl", r#"import "./a.dcdl""#), + ]) .unwrap(); let diagnostics = SnapshotEnvironment::new(cycle) .evaluate_contract(&ToolchainContract::new(1, vec![path("a.dcdl")], 1)) diff --git a/crates/flow/src/builtin.rs b/crates/flow/src/builtin.rs index 44091b75b..2514cd30a 100644 --- a/crates/flow/src/builtin.rs +++ b/crates/flow/src/builtin.rs @@ -7,9 +7,6 @@ const CODER_REVIEW_FLOW_SOURCE: &str = include_str!("../../../resources/flows/co #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct BuiltinFlowSource { pub slug: &'static str, - /// Monotonic resource revision. Increment when built-in semantics change; - /// Runtime also pins the compiled content digest. - pub revision: u64, pub path: &'static str, pub content: &'static str, } @@ -24,7 +21,7 @@ pub fn builtin_flow_source(slug: &str) -> Option { match slug { CODER_REVIEW_FLOW_SLUG => Some(BuiltinFlowSource { slug: CODER_REVIEW_FLOW_SLUG, - revision: 5, + path: "builtin/flows/coder-review.dcdl", content: CODER_REVIEW_FLOW_SOURCE, }), @@ -35,7 +32,7 @@ pub fn builtin_flow_source(slug: &str) -> Option { pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] { const SOURCES: &[BuiltinFlowSource] = &[BuiltinFlowSource { slug: CODER_REVIEW_FLOW_SLUG, - revision: 5, + path: "builtin/flows/coder-review.dcdl", content: CODER_REVIEW_FLOW_SOURCE, }]; @@ -64,7 +61,6 @@ mod tests { "MergeRequestShow", "MergeRequestReview", "MergeRequestComplete", - "new immutable revision", ] { assert!(!source.content.contains(stale), "stale contract {stale}"); } @@ -74,10 +70,6 @@ mod tests { fn every_builtin_flow_compiles_and_matches_catalog_identity() { assert!(!builtin_flow_sources().is_empty()); for source in builtin_flow_sources() { - assert!( - source.revision > 0, - "built-in Flow revision must be positive" - ); let definition = source.compile().unwrap_or_else(|error| { panic!( "builtin Flow {} failed to compile: {:?}", @@ -88,7 +80,6 @@ mod tests { let selected = builtin_flow_source(source.slug).expect("builtin Flow must be selectable"); assert_eq!(selected.content, source.content); - assert_eq!(selected.revision, source.revision); } } } diff --git a/crates/flow/src/coordinator.rs b/crates/flow/src/coordinator.rs index aec1ef0b8..295c5e116 100644 --- a/crates/flow/src/coordinator.rs +++ b/crates/flow/src/coordinator.rs @@ -21,10 +21,8 @@ pub enum FlowInstanceStatus { pub struct FlowInstance { pub instance_id: String, pub definition_id: String, - pub definition_revision: u64, pub definition_digest: String, pub current_state: StateId, - pub state_revision: u64, pub status: FlowInstanceStatus, pub active_attempt_id: Option, } @@ -33,7 +31,6 @@ impl FlowInstance { pub fn start( instance_id: impl Into, definition_id: impl Into, - definition_revision: u64, definition: &CompiledFlowDefinition, ) -> Result { let instance_id = instance_id.into(); @@ -41,11 +38,6 @@ impl FlowInstance { ensure_non_empty("instance_id", &instance_id) .and_then(|_| ensure_non_empty("definition_id", &definition_id)) .map_err(FlowTransitionError::InvalidRequest)?; - if definition_revision == 0 { - return Err(FlowTransitionError::InvalidRequest( - "definition_revision must be positive".to_string(), - )); - } let initial_state = definition.state(&definition.initial).ok_or_else(|| { FlowTransitionError::Invariant("compiled initial state is missing".to_string()) })?; @@ -57,10 +49,8 @@ impl FlowInstance { Ok(Self { instance_id, definition_id, - definition_revision, definition_digest: definition.content_digest.clone(), current_state: definition.initial.clone(), - state_revision: 0, status, active_attempt_id: None, }) @@ -77,9 +67,7 @@ pub struct FlowTransitionRequest { pub struct FlowTransitionAttempt { pub attempt_id: String, pub instance_id: String, - pub definition_revision: u64, pub definition_digest: String, - pub checked_state_revision: u64, pub from_state: StateId, pub reason: String, pub transitions: Vec, @@ -174,7 +162,6 @@ pub enum FlowEventKind { TransitionRequested { attempt_id: String, state_id: StateId, - state_revision: u64, reason: String, transitions: Vec, }, @@ -201,7 +188,6 @@ pub enum FlowEventKind { StateEntered { attempt_id: String, state_id: StateId, - state_revision: u64, status: FlowInstanceStatus, }, } @@ -214,7 +200,7 @@ pub struct FlowRuntimeEvent { /// Durable Flow authority owned by one Runtime Worker. /// -/// Workspace authority resolves and revisions the source, but never mutates +/// Workspace authority resolves the exact source content, but never mutates /// this value. Runtime persists the complete snapshot with the Worker session /// and replaces it only after the corresponding session-log write succeeds. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -240,12 +226,8 @@ impl FlowRuntimeState { "resolved Flow selector does not match compiled definition name".to_string(), )); } - let instance = FlowInstance::start( - instance_id, - source.flow_id.clone(), - source.revision, - &source.definition, - )?; + let instance = + FlowInstance::start(instance_id, source.flow_id.clone(), &source.definition)?; let initial = source .definition .state(&source.definition.initial) @@ -257,7 +239,6 @@ impl FlowRuntimeState { event: FlowEventKind::StateEntered { attempt_id: String::new(), state_id: instance.current_state.clone(), - state_revision: instance.state_revision, status: instance.status, }, }; @@ -282,8 +263,22 @@ impl FlowRuntimeState { if let Some(attempt) = &self.active_attempt { return Ok(attempt.clone()); } + let attempt_id = attempt_id.into(); + // The event log is the identity authority: an attempt ID is used once, + // including failed/cancelled attempts. This prevents a delayed result + // from an earlier visit to the same state from matching a later attempt. + if self.events.iter().any(|event| { + matches!( + &event.event, + FlowEventKind::TransitionRequested { attempt_id: used, .. } if used == &attempt_id + ) + }) { + return Err(FlowTransitionError::InvalidRequest( + "transition attempt ID has already been used".to_string(), + )); + } let request = FlowTransitionRequest { - attempt_id: attempt_id.into(), + attempt_id, reason: reason.into(), }; let (attempt, events) = begin_transition(&mut self.instance, &self.definition, request)?; @@ -329,7 +324,7 @@ pub enum FlowTransitionError { NotActive, #[error("another transition attempt is already active")] AttemptInProgress, - #[error("Flow definition does not match the instance's pinned revision")] + #[error("Flow definition content does not match the instance's pinned digest")] DefinitionMismatch, #[error("invalid transition request: {0}")] InvalidRequest(String), @@ -337,7 +332,7 @@ pub enum FlowTransitionError { Invariant(String), } -pub fn begin_transition( +fn begin_transition( instance: &mut FlowInstance, definition: &CompiledFlowDefinition, request: FlowTransitionRequest, @@ -374,9 +369,7 @@ pub fn begin_transition( let attempt = FlowTransitionAttempt { attempt_id: request.attempt_id, instance_id: instance.instance_id.clone(), - definition_revision: instance.definition_revision, definition_digest: instance.definition_digest.clone(), - checked_state_revision: instance.state_revision, from_state: instance.current_state.clone(), reason: request.reason, transitions, @@ -387,7 +380,6 @@ pub fn begin_transition( FlowEventKind::TransitionRequested { attempt_id: attempt.attempt_id.clone(), state_id: attempt.from_state.clone(), - state_revision: attempt.checked_state_revision, reason: attempt.reason.clone(), transitions: attempt.transitions.clone(), }, @@ -398,20 +390,23 @@ pub fn begin_transition( Ok((attempt, events)) } -pub fn resolve_transition( +fn resolve_transition( instance: &mut FlowInstance, definition: &CompiledFlowDefinition, mut attempt: FlowTransitionAttempt, outcome: FlowVerifierOutcome, ) -> Result { - if instance.active_attempt_id.as_deref() != Some(attempt.attempt_id.as_str()) { + // Accept a result only for the currently reserved operation on this instance. + // A completed/cancelled attempt has already released that reservation. + if instance.instance_id != attempt.instance_id + || instance.active_attempt_id.as_deref() != Some(attempt.attempt_id.as_str()) + { return Err(FlowTransitionError::InvalidRequest( "attempt is not the active/latest attempt for this Flow instance".to_string(), )); } if definition.content_digest != instance.definition_digest || attempt.definition_digest != instance.definition_digest - || attempt.definition_revision != instance.definition_revision { return Err(FlowTransitionError::DefinitionMismatch); } @@ -447,7 +442,7 @@ pub fn resolve_transition( ) } FlowVerifierOutcome::Completed { results } => { - if instance.state_revision != attempt.checked_state_revision + if instance.status != FlowInstanceStatus::Active || instance.current_state != attempt.from_state { attempt.status = FlowAttemptStatus::Rejected; @@ -604,9 +599,6 @@ fn resolve_completed_results( results, }); instance.current_state = transition.target.clone(); - instance.state_revision = instance.state_revision.checked_add(1).ok_or_else(|| { - FlowTransitionError::Invariant("Flow state revision overflowed".to_string()) - })?; instance.status = if transition.target.as_str() == CANCELLED_STATE_ID { FlowInstanceStatus::Cancelled } else if target_state.terminal { @@ -617,7 +609,6 @@ fn resolve_completed_results( events.push(FlowEventKind::StateEntered { attempt_id: attempt.attempt_id.clone(), state_id: instance.current_state.clone(), - state_revision: instance.state_revision, status: instance.status, }); Ok(FlowTransitionResolution { @@ -735,16 +726,109 @@ mod tests { attempt } + fn runtime_state() -> FlowRuntimeState { + let definition = definition(); + FlowRuntimeState::start( + &crate::ResolvedFlowSource { + selector: "workspace:simple".parse().unwrap(), + workspace_id: "workspace-1".into(), + flow_id: "flow-1".into(), + content_digest: definition.content_digest.clone(), + definition, + }, + "instance-1", + ) + .unwrap() + .0 + } + + #[test] + fn cancelled_attempt_id_cannot_be_reused_even_after_restore() { + let mut state = runtime_state(); + state.begin_or_recover_transition("first", "ready").unwrap(); + state + .resolve_active_transition("first", FlowVerifierOutcome::Cancelled) + .unwrap(); + let mut restored: FlowRuntimeState = + serde_json::from_value(serde_json::to_value(&state).unwrap()).unwrap(); + let before = restored.clone(); + assert!(matches!( + restored.begin_or_recover_transition("first", "try again"), + Err(FlowTransitionError::InvalidRequest(_)) + )); + assert_eq!(restored, before); + restored + .begin_or_recover_transition("second", "try again") + .unwrap(); + assert_eq!( + restored.instance.active_attempt_id.as_deref(), + Some("second") + ); + } + + #[test] + fn late_result_cannot_consume_a_new_attempt_in_the_same_state() { + let mut state = runtime_state(); + state.begin_or_recover_transition("first", "ready").unwrap(); + state + .resolve_active_transition("first", FlowVerifierOutcome::Cancelled) + .unwrap(); + state + .begin_or_recover_transition("second", "ready") + .unwrap(); + let before = state.clone(); + assert!(matches!( + state.resolve_active_transition( + "first", + FlowVerifierOutcome::Completed { results: vec![] } + ), + Err(FlowTransitionError::InvalidRequest(_)) + )); + assert_eq!(state, before); + state + .resolve_active_transition("second", FlowVerifierOutcome::Cancelled) + .unwrap(); + assert!(state.active_attempt.is_none()); + } + + #[test] + fn source_content_is_checked_before_start_and_before_accepting_a_result() { + let mut state = runtime_state(); + let mut changed = state.definition.clone(); + changed.content_digest = "different-content".into(); + assert_eq!( + begin_transition( + &mut state.instance, + &changed, + FlowTransitionRequest { + attempt_id: "first".into(), + reason: "ready".into(), + } + ) + .unwrap_err(), + FlowTransitionError::DefinitionMismatch + ); + assert!(state.instance.active_attempt_id.is_none()); + state.begin_or_recover_transition("first", "ready").unwrap(); + state.definition = changed; + let before = state.clone(); + assert_eq!( + state + .resolve_active_transition("first", FlowVerifierOutcome::Cancelled) + .unwrap_err(), + FlowTransitionError::DefinitionMismatch + ); + assert_eq!(state, before); + } + #[test] fn exactly_one_met_transition_enters_terminal_state() { let definition = definition(); let mut instance = FlowInstance { instance_id: "instance-1".to_string(), definition_id: "definition-1".to_string(), - definition_revision: 1, definition_digest: definition.content_digest.clone(), current_state: definition.initial.clone(), - state_revision: 0, status: FlowInstanceStatus::Active, active_attempt_id: None, }; @@ -770,7 +854,7 @@ mod tests { ) .unwrap(); assert_eq!(instance.current_state.as_str(), "done"); - assert_eq!(instance.state_revision, 1); + assert_eq!(instance.status, FlowInstanceStatus::Completed); assert!(resolution.rejection.is_none()); assert!(matches!( @@ -788,10 +872,8 @@ mod tests { let mut instance = FlowInstance { instance_id: "instance-1".to_string(), definition_id: "definition-1".to_string(), - definition_revision: 1, definition_digest: definition.content_digest.clone(), current_state: definition.initial.clone(), - state_revision: 0, status: FlowInstanceStatus::Active, active_attempt_id: None, }; @@ -827,10 +909,8 @@ mod tests { let mut instance = FlowInstance { instance_id: "instance-1".to_string(), definition_id: "definition-1".to_string(), - definition_revision: 1, definition_digest: definition.content_digest.clone(), current_state: definition.initial.clone(), - state_revision: 0, status: FlowInstanceStatus::Active, active_attempt_id: None, }; @@ -852,7 +932,7 @@ mod tests { ) .unwrap(); assert_eq!(instance.current_state.as_str(), "work"); - assert_eq!(instance.state_revision, 0); + assert_eq!(instance.active_attempt_id, None); assert_eq!( resolution.rejection.unwrap().code, @@ -866,10 +946,8 @@ mod tests { let mut instance = FlowInstance { instance_id: "instance-1".to_string(), definition_id: "definition-1".to_string(), - definition_revision: 1, definition_digest: definition.content_digest.clone(), current_state: definition.initial.clone(), - state_revision: 0, status: FlowInstanceStatus::Active, active_attempt_id: None, }; @@ -901,15 +979,13 @@ mod tests { let mut instance = FlowInstance { instance_id: "instance-1".to_string(), definition_id: "definition-1".to_string(), - definition_revision: 1, definition_digest: definition.content_digest.clone(), current_state: definition.initial.clone(), - state_revision: 0, status: FlowInstanceStatus::Active, active_attempt_id: None, }; let attempt = begin(&mut instance); - instance.state_revision = 1; + instance.current_state = StateId::new("done").unwrap(); let results = attempt .transitions .iter() @@ -938,10 +1014,8 @@ mod tests { let mut instance = FlowInstance { instance_id: "instance-1".to_string(), definition_id: "definition-1".to_string(), - definition_revision: 1, definition_digest: definition.content_digest.clone(), current_state: definition.initial.clone(), - state_revision: 0, status: FlowInstanceStatus::Active, active_attempt_id: None, }; @@ -967,7 +1041,7 @@ mod tests { FlowRejectionCode::MultipleConditionsMet ); assert_eq!(instance.current_state.as_str(), "work"); - assert_eq!(instance.state_revision, 0); + assert!( resolution .events @@ -982,10 +1056,8 @@ mod tests { let mut instance = FlowInstance { instance_id: "instance-1".to_string(), definition_id: "definition-1".to_string(), - definition_revision: 1, definition_digest: definition.content_digest.clone(), current_state: definition.initial.clone(), - state_revision: 0, status: FlowInstanceStatus::Active, active_attempt_id: None, }; @@ -1015,10 +1087,8 @@ mod tests { let mut instance = FlowInstance { instance_id: "instance-1".to_string(), definition_id: "definition-1".to_string(), - definition_revision: 1, definition_digest: definition.content_digest.clone(), current_state: definition.initial.clone(), - state_revision: 0, status: FlowInstanceStatus::Active, active_attempt_id: None, }; diff --git a/crates/flow/src/selector.rs b/crates/flow/src/selector.rs index 4c0f31616..6e86c185a 100644 --- a/crates/flow/src/selector.rs +++ b/crates/flow/src/selector.rs @@ -124,7 +124,6 @@ pub struct ResolvedFlowSource { pub selector: FlowSelector, pub workspace_id: String, pub flow_id: String, - pub revision: u64, pub content_digest: String, pub definition: crate::CompiledFlowDefinition, } diff --git a/crates/job/src/lib.rs b/crates/job/src/lib.rs index 8426b4b2c..6c58e4784 100644 --- a/crates/job/src/lib.rs +++ b/crates/job/src/lib.rs @@ -100,8 +100,6 @@ pub struct JobRequest { /// Stable caller-selected idempotency key within the host's namespace. pub job_id: String, pub purpose: String, - /// Revision fenced by the structured result. - pub input_revision: String, pub input_ref: String, /// Bounded immutable snapshot; must not contain credentials. pub input: Value, @@ -126,7 +124,6 @@ impl JobRequest { pub fn validate_fields(&self) -> Result<()> { validate_identifier("job_id", &self.job_id, 256)?; validate_identifier("purpose", &self.purpose, MAX_JOB_PURPOSE_BYTES)?; - validate_identifier("input_revision", &self.input_revision, 256)?; validate_bounded_text("input_ref", &self.input_ref, MAX_JOB_REFERENCE_BYTES)?; validate_bounded_text("instruction", &self.instruction, MAX_JOB_INSTRUCTION_BYTES)?; self.profile_selector()?; @@ -157,6 +154,13 @@ impl JobRequest { fingerprint(self) } + /// Content identity for result fencing; hosts compare it with the reserved + /// attempt's input, never with a caller-selected counter. + pub fn input_digest(&self) -> Result { + self.validate_input()?; + fingerprint(&self.input) + } + pub fn worker_input(&self, attempt_id: &str) -> Result { worker_input(self, attempt_id) } @@ -167,7 +171,8 @@ impl JobRequest { pub struct JobResultSubmission { pub job_id: String, pub attempt_id: String, - pub input_revision: String, + /// SHA-256 of the exact JSON encoding of the immutable input snapshot. + pub input_digest: String, pub result: Value, } @@ -202,7 +207,8 @@ pub enum JobAttemptState { #[serde(deny_unknown_fields)] pub struct JobOutcome { pub job_id: String, - pub input_revision: String, + /// SHA-256 of the exact JSON encoding of the immutable input snapshot. + pub input_digest: String, pub attempt_id: String, pub attempt: u8, pub state: JobState, @@ -263,11 +269,11 @@ pub fn worker_input_with_label( let input = serde_json::to_string(&request.input) .map_err(|error| JobError::InvalidInput(format!("serialize Job input: {error}")))?; Ok(format!( - "{}\n\n{label} envelope (immutable):\njob_id: {}\nattempt_id: {}\ninput_revision: {}\ninput_ref: {}\ninput_json: {}\n\nReturn success only through the structured {label} result capability. Final prose and Worker Idle/Stopped state are not result authority.", + "{}\n\n{label} envelope (immutable):\njob_id: {}\nattempt_id: {}\ninput_digest: {}\ninput_ref: {}\ninput_json: {}\n\nReturn success only through the structured {label} result capability. Final prose and Worker Idle/Stopped state are not result authority.", request.instruction, request.job_id, attempt_id, - request.input_revision, + request.input_digest()?, request.input_ref, input )) diff --git a/crates/job/tests/contract.rs b/crates/job/tests/contract.rs index fdfd328ff..141224323 100644 --- a/crates/job/tests/contract.rs +++ b/crates/job/tests/contract.rs @@ -5,7 +5,6 @@ fn request() -> JobRequest { JobRequest { job_id: "check:r7".into(), purpose: "snapshot_check".into(), - input_revision: "7".into(), input_ref: "snapshot://7".into(), input: json!({"title": "Check me"}), instruction: "Check this immutable snapshot.".into(), @@ -15,6 +14,25 @@ fn request() -> JobRequest { } } +#[test] +fn input_content_identity_is_computed_and_old_counter_is_not_accepted() { + let original = request(); + let mut changed = original.clone(); + changed.input = json!({"title": "changed"}); + assert_ne!( + original.input_digest().unwrap(), + changed.input_digest().unwrap() + ); + changed.instruction.push_str(" Extra instruction."); + assert_eq!( + changed.input_digest().unwrap(), + fingerprint(&changed.input).unwrap() + ); + let mut old = serde_json::to_value(&original).unwrap(); + old["input_revision"] = json!(7); + assert!(serde_json::from_value::(old).is_err()); +} + #[test] fn shared_lifecycle_enums_round_trip_all_host_states() { for (state, name) in [ @@ -53,7 +71,7 @@ fn shared_lifecycle_enums_round_trip_all_host_states() { fn outcome_is_a_neutral_read_snapshot_with_optional_result_and_failure() { let mut outcome = JobOutcome { job_id: "job-1".into(), - input_revision: "7".into(), + input_digest: request().input_digest().unwrap(), attempt_id: "job-1:attempt:2".into(), attempt: 2, state: JobState::Pending, @@ -66,7 +84,7 @@ fn outcome_is_a_neutral_read_snapshot_with_optional_result_and_failure() { assert_eq!( encoded, json!({ - "job_id": "job-1", "input_revision": "7", "attempt_id": "job-1:attempt:2", + "job_id": "job-1", "input_digest": request().input_digest().unwrap(), "attempt_id": "job-1:attempt:2", "attempt": 2, "state": "pending", "attempt_state": "dispatching", "result": null, "failure_category": null, "failure_detail": null }) @@ -124,7 +142,7 @@ fn outcome_is_a_neutral_read_snapshot_with_optional_result_and_failure() { fn neutral_request_and_result_have_strict_round_trip_contracts() { let request = request(); let encoded = serde_json::to_value(&request).unwrap(); - assert_eq!(encoded.as_object().unwrap().len(), 8); + assert_eq!(encoded.as_object().unwrap().len(), 7); assert!(encoded.get("serialization_key").is_none()); assert_eq!( serde_json::from_value::(encoded.clone()).unwrap(), @@ -137,10 +155,11 @@ fn neutral_request_and_result_have_strict_round_trip_contracts() { with_provenance["source_worker"] = json!({}); assert!(serde_json::from_value::(with_provenance).is_err()); + let input_digest = request.input_digest().unwrap(); let submission = JobResultSubmission { job_id: request.job_id, attempt_id: "check:r7:attempt:1".into(), - input_revision: request.input_revision, + input_digest, result: json!({"ok": true}), }; let mut encoded = serde_json::to_value(&submission).unwrap(); @@ -168,7 +187,6 @@ fn fingerprint_covers_every_immutable_field_and_preserves_replay() { for mutate in [ |r: &mut JobRequest| r.job_id.push('x'), |r: &mut JobRequest| r.purpose.push('x'), - |r: &mut JobRequest| r.input_revision.push('x'), |r: &mut JobRequest| r.input_ref.push('x'), |r: &mut JobRequest| r.input = json!({"title": "changed"}), |r: &mut JobRequest| r.instruction.push('x'), @@ -216,7 +234,6 @@ fn registry_profiles_are_not_authority_or_a_fixed_allowlist() { fn validation_enforces_utf8_bytes_and_metadata_bounds() { let mut r = request(); r.job_id = "x".repeat(256); - r.input_revision = "x".repeat(256); r.purpose = "x".repeat(MAX_JOB_PURPOSE_BYTES); r.input_ref = "x".repeat(MAX_JOB_REFERENCE_BYTES); r.instruction = "x".repeat(MAX_JOB_INSTRUCTION_BYTES); @@ -225,7 +242,6 @@ fn validation_enforces_utf8_bytes_and_metadata_bounds() { r.validate().unwrap(); for mutate in [ |r: &mut JobRequest| r.job_id.push('x'), - |r: &mut JobRequest| r.input_revision.push('x'), |r: &mut JobRequest| r.purpose.push('x'), |r: &mut JobRequest| r.input_ref.push('x'), |r: &mut JobRequest| r.instruction.push('x'), @@ -287,7 +303,7 @@ fn result_digest_and_failure_detail_bound_exact_utf8_encoding() { } #[test] -fn worker_input_is_revision_and_attempt_bound_not_final_prose_authority() { +fn worker_input_is_content_and_attempt_bound_not_final_prose_authority() { let request = request(); let attempt = attempt_id(&request.job_id, 2); assert_eq!(attempt, "check:r7:attempt:2"); @@ -297,7 +313,7 @@ fn worker_input_is_revision_and_attempt_bound_not_final_prose_authority() { "Job envelope (immutable):", "job_id: check:r7", "attempt_id: check:r7:attempt:2", - "input_revision: 7", + "input_digest: sha256:", "input_ref: snapshot://7", r#"input_json: {"title":"Check me"}"#, "structured Job result capability", diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 3c8ed2c06..938a3b62c 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -391,8 +391,7 @@ impl ResolvedMemoryFeatureConfig { return Err("disabled Memory feature must not carry Workspace settings"); } if let Some(settings) = &self.workspace_settings - && (settings.settings_revision == 0 - || !is_normalized_workspace_memory_language(&settings.language)) + && !is_normalized_workspace_memory_language(&settings.language) { return Err("Memory Workspace settings snapshot metadata is invalid"); } @@ -473,8 +472,7 @@ impl ResolvedSubjektivFeatureConfig { return Err("disabled subjektiv feature must not carry Workspace settings"); } if let Some(settings) = &self.workspace_settings - && (settings.settings_revision == 0 - || !is_normalized_workspace_memory_language(&settings.language)) + && !is_normalized_workspace_memory_language(&settings.language) { return Err("subjektiv Workspace settings snapshot metadata is invalid"); } @@ -705,10 +703,8 @@ pub fn is_normalized_workspace_memory_language(language: &str) -> bool { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct WorkspaceMemorySettingsSnapshot { - /// Workspace that owns the settings revision. + /// Workspace that owns these settings. pub workspace_id: String, - /// Monotonic Workspace Memory settings revision. - pub settings_revision: u64, /// Normalized language used for Memory extraction and consolidation output. pub language: String, } @@ -1155,7 +1151,8 @@ struct LegacyMemoryConfig { consolidation_threshold_bytes: Option, } -const RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION: u64 = 3; +const RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION: u64 = 4; +const MEMORY_COUNTER_MANIFEST_SNAPSHOT_SCHEMA_VERSION: u64 = 3; const PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION: u64 = 2; /// Serialize a resolved Worker Manifest for durable Worker-specific storage. @@ -1187,6 +1184,7 @@ pub fn read_persisted_worker_manifest_snapshot( )) })?; if version != RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION + && version != MEMORY_COUNTER_MANIFEST_SNAPSHOT_SCHEMA_VERSION && version != PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION { return Err(serde_json::Error::io(std::io::Error::new( @@ -1218,6 +1216,9 @@ pub fn read_persisted_worker_manifest_snapshot( if version == PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION { migrate_legacy_manifest_authority(&mut manifest)?; } + if version != RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION { + migrate_legacy_memory_settings_counter(&mut manifest)?; + } return validate_persisted_worker_manifest(serde_json::from_value(manifest)?); } @@ -1240,6 +1241,32 @@ fn validate_persisted_worker_manifest( Ok(manifest) } +/// Frozen migration for schema 2/3 resolved manifests. Keep actual Workspace +/// identity and language unchanged; the obsolete sequence has no execution role. +fn migrate_legacy_memory_settings_counter( + manifest: &mut serde_json::Value, +) -> Result<(), serde_json::Error> { + for namespace in ["memory", "subjektiv"] { + let Some(settings) = manifest + .get_mut("feature") + .and_then(|feature| feature.get_mut(namespace)) + .and_then(|feature| feature.get_mut("workspace_settings")) + .and_then(serde_json::Value::as_object_mut) + else { + continue; + }; + if let Some(counter) = settings.remove("settings_revision") + && !counter.as_u64().is_some_and(|counter| counter > 0) + { + return Err(serde_json::Error::io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "legacy Workspace Memory settings snapshot has an invalid counter", + ))); + } + } + Ok(()) +} + fn migrate_legacy_manifest_authority( manifest: &mut serde_json::Value, ) -> Result<(), serde_json::Error> { @@ -1385,6 +1412,7 @@ fn migrate_legacy_resolved_manifest_snapshot( } feature.insert("memory".to_string(), resolved); migrate_legacy_manifest_authority(&mut snapshot)?; + migrate_legacy_memory_settings_counter(&mut snapshot)?; validate_persisted_worker_manifest(serde_json::from_value(snapshot)?) } @@ -1749,7 +1777,6 @@ model_id = "claude-sonnet-4-20250514" worker_max_turns = 2\n\n\ [feature.memory.workspace_settings]\n\ workspace_id = \"workspace-1\"\n\ - settings_revision = 7\n\ language = \"日本語\"\n" ); let manifest = WorkerManifest::from_toml(&toml).unwrap(); @@ -1774,7 +1801,6 @@ model_id = "claude-sonnet-4-20250514" fn resolved_memory_execution_validation_fails_closed() { let snapshot = WorkspaceMemorySettingsSnapshot { workspace_id: "workspace-1".to_string(), - settings_revision: 1, language: "English".to_string(), }; let mut enabled = ResolvedMemoryFeatureConfig::default(); @@ -1793,7 +1819,6 @@ model_id = "claude-sonnet-4-20250514" fn resolved_subjektiv_policy_is_inert_until_trusted_attachment() { let snapshot = WorkspaceMemorySettingsSnapshot { workspace_id: "workspace-1".to_string(), - settings_revision: 1, language: "English".to_string(), }; let mut enabled = ResolvedSubjektivFeatureConfig::default(); @@ -1816,6 +1841,80 @@ model_id = "claude-sonnet-4-20250514" assert!(WorkerManifest::from_toml(&toml).is_err()); } + #[test] + fn persisted_counter_migration_preserves_memory_and_subject_settings() { + for version in [2, 3] { + let mut manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap(); + manifest.feature.memory.profile.enabled = true; + manifest.feature.subjektiv.profile.enabled = true; + let settings = WorkspaceMemorySettingsSnapshot { + workspace_id: "workspace-1".into(), + language: "日本語".into(), + }; + manifest + .feature + .memory + .bind_workspace_settings(settings.clone()) + .unwrap(); + manifest + .feature + .subjektiv + .bind_workspace_settings(settings.clone()) + .unwrap(); + let mut snapshot = write_persisted_worker_manifest_snapshot(&manifest).unwrap(); + snapshot["schema_version"] = version.into(); + for namespace in ["memory", "subjektiv"] { + snapshot["manifest"]["feature"][namespace]["workspace_settings"]["settings_revision"] = + 99.into(); + } + let restored = read_persisted_worker_manifest_snapshot(snapshot).unwrap(); + assert_eq!( + restored.feature.memory.workspace_settings(), + Some(settings.clone()) + ); + assert_eq!( + restored.feature.subjektiv.workspace_settings(), + Some(settings) + ); + let current = write_persisted_worker_manifest_snapshot(&restored).unwrap(); + assert_eq!(current["schema_version"], 4); + for namespace in ["memory", "subjektiv"] { + assert_eq!( + current["manifest"]["feature"][namespace]["workspace_settings"], + serde_json::json!({ + "workspace_id": "workspace-1", "language": "日本語" + }) + ); + } + read_persisted_worker_manifest_snapshot(current).unwrap(); + } + } + + #[test] + fn current_settings_snapshot_rejects_removed_counter_outside_migration() { + assert!( + serde_json::from_value::(serde_json::json!({ + "workspace_id": "workspace-1", "language": "English", "settings_revision": 9 + })) + .is_err() + ); + let settings: WorkspaceMemorySettingsSnapshot = serde_json::from_value(serde_json::json!({ + "workspace_id": "workspace-1", "language": "English" + })) + .unwrap(); + let mut memory = ResolvedMemoryFeatureConfig::default(); + memory.profile.enabled = true; + memory.bind_workspace_settings(settings.clone()).unwrap(); + assert!(memory.validate_execution().is_ok()); + for language in ["", " English", "English\n"] { + memory.workspace_settings = Some(WorkspaceMemorySettingsSnapshot { + language: language.into(), + ..settings.clone() + }); + assert!(memory.validate_execution().is_err()); + } + } + #[test] fn persisted_manifest_adapter_migrates_legacy_memory_authority() { let mut manifest = @@ -1863,7 +1962,7 @@ model_id = "claude-sonnet-4-20250514" "Français" ); let current = write_persisted_worker_manifest_snapshot(&migrated).unwrap(); - assert_eq!(current["schema_version"], 3); + assert_eq!(current["schema_version"], 4); assert!(current["manifest"].get("memory").is_none()); let mut disabled = @@ -1905,7 +2004,7 @@ model_id = "claude-sonnet-4-20250514" let restored = read_persisted_worker_manifest_snapshot(versioned).unwrap(); let current = write_persisted_worker_manifest_snapshot(&restored).unwrap(); - assert_eq!(current["schema_version"], 3); + assert_eq!(current["schema_version"], 4); assert!(current["manifest"].get("plugins").is_none()); assert!(current["manifest"]["feature"].get("plugins").is_none()); assert!(current["manifest"]["feature"].get("workers").is_none()); @@ -1976,7 +2075,7 @@ model_id = "claude-sonnet-4-20250514" assert!( read_persisted_worker_manifest_snapshot(serde_json::json!({ - "schema_version": 4, + "schema_version": 5, "manifest": manifest, })) .is_err() diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 1dfd34ef5..454643df7 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -1489,7 +1489,6 @@ mod tests { let tmp = TempDir::new().unwrap(); for (field, value) in [ ("workspace_id", serde_json::json!("workspace-a")), - ("settings_revision", serde_json::json!(2)), ("language", serde_json::json!("Japanese")), ] { let artifact = serde_json::json!({ diff --git a/crates/memory/src/audit.rs b/crates/memory/src/audit.rs index dbbce0b3e..eff4f9b13 100644 --- a/crates/memory/src/audit.rs +++ b/crates/memory/src/audit.rs @@ -184,8 +184,6 @@ impl OperationCounts { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct MemorySettingsAudit { pub workspace_id: String, - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub settings_revision: u64, pub language: String, } diff --git a/crates/memory/src/schema/common.rs b/crates/memory/src/schema/common.rs index 0bc05360d..18d8bda93 100644 --- a/crates/memory/src/schema/common.rs +++ b/crates/memory/src/schema/common.rs @@ -108,8 +108,9 @@ pub struct EvidenceOrigin { #[serde(default, skip_serializing_if = "Option::is_none")] pub flow_definition_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub flow_definition_revision: Option, + /// SHA-256 of the exact pinned Flow definition content, when available. + /// Historical origins without captured definition content leave this absent. + pub flow_definition_fingerprint: Option, } /// Host-resolved source/evidence metadata for an individual staging claim. @@ -156,6 +157,33 @@ pub fn split_frontmatter(content: &str) -> Result<(&str, &str), RecordLintError> mod tests { use super::*; + #[test] + fn flow_origin_captures_content_identity_without_a_state_counter() { + let origin: EvidenceOrigin = serde_json::from_value(serde_json::json!({ + "kind": "flow_instruction", + "flow_definition_id": "flow-definition", + "flow_definition_fingerprint": "a".repeat(64) + })) + .unwrap(); + assert_eq!( + origin.flow_definition_fingerprint.as_deref(), + Some("a".repeat(64).as_str()) + ); + let value = serde_json::to_value(&origin).unwrap(); + assert_eq!(value["flow_definition_fingerprint"], "a".repeat(64)); + let historical: EvidenceOrigin = serde_json::from_value(serde_json::json!({ + "kind": "flow_instruction", "flow_definition_id": "flow-definition" + })) + .unwrap(); + assert!(historical.flow_definition_fingerprint.is_none()); + assert!( + serde_json::to_value(historical) + .unwrap() + .get("flow_definition_fingerprint") + .is_none() + ); + } + #[test] fn splits_simple() { let doc = "---\nfoo: 1\n---\nbody here\n"; diff --git a/crates/merge-request/Cargo.toml b/crates/merge-request/Cargo.toml index 61fa80fe0..7176f7bd0 100644 --- a/crates/merge-request/Cargo.toml +++ b/crates/merge-request/Cargo.toml @@ -11,6 +11,7 @@ schemars = { workspace = true, features = ["chrono04"] } serde = { workspace = true, features = ["derive"] } serde_json.workspace = true thiserror.workspace = true +ticket = { path = "../ticket" } uuid = { workspace = true, features = ["v7"] } [dev-dependencies] diff --git a/crates/merge-request/src/lib.rs b/crates/merge-request/src/lib.rs index c0114b142..2d4f602ac 100644 --- a/crates/merge-request/src/lib.rs +++ b/crates/merge-request/src/lib.rs @@ -8,7 +8,7 @@ use std::{ use thiserror::Error; use uuid::Uuid; -const SCHEMA_VERSION: i64 = 12; +const SCHEMA_VERSION: i64 = 13; const MAX_BODY_BYTES: usize = 16 * 1024; const DOMAIN_TABLES: [&str; 5] = [ "merge_requests", @@ -103,7 +103,7 @@ pub struct ReviewRequestedEvent { pub sequence: u64, pub subject_ref: String, #[serde(default)] - pub ticket_item_revision: String, + pub ticket_content_digest: String, #[serde(default)] pub ticket_merge_request_subjects: Vec, pub requested_by: WorkerIdentity, @@ -118,7 +118,7 @@ pub struct ReviewEvent { pub request_event_id: String, pub subject_ref: String, #[serde(default)] - pub ticket_item_revision: String, + pub ticket_content_digest: String, #[serde(default)] pub ticket_merge_request_subjects: Vec, pub decision: ReviewDecision, @@ -249,7 +249,7 @@ pub enum MergeRequestEvidenceError { ReviewRequestMissing, ReviewRequestMismatch, ApprovalNotEffective, - ItemRevisionMismatch, + TicketContentMismatch, SourceSnapshotMismatch, RequirementApprovalMissing, } @@ -268,7 +268,7 @@ impl MergeRequestEvidenceError { Self::ReviewRequestMissing => "review_request_missing", Self::ReviewRequestMismatch => "review_request_mismatch", Self::ApprovalNotEffective => "approval_not_effective", - Self::ItemRevisionMismatch => "item_revision_mismatch", + Self::TicketContentMismatch => "ticket_content_mismatch", Self::SourceSnapshotMismatch => "source_snapshot_mismatch", Self::RequirementApprovalMissing => "requirement_approval_missing", } @@ -290,14 +290,14 @@ impl MergeRequestEvidenceError { "Reviewer approval does not match linked ReviewRequested evidence" } Self::ApprovalNotEffective => "Reviewer approval is not the effective review", - Self::ItemRevisionMismatch => { - "Reviewer approval does not attest the current Ticket revision" + Self::TicketContentMismatch => { + "Reviewer approval does not attest the current Ticket digest" } Self::SourceSnapshotMismatch => { "Reviewer approval does not attest the exact linked source set" } Self::RequirementApprovalMissing => { - "no effective Reviewer approval attests the current Ticket revision and exact linked source set" + "no effective Reviewer approval attests the current Ticket digest and exact linked source set" } } } @@ -382,7 +382,7 @@ impl MergeRequest { }) .ok_or(MergeRequestEvidenceError::ReviewRequestMissing)?; if requested.subject_ref != review.subject_ref - || requested.ticket_item_revision != review.ticket_item_revision + || requested.ticket_content_digest != review.ticket_content_digest || requested.ticket_merge_request_subjects != review.ticket_merge_request_subjects || requested.reviewer != review.reviewer || requested.sequence >= review.sequence @@ -393,7 +393,7 @@ impl MergeRequest { } } -/// Finds an effective approval of the authoritative current Ticket revision and +/// Finds an effective approval of the authoritative current Ticket digest and /// exact linked source snapshot. Callers supply all linked requests and resolved /// source refs (recorded merged refs for completion); this function does not read /// authority, resolve selectors, require merged state, or mutate lifecycle state. @@ -401,7 +401,7 @@ impl MergeRequest { /// attestation. Snapshot ordering is immaterial, but duplicate ids are rejected. pub fn requirement_approval<'a>( requests: &'a [MergeRequest], - item_revision: &str, + content_digest: &str, subjects: &[MergeRequestReviewSubject], event_id: Option<&str>, ) -> Result<&'a ReviewEvent, MergeRequestEvidenceError> { @@ -436,8 +436,10 @@ pub fn requirement_approval<'a>( { return Err(MergeRequestEvidenceError::ApprovalNotEffective); } - if review.ticket_item_revision != item_revision || item_revision.trim().is_empty() { - return Err(MergeRequestEvidenceError::ItemRevisionMismatch); + if review.ticket_content_digest != content_digest + || content_digest.trim().is_empty() + { + return Err(MergeRequestEvidenceError::TicketContentMismatch); } if !same_source_snapshot(&review.ticket_merge_request_subjects, subjects) { return Err(MergeRequestEvidenceError::SourceSnapshotMismatch); @@ -508,7 +510,7 @@ pub struct OpenMergeRequest { pub struct RequestMergeRequestReview { pub merge_request_id: String, pub ticket_id: String, - pub ticket_item_revision: String, + pub ticket_content_digest: String, pub ticket_merge_request_subjects: Vec, pub subject_ref: String, pub child_session_id: String, @@ -604,6 +606,7 @@ pub struct CompleteMergeRequest { pub struct TicketCompletionEvent { pub operation_id: String, pub ticket_id: String, + /// Frozen historical payload only; not a content attestation or live API. pub item_revision: String, pub merge_request_ids: Vec, pub requirement_approval_event_id: String, @@ -805,11 +808,11 @@ impl MergeRequestStore { } let mut c = self.lock()?; let t = c.transaction()?; - let current_revision = current_ticket_revision(&t, &i.auth.workspace_id, &i.ticket_id)? + let current_digest = current_ticket_content_digest(&t, &i.auth.workspace_id, &i.ticket_id)? .ok_or(MergeRequestError::NotFound)?; - if current_revision != i.ticket_item_revision { + if current_digest != i.ticket_content_digest { return Err(MergeRequestError::Conflict( - "Ticket item revision changed before review request".into(), + "Ticket item digest changed before review request".into(), )); } let current_mr = load_mr(&t, &i.auth.workspace_id, &i.merge_request_id)? @@ -848,7 +851,7 @@ impl MergeRequestStore { event_id: Uuid::now_v7().to_string(), sequence: next_seq(&t, &mr.workspace_id, &mr.merge_request_id)?, subject_ref: i.subject_ref, - ticket_item_revision: i.ticket_item_revision, + ticket_content_digest: i.ticket_content_digest, ticket_merge_request_subjects: i.ticket_merge_request_subjects, requested_by: WorkerIdentity { runtime_id: i.auth.runtime_id, @@ -944,12 +947,12 @@ impl MergeRequestStore { "review grant invalid".into(), )); }; - let (ticket_item_revision, ticket_merge_request_subjects) = load_mr(&t, &ws, &mr)? + let (ticket_content_digest, ticket_merge_request_subjects) = load_mr(&t, &ws, &mr)? .and_then(|request| { request.thread.into_iter().find_map(|event| match event { MergeRequestThreadEvent::ReviewRequested(event) if event.event_id == req => { Some(( - event.ticket_item_revision, + event.ticket_content_digest, event.ticket_merge_request_subjects, )) } @@ -982,7 +985,7 @@ impl MergeRequestStore { sequence: next_seq(&t, &ws, &mr)?, request_event_id: req, subject_ref: subject, - ticket_item_revision, + ticket_content_digest, ticket_merge_request_subjects, decision: i.decision, body: i.body, @@ -1056,13 +1059,12 @@ impl MergeRequestStore { } if let Some(review) = &review { let connection = self.lock()?; - let current_revision = - current_ticket_revision(&connection, &i.auth.workspace_id, &i.ticket_id)? + let current_digest = + current_ticket_content_digest(&connection, &i.auth.workspace_id, &i.ticket_id)? .ok_or(MergeRequestError::NotFound)?; - if review.ticket_item_revision != current_revision { + if review.ticket_content_digest != current_digest { b.push( - "approval predates the current Ticket revision; fresh review is required" - .into(), + "approval predates the current Ticket digest; fresh review is required".into(), ); } } @@ -1147,12 +1149,12 @@ impl MergeRequestStore { )); } let connection = self.lock()?; - let current_revision = - current_ticket_revision(&connection, &mr.workspace_id, &i.ticket_id)? + let current_digest = + current_ticket_content_digest(&connection, &mr.workspace_id, &i.ticket_id)? .ok_or(MergeRequestError::NotFound)?; - if review.ticket_item_revision != current_revision { + if review.ticket_content_digest != current_digest { return Err(MergeRequestError::NotReady( - "approval predates the current Ticket revision; fresh review is required".into(), + "approval predates the current Ticket digest; fresh review is required".into(), )); } let state: Option = connection @@ -1227,12 +1229,12 @@ impl MergeRequestStore { "current effective review does not approve the source ref".into(), )); } - let current_revision = - current_ticket_revision(&transaction, &mr.workspace_id, &i.ticket_id)? + let current_digest = + current_ticket_content_digest(&transaction, &mr.workspace_id, &i.ticket_id)? .ok_or(MergeRequestError::NotFound)?; - if review.ticket_item_revision != current_revision { + if review.ticket_content_digest != current_digest { return Err(MergeRequestError::NotReady( - "approval predates the current Ticket revision; fresh review is required".into(), + "approval predates the current Ticket digest; fresh review is required".into(), )); } let state: Option = transaction @@ -1655,34 +1657,13 @@ fn linked_merge_request_ids( .map_err(Into::into) } -fn current_ticket_revision( +fn current_ticket_content_digest( connection: &Connection, workspace_id: &str, ticket_id: &str, ) -> Result, MergeRequestError> { - let event_index = connection - .query_row( - "SELECT event_index - FROM typed_ticket_events - WHERE workspace_id=?1 AND ticket_id=?2 - AND kind IN ('create','item_edit') - ORDER BY event_index DESC - LIMIT 1", - params![workspace_id, ticket_id], - |row| row.get::<_, i64>(0), - ) - .optional()?; - if let Some(event_index) = event_index { - return Ok(Some(format!("{ticket_id}:{event_index}"))); - } - connection - .query_row( - "SELECT updated_at FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", - params![workspace_id, ticket_id], - |row| row.get(0), - ) - .optional() - .map_err(Into::into) + ticket::sqlite_ticket_content_digest(connection, workspace_id, ticket_id) + .map_err(|error| MergeRequestError::Operation(error.to_string())) } fn load_mr(c: &Connection, w: &str, m: &str) -> Result, MergeRequestError> { @@ -1773,12 +1754,82 @@ pub fn migrate(c: &Connection) -> Result<(), MergeRequestError> { match schema_state(c)? { SchemaState::Fresh => fresh(c), SchemaState::Current(SCHEMA_VERSION) => verify(c), + SchemaState::Current(12) => migrate_v12_content_attestations(c), SchemaState::Current(v) => Err(MergeRequestError::Operation(format!( "unsupported schema {v}" ))), } } +/// Frozen v12 migration: historical counters cannot prove the reviewed content. +/// Preserve request/review identities and integration evidence, but do not +/// manufacture content attestations from today's Ticket. Fresh review is needed. +fn migrate_v12_content_attestations(c: &Connection) -> Result<(), MergeRequestError> { + let transaction = c.unchecked_transaction()?; + verify(&transaction)?; + // Keep the exact historical audit bytes independently of the current DTO + // projection. An unknown content attestation must not erase its old evidence. + transaction.execute_batch( + "CREATE TABLE merge_request_legacy_review_archive ( + workspace_id TEXT NOT NULL, + merge_request_id TEXT NOT NULL, + event_id TEXT NOT NULL, + payload_json TEXT NOT NULL, + PRIMARY KEY(workspace_id, merge_request_id, event_id) + ); + INSERT INTO merge_request_legacy_review_archive + SELECT workspace_id,merge_request_id,event_id,payload_json + FROM merge_request_thread_events WHERE kind IN ('review_requested','review'); + CREATE TRIGGER merge_request_legacy_review_archive_no_update + BEFORE UPDATE ON merge_request_legacy_review_archive + BEGIN SELECT RAISE(ABORT, 'historical review evidence is immutable'); END; + CREATE TRIGGER merge_request_legacy_review_archive_no_delete + BEFORE DELETE ON merge_request_legacy_review_archive + BEGIN SELECT RAISE(ABORT, 'historical review evidence is retained'); END;", + )?; + let rows = { + let mut statement = transaction.prepare( + "SELECT workspace_id,merge_request_id,event_id,payload_json FROM merge_request_thread_events WHERE kind IN ('review_requested','review')", + )?; + statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + })? + .collect::, _>>()? + }; + for (workspace_id, merge_request_id, event_id, payload) in rows { + let mut value: serde_json::Value = json(&payload)?; + let object = value + .as_object_mut() + .ok_or_else(|| MergeRequestError::Corrupt("review payload is not an object".into()))?; + object.remove("ticket_item_revision"); + object.insert( + "ticket_content_digest".into(), + serde_json::Value::String(String::new()), + ); + transaction.execute( + "UPDATE merge_request_thread_events SET payload_json=?4 WHERE workspace_id=?1 AND merge_request_id=?2 AND event_id=?3", + params![workspace_id, merge_request_id, event_id, value.to_string()], + )?; + } + transaction.execute( + "UPDATE merge_request_review_grants SET status='revoked',revoked_at=COALESCE(revoked_at,issued_at) WHERE status='issued'", + [], + )?; + transaction.execute( + "UPDATE merge_request_schema SET version=?1 WHERE singleton=1", + params![SCHEMA_VERSION], + )?; + verify(&transaction)?; + transaction.commit()?; + Ok(()) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SchemaState { Fresh, diff --git a/crates/merge-request/tests/evidence.rs b/crates/merge-request/tests/evidence.rs index feaf6337d..0176f7fad 100644 --- a/crates/merge-request/tests/evidence.rs +++ b/crates/merge-request/tests/evidence.rs @@ -24,7 +24,7 @@ fn subjects() -> Vec { fn add_review( request: &mut MergeRequest, event_id: &str, - revision: &str, + digest: &str, snapshot: &[MergeRequestReviewSubject], decision: ReviewDecision, ) { @@ -37,7 +37,7 @@ fn add_review( event_id: format!("request-{event_id}"), sequence, subject_ref: subject.subject_ref.clone(), - ticket_item_revision: revision.into(), + ticket_content_digest: digest.into(), ticket_merge_request_subjects: snapshot.to_vec(), requested_by: actor(), reviewer: actor(), @@ -48,7 +48,7 @@ fn add_review( sequence: sequence + 1, request_event_id: requested.event_id.clone(), subject_ref: requested.subject_ref.clone(), - ticket_item_revision: revision.into(), + ticket_content_digest: digest.into(), ticket_merge_request_subjects: snapshot.to_vec(), decision, body: String::new(), @@ -84,7 +84,7 @@ fn merged_requests() -> Vec { add_review( &mut request, &approval_id, - "revision-one", + "digest-one", &snapshot, ReviewDecision::Approve, ); @@ -109,22 +109,17 @@ fn merged_requests() -> Vec { } #[test] -fn postmerge_latest_approval_attests_the_current_revision_without_replacing_integration() { +fn postmerge_latest_approval_attests_the_current_digest_without_replacing_integration() { let mut requests = merged_requests(); let snapshot = subjects(); assert_eq!( - requirement_approval( - &requests, - "revision-two", - &snapshot, - Some("integration-MR-1") - ), - Err(MergeRequestEvidenceError::ItemRevisionMismatch) + requirement_approval(&requests, "digest-two", &snapshot, Some("integration-MR-1")), + Err(MergeRequestEvidenceError::TicketContentMismatch) ); add_review( &mut requests[0], "postmerge", - "revision-two", + "digest-two", &snapshot, ReviewDecision::Approve, ); @@ -134,28 +129,23 @@ fn postmerge_latest_approval_attests_the_current_revision_without_replacing_inte "integration-MR-1" ); assert_eq!( - requirement_approval(&requests, "revision-two", &snapshot, None) + requirement_approval(&requests, "digest-two", &snapshot, None) .unwrap() .event_id, "postmerge" ); assert_eq!( - requirement_approval(&requests, "revision-two", &snapshot, Some("postmerge")) + requirement_approval(&requests, "digest-two", &snapshot, Some("postmerge")) .unwrap() .event_id, "postmerge" ); assert_eq!( - requirement_approval( - &requests, - "revision-one", - &snapshot, - Some("integration-MR-1") - ), + requirement_approval(&requests, "digest-one", &snapshot, Some("integration-MR-1")), Err(MergeRequestEvidenceError::ApprovalNotEffective) ); assert_eq!( - requirement_approval(&requests, "revision-two", &snapshot, Some("unknown")), + requirement_approval(&requests, "digest-two", &snapshot, Some("unknown")), Err(MergeRequestEvidenceError::RequirementApprovalMissing) ); } @@ -167,80 +157,70 @@ fn later_request_changes_prevents_older_requirement_approval_from_being_effectiv add_review( &mut requests[0], "approved", - "revision-two", + "digest-two", &snapshot, ReviewDecision::Approve, ); add_review( &mut requests[0], "changes", - "revision-two", + "digest-two", &snapshot, ReviewDecision::RequestChanges, ); assert_eq!( - requirement_approval(&requests, "revision-two", &snapshot, None), + requirement_approval(&requests, "digest-two", &snapshot, None), Err(MergeRequestEvidenceError::RequirementApprovalMissing) ); assert_eq!( - requirement_approval(&requests, "revision-two", &snapshot, Some("approved")), + requirement_approval(&requests, "digest-two", &snapshot, Some("approved")), Err(MergeRequestEvidenceError::ApprovalNotEffective) ); assert_eq!( - requirement_approval(&requests, "revision-two", &snapshot, Some("changes")), + requirement_approval(&requests, "digest-two", &snapshot, Some("changes")), Err(MergeRequestEvidenceError::ApprovalNotApproved) ); assert!(requests[0].integration_approval().is_ok()); } #[test] -fn changed_revision_or_any_linked_source_invalidates_a_multiple_mr_attestation() { +fn changed_digest_or_any_linked_source_invalidates_a_multiple_mr_attestation() { let requests = merged_requests(); let original = subjects(); - assert!(requirement_approval(&requests, "revision-one", &original, None).is_ok()); + assert!(requirement_approval(&requests, "digest-one", &original, None).is_ok()); assert_eq!( - requirement_approval( - &requests, - "revision-two", - &original, - Some("integration-MR-1") - ), - Err(MergeRequestEvidenceError::ItemRevisionMismatch) + requirement_approval(&requests, "digest-two", &original, Some("integration-MR-1")), + Err(MergeRequestEvidenceError::TicketContentMismatch) ); let mut changed = original.clone(); changed[1].subject_ref = "changed-source-two".into(); assert_eq!( - requirement_approval( - &requests, - "revision-one", - &changed, - Some("integration-MR-1") - ), + requirement_approval(&requests, "digest-one", &changed, Some("integration-MR-1")), Err(MergeRequestEvidenceError::SourceSnapshotMismatch) ); let mut reversed = original.clone(); reversed.reverse(); - assert!(requirement_approval(&requests, "revision-one", &reversed, None).is_ok()); + assert!(requirement_approval(&requests, "digest-one", &reversed, None).is_ok()); // Even after the caller refreshes the linked request collection, an old // attestation cannot authorize completion against a smaller linked set. assert_eq!( requirement_approval( &requests[..1], - "revision-one", + "digest-one", &original[..1], Some("integration-MR-1") ), Err(MergeRequestEvidenceError::SourceSnapshotMismatch) ); assert_eq!( - requirement_approval(&requests, "revision-one", &original[..1], None), + requirement_approval(&requests, "digest-one", &original[..1], None), Err(MergeRequestEvidenceError::SourceSnapshotMismatch) ); assert_eq!( requirement_approval( &requests, - "revision-one", + "digest-one", &[original[0].clone(), original[0].clone()], None ), @@ -249,24 +229,24 @@ fn changed_revision_or_any_linked_source_invalidates_a_multiple_mr_attestation() } #[test] -fn refreshed_postmerge_approval_can_attest_a_changed_multiple_mr_set_and_revision() { +fn refreshed_postmerge_approval_can_attest_a_changed_multiple_mr_set_and_digest() { let mut requests = merged_requests(); let mut snapshot = subjects(); snapshot.pop(); requests.pop(); assert_eq!( - requirement_approval(&requests, "revision-two", &snapshot, None), + requirement_approval(&requests, "digest-two", &snapshot, None), Err(MergeRequestEvidenceError::RequirementApprovalMissing) ); add_review( &mut requests[0], "refreshed-set", - "revision-two", + "digest-two", &snapshot, ReviewDecision::Approve, ); assert_eq!( - requirement_approval(&requests, "revision-two", &snapshot, None) + requirement_approval(&requests, "digest-two", &snapshot, None) .unwrap() .event_id, "refreshed-set" @@ -282,7 +262,7 @@ fn requirement_approval_must_bind_its_own_mr_source_and_corresponding_request() let original = subjects(); for (case, expected) in [ ("missing", MergeRequestEvidenceError::ReviewRequestMissing), - ("revision", MergeRequestEvidenceError::ReviewRequestMismatch), + ("digest", MergeRequestEvidenceError::ReviewRequestMismatch), ("snapshot", MergeRequestEvidenceError::ReviewRequestMismatch), ("reviewer", MergeRequestEvidenceError::ReviewRequestMismatch), ("source", MergeRequestEvidenceError::ApprovalSourceMismatch), @@ -294,7 +274,7 @@ fn requirement_approval_must_bind_its_own_mr_source_and_corresponding_request() review.request_event_id = "absent-request".into(); } MergeRequestThreadEvent::ReviewRequested(requested) => match case { - "revision" => requested.ticket_item_revision = "other-revision".into(), + "digest" => requested.ticket_content_digest = "other-digest".into(), "snapshot" => { requested.ticket_merge_request_subjects.pop(); } @@ -309,12 +289,7 @@ fn requirement_approval_must_bind_its_own_mr_source_and_corresponding_request() } } assert_eq!( - requirement_approval( - &requests, - "revision-one", - &original, - Some("integration-MR-1") - ), + requirement_approval(&requests, "digest-one", &original, Some("integration-MR-1")), Err(expected), "{case}" ); @@ -339,14 +314,14 @@ fn revoked_requirement_approval_is_rejected_and_discovery_uses_another_valid_mr( assert_eq!( requirement_approval( &requests, - "revision-one", + "digest-one", &subjects(), Some("integration-MR-1") ), Err(MergeRequestEvidenceError::ApprovalRevoked) ); assert_eq!( - requirement_approval(&requests, "revision-one", &subjects(), None) + requirement_approval(&requests, "digest-one", &subjects(), None) .unwrap() .event_id, "integration-MR-2" @@ -441,8 +416,8 @@ fn evidence_error_codes_are_stable_machine_diagnostics_separate_from_messages() "approval_not_effective", ), ( - MergeRequestEvidenceError::ItemRevisionMismatch, - "item_revision_mismatch", + MergeRequestEvidenceError::TicketContentMismatch, + "ticket_content_mismatch", ), ( MergeRequestEvidenceError::SourceSnapshotMismatch, diff --git a/crates/merge-request/tests/store.rs b/crates/merge-request/tests/store.rs index 80b7dd1a9..52b364b69 100644 --- a/crates/merge-request/tests/store.rs +++ b/crates/merge-request/tests/store.rs @@ -35,11 +35,24 @@ fn auth_repo(repository_id: &str) -> MergeRequestAuth { ..auth() } } +fn content_digest(title: &str) -> String { + let connection = Connection::open_in_memory().unwrap(); + connection.execute_batch("CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,title TEXT,body TEXT); CREATE TABLE typed_ticket_targets(workspace_id TEXT,ticket_id TEXT,ordinal INTEGER,repository_key TEXT,ref_selector TEXT,access TEXT);").unwrap(); + connection + .execute( + "INSERT INTO typed_tickets VALUES('W','T',?1,'Requirements')", + [title], + ) + .unwrap(); + ticket::sqlite_ticket_content_digest(&connection, "W", "T") + .unwrap() + .unwrap() +} fn fixture() -> (tempfile::TempDir, MergeRequestStore) { let d = tempfile::tempdir().unwrap(); let p = d.path().join("db"); let c = Connection::open(&p).unwrap(); - c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO repositories VALUES('W','R2');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t');CREATE VIEW ticket_active_worker_assignments AS SELECT current.* FROM ticket_current_worker_assignments current JOIN typed_tickets ticket ON ticket.workspace_id=current.workspace_id AND ticket.ticket_id=current.ticket_id WHERE ticket.workflow_state NOT IN ('done','closed');").unwrap(); + c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,title TEXT NOT NULL,body TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_targets(workspace_id TEXT,ticket_id TEXT,ordinal INTEGER,repository_key TEXT,ref_selector TEXT,access TEXT);CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO repositories VALUES('W','R2');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t','Title','Requirements');CREATE VIEW ticket_active_worker_assignments AS SELECT current.* FROM ticket_current_worker_assignments current JOIN typed_tickets ticket ON ticket.workspace_id=current.workspace_id AND ticket.ticket_id=current.ticket_id WHERE ticket.workflow_state NOT IN ('done','closed');").unwrap(); drop(c); let a = Assignments(Arc::new(Mutex::new(CurrentAssignment { assignment_id: "A".into(), @@ -50,6 +63,221 @@ fn fixture() -> (tempfile::TempDir, MergeRequestStore) { let s = MergeRequestStore::open(&p, Arc::new(a), Arc::new(Repositories)).unwrap(); (d, s) } +/// Frozen v12 fixture: counters must never be promoted to content attestations. +fn freeze_v12_review_payloads(connection: &Connection) { + let rows = { + let mut statement = connection.prepare( + "SELECT event_id,payload_json FROM merge_request_thread_events WHERE kind IN ('review_requested','review')", + ).unwrap(); + statement + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .unwrap() + .collect::, _>>() + .unwrap() + }; + for (event_id, payload) in rows { + let mut value: serde_json::Value = serde_json::from_str(&payload).unwrap(); + let object = value.as_object_mut().unwrap(); + object.remove("ticket_content_digest"); + object.insert("ticket_item_revision".into(), serde_json::json!("T:0")); + connection + .execute( + "UPDATE merge_request_thread_events SET payload_json=?2 WHERE event_id=?1", + rusqlite::params![event_id, value.to_string()], + ) + .unwrap(); + } + connection + .execute("UPDATE merge_request_schema SET version=12", []) + .unwrap(); +} + +#[test] +fn v12_migration_revokes_old_grants_preserves_integration_and_requires_fresh_content_review() { + let (dir, store) = fixture(); + open(&store); + let approved = approve(&store, "source", "old-consumed"); + let completion = CompleteMergeRequest { + merge_request_id: "MR".into(), + ticket_id: "T".into(), + operation_id: "integration".into(), + approval_event_id: approved.event_id.clone(), + current_subject_ref: "source".into(), + target_ref_before: "before".into(), + target_ref_after: "after".into(), + strategy: MergeStrategy::FastForward, + resolution: ConflictResolution::None, + auth: auth(), + now: at(5), + }; + let merged = store.complete(completion.clone()).unwrap(); + request(&store, "source", "old-issued"); + let connection = Connection::open(dir.path().join("db")).unwrap(); + freeze_v12_review_payloads(&connection); + let original_reviews = { + let mut statement = connection.prepare( + "SELECT event_id,payload_json FROM merge_request_thread_events WHERE kind IN ('review_requested','review') ORDER BY event_id", + ).unwrap(); + statement + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .unwrap() + .collect::, _>>() + .unwrap() + }; + migrate(&connection).unwrap(); + let archived_reviews = { + let mut statement = connection.prepare( + "SELECT event_id,payload_json FROM merge_request_legacy_review_archive ORDER BY event_id", + ).unwrap(); + statement + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .unwrap() + .collect::, _>>() + .unwrap() + }; + assert_eq!( + archived_reviews, original_reviews, + "historical review bytes must survive conversion" + ); + assert!( + connection + .execute_batch("UPDATE merge_request_legacy_review_archive SET payload_json='{}'") + .is_err() + ); + assert!( + connection + .execute_batch("DELETE FROM merge_request_legacy_review_archive") + .is_err() + ); + let grants: Vec<(String, String, Option)> = connection.prepare( + "SELECT capability_token,status,revoked_at FROM merge_request_review_grants ORDER BY capability_token", + ).unwrap().query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) + .unwrap().collect::>().unwrap(); + assert_eq!(grants[0].0, "old-consumed"); + assert_eq!(grants[0].1, "consumed"); + assert_eq!(grants[1].0, "old-issued"); + assert_eq!(grants[1].1, "revoked"); + assert!(grants[1].2.is_some()); + let legacy_terms: i64 = connection.query_row( + "SELECT COUNT(*) FROM merge_request_thread_events WHERE payload_json LIKE '%ticket_item_revision%'", + [], |row| row.get(0), + ).unwrap(); + assert_eq!(legacy_terms, 0); + let saved = store.get_by_id("W", "MR").unwrap(); + assert_eq!(saved.merged_result().unwrap(), &merged); + let integration = saved.integration_approval().unwrap(); + assert_eq!(integration.event_id, approved.event_id); + assert!(integration.ticket_content_digest.is_empty()); + let snapshot = vec![MergeRequestReviewSubject { + merge_request_id: "MR".into(), + subject_ref: "source".into(), + }]; + assert_eq!( + requirement_approval( + &[saved], + &content_digest("Title"), + &snapshot, + Some(&approved.event_id) + ), + Err(MergeRequestEvidenceError::TicketContentMismatch), + ); + assert!(matches!( + store.authorize_review_submission("MR", "old-issued"), + Err(MergeRequestError::Unauthorized(_)), + )); + assert!( + store + .submit_review(SubmitMergeRequestReview { + merge_request_id: "MR".into(), + ticket_id: "T".into(), + current_subject_ref: "source".into(), + capability_token: "old-issued".into(), + decision: ReviewDecision::Approve, + body: "late old review".into(), + findings: vec![], + now: at(6), + }) + .is_err() + ); + assert_eq!( + store.complete(completion).unwrap(), + merged, + "recorded merge replay stays intact" + ); + let fresh = approve(&store, "source", "fresh"); + assert_eq!(fresh.ticket_content_digest, content_digest("Title")); + migrate(&connection).unwrap(); + let requests = store.list_for_ticket("W", "T").unwrap(); + assert_eq!( + requirement_approval( + &requests, + &content_digest("Title"), + &snapshot, + Some(&fresh.event_id) + ) + .unwrap(), + &fresh, + ); + assert_eq!(requests[0].merged_result().unwrap(), &merged); + assert_eq!( + connection + .query_row("SELECT version FROM merge_request_schema", [], |row| row + .get::<_, i64>(0)) + .unwrap(), + 13 + ); +} + +#[test] +fn v12_migration_rolls_back_payload_and_grant_changes_when_schema_update_fails() { + let (dir, store) = fixture(); + open(&store); + request(&store, "source", "old-issued"); + let connection = Connection::open(dir.path().join("db")).unwrap(); + freeze_v12_review_payloads(&connection); + let before: String = connection + .query_row( + "SELECT payload_json FROM merge_request_thread_events WHERE kind='review_requested'", + [], + |row| row.get(0), + ) + .unwrap(); + connection.execute_batch( + "CREATE TRIGGER reject_schema_update BEFORE UPDATE ON merge_request_schema BEGIN SELECT RAISE(ABORT,'injected migration failure'); END;", + ).unwrap(); + assert!(migrate(&connection).is_err()); + assert!( + connection + .prepare("SELECT * FROM merge_request_legacy_review_archive") + .is_err(), + "failed conversion must roll back its archive too" + ); + assert_eq!( + connection + .query_row("SELECT version FROM merge_request_schema", [], |row| row + .get::<_, i64>(0)) + .unwrap(), + 12 + ); + assert_eq!(connection.query_row("SELECT payload_json FROM merge_request_thread_events WHERE kind='review_requested'", [], |row| row.get::<_, String>(0)).unwrap(), before); + assert_eq!(connection.query_row("SELECT status FROM merge_request_review_grants WHERE capability_token='old-issued'", [], |row| row.get::<_, String>(0)).unwrap(), "issued"); + connection + .execute_batch("DROP TRIGGER reject_schema_update") + .unwrap(); + migrate(&connection).unwrap(); + assert!( + store + .authorize_review_submission("MR", "old-issued") + .is_err() + ); +} + fn open_for(s: &MergeRequestStore, merge_request_id: &str, repository_id: &str) { s.open_merge_request(OpenMergeRequest { merge_request_id: merge_request_id.into(), @@ -108,7 +336,7 @@ fn request_for( .collect(); s.request_review(RequestMergeRequestReview { merge_request_id: merge_request_id.into(), - ticket_item_revision: "t".into(), + ticket_content_digest: content_digest("Title"), ticket_merge_request_subjects, ticket_id: "T".into(), subject_ref: subject.into(), @@ -236,13 +464,13 @@ fn integration_preserves_selectors_ticket_state_and_assignment_and_replays_its_m assert!(active); let json = serde_json::to_string(&mr).unwrap(); for banned in [ - "revision_id", + "digest_id", "attempt_id", "base_commit", "head_commit", "source_commit", "result_commit", - "current_revision", + "current_digest", ] { assert!(!json.contains(banned), "{banned} in {json}") } @@ -375,7 +603,7 @@ fn review_revocation_invalidates_readiness() { } #[test] -fn fresh_schema_uses_version_12_and_reopens_as_current() { +fn fresh_schema_uses_version_13_and_reopens_as_current() { let c = Connection::open_in_memory().unwrap(); c.execute_batch( "CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,PRIMARY KEY(workspace_id,ticket_id));", @@ -388,7 +616,7 @@ fn fresh_schema_uses_version_12_and_reopens_as_current() { r.get::<_, i64>(0) }) .unwrap(), - 12 + 13 ); merge_request::migrate(&c).unwrap(); } @@ -724,9 +952,8 @@ fn ticket_rescope_requires_fresh_review_before_merge_and_remains_recoverable() { let stale = approve(&store, "subject", "token-stale"); let connection = Connection::open(dir.path().join("db")).unwrap(); connection - .execute( - "INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)", - [], + .execute_batch( + "UPDATE typed_tickets SET title='Rescoped' WHERE workspace_id='W' AND ticket_id='T'; INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)", ) .unwrap(); connection @@ -750,7 +977,7 @@ fn ticket_rescope_requires_fresh_review_before_merge_and_remains_recoverable() { readiness .blockers .iter() - .any(|blocker| blocker.contains("Ticket revision")) + .any(|blocker| blocker.contains("Ticket digest")) ); let stale_completion = CompleteMergeRequest { @@ -789,7 +1016,7 @@ fn ticket_rescope_requires_fresh_review_before_merge_and_remains_recoverable() { .request_review(RequestMergeRequestReview { merge_request_id: "MR".into(), ticket_id: "T".into(), - ticket_item_revision: "T:1".into(), + ticket_content_digest: content_digest("Rescoped"), ticket_merge_request_subjects: vec![MergeRequestReviewSubject { merge_request_id: "MR".into(), subject_ref: "subject".into(), @@ -852,9 +1079,8 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() { .unwrap(); let connection = Connection::open(dir.path().join("db")).unwrap(); connection - .execute( - "INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)", - [], + .execute_batch( + "UPDATE typed_tickets SET title='Rescoped' WHERE workspace_id='W' AND ticket_id='T'; INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)", ) .unwrap(); connection @@ -873,11 +1099,11 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() { assert_eq!( requirement_approval( &before_refresh, - "T:1", + &content_digest("Rescoped"), &snapshot, Some(&integration_approval.event_id), ), - Err(MergeRequestEvidenceError::ItemRevisionMismatch) + Err(MergeRequestEvidenceError::TicketContentMismatch) ); assert_eq!(before_refresh[0].merged_result().unwrap(), &merge); assert_eq!( @@ -899,7 +1125,7 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() { .request_review(RequestMergeRequestReview { merge_request_id: "MR".into(), ticket_id: "T".into(), - ticket_item_revision: "T:1".into(), + ticket_content_digest: content_digest("Rescoped"), ticket_merge_request_subjects: vec![MergeRequestReviewSubject { merge_request_id: "MR".into(), subject_ref: "subject".into(), @@ -927,7 +1153,7 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() { assert_eq!( requirement_approval( &refreshed, - "T:1", + &content_digest("Rescoped"), &snapshot, Some(&requirement_review.event_id), ) @@ -935,7 +1161,7 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() { &requirement_review ); assert_eq!( - requirement_approval(&refreshed, "T:1", &snapshot, None).unwrap(), + requirement_approval(&refreshed, &content_digest("Rescoped"), &snapshot, None).unwrap(), &requirement_review ); assert_eq!(refreshed[0].merged_result().unwrap(), &merge); @@ -970,7 +1196,7 @@ fn review_request_rejects_a_snapshot_that_omits_a_linked_merge_request() { let result = store.request_review(RequestMergeRequestReview { merge_request_id: "MR".into(), ticket_id: "T".into(), - ticket_item_revision: "t".into(), + ticket_content_digest: content_digest("Title"), ticket_merge_request_subjects: vec![MergeRequestReviewSubject { merge_request_id: "MR".into(), subject_ref: "subject-one".into(), @@ -1058,7 +1284,13 @@ fn partial_and_full_integration_retain_ticket_state_and_active_assignment() { }) .collect::>(); assert_eq!( - requirement_approval(&requests, "t", &snapshot, Some(&second.event_id)).unwrap(), + requirement_approval( + &requests, + &content_digest("Title"), + &snapshot, + Some(&second.event_id) + ) + .unwrap(), &second ); let first_request = requests @@ -1217,9 +1449,7 @@ fn persisted_integration_evidence_rejects_missing_mismatched_revoked_or_late_app match case { "source-mismatch" => altered.subject_ref = "different-source".into(), "not-approved" => altered.decision = ReviewDecision::RequestChanges, - "request-mismatch" => { - altered.ticket_item_revision = "different-revision".into() - } + "request-mismatch" => altered.ticket_content_digest = "different-digest".into(), _ => unreachable!(), } connection.execute( diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 17f8722bc..3bedc21bb 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -103,11 +103,15 @@ impl AuthenticatedInputSource { /// Caller-owned identity for one state-changing Worker command. /// -/// A controller accepts command ids in strictly increasing order. Exact retries -/// of an accepted id must retain the same command kind. +/// Commands operate on the controller state at admission time, not a caller's +/// previously observed Run. A controller accepts command ids in strictly +/// increasing order; retrying an accepted id cannot apply it to a later Run. +/// Exact retries must retain the same command kind. Removed preconditions are +/// rejected at the wire boundary rather than treated as arrival-time commands. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] +#[serde(deny_unknown_fields)] pub struct WorkerCommandEnvelope { pub command_id: u64, } @@ -312,16 +316,15 @@ pub enum Method { /// are immutable and therefore cannot be cancelled here. CancelPendingSubmission { submission_id: String, - expected_revision: u64, }, - /// Remove every queued submission while preserving the active run. + /// Remove exactly the observed ordered set of submissions, preserving the + /// active run. Reject if the FIFO content changed before admission. ClearPendingSubmissions { - expected_revision: u64, + expected_submission_ids: Vec, }, /// Activate the next queued submission while the Worker is idle. This is an /// explicit recovery operation and never resumes a paused run implicitly. ContinuePending { - expected_revision: u64, expected_head_id: String, }, Resume { @@ -365,7 +368,7 @@ pub enum Method { ListCompletions { kind: CompletionKind, prefix: String, - /// Client query generation. Echoed verbatim so identical-prefix ABA + /// Client query identity. Echoed verbatim so identical-prefix ABA /// responses cannot cross edits, target switches, or permission changes. #[serde(default, skip_serializing_if = "Option::is_none")] request_id: Option, @@ -766,7 +769,6 @@ pub enum InternalWorkerKind { pub struct CompactionLifecycle { pub schema_version: u32, pub compaction_id: String, - pub revision: u64, #[serde(default, skip_serializing_if = "Option::is_none")] pub internal_worker: Option, pub state: CompactionLifecycleState, @@ -806,14 +808,13 @@ pub struct InternalWorkerRef { pub kind: InternalWorkerKind, } -/// Reconnect state for one visible Internal Worker. The revision fences live -/// `Event::InternalWorker` updates that raced with parent snapshot assembly. +/// Reconnect state for one visible Internal Worker. The parent serializes +/// snapshot publication and subsequent live events on the same ordered stream. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct InternalWorkerSnapshot { pub worker: InternalWorkerRef, - pub revision: u64, pub session: SessionSnapshot, /// Public execution metadata for this child. Older snapshots and service /// workers may omit it; clients must never substitute the parent's values. @@ -869,12 +870,11 @@ pub struct PendingSubmissionSummary { pub byte_len: u64, } -/// Revisioned session-owned FIFO projection used by snapshots and live events. +/// Session-owned FIFO content projection used by snapshots and live events. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct PendingSubmissionsSnapshot { - pub revision: u64, #[serde(default)] pub notification_count: u32, /// Ordered display-only previews of waiting notifications, each bounded like @@ -1356,7 +1356,7 @@ pub enum Event { Snapshot { session: SessionSnapshot, greeting: Greeting, - /// Full revisioned live execution state. `Stopped` remains Runtime + /// Full live execution state. `Stopped` remains Runtime /// catalog authority and is deliberately not represented here. state: WorkerStateSnapshot, /// Unfinished model output that has already streamed in the current @@ -1370,10 +1370,10 @@ pub enum Event { }, /// A live event from a parent-owned Internal Worker. The payload reuses the /// normal Worker event vocabulary while the wrapper carries stable origin - /// identity and a per-child revision fence. + /// identity. Apply in arrival order on the parent's serialized stream; a + /// reconnect snapshot replaces the projection before live events resume. InternalWorker { worker: InternalWorkerRef, - revision: u64, event: Box, }, /// Terminal removal fence for one parent-owned Internal Worker session. @@ -1383,7 +1383,6 @@ pub enum Event { /// the projection. InternalWorkerRemoved { worker: InternalWorkerRef, - revision: u64, }, /// Server-side segment log rotated to a fresh `SegmentStart`. /// @@ -2276,6 +2275,28 @@ mod tests { assert!(error.to_string().contains("params")); } + #[test] + fn lifecycle_commands_reject_removed_nested_preconditions() { + for method in ["pause", "cancel", "resume", "compact", "shutdown"] { + let wire = serde_json::json!({ + "method": method, + "params": { "command": { "command_id": 11 } } + }); + // Current commands are arrival-time operations, not Run-bound CAS. + assert!(serde_json::from_value::(wire.clone()).is_ok()); + for field in [ + "expected_execution_generation", + "expected_worker_state_revision", + ] { + let mut old_command = wire.clone(); + old_command["params"]["command"][field] = serde_json::json!(1); + let error = serde_json::from_value::(old_command) + .expect_err("removed command preconditions must not be silently ignored"); + assert!(error.to_string().contains(field), "{method}: {error}"); + } + } + } + #[test] fn lifecycle_methods_roundtrip_with_command_identity() { for method in [ @@ -3048,7 +3069,6 @@ mod tests { CompactionLifecycle { schema_version: 2, compaction_id: "0192f0e8-4d84-7d6e-a000-000000000000".into(), - revision: 1, internal_worker: None, state, started_at_ms: 1_700_000_000_000, @@ -3069,7 +3089,9 @@ mod tests { assert_eq!(parsed["event"], "compact_start"); assert_eq!(parsed["data"]["lifecycle"]["state"], "running"); let decoded: Event = serde_json::from_str(&json).unwrap(); - assert!(matches!(decoded, Event::CompactStart { lifecycle } if lifecycle.revision == 1)); + assert!( + matches!(decoded, Event::CompactStart { lifecycle } if lifecycle.state == CompactionLifecycleState::Running) + ); } #[test] @@ -3292,7 +3314,6 @@ mod tests { parent_session_id: Some("parent-session".into()), kind: InternalWorkerKind::SubWorker, }, - revision: 7, event: Box::new(Event::TextDone { text: "result".into(), }), @@ -3300,14 +3321,9 @@ mod tests { let json = serde_json::to_string(&event).unwrap(); let decoded: Event = serde_json::from_str(&json).unwrap(); match decoded { - Event::InternalWorker { - worker, - revision, - event, - } => { + Event::InternalWorker { worker, event } => { assert_eq!(worker.session_id, "session-1"); assert_eq!(worker.parent_session_id.as_deref(), Some("parent-session")); - assert_eq!(revision, 7); assert!(matches!(*event, Event::TextDone { ref text } if text == "result")); } other => panic!("expected internal Worker event, got {other:?}"), @@ -3323,21 +3339,20 @@ mod tests { parent_session_id: Some("parent-session".into()), kind: InternalWorkerKind::SubWorker, }, - revision: 8, }; let json = serde_json::to_string(&event).unwrap(); let decoded: Event = serde_json::from_str(&json).unwrap(); assert!(matches!( decoded, - Event::InternalWorkerRemoved { worker, revision } - if worker.session_id == "session-1" && revision == 8 + Event::InternalWorkerRemoved { worker } + if worker.session_id == "session-1" )); } #[test] fn pending_notification_previews_are_optional_and_roundtrip() { let legacy = serde_json::json!({ - "revision": 1, "notification_count": 1, + "notification_count": 1, "head_id": "notification-head", "submissions": [] }); let mut snapshot: PendingSubmissionsSnapshot = diff --git a/crates/protocol/src/typescript.rs b/crates/protocol/src/typescript.rs index 201534567..a8e8a599d 100644 --- a/crates/protocol/src/typescript.rs +++ b/crates/protocol/src/typescript.rs @@ -322,7 +322,6 @@ fn rust_serialized_subscription_frame_fixtures() -> Vec { parent_session_id: Some("fixture-parent-session".into()), kind: InternalWorkerKind::SubWorker, }, - revision: 1, session: SessionSnapshot { pending_submissions: PendingSubmissionsSnapshot::default(), entries: Vec::new(), diff --git a/crates/runtime-api/src/lib.rs b/crates/runtime-api/src/lib.rs index 242780b6c..1cde155f9 100644 --- a/crates/runtime-api/src/lib.rs +++ b/crates/runtime-api/src/lib.rs @@ -250,7 +250,6 @@ pub struct WorkingDirectoryRepository { pub id: String, pub provider: String, pub source: RepositorySource, - pub source_revision: u64, pub source_fingerprint: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub selector: Option, @@ -291,9 +290,6 @@ pub struct BackendResourceHandle { pub operation: BackendResourceOperation, pub expires_at_unix_seconds: i64, pub nonce: String, - pub revision: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub generation: Option, pub max_bytes: u64, pub content_type: String, pub redaction: ResourceRedactionPolicy, @@ -305,14 +301,14 @@ pub struct BackendResourceHandle { #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct RepositorySshCredentialCandidate { pub credential_id: String, - pub credential_revision: u64, + pub public_key_fingerprint: String, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct RepositorySshMaterializationAccess { pub credential_candidates: Vec, pub host_trust_id: String, - pub host_trust_revision: u64, + pub host_key_fingerprint: String, pub access: RepositoryAccessMode, pub expires_at_epoch_seconds: u64, pub repository_id: String, @@ -326,7 +322,6 @@ pub struct RepositoryMaterializationContext { pub workspace_id: String, pub runtime_id: String, pub operation_id: String, - pub config_revision: u64, pub config_projection_digest: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub ssh: Option, @@ -427,7 +422,7 @@ pub struct BackendJobExecutionBinding { pub job_id: String, pub attempt_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub input_revision: Option, + pub input_digest: Option, #[serde(default)] pub subjektiv_consolidation: bool, } @@ -689,13 +684,11 @@ pub struct WorkerRetentionExecutionRequest { pub workspace_id: String, pub source_runtime_id: String, pub worker_id: WorkerId, - pub expected_worker_revision: String, pub source_created_at: String, pub removed_at: String, pub effective_profile: Option, pub retention_class: Option, pub policy_id: String, - pub policy_revision: u64, pub session_disposition: SessionDisposition, pub diagnostics_disposition: DiagnosticsDisposition, } @@ -718,7 +711,6 @@ pub struct WorkerSessionArchiveManifest { pub content_bytes: u64, pub content_file_count: u64, pub policy_id: String, - pub policy_revision: u64, pub operation_id: String, pub input_fingerprint: String, } @@ -727,7 +719,6 @@ pub struct WorkerSessionArchiveManifest { pub struct WorkerRetentionExecutionResult { pub operation_id: String, pub input_fingerprint: String, - pub expected_worker_revision: String, pub worker_id: WorkerId, pub session_disposition: SessionDisposition, pub diagnostics_disposition: DiagnosticsDisposition, diff --git a/crates/server-api/src/drive.rs b/crates/server-api/src/drive.rs index 4c0d2f05a..7ec7a729a 100644 --- a/crates/server-api/src/drive.rs +++ b/crates/server-api/src/drive.rs @@ -41,6 +41,39 @@ fn canonical_positive_decimal(value: &str) -> bool { && value.bytes().all(|byte| byte.is_ascii_digit()) && value.parse::().is_ok_and(|value| value > 0) } +fn node_mutation_id<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + if value.len() > 128 || value.chars().any(char::is_control) { + return Err(serde::de::Error::custom( + "invalid committed mutation request ID", + )); + } + Ok(value) +} +fn mutation_id<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let value = node_mutation_id(deserializer)?; + if value.is_empty() { + return Err(serde::de::Error::custom( + "expected a committed mutation request ID", + )); + } + Ok(value) +} +fn optional_mutation_id<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + Option::::deserialize(deserializer)? + .map(|value| { + if value.is_empty() || value.len() > 128 || value.chars().any(char::is_control) { + Err(serde::de::Error::custom( + "invalid committed mutation request ID", + )) + } else { + Ok(value) + } + }) + .transpose() +} fn bounded_text<'de, D: Deserializer<'de>>(deserializer: D) -> Result { let value = String::deserialize(deserializer)?; if value.len() > DRIVE_TEXT_MAX_BYTES { @@ -110,9 +143,10 @@ pub struct DriveEntry { pub parent: Option, pub name: String, pub kind: DriveEntryKind, - #[serde(deserialize_with = "decimal")] - #[schemars(regex(pattern = "^[1-9][0-9]*$"))] - pub revision: String, + #[serde(deserialize_with = "node_mutation_id")] + #[schemars(length(max = 128))] + /// Request ID of the last committed mutation, or empty for the immutable root. + pub last_mutation_id: String, /// File length in bytes; absent for folders. #[schemars(range(min = 0, max = 16_777_216))] pub size: Option, @@ -201,10 +235,10 @@ pub struct DriveReadChunkQuery { #[serde(deserialize_with = "decimal")] #[schemars(regex(pattern = "^[1-9][0-9]*$"))] pub id: String, - /// Bind a multi-chunk read to the same file revision. - #[serde(deserialize_with = "decimal")] - #[schemars(regex(pattern = "^[1-9][0-9]*$"))] - pub expected_revision: String, + /// Require each chunk to match the same committed mutation observed in metadata. + #[serde(deserialize_with = "mutation_id")] + #[schemars(length(max = 128))] + pub expected_mutation_id: String, #[schemars(range(min = 0, max = 16_777_216))] pub offset: u32, #[serde(default = "default_read_size", deserialize_with = "bounded_chunk")] @@ -219,8 +253,8 @@ pub struct DriveDownloadQuery { #[serde(deserialize_with = "decimal")] #[schemars(regex(pattern = "^[1-9][0-9]*$"))] pub id: String, - #[serde(default, deserialize_with = "optional_decimal")] - pub expected_revision: Option, + #[serde(default, deserialize_with = "optional_mutation_id")] + pub expected_mutation_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -248,26 +282,26 @@ pub enum DriveMutation { }, UpdateText { id: DriveEntryRef, - #[serde(deserialize_with = "decimal")] - #[schemars(regex(pattern = "^[1-9][0-9]*$"))] - expected_revision: String, + #[serde(deserialize_with = "mutation_id")] + #[schemars(length(max = 128))] + expected_mutation_id: String, #[serde(deserialize_with = "bounded_text")] text: String, content_type: String, }, Relocate { id: DriveEntryRef, - #[serde(deserialize_with = "decimal")] - #[schemars(regex(pattern = "^[1-9][0-9]*$"))] - expected_revision: String, + #[serde(deserialize_with = "mutation_id")] + #[schemars(length(max = 128))] + expected_mutation_id: String, parent: DriveEntryRef, name: String, }, Delete { id: DriveEntryRef, - #[serde(deserialize_with = "decimal")] - #[schemars(regex(pattern = "^[1-9][0-9]*$"))] - expected_revision: String, + #[serde(deserialize_with = "mutation_id")] + #[schemars(length(max = 128))] + expected_mutation_id: String, }, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -287,8 +321,8 @@ pub enum DriveUploadOperation { Update, } /// Flat query metadata accompanies the binary body. The service rejects invalid -/// combinations: create requires parent_id/name and forbids id/expected_revision; -/// update requires id/expected_revision and forbids parent_id/name. Both targets +/// combinations: create requires parent_id/name and forbids id/expected_mutation_id; +/// update requires id/expected_mutation_id and forbids parent_id/name. Both targets /// are bound by entry_workspace_id; declared size and SHA256 must match the body. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] @@ -303,8 +337,8 @@ pub struct DriveUploadQuery { pub name: Option, #[serde(default, deserialize_with = "optional_decimal")] pub id: Option, - #[serde(default, deserialize_with = "optional_decimal")] - pub expected_revision: Option, + #[serde(default, deserialize_with = "optional_mutation_id")] + pub expected_mutation_id: Option, pub content_type: String, /// Values above the file limit still decode as u32 so the service returns /// typed Limit rather than a generic request-decoding error. @@ -422,7 +456,7 @@ impl DriveApiError { message: match code { Denied => "Drive access denied", NotFound => "Drive resource not found", - Conflict => "Drive revision or name conflict", + Conflict => "Drive content, location, or name conflict", Invalid => "Invalid Drive request", Limit => "Drive request exceeds a limit", StorageUnavailable => "Drive storage unavailable", @@ -614,7 +648,7 @@ mod tests { let value = json!({ "entry":{"workspace_id":"a","node_id":"7"}, "parent":{"workspace_id":"a","node_id":"1"}, - "name":"file.txt","kind":"file","revision":"2","size":5, + "name":"file.txt","kind":"file","last_mutation_id":"2","size":5, "content_type":"text/plain","updated_by":"user-a","updated_at":"2026-10-08T00:00:00Z", "latest_url":"/api/w/a/drive/download?entry_workspace_id=a&id=7" }); @@ -665,7 +699,7 @@ mod tests { fn drive_json_mutations_accept_only_bounded_text_and_workspace_bound_cas() { let valid = json!({"request_id":"request-a", "mutation":{ "operation":"update_text", "id":{"workspace_id":"a","node_id":"7"}, - "expected_revision":"9007199254740993", "text":"hello", "content_type":"text/plain" + "expected_mutation_id":"write-資料/one", "text":"hello", "content_type":"text/plain" }}); let request: DriveMutationRequest = serde_json::from_value(valid.clone()).unwrap(); assert_eq!(serde_json::to_value(request).unwrap(), valid); @@ -675,9 +709,16 @@ mod tests { let mut invalid = valid.clone(); invalid["mutation"]["id"] = json!("7"); assert!(serde_json::from_value::(invalid).is_err()); - let mut invalid = valid.clone(); - invalid["mutation"]["expected_revision"] = json!("01"); - assert!(serde_json::from_value::(invalid).is_err()); + for bad in [ + "".to_string(), + "\ncontrol".to_string(), + "x".repeat(129), + "é".repeat(65), + ] { + let mut invalid = valid.clone(); + invalid["mutation"]["expected_mutation_id"] = json!(bad); + assert!(serde_json::from_value::(invalid).is_err()); + } // UTF-8 byte count, not character count, determines the text bound. let mut boundary = valid; boundary["mutation"]["text"] = json!("é".repeat(DRIVE_TEXT_MAX_BYTES / 2)); @@ -695,18 +736,18 @@ mod tests { ) .is_err() ); - assert!(serde_json::from_value::(json!({"entry_workspace_id":"a","id":"7","expected_revision":"1","offset":0,"length":size})).is_err()); + assert!(serde_json::from_value::(json!({"entry_workspace_id":"a","id":"7","expected_mutation_id":"1","offset":0,"length":size})).is_err()); } let default: DriveReadTextQuery = serde_json::from_value(json!({"entry_workspace_id":"a","id":"7"})).unwrap(); assert_eq!(default.max_bytes, 65536); - let chunk: DriveReadChunkQuery = serde_json::from_value(json!({"entry_workspace_id":"a","id":"7","expected_revision":"1","offset":0,"length":65536})).unwrap(); + let chunk: DriveReadChunkQuery = serde_json::from_value(json!({"entry_workspace_id":"a","id":"7","expected_mutation_id":"1","offset":0,"length":65536})).unwrap(); assert_eq!(chunk.length, 65536); } #[test] fn drive_upload_requires_binding_declared_integrity_and_request_identity() { - let valid = json!({"operation":"update","request_id":"request-a","entry_workspace_id":"a","id":"7","expected_revision":"2","content_type":"application/octet-stream","size":3,"sha256":"a".repeat(64)}); + let valid = json!({"operation":"update","request_id":"request-a","entry_workspace_id":"a","id":"7","expected_mutation_id":"2","content_type":"application/octet-stream","size":3,"sha256":"a".repeat(64)}); assert!(serde_json::from_value::(valid.clone()).is_ok()); for field in ["entry_workspace_id", "size", "sha256", "request_id"] { let mut missing = valid.clone(); @@ -725,7 +766,7 @@ mod tests { "service must receive oversized declared sizes for typed Limit classification" ); } - assert!(serde_json::from_value::(json!({"entry_workspace_id":"a","id":"7","expected_revision":"1","offset":DRIVE_FILE_MAX_BYTES,"length":1})).is_ok()); + assert!(serde_json::from_value::(json!({"entry_workspace_id":"a","id":"7","expected_mutation_id":"1","offset":DRIVE_FILE_MAX_BYTES,"length":1})).is_ok()); let mut invalid = valid; invalid["bytes"] = json!([0, 255]); assert!(serde_json::from_value::(invalid).is_err()); @@ -763,7 +804,12 @@ mod tests { for (code, wire, status, message) in [ (Denied, "denied", 403, "Drive access denied"), (NotFound, "not_found", 404, "Drive resource not found"), - (Conflict, "conflict", 409, "Drive revision or name conflict"), + ( + Conflict, + "conflict", + 409, + "Drive content, location, or name conflict", + ), (Invalid, "invalid", 400, "Invalid Drive request"), (Limit, "limit", 413, "Drive request exceeds a limit"), ( diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index a83c92496..fd9158b06 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -292,12 +292,12 @@ impl_openapi_schema!( SubjektivResidentSurfaceResponse, SubjektivResidentSurfaceSnapshot, SubjektivMemoryDetailQuery, - SubjektivMemoryRevisionsQuery, + SubjektivMemoryChangesQuery, SubjektivMemoryListQuery, SubjektivMemoryQueryRequest, SubjektivMemoryQueryResponse, SubjektivMemoryReadResponse, - SubjektivMemoryListRevisionsResponse, + SubjektivMemoryListChangesResponse, SubjektivStageCandidateRequest, SubjektivStageCandidateResponse, SubjektivRecordSessionRequest, @@ -1014,17 +1014,17 @@ pub trait ServerApi { ) -> Result; #[get( - "/api/w/{workspace_id}/config/source-tree/revisions/{revision}", + "/api/w/{workspace_id}/config/source-tree/history/{content_digest}", status = 200, error_status = 404, additional_error_statuses = [400, 401, 403, 500], bearer_auth = true, browser_auth = true )] - async fn workspace_config_revision( + async fn workspace_config_history( &self, #[path] workspace_id: String, - #[path] revision: String, + #[path] content_digest: String, ) -> Result; #[get( @@ -1270,20 +1270,20 @@ pub trait ServerApi { #[query] query: SubjektivMemoryDetailQuery, ) -> Result; #[get( - "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories/{memory_id}/revisions", + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories/{memory_id}/changes", status = 200, error_status = 404, additional_error_statuses = [400, 401, 403, 500], bearer_auth = true, browser_auth = true, )] - async fn subjektiv_memory_revisions( + async fn subjektiv_memory_changes( &self, #[path] workspace_id: String, #[path] subject_id: String, #[path] memory_id: String, - #[query] query: SubjektivMemoryRevisionsQuery, - ) -> Result; + #[query] query: SubjektivMemoryChangesQuery, + ) -> Result; #[post( "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/retire", status = 200, @@ -3853,7 +3853,6 @@ pub struct WorkspaceRuntimeConfigResponse { pub struct WorkspaceRuntimeConfigMetadata { pub id: String, pub digest: String, - pub revision: String, pub workspace_id: String, pub created_at: String, pub provenance: WorkspaceRuntimeConfigProvenance, @@ -3906,8 +3905,6 @@ pub enum WorkspaceRuntimeConfigDeclarationKind { #[serde(deny_unknown_fields)] pub struct WorkspaceRuntimePromptCatalog { pub templates: BTreeMap, - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub config_revision: u64, #[serde(default, skip_serializing_if = "String::is_empty")] pub source_digest: String, pub schema_fingerprint: String, @@ -3961,10 +3958,6 @@ pub struct WorkspaceRuntimeResourceHandle { #[schemars(range(min = 0, max = 9_007_199_254_740_991_i64))] pub expires_at_unix_seconds: i64, pub nonce: String, - pub revision: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub generation: Option, #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub max_bytes: u64, pub content_type: String, @@ -4365,9 +4358,6 @@ pub struct WorkspaceRepositoryRecord { pub provider: Option, pub source: RepositorySource, pub default_ref: Option, - #[cfg_attr(feature = "typescript", ts(type = "number"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub source_revision: u64, pub source_fingerprint: String, pub observed_status: RepositoryObservedStatus, pub observed_at: Option, @@ -4406,9 +4396,6 @@ pub struct WorkspaceCreateRequest { pub struct WorkspaceCreateResponse { pub workspace: WorkspaceSummary, pub repository: Option, - #[cfg_attr(feature = "typescript", ts(type = "number"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub config_revision: u64, pub request_fingerprint: String, pub replayed: bool, } @@ -4485,7 +4472,7 @@ pub struct WorkspaceMetadataSettingsResponse { pub workspace_id: String, pub display_name: String, pub created_at: String, - pub revision: String, + pub updated_at: String, pub source: String, pub diagnostics: Vec, } @@ -4496,7 +4483,7 @@ pub struct WorkspaceMetadataSettingsResponse { #[serde(deny_unknown_fields)] pub struct UpdateWorkspaceMetadataRequest { pub display_name: String, - pub revision: String, + pub expected_updated_at: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -4531,9 +4518,6 @@ pub struct WorkspaceSigningIdentityPublic { #[serde(skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "typescript", ts(optional))] pub public_key_fingerprint: Option, - #[cfg_attr(feature = "typescript", ts(type = "number"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub revision: u64, pub state: WorkspaceSigningIdentityState, pub created_at: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -4552,9 +4536,6 @@ pub struct WorkspacePublicIdentityBundle { pub algorithm: String, pub public_key: String, pub public_key_fingerprint: String, - #[cfg_attr(feature = "typescript", ts(type = "number"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub revision: u64, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -4592,9 +4573,6 @@ pub struct ConfigEntry { #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct ConfigTreeSnapshot { - #[cfg_attr(feature = "typescript", ts(type = "number"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub revision: u64, pub digest: String, pub entries: BTreeMap, } @@ -4691,21 +4669,16 @@ pub struct WorkspaceConfigTreeResponse { #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct ConfigCommitRequest { - #[cfg_attr(feature = "typescript", ts(type = "number"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub base_revision: u64, pub base_digest: String, pub changes: Vec, pub entrypoints: Vec, } -/// Effective Prompt catalog projected from one immutable Workspace config revision. +/// Effective Prompt catalog projected from one immutable Workspace config tree. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct WorkspacePromptProjection { pub workspace_id: String, - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub config_revision: u64, pub source_digest: String, pub projection_digest: String, pub schema_fingerprint: String, @@ -4717,8 +4690,6 @@ pub struct WorkspacePromptProjection { #[serde(deny_unknown_fields)] pub struct EffectivePromptCatalog { pub templates: BTreeMap, - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub config_revision: u64, #[serde(default, skip_serializing_if = "String::is_empty")] pub source_digest: String, pub schema_fingerprint: String, @@ -4746,9 +4717,6 @@ pub struct FlowSourceRecord { pub path: String, pub content: String, pub content_digest: String, - #[cfg_attr(feature = "typescript", ts(type = "number"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub revision: u64, pub created_at: String, pub updated_at: String, } @@ -4810,9 +4778,6 @@ pub struct ResolvedFlowSource { pub selector: String, pub workspace_id: String, pub flow_id: String, - #[cfg_attr(feature = "typescript", ts(type = "number"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub revision: u64, pub content_digest: String, pub definition: CompiledFlowDefinition, } @@ -4871,8 +4836,8 @@ pub struct SubjektivSubjectCreateRequest { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct SubjektivSubjectBehaviorUpdateRequest { - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub expected_behavior_revision: u64, + /// Exact previously observed user-managed behavior document. + pub expected_behavior_md: String, pub behavior_md: String, } @@ -4906,11 +4871,8 @@ pub struct SubjektivSubjectResponse { pub id: String, pub role: String, pub behavior_md: String, - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub behavior_revision: u64, pub state: SubjektivSubjectState, - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub store_revision: u64, + pub memory_fingerprint: String, pub created_at: String, pub updated_at: String, /// Current keyed-singleton owner, when one exists. This is a live Yoi @@ -4934,9 +4896,8 @@ pub struct SubjektivResidentSurfaceSnapshot { pub snapshot_id: String, pub body_md: String, #[serde(default)] - pub memory_refs: Vec, - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub built_from_store_revision: u64, + pub memory_refs: Vec, + pub built_from_memory_fingerprint: String, pub created_at: String, } @@ -5038,8 +4999,7 @@ impl From for SubjektivMemoryQueryRequest { #[serde(deny_unknown_fields)] pub struct SubjektivMemoryDetailQuery { #[serde(default, skip_serializing_if = "Option::is_none")] - #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] - pub revision: Option, + pub change_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub offset: Option, @@ -5055,7 +5015,7 @@ pub struct SubjektivMemoryDetailQuery { #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] -pub struct SubjektivMemoryRevisionsQuery { +pub struct SubjektivMemoryChangesQuery { #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(range(min = 1, max = 100))] pub limit: Option, @@ -5369,7 +5329,7 @@ pub const SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES: usize = 56 * 1024; pub const SUBJEKTIV_SESSION_MAX_READ_CONTENT_BYTES: usize = 16 * 1024; pub const SUBJEKTIV_SESSION_MAX_SNIPPET_BYTES: usize = 512; -/// Current lifecycle state of one confirmed subject Memory revision. +/// Current lifecycle state of one confirmed subject Memory change. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum SubjektivMemoryState { @@ -5378,10 +5338,10 @@ pub enum SubjektivMemoryState { Retracted, } -/// Requested state/change semantics for a staging-only revision proposal. +/// Requested state/change semantics for a staging-only change proposal. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "snake_case")] -pub enum SubjektivMemoryRevisionIntent { +pub enum SubjektivMemoryChangeIntent { Revise, Resolve, Retract, @@ -5390,20 +5350,18 @@ pub enum SubjektivMemoryRevisionIntent { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] -pub struct SubjektivMemoryRevisionRef { +pub struct SubjektivMemoryChangeRef { pub memory_id: String, - #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] - pub revision: u64, + pub change_id: String, } -/// Typed metadata retained with a revision proposal candidate for T-670. +/// Typed metadata retained with a change proposal candidate for T-670. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] -pub struct SubjektivMemoryRevisionProposal { +pub struct SubjektivMemoryChangeProposal { pub memory_id: String, - #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] - pub expected_revision: u64, - pub intent: SubjektivMemoryRevisionIntent, + pub expected_change_id: String, + pub intent: SubjektivMemoryChangeIntent, pub change_reason: String, } @@ -5411,8 +5369,7 @@ pub struct SubjektivMemoryRevisionProposal { #[serde(deny_unknown_fields)] pub struct SubjektivMemoryProposalTarget { pub memory_id: String, - #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] - pub expected_revision: u64, + pub expected_change_id: String, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -5436,8 +5393,7 @@ pub struct SubjektivMemoryQueryRequest { pub struct SubjektivMemoryReadRequest { pub memory_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] - #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] - pub revision: Option, + pub change_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub offset: Option, @@ -5455,7 +5411,7 @@ pub struct SubjektivMemoryReadRequest { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] -pub struct SubjektivMemoryListRevisionsRequest { +pub struct SubjektivMemoryListChangesRequest { pub memory_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(range(min = 1, max = 100))] @@ -5468,9 +5424,8 @@ pub struct SubjektivMemoryListRevisionsRequest { #[serde(deny_unknown_fields)] pub struct SubjektivMemoryValidateProposalRequest { pub memory_id: String, - #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] - pub expected_revision: u64, - pub intent: SubjektivMemoryRevisionIntent, + pub expected_change_id: String, + pub intent: SubjektivMemoryChangeIntent, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -5496,7 +5451,7 @@ pub struct SubjektivMemoryStageExplicitRequest { #[serde(default)] pub source_refs: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] - pub proposal: Option, + pub proposal: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -5515,11 +5470,10 @@ pub struct SubjektivMemoryCandidateReadRequest { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] -pub struct SubjektivMemoryRevisionProposalMetadata { +pub struct SubjektivMemoryChangeProposalMetadata { pub memory_id: String, - #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] - pub expected_revision: u64, - pub intent: SubjektivMemoryRevisionIntent, + pub expected_change_id: String, + pub intent: SubjektivMemoryChangeIntent, pub change_reason: String, } @@ -5538,7 +5492,7 @@ pub struct SubjektivMemoryCandidate { #[serde(default)] pub source_refs: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] - pub revision_proposal: Option, + pub change_proposal: Option, pub created_at: String, } @@ -5549,7 +5503,7 @@ pub struct SubjektivMemoryCandidateSummary { pub kind: memory::extract::CandidateKind, pub claim: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub revision_proposal: Option, + pub change_proposal: Option, pub created_at: String, } @@ -5566,8 +5520,7 @@ pub enum SubjektivMemoryApplyTarget { Create, Revise { memory_id: String, - #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] - expected_revision: u64, + expected_change_id: String, }, } @@ -5582,7 +5535,7 @@ pub struct SubjektivMemoryDraft { #[serde(default, skip_serializing_if = "Option::is_none")] pub staleness: Option, #[serde(default)] - pub derived_from: Vec, + pub derived_from: Vec, pub change_reason: String, } @@ -5605,7 +5558,7 @@ pub enum SubjektivMemoryCandidateDecision { Close { action: SubjektivMemoryCandidateCloseAction, #[serde(default)] - affected_memory: Vec, + affected_memory: Vec, }, } @@ -5630,8 +5583,7 @@ pub enum SubjektivMemoryAffectedOperation { #[serde(deny_unknown_fields)] pub struct SubjektivMemoryAffectedRef { pub memory_id: String, - #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] - pub revision: u64, + pub change_id: String, pub operation: SubjektivMemoryAffectedOperation, } @@ -5655,21 +5607,19 @@ pub struct SubjektivMemoryCandidateDecisionResponse { #[serde(default)] pub affected_memory: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] - pub memory: Option, - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub store_revision: u64, + pub memory: Option, + pub memory_fingerprint: String, pub surface_dirty: bool, } /// One bounded current Memory supplied to the surface editor. The Backend /// selects these records deterministically; callers cannot name arbitrary -/// Memory revisions or broaden the generation input. +/// Memory changes or broaden the generation input. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct SubjektivSurfaceMaterial { pub memory_id: String, - #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] - pub revision: u64, + pub change_id: String, pub kind: memory::extract::CandidateKind, pub body_md: String, pub why_useful: String, @@ -5685,11 +5635,10 @@ pub struct SubjektivSurfacePrepareRequest {} #[serde(deny_unknown_fields)] pub struct SubjektivSurfacePrepareResponse { pub generation_id: String, - /// Existing ready surface at this exact store revision, if no rebuild is needed. + /// Existing ready surface at this exact store change_id, if no rebuild is needed. #[serde(default, skip_serializing_if = "Option::is_none")] pub current_snapshot_id: Option, - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub store_revision: u64, + pub memory_fingerprint: String, #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub active_memory_count: usize, pub materials: Vec, @@ -5710,7 +5659,7 @@ pub struct SubjektivSurfacePrepareResponse { #[serde(deny_unknown_fields)] pub struct SubjektivSurfacePoint { pub body_md: String, - pub memory_refs: Vec, + pub memory_refs: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -5731,16 +5680,14 @@ pub struct SubjektivSurfaceFailureRequest { #[serde(deny_unknown_fields)] pub struct SubjektivSurfacePublishResponse { pub snapshot_id: String, - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub built_from_store_revision: u64, + pub built_from_memory_fingerprint: String, pub empty: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct SubjektivSurfaceFailureResponse { - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub store_revision: u64, + pub memory_fingerprint: String, pub status: String, } @@ -5749,8 +5696,6 @@ pub struct SubjektivSurfaceFailureResponse { pub struct SubjektivResidentContextOutput { /// Exact user-managed document. Empty means explicitly unset. pub behavior_md: String, - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub behavior_revision: u64, /// Independently-fresh generated Memory projection. pub memory_surface: memory::backend::MemoryResidentSummaryOutput, } @@ -5762,7 +5707,7 @@ pub enum SubjektivMemoryBackendOperation { ResidentContext(memory::backend::MemoryResidentSummaryOperation), Query(SubjektivMemoryQueryRequest), Read(SubjektivMemoryReadRequest), - ListRevisions(SubjektivMemoryListRevisionsRequest), + ListChanges(SubjektivMemoryListChangesRequest), ValidateProposal(SubjektivMemoryValidateProposalRequest), ReceiptStatus(SubjektivMemoryReceiptStatusRequest), StageExplicit(SubjektivMemoryStageExplicitRequest), @@ -5784,8 +5729,7 @@ pub struct SubjektivMemoryBackendRequest { #[serde(deny_unknown_fields)] pub struct SubjektivMemoryQueryItem { pub id: String, - #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] - pub revision: u64, + pub change_id: String, pub kind: memory::extract::CandidateKind, pub state: SubjektivMemoryState, pub claim: String, @@ -5825,10 +5769,8 @@ pub struct SubjektivMemoryEvidenceCandidate { #[serde(deny_unknown_fields)] pub struct SubjektivMemoryReadResponse { pub memory_id: String, - #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] - pub revision: u64, - #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] - pub current_revision: u64, + pub change_id: String, + pub current_change_id: String, pub kind: memory::extract::CandidateKind, pub state: SubjektivMemoryState, pub claim: String, @@ -5858,7 +5800,7 @@ pub struct SubjektivMemoryReadResponse { pub source_candidate_ids: Vec, pub source_candidates: Vec, /// Derivation references on this evidence page. - pub derived_from: Vec, + pub derived_from: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub evidence_next_cursor: Option, pub evidence_has_more: bool, @@ -5866,9 +5808,8 @@ pub struct SubjektivMemoryReadResponse { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] -pub struct SubjektivMemoryRevisionItem { - #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] - pub revision: u64, +pub struct SubjektivMemoryChangeItem { + pub change_id: String, pub kind: memory::extract::CandidateKind, pub state: SubjektivMemoryState, pub claim: String, @@ -5878,11 +5819,10 @@ pub struct SubjektivMemoryRevisionItem { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] -pub struct SubjektivMemoryListRevisionsResponse { +pub struct SubjektivMemoryListChangesResponse { pub memory_id: String, - #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] - pub current_revision: u64, - pub items: Vec, + pub current_change_id: String, + pub items: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub next_cursor: Option, pub has_more: bool, @@ -5892,11 +5832,10 @@ pub struct SubjektivMemoryListRevisionsResponse { #[serde(deny_unknown_fields)] pub struct SubjektivMemoryProposalValidationResponse { pub memory_id: String, - #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] - pub current_revision: u64, + pub current_change_id: String, pub kind: memory::extract::CandidateKind, pub state: SubjektivMemoryState, - pub intent: SubjektivMemoryRevisionIntent, + pub intent: SubjektivMemoryChangeIntent, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -5925,7 +5864,7 @@ pub struct SubjektivMemoryStageExplicitResponse { #[serde(default, skip_serializing_if = "Option::is_none")] pub target: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub intent: Option, + pub intent: Option, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -5935,7 +5874,7 @@ pub enum SubjektivMemoryBackendResponse { ResidentContext(SubjektivResidentContextOutput), Query(SubjektivMemoryQueryResponse), Read(SubjektivMemoryReadResponse), - ListRevisions(SubjektivMemoryListRevisionsResponse), + ListChanges(SubjektivMemoryListChangesResponse), ProposalValidated(SubjektivMemoryProposalValidationResponse), ReceiptStatus(SubjektivMemoryReceiptStatusResponse), Staged(SubjektivMemoryStageExplicitResponse), @@ -5948,7 +5887,7 @@ pub enum SubjektivMemoryBackendResponse { } pub const WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES: usize = 128; -pub const WORKSPACE_DELETION_MAX_REVISION_BYTES: usize = 128; +pub const WORKSPACE_DELETION_MAX_UPDATED_AT_BYTES: usize = 128; pub const WORKSPACE_DELETION_MAX_CONFIRMATION_BYTES: usize = 256; pub const WORKSPACE_DELETION_MAX_BLOCKERS: usize = 1024; pub const WORKSPACE_DELETION_MAX_CHILD_OPERATION_IDS: usize = 4096; @@ -5968,14 +5907,14 @@ where Ok(value) } -fn deserialize_workspace_deletion_revision<'de, D>(deserializer: D) -> Result +fn deserialize_workspace_deletion_updated_at<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, { let value = String::deserialize(deserializer)?; - if value.len() > WORKSPACE_DELETION_MAX_REVISION_BYTES { + if value.len() > WORKSPACE_DELETION_MAX_UPDATED_AT_BYTES { return Err(serde::de::Error::custom( - "Workspace deletion revision is too long", + "Workspace deletion update timestamp is too long", )); } Ok(value) @@ -6134,8 +6073,8 @@ pub struct WorkspaceDeletionResourceCounts { pub struct WorkspaceDeletionPreflightResponse { pub workspace_id: String, pub display_name: String, - /// Opaque persisted Workspace metadata revision used as a CAS fence. - pub expected_revision: String, + /// Observed Workspace metadata update timestamp used as a CAS fence. + pub expected_workspace_updated_at: String, pub can_delete: bool, pub resources: WorkspaceDeletionResourceCounts, pub blockers: Vec, @@ -6147,7 +6086,7 @@ pub struct WorkspaceDeletionPreflightResponse { #[serde(deny_unknown_fields)] pub struct WorkspaceDeletionRequest { pub operation_id: String, - pub expected_revision: String, + pub expected_workspace_updated_at: String, pub confirmation: String, } @@ -6211,8 +6150,8 @@ impl<'de> Deserialize<'de> for WorkspaceDeletionBlocker { struct WorkspaceDeletionPreflightResponseWire { workspace_id: String, display_name: String, - #[serde(deserialize_with = "deserialize_workspace_deletion_revision")] - expected_revision: String, + #[serde(deserialize_with = "deserialize_workspace_deletion_updated_at")] + expected_workspace_updated_at: String, can_delete: bool, resources: WorkspaceDeletionResourceCounts, #[serde(deserialize_with = "deserialize_workspace_deletion_blockers")] @@ -6228,7 +6167,7 @@ impl<'de> Deserialize<'de> for WorkspaceDeletionPreflightResponse { Ok(Self { workspace_id: wire.workspace_id, display_name: wire.display_name, - expected_revision: wire.expected_revision, + expected_workspace_updated_at: wire.expected_workspace_updated_at, can_delete: wire.can_delete, resources: wire.resources, blockers: wire.blockers, @@ -6241,8 +6180,8 @@ impl<'de> Deserialize<'de> for WorkspaceDeletionPreflightResponse { struct WorkspaceDeletionRequestWire { #[serde(deserialize_with = "deserialize_workspace_deletion_operation_id")] operation_id: String, - #[serde(deserialize_with = "deserialize_workspace_deletion_revision")] - expected_revision: String, + #[serde(deserialize_with = "deserialize_workspace_deletion_updated_at")] + expected_workspace_updated_at: String, #[serde(deserialize_with = "deserialize_workspace_deletion_confirmation")] confirmation: String, } @@ -6255,7 +6194,7 @@ impl<'de> Deserialize<'de> for WorkspaceDeletionRequest { let wire = WorkspaceDeletionRequestWire::deserialize(deserializer)?; Ok(Self { operation_id: wire.operation_id, - expected_revision: wire.expected_revision, + expected_workspace_updated_at: wire.expected_workspace_updated_at, confirmation: wire.confirmation, }) } @@ -6302,18 +6241,13 @@ impl<'de> Deserialize<'de> for WorkspaceDeletionOperationResponse { } } -/// Read-only Profile catalog projected from one active Workspace config revision. +/// Read-only Profile catalog projected from one active Workspace config tree. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", ts(optional_fields = nullable))] #[serde(deny_unknown_fields)] pub struct ProfileSettingsResponse { pub workspace_id: String, - pub registry_revision: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[cfg_attr(feature = "typescript", ts(optional, type = "number | null"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub config_revision: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub tree_digest: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -6354,7 +6288,6 @@ pub struct WorkspaceProfileSourceSummary { pub content_digest: String, pub provenance: WorkspaceProfileSourceProvenance, pub editable: bool, - pub revision: String, #[cfg_attr(feature = "typescript", ts(type = "number"))] #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub size_bytes: u64, @@ -6404,9 +6337,6 @@ pub struct RepositorySummary { pub kind: String, pub provider: String, pub source: RepositorySource, - #[cfg_attr(feature = "typescript", ts(type = "number"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub source_revision: u64, pub source_fingerprint: String, pub observed_status: RepositoryObservedStatus, #[serde(skip_serializing_if = "Option::is_none")] @@ -6493,9 +6423,7 @@ pub struct RepositorySshConnectionProbeResponse { pub port: u16, pub trust_state: RepositorySshConnectionTrustState, pub host_trust_id: String, - #[cfg_attr(feature = "typescript", ts(type = "number | null"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub expected_host_trust_revision: Option, + pub expected_host_key_fingerprint: Option, pub candidates: Vec, } @@ -6506,9 +6434,7 @@ pub struct ConfirmRepositorySshHostTrustRequest { pub operation_id: String, pub runtime_id: String, pub host_key: String, - #[cfg_attr(feature = "typescript", ts(type = "number | null"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub expected_host_trust_revision: Option, + pub expected_host_key_fingerprint: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -7094,7 +7020,7 @@ pub struct CurrentWorkerWorkdirCatalogResponse { pub items: Vec, pub next_cursor: Option, /// Opaque digest of the complete authoritative inventory and its occupancy. - pub revision: String, + pub digest: String, } /// Bounded, alias-ordered paging over the current Worker's active attachments. @@ -7127,7 +7053,7 @@ pub struct CurrentWorkerWorkdirAttachmentListResponse { pub items: Vec, pub next_offset: Option, /// Opaque digest of this caller's complete active connection set, independent of query paging/filtering. - pub revision: String, + pub digest: String, } #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -7268,7 +7194,6 @@ pub struct ObjectiveDetail { pub resource_key: String, pub title: String, pub state: String, - pub revision: String, pub created_at: Option, pub updated_at: Option, pub linked_tickets: Vec, @@ -7666,7 +7591,7 @@ pub struct TicketQueryItem { pub priority: String, pub created_at: Option, pub updated_at: Option, - pub item_revision: String, + pub content_digest: String, pub workspace_action_priority: String, pub matched_fields: Vec, pub snippet: Option, @@ -7721,7 +7646,7 @@ pub struct TicketDetail { pub priority: String, pub created_at: Option, pub updated_at: Option, - pub item_revision: String, + pub content_digest: String, pub queued_by: Option, pub queued_at: Option, #[cfg_attr(feature = "typescript", ts(type = "Array"))] @@ -7942,7 +7867,7 @@ pub struct BrowserEditTicketRequest { pub struct BrowserTransitionTicketStateRequest { pub state: BrowserTicketWorkflowState, pub operation_key: String, - pub expected_item_revision: String, + pub expected_content_digest: String, pub expected_state: BrowserTicketWorkflowState, pub reason: String, pub body: Option, @@ -7977,7 +7902,7 @@ pub struct BrowserQueueTicketRequest {} #[serde(deny_unknown_fields)] pub struct BrowserCloseTicketRequest { pub operation_key: String, - pub expected_item_revision: String, + pub expected_content_digest: String, pub expected_state: BrowserTicketWorkflowState, pub resolution: String, } @@ -8133,7 +8058,7 @@ macro_rules! merge_request_event { merge_request_event!(ReviewRequestedEvent { subject_ref: String, - ticket_item_revision: String, + ticket_content_digest: String, ticket_merge_request_subjects: Vec, requested_by: MergeRequestWorkerIdentity, reviewer: MergeRequestWorkerIdentity, @@ -8141,7 +8066,7 @@ merge_request_event!(ReviewRequestedEvent { merge_request_event!(ReviewEvent { request_event_id: String, subject_ref: String, - ticket_item_revision: String, + ticket_content_digest: String, ticket_merge_request_subjects: Vec, decision: ReviewDecision, body: String, @@ -8248,7 +8173,7 @@ pub struct MergeRequestThreadQuery { pub struct MergeRequestRefResponse { pub status: String, #[serde(rename = "ref")] - pub revision_ref: Option, + pub resolved_ref: Option, pub observed_at: String, #[serde(skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "typescript", ts(optional = nullable))] @@ -8358,7 +8283,7 @@ pub struct CompleteMergeRequestRequest { #[serde(deny_unknown_fields)] pub struct CompleteTicketRequest { pub operation_key: String, - pub expected_item_revision: String, + pub expected_content_digest: String, pub expected_state: ticket::TicketWorkflowState, pub reason: String, #[serde(default)] @@ -8373,8 +8298,7 @@ pub struct RuntimeManagementApiError { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub diagnostics: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub current_revision: Option, + pub current_binding_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub current_fingerprint: Option, #[serde(skip, default = "default_repository_error_status")] @@ -8385,12 +8309,12 @@ impl RuntimeManagementApiError { pub fn trust_conflict(conflict: RuntimeTrustConflictResponse) -> Self { Self { error: match conflict.error { - RuntimeTrustConflictKind::StaleRevision => "stale_revision".to_string(), + RuntimeTrustConflictKind::StaleBinding => "stale_binding".to_string(), RuntimeTrustConflictKind::FingerprintInUse => "fingerprint_in_use".to_string(), }, message: conflict.message, diagnostics: Vec::new(), - current_revision: conflict.current_revision, + current_binding_id: conflict.current_binding_id, current_fingerprint: conflict.current_fingerprint, status: 409, } @@ -8415,7 +8339,7 @@ impl From for RuntimeManagementApiError { error: error.error, message: error.message, diagnostics: error.diagnostics, - current_revision: None, + current_binding_id: None, current_fingerprint: None, status: error.status, } @@ -8780,7 +8704,6 @@ pub struct RuntimeCleanupPlanResponse { pub workspace_id: String, pub runtime_id: String, pub generated_at: String, - pub revision: String, pub digest: String, pub workers: Vec, pub workdirs: Vec, @@ -8790,7 +8713,6 @@ pub struct RuntimeCleanupPlanResponse { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ExecuteRuntimeCleanupRequest { - pub expected_plan_revision: String, pub expected_plan_digest: String, #[serde(default)] pub worker_target_ids: Vec, @@ -8952,20 +8874,11 @@ pub struct RuntimeVerificationEvidenceSummary { pub verified_at: Option, pub last_checked_at: String, pub last_outcome: RuntimeVerificationOutcome, - #[cfg_attr(feature = "typescript", ts(type = "number"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub binding_revision: u64, + pub binding_id: String, pub workspace_key_id: String, - #[cfg_attr(feature = "typescript", ts(type = "number"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub workspace_identity_revision: u64, - #[cfg_attr(feature = "typescript", ts(type = "number"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub workspace_trust_generation: u64, + pub workspace_public_key_fingerprint: String, + pub workspace_trust_id: String, pub runtime_public_key_fingerprint: String, - #[cfg_attr(feature = "typescript", ts(type = "number"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub runtime_identity_revision: u64, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -8974,15 +8887,11 @@ pub struct RuntimeVerificationEvidenceSummary { pub struct WorkspaceRuntimeBindingSummary { pub state: WorkspaceRuntimeBindingState, pub connection_state: RuntimeConnectionDisplayState, - #[cfg_attr(feature = "typescript", ts(type = "number"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub revision: u64, + pub binding_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_key_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - #[cfg_attr(feature = "typescript", ts(type = "number | null"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub workspace_key_generation: Option, + pub workspace_trust_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub verification: Option, } @@ -9039,9 +8948,7 @@ pub struct RuntimeTrustKeyState { #[serde(default, skip_serializing_if = "Option::is_none")] pub fingerprint: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - #[cfg_attr(feature = "typescript", ts(type = "number | null"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub revision: Option, + pub binding_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub created_at: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -9070,9 +8977,7 @@ pub struct RuntimeTrustAuditEntry { pub old_fingerprint: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub new_fingerprint: Option, - #[cfg_attr(feature = "typescript", ts(type = "number"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub revision: u64, + pub binding_id: String, pub at: String, } @@ -9100,9 +9005,7 @@ pub struct RuntimeTrustKeyRevealResponse { #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct RevokeRuntimeTrustKeyRequest { - #[cfg_attr(feature = "typescript", ts(type = "number"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub expected_revision: u64, + pub expected_binding_id: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -9110,9 +9013,7 @@ pub struct RevokeRuntimeTrustKeyRequest { #[serde(deny_unknown_fields)] pub struct RemoveRuntimeRequest { pub operation_id: String, - #[cfg_attr(feature = "typescript", ts(type = "number"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub expected_binding_revision: u64, + pub expected_binding_id: String, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -9147,7 +9048,7 @@ pub struct RuntimeRemovalOperationResponse { #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum RuntimeTrustConflictKind { - StaleRevision, + StaleBinding, FingerprintInUse, } @@ -9158,9 +9059,7 @@ pub struct RuntimeTrustConflictResponse { pub error: RuntimeTrustConflictKind, pub message: String, #[serde(default, skip_serializing_if = "Option::is_none")] - #[cfg_attr(feature = "typescript", ts(type = "number"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub current_revision: Option, + pub current_binding_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub current_fingerprint: Option, } @@ -9178,13 +9077,14 @@ pub struct RuntimePublicIdentityBundle { #[serde(deny_unknown_fields)] pub struct CreateRemoteRuntimeRequest { pub public_bundle: RuntimePublicIdentityBundle, + /// Exact Runtime-issued enrollment identity from `trust-workspace add/show`. + /// A key fingerprint alone cannot distinguish revocation and re-enrollment. + pub workspace_trust_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub display_name: Option, pub endpoint: String, #[serde(default, skip_serializing_if = "Option::is_none")] - #[cfg_attr(feature = "typescript", ts(type = "number | null"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub expected_revision: Option, + pub expected_binding_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -9225,9 +9125,7 @@ pub enum RuntimeConnectionTestFailureKind { pub struct RuntimeConnectionTestResponse { pub workspace_id: String, pub runtime_id: String, - #[cfg_attr(feature = "typescript", ts(type = "number"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub binding_revision: u64, + pub binding_id: String, pub connection_state: RuntimeConnectionDisplayState, pub verification: Option, pub checked_at: String, @@ -9447,10 +9345,6 @@ pub struct RuntimeResourceHandle { #[schemars(range(min = -9_007_199_254_740_991_i64, max = 9_007_199_254_740_991_i64))] pub expires_at_unix_seconds: i64, pub nonce: String, - pub revision: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub generation: Option, #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub max_bytes: u64, pub content_type: String, @@ -10161,7 +10055,7 @@ pub struct MemoryEvidenceOrigin { #[serde(default, skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "typescript", ts(optional, type = "number | null"))] #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub flow_definition_revision: Option, + pub flow_definition_fingerprint: Option, } /// Record-level source range for one Memory staging candidate. @@ -10293,23 +10187,23 @@ pub fn memory_api_typescript() -> String { export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, }; -export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_revision: number, behavior_md: string, }; +export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_md: string, behavior_md: string, }; -export type SubjektivSubjectResponse = { id: string, role: string, behavior_md: string, behavior_revision: number, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, }; +export type SubjektivSubjectResponse = { id: string, role: string, behavior_md: string, state: SubjektivSubjectState, memory_fingerprint: string, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, }; export type SubjektivSubjectListResponse = { limit: number, items: Array, next_cursor?: string | null, has_more: boolean, }; export type SubjektivMemoryState = "active" | "resolved" | "retracted"; -export type SubjektivMemoryRevisionRef = { memory_id: string, revision: number, }; +export type SubjektivMemoryChangeRef = { memory_id: string, change_id: string, }; export type SubjektivResidentSurfaceAvailability = "ungenerated" | "stale" | "failed" | "ready"; -export type SubjektivResidentSurfaceSnapshot = { snapshot_id: string, body_md: string, memory_refs: Array, built_from_store_revision: number, created_at: string, }; +export type SubjektivResidentSurfaceSnapshot = { snapshot_id: string, body_md: string, memory_refs: Array, built_from_memory_fingerprint: string, created_at: string, }; export type SubjektivResidentSurfaceResponse = { subject_id: string, availability: SubjektivResidentSurfaceAvailability, snapshot?: SubjektivResidentSurfaceSnapshot | null, }; -export type SubjektivMemoryQueryItem = { id: string, revision: number, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, excerpt: string, updated_at: string, }; +export type SubjektivMemoryQueryItem = { id: string, change_id: string, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, excerpt: string, updated_at: string, }; export type SubjektivMemoryQueryResponse = { items: Array, next_cursor?: string | null, has_more: boolean, }; @@ -10319,11 +10213,11 @@ export type SubjektivMemorySourceEvidenceRef = { session_id?: string | null, seg export type SubjektivMemoryEvidenceCandidate = { candidate_id: string, evidence: Array, evidence_total: number, evidence_truncated: boolean, source_refs: Array, source_refs_total: number, source_refs_truncated: boolean, }; -export type SubjektivMemoryReadResponse = { memory_id: string, revision: number, current_revision: number, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, body_md: string, why_useful: string, staleness?: string | null, change_reason: string, created_at: string, updated_at: string, body_offset: number, body_byte_offset: number, body_next_offset?: number | null, body_next_byte_offset?: number | null, body_truncated: boolean, source_candidate_ids: Array, source_candidates: Array, derived_from: Array, evidence_next_cursor?: string | null, evidence_has_more: boolean, }; +export type SubjektivMemoryReadResponse = { memory_id: string, change_id: string, current_change_id: string, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, body_md: string, why_useful: string, staleness?: string | null, change_reason: string, created_at: string, updated_at: string, body_offset: number, body_byte_offset: number, body_next_offset?: number | null, body_next_byte_offset?: number | null, body_truncated: boolean, source_candidate_ids: Array, source_candidates: Array, derived_from: Array, evidence_next_cursor?: string | null, evidence_has_more: boolean, }; -export type SubjektivMemoryRevisionItem = { revision: number, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, change_reason: string, updated_at: string, }; +export type SubjektivMemoryChangeItem = { change_id: string, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, change_reason: string, updated_at: string, }; -export type SubjektivMemoryListRevisionsResponse = { memory_id: string, current_revision: number, items: Array, next_cursor?: string | null, has_more: boolean, };"#; +export type SubjektivMemoryListChangesResponse = { memory_id: string, current_change_id: string, items: Array, next_cursor?: string | null, has_more: boolean, };"#; let limits = format!( "export const MEMORY_API_LIMITS = {{\n maxResponseBytes: {MEMORY_API_MAX_RESPONSE_BYTES},\n maxDocumentBytes: {MEMORY_API_MAX_DOCUMENT_BYTES},\n maxCollectionItems: {MEMORY_API_MAX_COLLECTION_ITEMS},\n maxStringBytes: {MEMORY_API_MAX_STRING_BYTES},\n maxIdentifierBytes: {MEMORY_API_MAX_IDENTIFIER_BYTES},\n}} as const;" @@ -10346,8 +10240,6 @@ export type SubjektivMemoryListRevisionsResponse = { memory_id: string, current_ #[serde(deny_unknown_fields)] pub struct WorkspaceMemorySettings { pub workspace_id: String, - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub settings_revision: u64, pub language: String, } @@ -10355,8 +10247,7 @@ pub struct WorkspaceMemorySettings { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct UpdateWorkspaceMemorySettingsRequest { - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub expected_revision: u64, + pub expected_language: String, pub language: String, } @@ -10372,9 +10263,6 @@ pub struct RepositorySshCredential { pub name: String, pub public_key_algorithm: String, pub public_key_fingerprint: String, - #[cfg_attr(feature = "typescript", ts(type = "number"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub current_revision: u64, pub status: String, pub created_at: String, pub rotated_at: Option, @@ -10412,9 +10300,6 @@ pub struct GenerateRepositorySshCredentialRequest { #[serde(deny_unknown_fields)] pub struct RepositorySshPublicKey { pub credential_id: String, - #[cfg_attr(feature = "typescript", ts(type = "number"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub current_revision: u64, pub public_key_algorithm: String, pub public_key_fingerprint: String, pub public_key: String, @@ -10425,9 +10310,7 @@ pub struct RepositorySshPublicKey { #[serde(deny_unknown_fields)] pub struct RotateRepositorySshCredentialRequest { pub operation_id: String, - #[cfg_attr(feature = "typescript", ts(type = "number"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub expected_revision: u64, + pub expected_public_key_fingerprint: String, pub private_key: String, #[serde(default)] pub passphrase: Option, @@ -10438,9 +10321,7 @@ pub struct RotateRepositorySshCredentialRequest { #[serde(deny_unknown_fields)] pub struct DeleteRepositorySshCredentialRequest { pub operation_id: String, - #[cfg_attr(feature = "typescript", ts(type = "number"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub expected_revision: u64, + pub expected_public_key_fingerprint: String, } /// Public metadata for an explicitly pinned SSH host key. @@ -10455,9 +10336,6 @@ pub struct RepositorySshHostTrust { pub key_algorithm: String, pub host_key: String, pub fingerprint: String, - #[cfg_attr(feature = "typescript", ts(type = "number"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub current_revision: u64, pub created_at: String, pub updated_at: String, #[serde(default)] @@ -10478,9 +10356,7 @@ pub struct PutRepositorySshHostTrustRequest { pub port: u16, pub host_key: String, #[serde(default)] - #[cfg_attr(feature = "typescript", ts(type = "number | null"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub expected_revision: Option, + pub expected_fingerprint: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -10504,9 +10380,7 @@ impl api_macros::HttpSuccess for RepositorySshHostTrustMutationResponse { #[serde(deny_unknown_fields)] pub struct DeleteRepositorySshHostTrustRequest { pub operation_id: String, - #[cfg_attr(feature = "typescript", ts(type = "number"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub expected_revision: u64, + pub expected_fingerprint: String, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -10534,9 +10408,6 @@ pub struct RepositorySshAccessBinding { #[serde(deny_unknown_fields)] pub struct RepositoryAccessProjection { pub workspace_id: String, - #[cfg_attr(feature = "typescript", ts(type = "number"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub config_revision: u64, pub projection_digest: String, pub bindings: Vec, } @@ -10623,9 +10494,6 @@ pub struct SkillProvenance { #[cfg_attr(feature = "typescript", ts(optional))] pub virtual_path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - #[cfg_attr(feature = "typescript", ts(optional, type = "number"))] - pub revision: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "typescript", ts(optional))] pub source_digest: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -10655,9 +10523,6 @@ pub enum SkillProjectionStatus { #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct SkillProjectionIdentity { - #[cfg_attr(feature = "typescript", ts(type = "number"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub config_revision: u64, pub tree_digest: String, } @@ -10754,7 +10619,6 @@ impl std::error::Error for SkillApiValidationError {} impl SkillProjectionIdentity { fn validate(&self) -> Result<(), SkillApiValidationError> { - validate_safe_integer(self.config_revision)?; validate_nonempty_string(&self.tree_digest, SKILL_API_MAX_DIGEST_BYTES) .map_err(|_| SkillApiValidationError::InvalidProjectionIdentity) } @@ -10769,9 +10633,6 @@ impl SkillProvenance { validate_optional_string(self.virtual_path.as_deref(), SKILL_API_MAX_PATH_BYTES)?; validate_optional_string(self.source_digest.as_deref(), SKILL_API_MAX_DIGEST_BYTES)?; validate_optional_string(self.tree_digest.as_deref(), SKILL_API_MAX_DIGEST_BYTES)?; - if let Some(revision) = self.revision { - validate_safe_integer(revision)?; - } let expected_prefix = match self.kind { SkillSourceKind::Builtin => "builtin:", @@ -10789,18 +10650,15 @@ impl SkillProvenance { match self.kind { SkillSourceKind::Builtin => { - if self.revision.is_some() || self.tree_digest.is_some() { + if self.tree_digest.is_some() { return Err(SkillApiValidationError::InvalidProvenance); } } SkillSourceKind::Workspace => { - let Some(revision) = self.revision else { - return Err(SkillApiValidationError::InvalidProvenance); - }; let Some(tree_digest) = self.tree_digest.as_deref() else { return Err(SkillApiValidationError::InvalidProvenance); }; - if revision != projection.config_revision || tree_digest != projection.tree_digest { + if tree_digest != projection.tree_digest { return Err(SkillApiValidationError::StaleProjection); } } @@ -10869,14 +10727,6 @@ impl SkillDetailResponse { } } -fn validate_safe_integer(value: u64) -> Result<(), SkillApiValidationError> { - if value <= SKILL_API_MAX_SAFE_INTEGER { - Ok(()) - } else { - Err(SkillApiValidationError::InvalidProjectionIdentity) - } -} - fn validate_collection(values: &[T], limit: usize) -> Result<(), SkillApiValidationError> { if values.len() <= limit { Ok(()) @@ -11522,7 +11372,6 @@ mod tests { "metadata": { "id": "bundle-a", "digest": "0123456789abcdef", - "revision": "revision-a", "workspace_id": "workspace-a", "created_at": "2026-01-01T00:00:00Z", "provenance": { @@ -11541,7 +11390,6 @@ mod tests { }], "prompt_catalog": { "templates": { "default": "Prompt" }, - "config_revision": 7, "source_digest": "source-digest", "schema_fingerprint": "schema-a", "toolchain_fingerprint": "toolchain-a", @@ -11572,8 +11420,6 @@ mod tests { "operation": "fetch_archive", "expires_at_unix_seconds": 1_800_000_000, "nonce": "nonce-a", - "revision": "revision-a", - "generation": 2, "max_bytes": 1_048_576, "content_type": "application/vnd.yoi.profile-source-archive+tar", "redaction": "runtime_internal_only", @@ -11860,9 +11706,9 @@ mod tests { "/api/w/{workspace_id}/config/source-tree/commit", ), ( - "workspace_config_revision", + "workspace_config_history", HttpMethod::Get, - "/api/w/{workspace_id}/config/source-tree/revisions/{revision}", + "/api/w/{workspace_id}/config/source-tree/history/{content_digest}", ), ( "workspace_config_entry", @@ -12017,7 +11863,6 @@ mod tests { fn skill_projection() -> SkillProjectionIdentity { SkillProjectionIdentity { - config_revision: 42, tree_digest: "tree-digest".to_string(), } } @@ -12027,7 +11872,6 @@ mod tests { kind: SkillSourceKind::Builtin, id: "builtin:errors".to_string(), virtual_path: Some("skills/errors/SKILL.md".to_string()), - revision: None, source_digest: Some("builtin-source-digest".to_string()), tree_digest: None, } @@ -12038,7 +11882,6 @@ mod tests { kind: SkillSourceKind::Workspace, id: "workspace:skills/release/SKILL.md".to_string(), virtual_path: Some("skills/release/SKILL.md".to_string()), - revision: Some(42), source_digest: Some("workspace-source-digest".to_string()), tree_digest: Some("tree-digest".to_string()), } @@ -12082,7 +11925,7 @@ mod tests { let decoded: SkillCatalogResponse = serde_json::from_str(&json).expect("deserialize Skill catalog"); assert_eq!(decoded, response); - assert!(!json.contains("\"revision\":null")); + assert!(!json.contains("\"revision\"")); assert!(!json.contains("\"tree_digest\":null")); } @@ -12113,9 +11956,9 @@ mod tests { } #[test] - fn skill_projection_validation_detects_stale_workspace_revision() { + fn skill_projection_validation_detects_stale_workspace_tree() { let mut provenance = workspace_skill_provenance(); - provenance.revision = Some(41); + provenance.tree_digest = Some("stale-tree-digest".to_string()); let response = SkillCatalogResponse { authority: "workspace-config-skills-v1".to_string(), projection: skill_projection(), @@ -12141,7 +11984,7 @@ mod tests { fn skill_dto_rejects_unknown_fields_and_unknown_provenance_kind() { let unknown_field = serde_json::json!({ "authority": "workspace-config-skills-v1", - "projection": {"config_revision": 42, "tree_digest": "tree-digest"}, + "projection": {"tree_digest": "tree-digest"}, "entries": [], "diagnostics": [], "body": "must not be accepted" @@ -12175,7 +12018,7 @@ mod tests { worker_id: Some("worker-1".to_string()), flow_selector: Some("builtin:coder-review".to_string()), flow_definition_id: Some("flow-1".to_string()), - flow_definition_revision: Some(7), + flow_definition_fingerprint: Some("sha256:flow-definition".into()), }; let encoded = serde_json::to_value(&origin).unwrap(); let decoded: MemoryEvidenceOrigin = serde_json::from_value(encoded).unwrap(); @@ -12360,7 +12203,7 @@ mod tests { let preflight = WorkspaceDeletionPreflightResponse { workspace_id: "workspace-test".to_string(), display_name: "Test".to_string(), - expected_revision: "revision-7".to_string(), + expected_workspace_updated_at: "2026-01-02T00:00:00Z".to_string(), can_delete: true, resources: WorkspaceDeletionResourceCounts { workers: 2, @@ -12386,7 +12229,7 @@ mod tests { assert!( serde_json::from_value::(serde_json::json!({ "operation_id": "delete-test", - "expected_revision": "revision-7", + "expected_workspace_updated_at": "2026-01-02T00:00:00Z", "confirmation": "Test", "workspace_id": "caller-controlled" })) @@ -12395,7 +12238,7 @@ mod tests { assert!( serde_json::from_value::(serde_json::json!({ "operation_id": "x".repeat(WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES + 1), - "expected_revision": "revision-7", + "expected_workspace_updated_at": "2026-01-02T00:00:00Z", "confirmation": "Test" })) .is_err() @@ -12540,7 +12383,7 @@ mod tests { ); let list = CurrentWorkerWorkdirAttachmentListResponse { workspace_id: "workspace".to_string(), - revision: "opaque-revision".to_string(), + digest: "inventory-digest".to_string(), next_offset: Some(1), items: vec![CurrentWorkerWorkdirAttachmentItem { alias: "checkout".to_string(), @@ -12559,7 +12402,7 @@ mod tests { } #[test] - fn current_worker_workdir_catalog_contract_is_bounded_and_revision_is_required() { + fn current_worker_workdir_catalog_contract_is_bounded_and_digest_is_required() { let query = CurrentWorkerWorkdirCatalogQuery { limit: Some(100), cursor: Some("stable-id".to_string()), @@ -12591,7 +12434,7 @@ mod tests { workspace_id: "workspace".to_string(), items: Vec::new(), next_cursor: Some("scanned-legacy-row".to_string()), - revision: "opaque".to_string(), + digest: "inventory-digest".to_string(), }; assert_eq!( serde_json::from_value::( @@ -12661,7 +12504,6 @@ mod tests { "kind": "git", "provider": "git", "source": {"kind": "local_path", "uri": "/srv/project"}, - "source_revision": 1, "source_fingerprint": "sha256:test", "observed_status": "ready", "record_authority": "workspace-control-plane" @@ -12727,7 +12569,7 @@ mod tests { "trust_key": { "status": "active", "fingerprint": "SHA256:test", - "revision": 2, + "binding_id": "binding-new", "created_at": "2026-09-01T12:00:00Z", "updated_at": "2026-09-01T13:00:00Z" }, @@ -12736,7 +12578,7 @@ mod tests { "actor_account_id": "account-owner", "old_fingerprint": "SHA256:old", "new_fingerprint": "SHA256:test", - "revision": 2, + "binding_id": "binding-new", "at": "2026-09-01T13:00:00Z" }] }); @@ -12755,19 +12597,34 @@ mod tests { ); assert!( serde_json::from_value::(serde_json::json!({ - "expected_revision": 1, + "expected_binding_id": "trust-observed", "delete_runtime": true })) .is_err() ); } + #[test] + fn runtime_registration_requires_explicit_runtime_issued_trust_identity() { + let mut value = serde_json::json!({ + "public_bundle": {"identity_id": "runtime", "public_key": "public-key"}, + "endpoint": "https://runtime.example.test", + "workspace_trust_id": "runtime-issued-enrollment" + }); + let request: CreateRemoteRuntimeRequest = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(request.workspace_trust_id, "runtime-issued-enrollment"); + value.as_object_mut().unwrap().remove("workspace_trust_id"); + assert!(serde_json::from_value::(value.clone()).is_err()); + value["workspace_key_generation"] = serde_json::json!(1); + assert!(serde_json::from_value::(value).is_err()); + } + #[test] fn runtime_connection_test_response_is_closed_and_typed() { let compatible = serde_json::json!({ "workspace_id": "workspace-test", "runtime_id": "runtime-test", - "binding_revision": 3, + "binding_id": "binding-test", "connection_state": "verified", "verification": null, "checked_at": "2026-09-01T12:00:00Z", @@ -12786,11 +12643,81 @@ mod tests { assert!(serde_json::from_value::(unknown).is_err()); } + #[test] + fn runtime_trust_contract_binds_concrete_keys_and_binding_lifetimes() { + let evidence = RuntimeVerificationEvidenceSummary { + verified_at: Some("2026-10-06T00:00:00Z".into()), + last_checked_at: "2026-10-06T00:00:00Z".into(), + last_outcome: RuntimeVerificationOutcome::Verified, + binding_id: "binding-new".into(), + workspace_key_id: "workspace-key".into(), + workspace_public_key_fingerprint: "sha256:workspace-key".into(), + workspace_trust_id: "trust-new".into(), + runtime_public_key_fingerprint: "sha256:runtime-key".into(), + }; + round_trip(evidence.clone()); + let mut stale = serde_json::to_value(evidence).unwrap(); + stale["workspace_identity_revision"] = serde_json::json!(1); + assert!(serde_json::from_value::(stale).is_err()); + round_trip(RevokeRuntimeTrustKeyRequest { + expected_binding_id: "binding-observed".into(), + }); + assert!( + serde_json::from_value::(serde_json::json!({ + "expected_revision": 1 + })) + .is_err() + ); + assert!( + serde_json::from_value::(serde_json::json!({ + "operation_id": "remove-binding", + "expected_binding_revision": 1 + })) + .is_err() + ); + round_trip(RemoveRuntimeRequest { + operation_id: "remove-binding".into(), + expected_binding_id: "binding-observed".into(), + }); + } + + #[test] + fn repository_key_mutations_require_observed_key_fingerprints() { + round_trip(RotateRepositorySshCredentialRequest { + operation_id: "rotate-key".into(), + expected_public_key_fingerprint: "sha256:observed-key".into(), + private_key: "fixture-not-a-real-key".into(), + passphrase: None, + }); + round_trip(PutRepositorySshHostTrustRequest { + operation_id: "trust-host".into(), + host_trust_id: "host-trust".into(), + hostname: "example.test".into(), + port: 22, + host_key: "fixture-host-key".into(), + expected_fingerprint: None, + }); + assert!( + serde_json::from_value::(serde_json::json!({ + "operation_id": "delete-key", + "expected_revision": 1 + })) + .is_err() + ); + assert!( + serde_json::from_value::(serde_json::json!({ + "operation_id": "delete-trust", + "expected_revision": 1 + })) + .is_err() + ); + } + #[test] fn ticket_completion_wire_requires_cas_and_judgment_not_merge_evidence() { let request = serde_json::json!({ "operation_key": "investigation-done", - "expected_item_revision": "ticket:1", + "expected_content_digest": "ticket:1", "expected_state": "planning", "reason": "Question answered in the thread" }); @@ -12798,7 +12725,7 @@ mod tests { assert!(parsed.references.is_empty()); for field in [ "operation_key", - "expected_item_revision", + "expected_content_digest", "expected_state", "reason", ] { @@ -12836,7 +12763,7 @@ mod tests { assert!(output.contains("export type WorkspaceConfigTreeResponse =")); assert!(output.contains("export type ConfigCommitRequest =")); assert!(output.contains("export type ProfileSettingsResponse =")); - assert!(output.contains("config_revision?: number | null")); + assert!(!output.contains("config_revision")); assert!(output.contains("provenance: WorkspaceProfileSourceProvenance")); assert!(output.contains( "export type WorkspaceProfileSourceProvenance = \"project_profile_source_tree\"" @@ -12908,20 +12835,20 @@ mod tests { let diagnostic = Diagnostic { code: "profile_projection_warning".to_string(), severity: DiagnosticSeverity::Warning, - message: "projected from the active config revision".to_string(), + message: "projected from the active config content".to_string(), }; let metadata = WorkspaceMetadataSettingsResponse { workspace_id: "workspace-test".to_string(), display_name: "Test".to_string(), created_at: "2026-01-01T00:00:00Z".to_string(), - revision: "sha256:metadata".to_string(), + updated_at: "2026-01-02T00:00:00Z".to_string(), source: "workspace-config".to_string(), diagnostics: vec![diagnostic.clone()], }; round_trip(metadata.clone()); round_trip(UpdateWorkspaceMetadataRequest { display_name: "Renamed".to_string(), - revision: metadata.revision.clone(), + expected_updated_at: metadata.updated_at.clone(), }); round_trip(WorkspaceMetadataMutationResponse { workspace: metadata, @@ -12930,8 +12857,6 @@ mod tests { round_trip(ProfileSettingsResponse { workspace_id: "workspace-test".to_string(), - registry_revision: "config-source:7:sha256:tree:sha256:projection".to_string(), - config_revision: Some(7), tree_digest: Some("sha256:tree".to_string()), projection_digest: Some("sha256:projection".to_string()), default_profile: Some("workspace:coder".to_string()), @@ -12954,7 +12879,6 @@ mod tests { content_digest: "sha256:source".to_string(), provenance: WorkspaceProfileSourceProvenance::ProjectProfileSourceTree, editable: false, - revision: "config-source:7".to_string(), size_bytes: 128, diagnostics: vec![], }], @@ -12963,14 +12887,12 @@ mod tests { let absent_optional_fields = serde_json::json!({ "workspace_id": "workspace-test", - "registry_revision": "builtin", "profiles": [], "sources": [], "diagnostics": [] }); let decoded: ProfileSettingsResponse = serde_json::from_value(absent_optional_fields.clone()).unwrap(); - assert_eq!(decoded.config_revision, None); assert_eq!(decoded.tree_digest, None); assert_eq!(decoded.projection_digest, None); assert_eq!(decoded.default_profile, None); @@ -12989,7 +12911,6 @@ mod tests { algorithm: "ed25519".to_string(), public_key: None, public_key_fingerprint: None, - revision: 1, state: WorkspaceSigningIdentityState::PendingProvisioning, created_at: "2026-01-01T00:00:00Z".to_string(), provisioned_at: None, @@ -13004,7 +12925,6 @@ mod tests { "workspace_id": "workspace-test", "key_id": "WK-test", "algorithm": "ed25519", - "revision": 1, "state": "pending_provisioning", "created_at": "2026-01-01T00:00:00Z" } diff --git a/crates/server-api/src/repository_openapi_typescript.rs b/crates/server-api/src/repository_openapi_typescript.rs index 658133616..cd0dcf3cb 100644 --- a/crates/server-api/src/repository_openapi_typescript.rs +++ b/crates/server-api/src/repository_openapi_typescript.rs @@ -1996,33 +1996,39 @@ mod tests { #[test] fn generator_rejects_lossy_safe_integers_and_preserves_bounded_ranges() { - let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); - document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["maximum"] = + let mut fixture: Value = serde_json::from_str(OPENAPI).unwrap(); + fixture["components"]["schemas"]["RepositorySummary"]["properties"]["size_bytes"] = serde_json::json!({"type": "integer", "format": "uint64", "minimum": 0, "maximum": 9_007_199_254_740_991_u64}); + fixture["components"]["schemas"]["RepositorySummary"]["required"] + .as_array_mut() + .unwrap() + .push(serde_json::json!("size_bytes")); + let mut document = fixture.clone(); + document["components"]["schemas"]["RepositorySummary"]["properties"]["size_bytes"]["maximum"] = serde_json::json!(9_007_199_254_740_992_u64); let error = generate_repository_typescript(&document.to_string()).unwrap_err(); assert!(error.to_string().contains("safe-number")); - let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); - document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["minimum"] = + let mut document = fixture.clone(); + document["components"]["schemas"]["RepositorySummary"]["properties"]["size_bytes"]["minimum"] = serde_json::json!(-1); let error = generate_repository_typescript(&document.to_string()).unwrap_err(); assert!(error.to_string().contains("safe-number")); - let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); - document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["maximum"] = + let mut document = fixture.clone(); + document["components"]["schemas"]["RepositorySummary"]["properties"]["size_bytes"]["maximum"] = serde_json::json!(9_007_199_254_740_990_u64); let output = generate_repository_typescript(&document.to_string()).unwrap(); assert!(output.contains( - "source_revision: number & { readonly \"__yoiIntegerRange\": \"0..9007199254740990\"; }" + "size_bytes: number & { readonly \"__yoiIntegerRange\": \"0..9007199254740990\"; }" )); - let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); - document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["format"] = + let mut document = fixture.clone(); + document["components"]["schemas"]["RepositorySummary"]["properties"]["size_bytes"]["format"] = serde_json::json!("int64"); let error = generate_repository_typescript(&document.to_string()).unwrap_err(); assert!(error.to_string().contains("unsupported integer format")); - let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); + let mut document = fixture.clone(); document["components"]["schemas"]["RepositorySummary"]["properties"]["git"]["anyOf"] .as_array_mut() .unwrap() diff --git a/crates/server-api/src/workspace_config.rs b/crates/server-api/src/workspace_config.rs index 905c9248c..023affda7 100644 --- a/crates/server-api/src/workspace_config.rs +++ b/crates/server-api/src/workspace_config.rs @@ -82,9 +82,6 @@ pub struct WorkspaceConfigNode { pub struct WorkspaceConfigObserveResponse { pub connection_id: String, pub validator: String, - #[cfg_attr(feature = "typescript", ts(type = "number"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub revision: u64, pub digest: String, /// Canonical commit entrypoints from this exact metadata snapshot. pub entrypoints: Vec, @@ -121,9 +118,6 @@ pub struct WorkspaceConfigCommitRequest { #[serde(deny_unknown_fields)] pub struct WorkspaceConfigCommitResponse { pub validator: String, - #[cfg_attr(feature = "typescript", ts(type = "number"))] - #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] - pub revision: u64, pub digest: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -178,11 +172,11 @@ mod tests { #[test] fn workspace_config_observation_is_metadata_only_and_has_same_snapshot_cas_inputs() { - let value = serde_json::json!({"connection_id":"c", "validator":"v", "revision":7, "digest":"d", "entrypoints":["main.dcdl"], "nodes":[{"path":"", "kind":"directory", "validator":"v", "digest":null, "content_type":null, "operations":["apply_changes"]}]}); + let value = serde_json::json!({"connection_id":"c", "validator":"v", "digest":"d", "entrypoints":["main.dcdl"], "nodes":[{"path":"", "kind":"directory", "validator":"v", "digest":null, "content_type":null, "operations":["apply_changes"]}]}); let response: WorkspaceConfigObserveResponse = serde_json::from_value(value.clone()).unwrap(); assert_eq!(serde_json::to_value(response).unwrap(), value); - for field in ["revision", "digest", "entrypoints"] { + for field in ["validator", "digest", "entrypoints"] { let mut missing = value.clone(); missing.as_object_mut().unwrap().remove(field); assert!(serde_json::from_value::(missing).is_err()); @@ -208,6 +202,19 @@ mod tests { ); } + #[test] + fn workspace_config_commit_response_is_content_based() { + let value = serde_json::json!({"validator":"v", "digest":"d"}); + let response: WorkspaceConfigCommitResponse = + serde_json::from_value(value.clone()).unwrap(); + assert_eq!(serde_json::to_value(response).unwrap(), value); + for field in ["validator", "digest"] { + let mut missing = value.clone(); + missing.as_object_mut().unwrap().remove(field); + assert!(serde_json::from_value::(missing).is_err()); + } + } + #[test] fn workspace_config_failure_classification_roundtrips_and_rejections_are_pre_effects() { for classification in [ diff --git a/crates/session-store/src/history.rs b/crates/session-store/src/history.rs index d93bead53..4db39dd3d 100644 --- a/crates/session-store/src/history.rs +++ b/crates/session-store/src/history.rs @@ -51,7 +51,9 @@ pub enum LoggedSessionHistoryOrigin { FlowInstruction { selector: String, definition_id: String, - definition_revision: u64, + /// Exact compiled source used for these instructions; absent in older logs. + #[serde(default, skip_serializing_if = "Option::is_none")] + definition_digest: Option, instance_id: String, state_id: String, }, diff --git a/crates/session-store/src/public_snapshot.rs b/crates/session-store/src/public_snapshot.rs index 12cb198b3..838f394a1 100644 --- a/crates/session-store/src/public_snapshot.rs +++ b/crates/session-store/src/public_snapshot.rs @@ -3662,7 +3662,7 @@ mod tests { LoggedSessionHistoryOrigin::FlowInstruction { selector: "builtin:coder-review".into(), definition_id: "flow-definition".into(), - definition_revision: 7, + definition_digest: Some("sha256:flow-source".into()), instance_id: "flow-instance".into(), state_id: "implement".into(), }, @@ -3695,7 +3695,7 @@ mod tests { origin: LoggedSessionHistoryOrigin::FlowInstruction { selector: "builtin:coder-review".into(), definition_id: "flow-definition".into(), - definition_revision: 7, + definition_digest: Some("sha256:flow-source".into()), instance_id: "flow-instance".into(), state_id: "implement".into(), }, diff --git a/crates/session-store/src/segment_log.rs b/crates/session-store/src/segment_log.rs index 0201a0b3a..8c093d3e3 100644 --- a/crates/session-store/src/segment_log.rs +++ b/crates/session-store/src/segment_log.rs @@ -1156,7 +1156,7 @@ mod tests { )))], extensions: vec![SessionExtension::new( "flow.runtime.v1", - serde_json::json!({ "state": "implement", "revision": 0 }), + serde_json::json!({ "state": "implement" }), )], }; let json = serde_json::to_string(&entry).unwrap(); diff --git a/crates/session-store/src/system_item.rs b/crates/session-store/src/system_item.rs index 0f00465cd..c8acacd6c 100644 --- a/crates/session-store/src/system_item.rs +++ b/crates/session-store/src/system_item.rs @@ -77,7 +77,6 @@ impl SystemReminder { pub struct PromptRenderProvenance { #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_id: Option, - pub config_revision: u64, pub source_digest: String, pub projection_digest: String, pub logical_name: String, @@ -176,10 +175,14 @@ pub enum SystemItem { }, /// A Host-fetched change to user-managed Subject behavior. Each changed - /// revision is appended rather than rewriting prior prompt/session content. + /// body is appended rather than rewriting prior prompt/session content. SubjectBehaviorRefresh { body: String, - behavior_revision: u64, + /// Exact user-managed behavior acknowledged after durable commit. Old + /// logs omit this value; an empty value is not an acknowledgement of a + /// nonempty behavior and does not replace their stored rendered body. + #[serde(default)] + behavior_md: String, #[serde(default, skip_serializing_if = "Option::is_none")] prompt_provenance: Option, }, @@ -292,22 +295,31 @@ mod tests { } #[test] - fn subject_behavior_refresh_round_trips_with_revision_and_exact_body() { + fn subject_behavior_refresh_round_trips_with_exact_body() { let item = SystemItem::SubjectBehaviorRefresh { body: "current Subject context".to_string(), - behavior_revision: 7, + behavior_md: "exact behavior".to_string(), prompt_provenance: None, }; let raw = serde_json::to_string(&item).unwrap(); let parsed: SystemItem = serde_json::from_str(&raw).unwrap(); assert_eq!(parsed.history_text(), "current Subject context"); - assert!(matches!( - parsed, - SystemItem::SubjectBehaviorRefresh { - behavior_revision: 7, - .. - } - )); + assert!( + matches!(parsed, SystemItem::SubjectBehaviorRefresh { behavior_md, .. } if behavior_md == "exact behavior") + ); + } + + #[test] + fn frozen_subject_behavior_log_reads_old_counter_without_republishing_it() { + let old = r#"{"kind":"subject_behavior_refresh","body":"exact archived body","behavior_revision":7}"#; + let item: SystemItem = serde_json::from_str(old).unwrap(); + assert_eq!(item.history_text(), "exact archived body"); + assert!( + serde_json::to_value(item) + .unwrap() + .get("behavior_revision") + .is_none() + ); } #[test] diff --git a/crates/standalone/src/job_store.rs b/crates/standalone/src/job_store.rs index c0dd3da43..2a1110be3 100644 --- a/crates/standalone/src/job_store.rs +++ b/crates/standalone/src/job_store.rs @@ -13,7 +13,10 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use thiserror::Error; -const SCHEMA_VERSION: i64 = 2; +#[path = "job_store/legacy_migrations.rs"] +mod legacy_migrations; + +const SCHEMA_VERSION: i64 = 3; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -69,7 +72,10 @@ impl JobSnapshot { pub fn outcome(&self) -> job::JobOutcome { job::JobOutcome { job_id: self.request.job_id.clone(), - input_revision: self.request.input_revision.clone(), + input_digest: self + .request + .input_digest() + .expect("validated immutable Job input"), attempt_id: self.attempt.attempt_id.clone(), attempt: self.attempt.number, state: match self.state { @@ -135,8 +141,8 @@ pub enum JobStoreError { ConcurrentLimit, #[error("result or lifecycle operation is not bound to the current Job attempt")] AttemptMismatch, - #[error("result input revision does not match the immutable request")] - RevisionMismatch, + #[error("result input digest does not match the immutable request")] + DigestMismatch, #[error("accepted Job result cannot be changed")] ResultConflict, #[error("invalid Job transition: {0}")] @@ -176,8 +182,11 @@ impl JobStore { tx.execute_batch(GRANT_SCHEMA)?; tx.pragma_update(None, "user_version", SCHEMA_VERSION)?; } - 1 => { - tx.execute_batch(GRANT_SCHEMA)?; + 1 | 2 => { + if version == 1 { + tx.execute_batch(GRANT_SCHEMA)?; + } + legacy_migrations::migrate_legacy_jobs(&tx)?; tx.pragma_update(None, "user_version", SCHEMA_VERSION)?; } SCHEMA_VERSION => {} @@ -370,8 +379,8 @@ impl JobStore { .transaction_with_behavior(TransactionBehavior::Immediate)?; let snapshot = require_snapshot(&tx, &bound.job_id)?; check_attempt(&snapshot, &bound.attempt_id)?; - if snapshot.request.input_revision != bound.input_revision { - return Err(JobStoreError::RevisionMismatch); + if snapshot.request.input_digest()? != bound.input_digest { + return Err(JobStoreError::DigestMismatch); } let (digest, encoded) = job::result_digest(&bound.result, snapshot.request.limits.max_result_bytes)?; @@ -527,13 +536,13 @@ fn insert_attempt( number: u8, ) -> Result<(), JobStoreError> { conn.execute( - "INSERT INTO job_attempts (job_id, number, attempt_id, input_revision, state) + "INSERT INTO job_attempts (job_id, number, attempt_id, input_digest, state) VALUES (?1, ?2, ?3, ?4, 'reserved')", params![ request.job_id, number, job::attempt_id(&request.job_id, number), - request.input_revision + request.input_digest()? ], )?; Ok(()) @@ -669,7 +678,7 @@ CREATE TABLE job_attempts ( job_id TEXT NOT NULL REFERENCES job_intents(job_id), number INTEGER NOT NULL CHECK (number BETWEEN 1 AND 255), attempt_id TEXT NOT NULL UNIQUE, - input_revision TEXT NOT NULL, + input_digest TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('reserved', 'dispatched', 'completed', 'failed', 'unknown', 'cancelled')), result_json TEXT, result_digest TEXT, @@ -685,7 +694,7 @@ CREATE TRIGGER job_intent_immutable BEFORE UPDATE OF job_id, request_json, fingerprint, serialization_key, max_concurrent ON job_intents BEGIN SELECT RAISE(ABORT, 'immutable Job intent'); END; CREATE TRIGGER job_attempt_immutable BEFORE UPDATE OF - job_id, number, attempt_id, input_revision ON job_attempts + job_id, number, attempt_id, input_digest ON job_attempts BEGIN SELECT RAISE(ABORT, 'immutable Job attempt identity'); END; CREATE TRIGGER job_result_immutable BEFORE UPDATE OF result_json, result_digest ON job_attempts WHEN OLD.result_json IS NOT NULL AND @@ -705,7 +714,6 @@ mod tests { JobRequest { job_id: id.into(), purpose: "fixture".into(), - input_revision: "revision-1".into(), input_ref: "fixture:input".into(), input: json!({"immutable": [1, 2, 3]}), instruction: "Produce a structured result".into(), @@ -725,7 +733,10 @@ mod tests { JobResultSubmission { job_id: snapshot.request.job_id.clone(), attempt_id: snapshot.attempt.attempt_id.clone(), - input_revision: snapshot.request.input_revision.clone(), + input_digest: snapshot + .request + .input_digest() + .expect("validated immutable Job input"), result, } } @@ -762,10 +773,10 @@ mod tests { for sql in [ "UPDATE job_intents SET request_json = '{}' WHERE job_id = 'identity'", "UPDATE job_intents SET fingerprint = 'changed' WHERE job_id = 'identity'", - "UPDATE job_attempts SET input_revision = 'changed' WHERE job_id = 'identity'", + "UPDATE job_attempts SET input_digest = 'changed' WHERE job_id = 'identity'", "UPDATE job_attempts SET attempt_id = 'changed' WHERE job_id = 'identity'", "UPDATE job_intents SET current_attempt = 2 WHERE job_id = 'identity'", - "INSERT INTO job_attempts (job_id, number, attempt_id, input_revision, state) + "INSERT INTO job_attempts (job_id, number, attempt_id, input_digest, state) VALUES ('absent', 1, 'absent:attempt:1', 'r', 'reserved')", ] { assert!( @@ -818,9 +829,6 @@ mod tests { assert_eq!(store.reserve(original.clone()).unwrap(), snapshot); let mut variants = Vec::new(); let mut changed = original.clone(); - changed.input_revision = "revision-2".into(); - variants.push(changed); - let mut changed = original.clone(); changed.input = json!({"changed": true}); variants.push(changed); let mut changed = original.clone(); @@ -917,12 +925,16 @@ mod tests { Err(JobStoreError::AttemptMismatch) )); let prior: (String, String, String) = store.conn.query_row( - "SELECT state, failure, input_revision FROM job_attempts WHERE job_id = 'retry' AND number = 1", + "SELECT state, failure, input_digest FROM job_attempts WHERE job_id = 'retry' AND number = 1", [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), ).unwrap(); assert_eq!( prior, - ("failed".into(), "timeout".into(), "revision-1".into()) + ( + "failed".into(), + "timeout".into(), + original.input_digest().unwrap() + ) ); store .finish( @@ -960,10 +972,10 @@ mod tests { Err(JobStoreError::AttemptMismatch) )); let mut invalid = valid.clone(); - invalid.input_revision = "revision-2".into(); + invalid.input_digest = "wrong-digest".into(); assert!(matches!( store.accept(&invalid), - Err(JobStoreError::RevisionMismatch) + Err(JobStoreError::DigestMismatch) )); let mut invalid = valid.clone(); invalid.result = json!("x".repeat(32)); @@ -1537,7 +1549,7 @@ mod tests { let absent = JobResultSubmission { job_id: "missing".into(), attempt_id: "attempt".into(), - input_revision: "r".into(), + input_digest: request("missing").input_digest().unwrap(), result: json!({}), }; assert!(matches!( diff --git a/crates/standalone/src/job_store/legacy_migrations.rs b/crates/standalone/src/job_store/legacy_migrations.rs new file mode 100644 index 000000000..30c85827f --- /dev/null +++ b/crates/standalone/src/job_store/legacy_migrations.rs @@ -0,0 +1,295 @@ +use super::*; + +/// Frozen schema-1/2 adapter. Preserve original intent and attempt bindings +/// before replacing counters with identities derived from immutable input. +pub(super) fn migrate_legacy_jobs(conn: &Connection) -> Result<(), JobStoreError> { + conn.execute_batch( + "CREATE TABLE legacy_job_archive ( + job_id TEXT PRIMARY KEY NOT NULL, + request_json TEXT NOT NULL, + fingerprint TEXT NOT NULL, + attempts_json TEXT NOT NULL + ); + DROP TRIGGER job_intent_immutable; + DROP TRIGGER job_attempt_immutable;", + )?; + let intents = conn + .prepare("SELECT job_id, request_json, fingerprint FROM job_intents")? + .query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + )) + })? + .collect::, _>>()?; + for (id, original, fingerprint) in intents { + let attempts = conn.prepare( + "SELECT number, attempt_id, input_revision, state, result_json, result_digest, failure + FROM job_attempts WHERE job_id = ?1 ORDER BY number", + )?.query_map([&id], |r| Ok(serde_json::json!({ + "number":r.get::<_, u8>(0)?, "attempt_id":r.get::<_, String>(1)?, + "input_revision":r.get::<_, String>(2)?, "state":r.get::<_, String>(3)?, + "result_json":r.get::<_, Option>(4)?, "result_digest":r.get::<_, Option>(5)?, + "failure":r.get::<_, Option>(6)? + })))?.collect::, _>>()?; + conn.execute( + "INSERT INTO legacy_job_archive VALUES (?1, ?2, ?3, ?4)", + params![id, original, fingerprint, serde_json::to_string(&attempts)?], + )?; + let mut value: Value = serde_json::from_str(&original)?; + value + .as_object_mut() + .ok_or_else(|| JobStoreError::Corrupt("legacy request is not an object".into()))? + .remove("input_revision"); + let request: JobRequest = serde_json::from_value(value)?; + if request.job_id != id { + return Err(JobStoreError::Corrupt( + "legacy request Job id mismatch".into(), + )); + } + conn.execute( + "UPDATE job_intents SET request_json = ?2, fingerprint = ?3 WHERE job_id = ?1", + params![id, serde_json::to_string(&request)?, request.fingerprint()?], + )?; + conn.execute( + "UPDATE job_attempts SET input_revision = ?2 WHERE job_id = ?1", + params![id, request.input_digest()?], + )?; + } + conn.execute_batch( + "ALTER TABLE job_attempts RENAME COLUMN input_revision TO input_digest; + CREATE TRIGGER job_intent_immutable BEFORE UPDATE OF + job_id, request_json, fingerprint, serialization_key, max_concurrent ON job_intents + BEGIN SELECT RAISE(ABORT, 'immutable Job intent'); END; + CREATE TRIGGER job_attempt_immutable BEFORE UPDATE OF + job_id, number, attempt_id, input_digest ON job_attempts + BEGIN SELECT RAISE(ABORT, 'immutable Job attempt identity'); END; + CREATE TRIGGER legacy_job_archive_immutable BEFORE UPDATE ON legacy_job_archive + BEGIN SELECT RAISE(ABORT, 'immutable legacy Job archive'); END; + CREATE TRIGGER legacy_job_archive_retained BEFORE DELETE ON legacy_job_archive + BEGIN SELECT RAISE(ABORT, 'retained legacy Job archive'); END;", + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn request(id: &str) -> JobRequest { + JobRequest { + job_id: id.into(), + purpose: "migration".into(), + input_ref: "fixture:input".into(), + input: json!({"immutable":[1,2,3]}), + instruction: "Return a result".into(), + profile: "builtin:job".into(), + serialization_key: None, + limits: Default::default(), + } + } + + fn seed(path: &Path, version: i64) -> (String, String, String) { + let mut conn = Connection::open(path).unwrap(); + feature_storage::configure_connection(&conn).unwrap(); + let tx = conn.transaction().unwrap(); + tx.execute_batch(&SCHEMA.replace("input_digest", "input_revision")) + .unwrap(); + if version == 2 { + tx.execute_batch(GRANT_SCHEMA).unwrap(); + } + let mut original = serde_json::to_value(request("done")).unwrap(); + original["input_revision"] = json!("old-token"); + let original = serde_json::to_string_pretty(&original).unwrap(); + let receipt = "{\"accepted\":true,\"input_revision\":\"receipt-domain-data\"}".to_string(); + let digest = job::result_digest( + &serde_json::from_str(&receipt).unwrap(), + job::ABSOLUTE_MAX_RESULT_BYTES, + ) + .unwrap() + .0; + tx.execute("INSERT INTO job_intents (job_id,request_json,fingerprint,max_concurrent,state,current_attempt,acknowledged) + VALUES ('done',?1,'legacy-fingerprint',8,'completed',2,1)", [&original]).unwrap(); + tx.execute( + "INSERT INTO job_attempts (job_id,number,attempt_id,input_revision,state,failure) + VALUES ('done',1,'done:attempt:1','old-token','failed','timeout')", + [], + ) + .unwrap(); + tx.execute("INSERT INTO job_attempts (job_id,number,attempt_id,input_revision,state,result_json,result_digest) + VALUES ('done',2,'done:attempt:2','old-token','completed',?1,?2)", params![receipt,digest]).unwrap(); + let grant = "{\"subject_id\":\"local-subject\",\"candidate_ids\":[\"c1\"]}".to_string(); + if version == 2 { + tx.execute("INSERT INTO job_domain_grants VALUES ('done',?1)", [&grant]) + .unwrap(); + } + for (id, state) in [("pending", "reserved"), ("running", "dispatched")] { + let mut value = serde_json::to_value(request(id)).unwrap(); + value["input_revision"] = json!("old-token"); + tx.execute("INSERT INTO job_intents (job_id,request_json,fingerprint,max_concurrent,state,current_attempt) + VALUES (?1,?2,'legacy-fingerprint',8,'pending',1)", params![id,serde_json::to_string(&value).unwrap()]).unwrap(); + tx.execute( + "INSERT INTO job_attempts (job_id,number,attempt_id,input_revision,state) + VALUES (?1,1,?2,'old-token',?3)", + params![id, job::attempt_id(id, 1), state], + ) + .unwrap(); + } + tx.pragma_update(None, "user_version", version).unwrap(); + tx.commit().unwrap(); + (original, receipt, grant) + } + + #[test] + fn legacy_versions_preserve_intent_receipts_attempts_grants_and_replay() { + for version in [1, 2] { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("jobs.sqlite3"); + let (original, receipt, grant) = seed(&path, version); + let mut store = JobStore::open(&path).unwrap(); + let grant_value = + (version == 2).then(|| serde_json::from_str::(&grant).unwrap()); + let snapshot = store + .reserve_granted(request("done"), grant_value.clone()) + .unwrap(); + assert!(snapshot.acknowledged); + assert_eq!(snapshot.state, JobState::Completed); + assert_eq!(snapshot.attempt.number, 2); + assert_eq!( + snapshot.result, + Some(serde_json::from_str(&receipt).unwrap()) + ); + let replay = JobResultSubmission { + job_id: "done".into(), + attempt_id: snapshot.attempt.attempt_id.clone(), + input_digest: request("done").input_digest().unwrap(), + result: snapshot.result.clone().unwrap(), + }; + assert_eq!(store.accept(&replay).unwrap(), snapshot); + let mut stale = replay.clone(); + stale.attempt_id = "done:attempt:1".into(); + assert!(matches!( + store.accept(&stale), + Err(JobStoreError::AttemptMismatch) + )); + let mut changed = request("done"); + changed.input = json!({"different":true}); + assert!(matches!( + store.reserve_granted(changed, grant_value.clone()), + Err(JobStoreError::IntentConflict(_)) + )); + let archived: (String, String, String) = store.conn.query_row( + "SELECT request_json,fingerprint,attempts_json FROM legacy_job_archive WHERE job_id='done'", [], + |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?))).unwrap(); + assert_eq!(archived.0, original); + assert_eq!(archived.1, "legacy-fingerprint"); + let attempts: Value = serde_json::from_str(&archived.2).unwrap(); + assert_eq!(attempts[0]["failure"], "timeout"); + assert_eq!(attempts[1]["input_revision"], "old-token"); + assert_eq!(attempts[1]["result_json"], receipt); + if version == 2 { + let persisted: String = store + .conn + .query_row("SELECT grant_json FROM job_domain_grants", [], |r| r.get(0)) + .unwrap(); + assert_eq!(persisted, grant); + } + let digests: Vec = store + .conn + .prepare( + "SELECT input_digest FROM job_attempts WHERE job_id='done' ORDER BY number", + ) + .unwrap() + .query_map([], |r| r.get(0)) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!(digests, vec![request("done").input_digest().unwrap(); 2]); + assert_eq!(store.get("running").unwrap().state, JobState::Unknown); + assert_eq!( + store.reserve(request("pending")).unwrap().attempt.state, + JobAttemptState::Reserved + ); + assert!( + store + .conn + .execute_batch("DELETE FROM legacy_job_archive") + .is_err() + ); + assert!( + store + .conn + .execute_batch("UPDATE legacy_job_archive SET fingerprint='changed'") + .is_err() + ); + assert!( + store + .conn + .execute_batch("UPDATE job_intents SET fingerprint='changed'") + .is_err() + ); + assert!( + store + .conn + .execute_batch("UPDATE job_attempts SET input_digest='changed'") + .is_err() + ); + assert!( + !store + .conn + .prepare("PRAGMA foreign_key_check") + .unwrap() + .exists([]) + .unwrap() + ); + drop(store); + let mut store = JobStore::open(&path).unwrap(); + assert_eq!( + store.reserve_granted(request("done"), grant_value).unwrap(), + snapshot + ); + } + } + + #[test] + fn failed_migration_rolls_back_schema_archive_and_immutable_triggers() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("jobs.sqlite3"); + seed(&path, 2); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "DROP TRIGGER job_intent_immutable; + UPDATE job_intents SET request_json='{}' WHERE job_id='running'; + CREATE TRIGGER job_intent_immutable BEFORE UPDATE OF request_json ON job_intents + BEGIN SELECT RAISE(ABORT,'immutable'); END;", + ) + .unwrap(); + drop(conn); + assert!(JobStore::open(&path).is_err()); + let conn = Connection::open(&path).unwrap(); + assert_eq!( + conn.pragma_query_value(None, "user_version", |r| r.get::<_, i64>(0)) + .unwrap(), + 2 + ); + assert!(conn.prepare("SELECT * FROM legacy_job_archive").is_err()); + let binding: String = conn + .query_row( + "SELECT input_revision FROM job_attempts WHERE job_id='done' AND number=2", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(binding, "old-token"); + assert!( + conn.execute_batch("UPDATE job_intents SET request_json='{}' WHERE job_id='done'") + .is_err() + ); + assert!( + conn.execute_batch("UPDATE job_attempts SET input_revision='changed'") + .is_err() + ); + } +} diff --git a/crates/standalone/src/jobs.rs b/crates/standalone/src/jobs.rs index 0753e1937..795aa2d50 100644 --- a/crates/standalone/src/jobs.rs +++ b/crates/standalone/src/jobs.rs @@ -219,7 +219,10 @@ impl StandaloneJobs { store: self.store.clone(), job_id: job_id.into(), attempt_id: snapshot.attempt.attempt_id.clone(), - input_revision: snapshot.request.input_revision.clone(), + input_digest: snapshot + .request + .input_digest() + .map_err(JobStoreError::from)?, deadline, }); let grant = match ( @@ -295,7 +298,10 @@ impl StandaloneJobs { store: store.clone(), job_id: id.clone(), attempt_id: attempt_id.clone(), - input_revision: snapshot.request.input_revision.clone(), + input_digest: snapshot + .request + .input_digest() + .map_err(JobStoreError::from)?, deadline, }); let (cancel, mut cancellation) = watch::channel(false); @@ -451,14 +457,14 @@ struct BoundResultSink { store: Arc>>, job_id: String, attempt_id: String, - input_revision: String, + input_digest: String, deadline: tokio::time::Instant, } impl worker::job::JobResultSink for BoundResultSink { fn submit(&self, submission: JobResultSubmission) -> Result<(), String> { if submission.job_id != self.job_id || submission.attempt_id != self.attempt_id - || submission.input_revision != self.input_revision + || submission.input_digest != self.input_digest { return Err("Job result capability binding mismatch".into()); } @@ -482,7 +488,7 @@ struct BoundAttemptFence { store: Arc>>, job_id: String, attempt_id: String, - input_revision: String, + input_digest: String, deadline: tokio::time::Instant, } impl JobAttemptFence for BoundAttemptFence { @@ -499,7 +505,7 @@ impl JobAttemptFence for BoundAttemptFence { if snapshot.state != JobState::Pending || snapshot.attempt.state != JobAttemptState::Dispatched || snapshot.attempt.attempt_id != self.attempt_id - || snapshot.request.input_revision != self.input_revision + || snapshot.request.input_digest().map_err(|e| e.to_string())? != self.input_digest { return Err("Job domain grant is not bound to the current live attempt".into()); } diff --git a/crates/standalone/src/jobs/tests.rs b/crates/standalone/src/jobs/tests.rs index 213afa6cf..cc5d00f7b 100644 --- a/crates/standalone/src/jobs/tests.rs +++ b/crates/standalone/src/jobs/tests.rs @@ -11,7 +11,6 @@ fn request(id: &str) -> JobRequest { JobRequest { job_id: id.into(), purpose: "test".into(), - input_revision: "r1".into(), input_ref: "fixture:input".into(), input: serde_json::json!({"data":42}), instruction: "Return a structured result".into(), @@ -25,7 +24,7 @@ fn sink(jobs: &StandaloneJobs, id: &str, attempt: &str) -> BoundResultSink { store: jobs.store.clone(), job_id: id.into(), attempt_id: attempt.into(), - input_revision: "r1".into(), + input_digest: request(id).input_digest().unwrap(), deadline: tokio::time::Instant::now() + Duration::from_secs(10), } } @@ -33,7 +32,7 @@ fn result(id: &str, attempt: &str) -> JobResultSubmission { JobResultSubmission { job_id: id.into(), attempt_id: attempt.into(), - input_revision: "r1".into(), + input_digest: request(id).input_digest().unwrap(), result: serde_json::json!({"valid":true}), } } @@ -99,7 +98,7 @@ type CapturedDomainAttempt = (JobSnapshot, Arc); #[derive(Default)] struct RecordingDomainProvider(Mutex>); fn domain_grant(id: &str) -> Value { - json!({"consumer":"test-domain", "job_id":id, "input_revision":"r1"}) + json!({"consumer":"test-domain", "job_id":id, "input_digest":request(id).input_digest().unwrap()}) } fn domain_request(id: &str) -> JobRequest { let mut intent = request(id); @@ -244,7 +243,7 @@ async fn persisted_grant_requires_provider_and_missing_grant_cannot_be_derived_f } #[tokio::test] -async fn foreign_job_revision_and_consumer_grants_are_refused_before_model_dispatch() { +async fn foreign_job_digest_and_consumer_grants_are_refused_before_model_dispatch() { let temp = tempfile::tempdir().unwrap(); let jobs = StandaloneJobs::open(&temp.path().join("jobs.sqlite3"), temp.path().to_path_buf()).unwrap(); @@ -253,12 +252,12 @@ async fn foreign_job_revision_and_consumer_grants_are_refused_before_model_dispa for (id, grant) in [ ("foreign-job", domain_grant("another-job")), ( - "foreign-revision", - json!({"consumer":"test-domain","job_id":"foreign-revision","input_revision":"r0"}), + "foreign-digest", + json!({"consumer":"test-domain","job_id":"foreign-digest","input_digest":"r0"}), ), ( "foreign-consumer", - json!({"consumer":"another-domain","job_id":"foreign-consumer","input_revision":"r1"}), + json!({"consumer":"another-domain","job_id":"foreign-consumer","input_digest":request("foreign-consumer").input_digest().unwrap()}), ), ] { let reserved = jobs.request_granted(domain_request(id), grant).unwrap(); @@ -378,7 +377,7 @@ async fn bound_domain_does_not_change_root_generic_result_only_execution() { } #[tokio::test] -async fn host_result_capability_cannot_cross_job_attempt_revision_or_deadline() { +async fn host_result_capability_cannot_cross_job_attempt_digest_or_deadline() { let temp = tempfile::tempdir().unwrap(); let jobs = StandaloneJobs::open(&temp.path().join("jobs.sqlite3"), temp.path().to_path_buf()).unwrap(); @@ -398,7 +397,7 @@ async fn host_result_capability_cannot_cross_job_attempt_revision_or_deadline() ); assert!(bound.submit(result("first", "first:attempt:2")).is_err()); let mut stale = result("first", &first.attempt.attempt_id); - stale.input_revision = "r0".into(); + stale.input_digest = "r0".into(); assert!(bound.submit(stale).is_err()); let mut expired = sink(&jobs, "first", &first.attempt.attempt_id); expired.deadline = tokio::time::Instant::now() - Duration::from_secs(1); diff --git a/crates/standalone/src/store.rs b/crates/standalone/src/store.rs index 6d119a6af..e7ba2984c 100644 --- a/crates/standalone/src/store.rs +++ b/crates/standalone/src/store.rs @@ -17,7 +17,7 @@ const LEASE_FILE: &str = "lease.json"; const LEASE_LOCK_FILE: &str = "lease.lock"; const SESSIONS_DIR: &str = "sessions"; const WORKER_DIR: &str = "worker"; -const SCHEMA_VERSION: u32 = 1; +const SCHEMA_VERSION: u32 = 2; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct StandaloneCwdIdentity { @@ -79,7 +79,6 @@ pub enum StandaloneShutdownReason { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StandaloneWorkerRecord { pub schema_version: u32, - pub revision: u64, pub worker_id: WorkerId, /// User-facing Worker name resolved from the profile. pub worker_name: String, @@ -194,7 +193,6 @@ impl StandaloneWorkerStore { let now = now_unix_ms()?; let record = StandaloneWorkerRecord { schema_version: SCHEMA_VERSION, - revision: 1, worker_id: allocation.worker_id, worker_name: manifest.worker.name.clone(), storage_key, @@ -326,13 +324,12 @@ impl StandaloneWorkerStore { active_segment_id: Option, ) -> Result { let mut next = record.clone(); - next.revision = next.revision.saturating_add(1); next.updated_at_unix_ms = now_unix_ms()?; next.active_session_id = active_session_id; next.active_segment_id = active_segment_id; next.status = StandaloneWorkerStatus::Active; next.shutdown_reason = None; - self.commit_record(Some(record.revision), &next)?; + self.commit_record(Some(record), &next)?; Ok(next) } @@ -344,13 +341,12 @@ impl StandaloneWorkerStore { reason: StandaloneShutdownReason, ) -> Result { let mut next = record.clone(); - next.revision = next.revision.saturating_add(1); next.updated_at_unix_ms = now_unix_ms()?; next.active_session_id = active_session_id; next.active_segment_id = active_segment_id; next.status = StandaloneWorkerStatus::Stopped; next.shutdown_reason = Some(reason); - self.commit_record(Some(record.revision), &next)?; + self.commit_record(Some(record), &next)?; Ok(next) } @@ -405,7 +401,7 @@ impl StandaloneWorkerStore { fn commit_record( &self, - expected_revision: Option, + expected_record: Option<&StandaloneWorkerRecord>, next: &StandaloneWorkerRecord, ) -> Result<(), StandaloneStoreError> { let dir = self.worker_dir(next.worker_id); @@ -421,19 +417,21 @@ impl StandaloneWorkerStore { StandaloneStoreError::Io(error) } })?; - writeln!(marker_file, "{}", next.revision).map_err(StandaloneStoreError::Io)?; + writeln!(marker_file, "{}", next.worker_id).map_err(StandaloneStoreError::Io)?; marker_file.sync_all().map_err(StandaloneStoreError::Io)?; sync_directory(&dir)?; - if let Some(expected) = expected_revision { + if let Some(expected) = expected_record { let current = self.load_record_while_committing(next.worker_id)?; - if current.revision != expected { - let _ = fs::remove_file(&marker); - return Err(StandaloneStoreError::RevisionConflict { - id: next.worker_id, - expected, - found: current.revision, - }); + // The create-new commit marker excludes other writers across this + // comparison and replacement. Compare the observed metadata itself, + // including its manifest, pointer and lifecycle state, not a counter. + if serde_json::to_value(¤t).map_err(StandaloneStoreError::Json)? + != serde_json::to_value(expected).map_err(StandaloneStoreError::Json)? + { + fs::remove_file(&marker).map_err(StandaloneStoreError::Io)?; + sync_directory(&dir)?; + return Err(StandaloneStoreError::MetadataChanged(next.worker_id)); } } @@ -681,6 +679,19 @@ fn decode_worker_record( "standalone Worker record must be an object", )) })?; + // Frozen schema-1 adapter: the counter was not domain data. Preserve + // every metadata field and use content comparison for future writes. + if object + .get("schema_version") + .and_then(serde_json::Value::as_u64) + == Some(1) + { + object.remove("revision"); + object.insert( + "schema_version".to_string(), + serde_json::json!(SCHEMA_VERSION), + ); + } let persisted_manifest = object.remove("manifest").ok_or_else(|| { serde_json::Error::io(io::Error::new( io::ErrorKind::InvalidData, @@ -770,14 +781,8 @@ pub enum StandaloneStoreError { LeaseOwnershipLost, #[error("standalone Worker {0} must be stopped before deletion")] DeleteActive(WorkerId), - #[error( - "standalone Worker {id} metadata revision changed (expected {expected}, found {found})" - )] - RevisionConflict { - id: WorkerId, - expected: u64, - found: u64, - }, + #[error("standalone Worker {0} metadata changed since it was read")] + MetadataChanged(WorkerId), #[error("system clock is before the Unix epoch or out of range")] Clock, #[error("standalone metadata serialization failed")] @@ -816,7 +821,6 @@ permission = "write" let manifest = test_manifest(); let record = StandaloneWorkerRecord { schema_version: SCHEMA_VERSION, - revision: 6, worker_id, worker_name: manifest.worker.name.clone(), storage_key: "standalone-test".to_string(), @@ -859,6 +863,122 @@ permission = "write" ); } + #[test] + fn metadata_write_compares_observed_content_even_with_equal_timestamps() { + let root = tempfile::tempdir().unwrap(); + let store = StandaloneWorkerStore::open(root.path().join("workers")).unwrap(); + let allocation = store + .allocate(root.path(), StaleLeasePolicy::Reject) + .unwrap(); + let record = store + .commit_created( + &allocation, + test_manifest(), + "metadata-test".into(), + session_store::new_session_id(), + None, + ) + .unwrap(); + // Simulate another committed pointer change in the same clock tick. + let mut changed = record.clone(); + changed.active_session_id = session_store::new_session_id(); + store.commit_record(Some(&record), &changed).unwrap(); + assert!(matches!( + store.mark_stopped( + &record, + record.active_session_id, + None, + StandaloneShutdownReason::UserExit + ), + Err(StandaloneStoreError::MetadataChanged(_)) + )); + assert_eq!( + store.load(record.worker_id).unwrap().active_session_id, + changed.active_session_id + ); + assert!( + !store + .worker_dir(record.worker_id) + .join(COMMIT_MARKER) + .exists() + ); + let stopped = store + .mark_stopped( + &changed, + changed.active_session_id, + None, + StandaloneShutdownReason::UserExit, + ) + .unwrap(); + assert_eq!(stopped.status, StandaloneWorkerStatus::Stopped); + } + + #[test] + fn schema_one_metadata_preserves_content_without_an_update_counter() { + let root = tempfile::tempdir().unwrap(); + let store = StandaloneWorkerStore::open(root.path().join("workers")).unwrap(); + let allocation = store + .allocate(root.path(), StaleLeasePolicy::Reject) + .unwrap(); + let record = store + .commit_created_connected( + &allocation, + test_manifest(), + "migration-test".into(), + session_store::new_session_id(), + None, + Some(crate::subjektiv::StandaloneSubjectBinding { + scope_id: "local-scope".into(), + subject_id: "local-subject".into(), + }), + ) + .unwrap(); + let record = store + .mark_stopped( + &record, + record.active_session_id, + Some(session_store::new_segment_id()), + StandaloneShutdownReason::UserExit, + ) + .unwrap(); + let mut persisted = Vec::new(); + write_worker_record(&mut persisted, &record).unwrap(); + let mut legacy: serde_json::Value = serde_json::from_slice(&persisted).unwrap(); + legacy["schema_version"] = serde_json::json!(1); + legacy["revision"] = serde_json::json!(42); + let legacy_bytes = serde_json::to_vec(&legacy).unwrap(); + fs::write( + store.worker_dir(record.worker_id).join(RECORD_FILE), + &legacy_bytes, + ) + .unwrap(); + let migrated = store.load(record.worker_id).unwrap(); + assert_eq!( + serde_json::to_value(&migrated).unwrap(), + serde_json::to_value(&record).unwrap() + ); + let mut encoded = Vec::new(); + write_worker_record(&mut encoded, &migrated).unwrap(); + let saved: serde_json::Value = serde_json::from_slice(&encoded).unwrap(); + assert!(saved.get("revision").is_none()); + assert_eq!(saved["schema_version"], 2); + let resumed = store + .update_active_pointer( + &migrated, + migrated.active_session_id, + migrated.active_segment_id, + ) + .unwrap(); + let disk: serde_json::Value = serde_json::from_slice( + &fs::read(store.worker_dir(record.worker_id).join(RECORD_FILE)).unwrap(), + ) + .unwrap(); + assert!(disk.get("revision").is_none()); + assert_eq!(disk["schema_version"], 2); + assert_eq!(resumed.subject, record.subject); + assert_eq!(resumed.active_segment_id, record.active_segment_id); + } + #[test] fn lease_liveness_requires_positive_live_or_stale_evidence() { assert_eq!( diff --git a/crates/standalone/src/subjektiv.rs b/crates/standalone/src/subjektiv.rs index 132ad588a..357f2679f 100644 --- a/crates/standalone/src/subjektiv.rs +++ b/crates/standalone/src/subjektiv.rs @@ -352,7 +352,7 @@ fn operation_error(error: subjektiv::api::OperationError) -> SubjektivHostError } else if message.contains("candidate_decision_conflict") { "candidate_decision_conflict" } else { - "revision_conflict" + "change_conflict" } .into(), message, @@ -655,7 +655,7 @@ impl LocalSubjectHost { worker_id: Some(self.worker_id.to_string()), flow_selector: None, flow_definition_id: None, - flow_definition_revision: None, + flow_definition_fingerprint: None, }; anchor.origin = Some(origin.clone()); source.origin = Some(origin); @@ -835,8 +835,7 @@ impl SubjektivHost for LocalSubjectHost { job::DEFAULT_MAX_RESULT_BYTES, ) .map_err(operation_error)?; - let subject = self - .catalog + self.catalog .store .subject(&self.binding.subject_id) .map_err(domain_error)? @@ -847,7 +846,7 @@ impl SubjektivHost for LocalSubjectHost { }; let request=job::JobRequest { job_id:format!("subjektiv-consolidation:{}",uuid::Uuid::now_v7()),purpose:"subjektiv_consolidation".into(), - input_revision:subject.store_revision.to_string(),input_ref:format!("subjektiv://{}/consolidation",self.binding.subject_id), + input_ref:format!("subjektiv://{}/consolidation",self.binding.subject_id), input:serde_json::json!({"subject_id":self.binding.subject_id,"candidate_ids":grant.candidate_ids}), instruction:"Resolve ONLY candidate_ids with MemoryStagingList/Read, recall and MemoryApplyCandidate. Already resolved IDs are durable receipts; do not repeat them. Leave new candidates for a later Job. When the entire immutable batch has a disposition, call SubmitJobResult with result: {subject_id, candidate_ids}. The Host awaits clean-context surface generation and verifies actual receipts/outcome before accepting success; never supply surface claims.".into(), profile:"builtin:standalone-subjektiv-consolidation".into(),serialization_key:Some(format!("subjektiv:{}",self.binding.subject_id)), diff --git a/crates/standalone/tests/host.rs b/crates/standalone/tests/host.rs index e962bcd57..a5a632a5e 100644 --- a/crates/standalone/tests/host.rs +++ b/crates/standalone/tests/host.rs @@ -487,7 +487,7 @@ async fn standalone_restore_recovers_only_a_proven_stale_lease() -> TestResult { source: manifest::ProfileRegistrySource::User, name: "user-standalone".to_string(), path: None, - provenance: Some("user-config-revision-7".to_string()), + provenance: Some("user-config-digest".to_string()), }, profile: Some(manifest::ProfileMetadata { name: Some("User standalone".to_string()), diff --git a/crates/standalone/tests/jobs.rs b/crates/standalone/tests/jobs.rs index 2c4d71688..c7c35346a 100644 --- a/crates/standalone/tests/jobs.rs +++ b/crates/standalone/tests/jobs.rs @@ -91,7 +91,6 @@ fn request(id: &str) -> JobRequest { JobRequest { job_id: id.into(), purpose: "generic_test".into(), - input_revision: "r1".into(), input_ref: "test:immutable".into(), input: serde_json::json!({"test_input":42}), instruction: "Return a structured validity result".into(), diff --git a/crates/standalone/tests/subjektiv.rs b/crates/standalone/tests/subjektiv.rs index b7464bc43..c6d759d8b 100644 --- a/crates/standalone/tests/subjektiv.rs +++ b/crates/standalone/tests/subjektiv.rs @@ -273,7 +273,7 @@ impl ScriptedClient { "SubmitMemorySurface", json!({"points":[{ "body_md":format!("- {}", material["body_md"].as_str().unwrap()), - "memory_refs":[{"memory_id":material["memory_id"], "revision":material["revision"]}] + "memory_refs":[{"memory_id":material["memory_id"], "change_id":material["change_id"]}] }]}), ) } @@ -1155,7 +1155,7 @@ async fn automatic_generic_consolidation_applies_candidate_publishes_surface_and ); let memory = store.list_memories(&subject.id).unwrap().pop().unwrap(); assert_eq!(memory.claim, claim); - assert_eq!(memory.revision, 1); + assert!(!memory.change_id.is_empty()); assert_eq!(memory.source_candidate_ids, vec![ids[0].as_str().unwrap()]); let surface = store.resident_surface(&subject.id).unwrap(); assert_eq!(surface.availability, subjektiv::SurfaceAvailability::Ready); @@ -1167,14 +1167,14 @@ async fn automatic_generic_consolidation_applies_candidate_publishes_surface_and assert!(snapshot.body_md.contains(claim)); assert_eq!( snapshot.memory_refs, - vec![subjektiv::MemoryRevisionRef { + vec![subjektiv::MemoryChangeRef { memory_id: memory.id.clone(), - revision: 1 + change_id: memory.change_id.clone() }] ); assert_eq!( - snapshot.built_from_store_revision, - accepted["surface"]["store_revision"].as_u64().unwrap() + snapshot.built_from_memory_fingerprint, + accepted["surface"]["memory_fingerprint"].as_str().unwrap() ); memory }); @@ -1260,7 +1260,7 @@ async fn transport_loss_after_candidate_apply_preserves_memory_and_later_job_onl let memory = store.list_memories(&subject.id).unwrap(); assert_eq!(memory.len(), 1); assert_eq!(memory[0].claim, claim); - assert_eq!(memory[0].revision, 1); + assert!(!memory[0].change_id.is_empty()); assert_ne!( store.resident_surface(&subject.id).unwrap().availability, subjektiv::SurfaceAvailability::Ready diff --git a/crates/subjektiv/Cargo.toml b/crates/subjektiv/Cargo.toml index 1b4fd76a8..9480041cf 100644 --- a/crates/subjektiv/Cargo.toml +++ b/crates/subjektiv/Cargo.toml @@ -13,6 +13,7 @@ manifest.workspace = true rusqlite.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true +sha2.workspace = true server-api.workspace = true thiserror.workspace = true uuid = { workspace = true, features = ["v7"] } diff --git a/crates/subjektiv/README.md b/crates/subjektiv/README.md index 8f5394522..ec717a162 100644 --- a/crates/subjektiv/README.md +++ b/crates/subjektiv/README.md @@ -17,7 +17,9 @@ identity under its operator-managed state directory, **not a fake Workspace**. Subject IDs are independently issued random domain identities. An ID is a lookup key, never an authorization credential. -Existing SQL schema and migrations are retained. In particular, +Historical migration plans remain frozen. Migration 7 replaces state counters +with opaque Memory change identities and explicit predecessor history; the +original serialized records are retained in an immutable audit archive. `store_scope.workspace_id` still stores the physical storage scope, under its historical column name. Existing Backend placement and snapshot manifest keys are unchanged. Optional historical `EvidenceOrigin.workspace_id` values continue @@ -29,7 +31,7 @@ Workspace value. `workspace_id()` is a Backend compatibility alias for `api::execute(store, &HostOperationContext, operation)` is the common dispatcher used by Backend and local Hosts. It owns query/cursor rules, bounded body and -nested provenance projections, immutable revision reads, proposal validation, +nested provenance projections, immutable change reads, proposal validation, explicit staging/receipt rules, resident context, candidate decisions and surface generation/publication/failure projections. @@ -72,5 +74,24 @@ helpers never run or replay a Job. The store's `prepare_job_surface_generation`, `require_job_surface_generation`, and `validate_job_surface_outcome` are public trusted-domain methods for both Backend and local Hosts. +## Content conditions and history + +Memory updates require the exact `expected_change_id` read by the caller. +Each committed change receives a new opaque ID and retains `previous_change_id`; +IDs are never sorted or incremented to infer history. Body/evidence continuations +pin that immutable ID, and history cursors pin a predecessor-chain head. + +Subject behavior updates compare `expected_behavior_md` byte-for-byte and do not +participate in Memory freshness. Surface runs, publications and query cursors use +`memory_fingerprint`, a SHA-256 digest of the length-framed, ordered current +`(memory_id, change_id)` identities. The Subject stores no redundant freshness +counter. Publication remains first-wins for the captured Memory input set. + +Old Surface input sets cannot be proven from historical counters. Migration 7 +retains those snapshots and runs as archival history with deliberately detached +input identities, marks existing surfaces stale, and requires regeneration. +Old Flow provenance keeps its exact original bytes in the archive; a definition +fingerprint is not fabricated from an old numeric label. + Dependencies: `feature-storage`, `chrono`, `memory`, `manifest`, `rusqlite`, -`serde`, `serde_json`, `server-api`, `thiserror`, `uuid`; tests use `tempfile`. +`serde`, `serde_json`, `server-api`, `sha2`, `thiserror`, `uuid`; tests use `tempfile`. diff --git a/crates/subjektiv/src/api.rs b/crates/subjektiv/src/api.rs index cd6dc64b5..8295aa7ab 100644 --- a/crates/subjektiv/src/api.rs +++ b/crates/subjektiv/src/api.rs @@ -24,7 +24,7 @@ pub const SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES: usize = 40 * 1024; #[serde(deny_unknown_fields)] struct SubjektivQueryCursor { subject_id: String, - store_revision: u64, + memory_fingerprint: String, query: Option, kinds: Vec, states: Vec, @@ -33,10 +33,10 @@ struct SubjektivQueryCursor { #[derive(Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -struct SubjektivRevisionCursor { +struct SubjektivChangeCursor { subject_id: String, memory_id: String, - max_revision: u64, + head_change_id: String, offset: usize, } @@ -45,8 +45,8 @@ struct SubjektivRevisionCursor { struct SubjektivEvidenceCursor { subject_id: String, memory_id: String, - revision: u64, - /// Candidate/derivation reference offset within the immutable revision. + change_id: String, + /// Candidate/derivation reference offset within the immutable change_id. offset: usize, /// Nested offsets keep compatible historical candidates with more anchors /// resumable without admitting new over-limit records. @@ -92,14 +92,14 @@ pub fn subjektiv_surface_prepare_response( server_api::SubjektivSurfacePrepareResponse { generation_id: generation.id, current_snapshot_id: None, - store_revision: generation.store_revision, + memory_fingerprint: generation.memory_fingerprint, active_memory_count: generation.active_memory_count, materials: generation .materials .into_iter() .map(|material| server_api::SubjektivSurfaceMaterial { memory_id: material.memory_id, - revision: material.revision, + change_id: material.change_id.clone(), kind: material.kind, body_md: material.body_md, why_useful: material.why_useful, @@ -120,7 +120,7 @@ pub fn subjektiv_candidate_summary( candidate_id: candidate.id, kind: candidate.kind, claim: candidate.claim, - revision_proposal: candidate.revision_proposal.map(subjektiv_proposal_metadata), + change_proposal: candidate.change_proposal.map(subjektiv_proposal_metadata), created_at: candidate.created_at, } } @@ -137,30 +137,26 @@ pub fn subjektiv_candidate( source: candidate.source, evidence: candidate.evidence, source_refs: candidate.source_refs, - revision_proposal: candidate.revision_proposal.map(subjektiv_proposal_metadata), + change_proposal: candidate.change_proposal.map(subjektiv_proposal_metadata), created_at: candidate.created_at, } } pub fn subjektiv_proposal_metadata( - proposal: crate::RevisionProposal, -) -> server_api::SubjektivMemoryRevisionProposalMetadata { - server_api::SubjektivMemoryRevisionProposalMetadata { + proposal: crate::ChangeProposal, +) -> server_api::SubjektivMemoryChangeProposalMetadata { + server_api::SubjektivMemoryChangeProposalMetadata { memory_id: proposal.memory_id, - expected_revision: proposal.expected_revision, + expected_change_id: proposal.expected_change_id.clone(), intent: match proposal.intent { - crate::RevisionProposalIntent::Revise => { - server_api::SubjektivMemoryRevisionIntent::Revise + crate::ChangeProposalIntent::Revise => server_api::SubjektivMemoryChangeIntent::Revise, + crate::ChangeProposalIntent::Resolve => { + server_api::SubjektivMemoryChangeIntent::Resolve } - crate::RevisionProposalIntent::Resolve => { - server_api::SubjektivMemoryRevisionIntent::Resolve - } - crate::RevisionProposalIntent::Retract => { - server_api::SubjektivMemoryRevisionIntent::Retract - } - crate::RevisionProposalIntent::Reopen => { - server_api::SubjektivMemoryRevisionIntent::Reopen + crate::ChangeProposalIntent::Retract => { + server_api::SubjektivMemoryChangeIntent::Retract } + crate::ChangeProposalIntent::Reopen => server_api::SubjektivMemoryChangeIntent::Reopen, }, change_reason: proposal.change_reason, } @@ -172,15 +168,13 @@ pub fn subjektiv_candidate_decision( let decision = match input.decision { server_api::SubjektivMemoryCandidateDecision::Apply { target, memory } => { let target = match target { - server_api::SubjektivMemoryApplyTarget::Create => { - crate::MemoryRevisionTarget::Create - } + server_api::SubjektivMemoryApplyTarget::Create => crate::MemoryChangeTarget::Create, server_api::SubjektivMemoryApplyTarget::Revise { memory_id, - expected_revision, - } => crate::MemoryRevisionTarget::Revise { + expected_change_id, + } => crate::MemoryChangeTarget::Revise { memory_id, - expected_revision, + expected_change_id, }, }; crate::CandidateDecision::Apply { @@ -196,9 +190,9 @@ pub fn subjektiv_candidate_decision( derived_from: memory .derived_from .into_iter() - .map(|reference| crate::MemoryRevisionRef { + .map(|reference| crate::MemoryChangeRef { memory_id: reference.memory_id, - revision: reference.revision, + change_id: reference.change_id.clone(), }) .collect(), change_reason: memory.change_reason, @@ -225,9 +219,9 @@ pub fn subjektiv_candidate_decision( }, affected_memory: affected_memory .into_iter() - .map(|reference| crate::MemoryRevisionRef { + .map(|reference| crate::MemoryChangeRef { memory_id: reference.memory_id, - revision: reference.revision, + change_id: reference.change_id.clone(), }) .collect(), }, @@ -281,17 +275,17 @@ pub fn subjektiv_candidate_decision_response( .into_iter() .map(|reference| server_api::SubjektivMemoryAffectedRef { memory_id: reference.memory_id, - revision: reference.revision, + change_id: reference.change_id.clone(), operation, }) .collect(), memory: receipt .memory - .map(|memory| server_api::SubjektivMemoryRevisionRef { + .map(|memory| server_api::SubjektivMemoryChangeRef { memory_id: memory.id, - revision: memory.revision, + change_id: memory.change_id.clone(), }), - store_revision: receipt.store_revision, + memory_fingerprint: receipt.memory_fingerprint, surface_dirty: receipt.surface_dirty, } } @@ -316,10 +310,19 @@ pub fn subjektiv_memory_query( .filter(|value| !value.is_empty()); let kinds = canonical_subjektiv_kinds(input.kinds)?; let states = canonical_subjektiv_states(input.states)?; - let subject = store + let _subject = store .subject(subject_id) .map_err(subjektiv_store_error)? .ok_or_else(|| OperationError::SubjectNotFound(subject_id.to_string()))?; + let all_records = store + .list_memories(subject_id) + .map_err(subjektiv_store_error)?; + let memory_fingerprint = crate::memory_identity_fingerprint( + &all_records + .iter() + .map(|r| (r.id.clone(), r.change_id.clone())) + .collect::>(), + ); let mut offset = 0; if let Some(cursor) = input.cursor { let cursor: SubjektivQueryCursor = decode_subjektiv_cursor("query", &cursor)?; @@ -333,7 +336,7 @@ pub fn subjektiv_memory_query( ) .into()); } - if cursor.store_revision != subject.store_revision { + if cursor.memory_fingerprint != memory_fingerprint { return Err(OperationError::Conflict( "stale_cursor: subjektiv query cursor is stale because Memory changed; restart the query".into(), ) @@ -344,9 +347,7 @@ pub fn subjektiv_memory_query( let kind_filter = kinds.iter().cloned().collect::>(); let state_filter = states.iter().cloned().collect::>(); - let records = store - .list_memories(subject_id) - .map_err(subjektiv_store_error)? + let records = all_records .into_iter() .filter(|record| { kind_filter.contains(record.kind.as_str()) @@ -369,7 +370,7 @@ pub fn subjektiv_memory_query( .iter() .map(|record| server_api::SubjektivMemoryQueryItem { id: record.id.clone(), - revision: record.revision, + change_id: record.change_id.clone(), kind: record.kind.clone(), state: api_memory_state(record.state), claim: record.claim.clone(), @@ -383,7 +384,7 @@ pub fn subjektiv_memory_query( "query", &SubjektivQueryCursor { subject_id: subject_id.to_string(), - store_revision: subject.store_revision, + memory_fingerprint: memory_fingerprint, query, kinds, states, @@ -407,18 +408,18 @@ pub fn subjektiv_memory_read( let current = store .scoped_memory(subject_id, &input.memory_id) .map_err(subjektiv_store_error)?; - let record = match input.revision { - Some(0) => { - return Err( - OperationError::InvalidInput("revision must be a positive integer".into()).into(), - ); + let record = match input.change_id.clone() { + Some(id) if id.trim().is_empty() => { + return Err(OperationError::InvalidInput( + "change_id must be nonempty".into(), + )); } - Some(revision) => store - .scoped_memory_revision(subject_id, &input.memory_id, revision) + Some(change_id) => store + .scoped_memory_change(subject_id, &input.memory_id, change_id.clone()) .map_err(subjektiv_store_error)? .ok_or_else(|| { OperationError::InvalidInput(format!( - "memory_revision_not_found: Memory `{}` has no revision {revision}", + "memory_change_not_found: Memory `{}` has no change_id {change_id}", input.memory_id )) })?, @@ -433,9 +434,9 @@ pub fn subjektiv_memory_read( } let body_offset = input.offset.unwrap_or(0); let body_byte_offset = input.byte_offset.unwrap_or(0); - if (body_offset > 0 || body_byte_offset > 0) && input.revision.is_none() { + if (body_offset > 0 || body_byte_offset > 0) && input.change_id.is_none() { return Err(OperationError::InvalidInput( - "body continuation requires the exact revision returned by the first read".into(), + "body continuation requires the exact change_id returned by the first read".into(), ) .into()); } @@ -477,10 +478,10 @@ pub fn subjektiv_memory_read( let cursor: SubjektivEvidenceCursor = decode_subjektiv_cursor("evidence", &cursor)?; if cursor.subject_id != subject_id || cursor.memory_id != record.id - || cursor.revision != record.revision + || cursor.change_id != record.change_id { return Err(OperationError::InvalidInput( - "subjektiv evidence cursor does not match subject, Memory, or revision".into(), + "subjektiv evidence cursor does not match subject, Memory, or change_id".into(), ) .into()); } @@ -570,9 +571,9 @@ pub fn subjektiv_memory_read( } let derived_index = evidence_offset - record.source_candidate_ids.len(); let reference = &record.derived_from[derived_index]; - derived_from.push(server_api::SubjektivMemoryRevisionRef { + derived_from.push(server_api::SubjektivMemoryChangeRef { memory_id: reference.memory_id.clone(), - revision: reference.revision, + change_id: reference.change_id.clone(), }); next_evidence_offset += 1; } else if nested_evidence_offset != 0 || nested_source_ref_offset != 0 { @@ -592,7 +593,7 @@ pub fn subjektiv_memory_read( &SubjektivEvidenceCursor { subject_id: subject_id.to_string(), memory_id: record.id.clone(), - revision: record.revision, + change_id: record.change_id.clone(), offset: next_evidence_offset, evidence_offset: next_nested_evidence_offset, source_ref_offset: next_nested_source_ref_offset, @@ -603,8 +604,8 @@ pub fn subjektiv_memory_read( let mut response = server_api::SubjektivMemoryReadResponse { memory_id: record.id, - revision: record.revision, - current_revision: current.revision, + change_id: record.change_id.clone(), + current_change_id: current.change_id.clone(), kind: record.kind, state: api_memory_state(record.state), claim: record.claim, @@ -635,36 +636,41 @@ pub fn subjektiv_memory_read( Ok(response) } -pub fn subjektiv_memory_list_revisions( +pub fn subjektiv_memory_list_changes( store: &crate::SubjektivStore, subject_id: &str, - input: server_api::SubjektivMemoryListRevisionsRequest, -) -> OperationResult { + input: server_api::SubjektivMemoryListChangesRequest, +) -> OperationResult { let limit = bounded_subjektiv_limit(input.limit, SUBJEKTIV_QUERY_DEFAULT_LIMIT)?; let current = store .scoped_memory(subject_id, &input.memory_id) .map_err(subjektiv_store_error)?; - let (max_revision, offset) = if let Some(cursor) = input.cursor { - let cursor: SubjektivRevisionCursor = decode_subjektiv_cursor("revisions", &cursor)?; + let (head_change_id, offset) = if let Some(cursor) = input.cursor { + let cursor: SubjektivChangeCursor = decode_subjektiv_cursor("changes", &cursor)?; if cursor.subject_id != subject_id || cursor.memory_id != input.memory_id { return Err(OperationError::InvalidInput( - "subjektiv revision cursor does not match subject or Memory".into(), + "subjektiv change_id cursor does not match subject or Memory".into(), ) .into()); } - (cursor.max_revision, cursor.offset) + (cursor.head_change_id, cursor.offset) } else { - (current.revision, 0) + (current.change_id.clone(), 0) }; let records = store - .list_memory_revisions(subject_id, &input.memory_id) + .list_memory_changes(subject_id, &input.memory_id) .map_err(subjektiv_store_error)? .into_iter() - .filter(|record| record.revision <= max_revision) + .skip_while(|record| record.change_id != head_change_id) .collect::>(); + if records.is_empty() { + return Err(OperationError::InvalidInput( + "subjektiv history cursor refers to an unknown change".into(), + )); + } if offset > records.len() { return Err(OperationError::InvalidInput( - "subjektiv revision cursor offset is invalid".into(), + "subjektiv change_id cursor offset is invalid".into(), ) .into()); } @@ -672,8 +678,8 @@ pub fn subjektiv_memory_list_revisions( let has_more = end < records.len(); let items = records[offset..end] .iter() - .map(|record| server_api::SubjektivMemoryRevisionItem { - revision: record.revision, + .map(|record| server_api::SubjektivMemoryChangeItem { + change_id: record.change_id.clone(), kind: record.kind.clone(), state: api_memory_state(record.state), claim: record.claim.clone(), @@ -684,19 +690,19 @@ pub fn subjektiv_memory_list_revisions( let next_cursor = has_more .then(|| { encode_subjektiv_cursor( - "revisions", - &SubjektivRevisionCursor { + "changes", + &SubjektivChangeCursor { subject_id: subject_id.to_string(), memory_id: input.memory_id.clone(), - max_revision, + head_change_id, offset: end, }, ) }) .transpose()?; - Ok(server_api::SubjektivMemoryListRevisionsResponse { + Ok(server_api::SubjektivMemoryListChangesResponse { memory_id: input.memory_id, - current_revision: current.revision, + current_change_id: current.change_id.clone(), items, next_cursor, has_more, @@ -708,25 +714,25 @@ pub fn subjektiv_memory_validate_proposal( subject_id: &str, input: server_api::SubjektivMemoryValidateProposalRequest, ) -> OperationResult { - if input.expected_revision == 0 { + if input.expected_change_id.trim().is_empty() { return Err( - OperationError::InvalidInput("expected_revision must be positive".into()).into(), + OperationError::InvalidInput("expected_change_id must be nonempty".into()).into(), ); } let current = store .scoped_memory(subject_id, &input.memory_id) .map_err(subjektiv_store_error)?; - if current.revision != input.expected_revision { + if current.change_id != input.expected_change_id { return Err(OperationError::Conflict(format!( - "revision_conflict: Memory `{}` expected {}, current {}", - input.memory_id, input.expected_revision, current.revision + "change_conflict: Memory `{}` expected {}, current {}", + input.memory_id, input.expected_change_id, current.change_id )) .into()); } validate_subjektiv_proposal_transition(current.state, input.intent)?; Ok(server_api::SubjektivMemoryProposalValidationResponse { memory_id: input.memory_id, - current_revision: current.revision, + current_change_id: current.change_id.clone(), kind: current.kind, state: api_memory_state(current.state), intent: input.intent, @@ -821,10 +827,10 @@ pub fn subjektiv_memory_stage_explicit( .proposal .as_ref() .map(|proposal| { - crate::RevisionProposal::new( + crate::ChangeProposal::new( domain_proposal_intent(proposal.intent), proposal.memory_id.clone(), - proposal.expected_revision, + proposal.expected_change_id.clone(), proposal.change_reason.clone(), ) }) @@ -836,7 +842,7 @@ pub fn subjektiv_memory_stage_explicit( .map_err(subjektiv_store_error)?; if current.kind != input.kind { return Err(OperationError::InvalidInput( - "revision proposal kind must be derived from its target Memory".into(), + "change_id proposal kind must be derived from its target Memory".into(), ) .into()); } @@ -859,7 +865,7 @@ pub fn subjektiv_memory_stage_explicit( let mut staging = crate::SubjectStagingRecord::attach(&attribution.subject_id, staging); if let Some(proposal) = proposal.clone() { staging - .attach_revision_proposal(proposal) + .attach_change_proposal(proposal) .map_err(subjektiv_store_error)?; } let (staged, _) = store @@ -873,7 +879,7 @@ pub fn subjektiv_memory_stage_explicit( .as_ref() .map(|proposal| server_api::SubjektivMemoryProposalTarget { memory_id: proposal.memory_id.clone(), - expected_revision: proposal.expected_revision, + expected_change_id: proposal.expected_change_id.clone(), }), intent: proposal .as_ref() @@ -883,10 +889,10 @@ pub fn subjektiv_memory_stage_explicit( pub fn validate_subjektiv_proposal_transition( state: crate::MemoryState, - intent: server_api::SubjektivMemoryRevisionIntent, + intent: server_api::SubjektivMemoryChangeIntent, ) -> OperationResult<()> { use crate::MemoryState::{Active, Resolved}; - use server_api::SubjektivMemoryRevisionIntent::{Reopen, Resolve, Retract, Revise}; + use server_api::SubjektivMemoryChangeIntent::{Reopen, Resolve, Retract, Revise}; let valid = match intent { Revise => matches!(state, Active | Resolved), Resolve => state == Active, @@ -979,32 +985,24 @@ pub fn subjektiv_memory_state_name(state: crate::MemoryState) -> &'static str { } pub fn domain_proposal_intent( - intent: server_api::SubjektivMemoryRevisionIntent, -) -> crate::RevisionProposalIntent { + intent: server_api::SubjektivMemoryChangeIntent, +) -> crate::ChangeProposalIntent { match intent { - server_api::SubjektivMemoryRevisionIntent::Revise => crate::RevisionProposalIntent::Revise, - server_api::SubjektivMemoryRevisionIntent::Resolve => { - crate::RevisionProposalIntent::Resolve - } - server_api::SubjektivMemoryRevisionIntent::Retract => { - crate::RevisionProposalIntent::Retract - } - server_api::SubjektivMemoryRevisionIntent::Reopen => crate::RevisionProposalIntent::Reopen, + server_api::SubjektivMemoryChangeIntent::Revise => crate::ChangeProposalIntent::Revise, + server_api::SubjektivMemoryChangeIntent::Resolve => crate::ChangeProposalIntent::Resolve, + server_api::SubjektivMemoryChangeIntent::Retract => crate::ChangeProposalIntent::Retract, + server_api::SubjektivMemoryChangeIntent::Reopen => crate::ChangeProposalIntent::Reopen, } } pub fn api_proposal_intent( - intent: crate::RevisionProposalIntent, -) -> server_api::SubjektivMemoryRevisionIntent { + intent: crate::ChangeProposalIntent, +) -> server_api::SubjektivMemoryChangeIntent { match intent { - crate::RevisionProposalIntent::Revise => server_api::SubjektivMemoryRevisionIntent::Revise, - crate::RevisionProposalIntent::Resolve => { - server_api::SubjektivMemoryRevisionIntent::Resolve - } - crate::RevisionProposalIntent::Retract => { - server_api::SubjektivMemoryRevisionIntent::Retract - } - crate::RevisionProposalIntent::Reopen => server_api::SubjektivMemoryRevisionIntent::Reopen, + crate::ChangeProposalIntent::Revise => server_api::SubjektivMemoryChangeIntent::Revise, + crate::ChangeProposalIntent::Resolve => server_api::SubjektivMemoryChangeIntent::Resolve, + crate::ChangeProposalIntent::Retract => server_api::SubjektivMemoryChangeIntent::Retract, + crate::ChangeProposalIntent::Reopen => server_api::SubjektivMemoryChangeIntent::Reopen, } } @@ -1189,6 +1187,7 @@ pub fn bounded_subjektiv_evidence_origin( &mut origin.worker_id, &mut origin.flow_selector, &mut origin.flow_definition_id, + &mut origin.flow_definition_fingerprint, ] { if let Some(value) = field.take() { *field = Some(bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES)); @@ -1294,10 +1293,10 @@ pub fn subjektiv_store_error(error: crate::SubjektivError) -> OperationError { crate::SubjektivError::SubjectScopeMismatch { .. } => { OperationError::PermissionDenied(format!("subject_scope_mismatch: {error}")) } - crate::SubjektivError::RevisionConflict { .. } + crate::SubjektivError::MemoryChangeConflict { .. } | crate::SubjektivError::SubjectBehaviorConflict { .. } | crate::SubjektivError::SurfaceGenerationConflict(_) => { - OperationError::Conflict(format!("revision_conflict: {error}")) + OperationError::Conflict(format!("change_conflict: {error}")) } crate::SubjektivError::CandidateResolved(_) | crate::SubjektivError::DecisionRequestConflict(_) => { diff --git a/crates/subjektiv/src/api/operation.rs b/crates/subjektiv/src/api/operation.rs index 253408ad7..857909349 100644 --- a/crates/subjektiv/src/api/operation.rs +++ b/crates/subjektiv/src/api/operation.rs @@ -28,14 +28,13 @@ pub fn execute( .map_err(subjektiv_store_error)?; Response::ResidentContext(server_api::SubjektivResidentContextOutput { behavior_md: resident.subject.behavior_md, - behavior_revision: resident.subject.behavior_revision, memory_surface: resident_summary_output(resident.surface), }) } Op::Query(input) => Response::Query(subjektiv_memory_query(store, subject_id, input)?), Op::Read(input) => Response::Read(subjektiv_memory_read(store, subject_id, input)?), - Op::ListRevisions(input) => { - Response::ListRevisions(subjektiv_memory_list_revisions(store, subject_id, input)?) + Op::ListChanges(input) => { + Response::ListChanges(subjektiv_memory_list_changes(store, subject_id, input)?) } Op::ValidateProposal(input) => { context.body()?; @@ -169,7 +168,7 @@ pub fn execute( if resident.availability == crate::SurfaceAvailability::Ready { response.current_snapshot_id = resident .snapshot - .filter(|s| s.built_from_store_revision == response.store_revision) + .filter(|s| s.built_from_memory_fingerprint == response.memory_fingerprint) .map(|s| s.id); } Response::SurfacePrepared(response) @@ -194,9 +193,9 @@ pub fn execute( memory_refs: point .memory_refs .into_iter() - .map(|r| crate::MemoryRevisionRef { + .map(|r| crate::MemoryChangeRef { memory_id: r.memory_id, - revision: r.revision, + change_id: r.change_id, }) .collect(), }) @@ -206,7 +205,7 @@ pub fn execute( .map_err(subjektiv_store_error)?; Response::SurfacePublished(server_api::SubjektivSurfacePublishResponse { snapshot_id: snapshot.id, - built_from_store_revision: snapshot.built_from_store_revision, + built_from_memory_fingerprint: snapshot.built_from_memory_fingerprint, empty: snapshot.body_md.is_empty(), }) } @@ -222,7 +221,7 @@ pub fn execute( ) .map_err(subjektiv_store_error)?; } - let revision = store + let memory_fingerprint = store .fail_surface_generation(subject_id, &input.generation_id, &input.reason_code) .map_err(subjektiv_store_error)?; let status = if let Some(job) = job { @@ -230,7 +229,7 @@ pub fn execute( .validate_job_surface_outcome( subject_id, &input.generation_id, - revision, + memory_fingerprint.clone(), "failed", None, Some(&input.reason_code), @@ -247,7 +246,7 @@ pub fn execute( "failed" }; Response::SurfaceFailed(server_api::SubjektivSurfaceFailureResponse { - store_revision: revision, + memory_fingerprint, status: status.into(), }) } diff --git a/crates/subjektiv/src/api/tests.rs b/crates/subjektiv/src/api/tests.rs index eab46e930..00c3429f8 100644 --- a/crates/subjektiv/src/api/tests.rs +++ b/crates/subjektiv/src/api/tests.rs @@ -23,10 +23,15 @@ fn query(cursor: Option) -> Op { }) } -fn read(id: &str, revision: Option, offset: Option, byte_offset: Option) -> Op { +fn read( + id: &str, + change_id: Option, + offset: Option, + byte_offset: Option, +) -> Op { Op::Read(server_api::SubjektivMemoryReadRequest { memory_id: id.into(), - revision, + change_id, offset, byte_offset, limit: Some(1), @@ -86,7 +91,7 @@ fn neutral_scope_reopen_and_context_attenuation() { } #[test] -fn shared_query_cursor_and_utf8_fixed_revision_projection() { +fn shared_query_cursor_and_utf8_fixed_change_projection() { let temp = tempfile::tempdir().unwrap(); let (_manager, store) = open(temp.path(), "local-random-scope"); let subject = store @@ -148,7 +153,12 @@ fn shared_query_cursor_and_utf8_fixed_revision_projection() { let Response::Read(page) = execute( &store, &context, - read(&first.id, Some(1), offset, byte_offset), + read( + &first.id, + Some(first.change_id.clone()), + offset, + byte_offset, + ), ) .unwrap() else { panic!() @@ -247,7 +257,7 @@ fn explicit_receipts_candidate_batch_and_decision_retries_share_logic() { entry_range: Some([1, 1]), kind: memory::schema::EvidenceKind::new("model_output"), origin: None, - excerpt: Some("Use immutable revisions".into()), + excerpt: Some("Use immutable changes".into()), summary: None, }], source_refs: vec![memory::schema::SourceEvidenceRef { @@ -319,7 +329,7 @@ fn explicit_receipts_candidate_batch_and_decision_retries_share_logic() { kind: CandidateKind::Lesson, state: server_api::SubjektivMemoryState::Active, claim: "Preserve correction history".into(), - body_md: "Use immutable revisions".into(), + body_md: "Use immutable changes".into(), why_useful: "Allows auditing".into(), staleness: None, derived_from: vec![], @@ -341,3 +351,104 @@ fn explicit_receipts_candidate_batch_and_decision_retries_share_logic() { ) .unwrap(); } + +#[test] +fn history_pages_pin_ancestry_and_reject_unknown_heads() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, store) = open(temp.path(), "scope"); + let subject = store + .create_subject(SubjectRole::new("companion").unwrap()) + .unwrap(); + let draft = |body: &str| { + MemoryDraft::active(CandidateKind::Lesson, "claim", body, "useful", "evidence") + }; + let first = store.create_memory(&subject.id, draft("first")).unwrap(); + let second = store + .revise_memory( + &subject.id, + &first.id, + first.change_id.clone(), + draft("second"), + ) + .unwrap(); + let input = |cursor| server_api::SubjektivMemoryListChangesRequest { + memory_id: first.id.clone(), + limit: Some(1), + cursor, + }; + let page = subjektiv_memory_list_changes(&store, &subject.id, input(None)).unwrap(); + assert_eq!(page.items[0].change_id, second.change_id); + assert!(page.has_more); + let third = store + .revise_memory( + &subject.id, + &first.id, + second.change_id.clone(), + draft("third"), + ) + .unwrap(); + let next = subjektiv_memory_list_changes(&store, &subject.id, input(page.next_cursor)).unwrap(); + assert_eq!(next.items[0].change_id, first.change_id); + assert_eq!(next.current_change_id, third.change_id); + assert!(!next.has_more); + let invalid = encode_subjektiv_cursor( + "changes", + &SubjektivChangeCursor { + subject_id: subject.id.clone(), + memory_id: first.id.clone(), + head_change_id: "not-a-history-entry".into(), + offset: 0, + }, + ) + .unwrap(); + assert!(subjektiv_memory_list_changes(&store, &subject.id, input(Some(invalid))).is_err()); + let historic = subjektiv_memory_read( + &store, + &subject.id, + server_api::SubjektivMemoryReadRequest { + memory_id: first.id, + change_id: Some(second.change_id.clone()), + offset: None, + byte_offset: None, + limit: None, + evidence_cursor: None, + }, + ) + .unwrap(); + assert_eq!(historic.body_md, "second"); + assert_eq!(historic.change_id, second.change_id); + assert_eq!(historic.current_change_id, third.change_id); +} + +#[test] +fn query_content_condition_ignores_behavior_and_detects_aba_memory_writes() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, store) = open(temp.path(), "scope"); + let subject = store + .create_subject(SubjectRole::new("companion").unwrap()) + .unwrap(); + let draft = |body: &str| { + MemoryDraft::active(CandidateKind::Lesson, "claim", body, "useful", "evidence") + }; + let first = store.create_memory(&subject.id, draft("A")).unwrap(); + store.create_memory(&subject.id, draft("other")).unwrap(); + let context = HostOperationContext::validated_body(&store, &subject.id, None).unwrap(); + let Response::Query(page) = execute(&store, &context, query(None)).unwrap() else { + panic!() + }; + let cursor = page.next_cursor.unwrap(); + store + .update_subject_behavior(&subject.id, "", "New behavior".into()) + .unwrap(); + execute(&store, &context, query(Some(cursor.clone()))).unwrap(); + let second = store + .revise_memory(&subject.id, &first.id, first.change_id.clone(), draft("B")) + .unwrap(); + store + .revise_memory(&subject.id, &first.id, second.change_id, draft("A")) + .unwrap(); + assert!(matches!( + execute(&store, &context, query(Some(cursor))), + Err(OperationError::Conflict(_)) + )); +} diff --git a/crates/subjektiv/src/job.rs b/crates/subjektiv/src/job.rs index 6b65b48ab..7b29c4e0e 100644 --- a/crates/subjektiv/src/job.rs +++ b/crates/subjektiv/src/job.rs @@ -65,7 +65,7 @@ struct ConsolidationResult { struct SurfaceResult { availability: String, generation_id: String, - store_revision: u64, + memory_fingerprint: String, #[serde(default)] snapshot_id: Option, #[serde(default)] @@ -105,7 +105,7 @@ pub fn validate_result( .validate_job_surface_outcome( subject_id, &result.surface.generation_id, - result.surface.store_revision, + result.surface.memory_fingerprint, &result.surface.availability, result.surface.snapshot_id.as_deref(), result.surface.reason_code.as_deref(), @@ -118,7 +118,7 @@ pub fn validate_result( } /// Enriches a previously verified result with compact durable dispositions. Full -/// reasons, affected revisions and provenance remain in the receipt store. This +/// reasons, affected changes and provenance remain in the receipt store. This /// deterministic projection neither applies candidates nor creates a new ledger. pub fn with_dispositions( store: &SubjektivStore, diff --git a/crates/subjektiv/src/job/tests.rs b/crates/subjektiv/src/job/tests.rs index a2e447686..5cc3dcf6d 100644 --- a/crates/subjektiv/src/job/tests.rs +++ b/crates/subjektiv/src/job/tests.rs @@ -22,7 +22,7 @@ fn escaped_ids_use_exact_t704_complete_result_budget() { .collect(); let result = serde_json::json!({ "subject_id": "\\\"".repeat(128), "candidate_ids": batch, "candidate_dispositions": dispositions, - "surface": { "availability": "failed", "generation_id": "g".repeat(100), "store_revision": u64::MAX, "reason_code": "\\\"".repeat(128) } + "surface": { "availability": "failed", "generation_id": "g".repeat(100), "memory_fingerprint": "f".repeat(64), "reason_code": "\\\"".repeat(128) } }); assert!(serde_json::to_vec(&result).unwrap().len() <= MAX_RESULT_BYTES as usize); assert!( @@ -119,7 +119,7 @@ fn result_checks_durable_dispositions_and_exact_job_surface_outcome() { .publish_surface_generation(&subject.id, &generation.id, vec![]) .unwrap(); let ready = serde_json::json!({"subject_id": subject.id, "candidate_ids": batch, - "surface": {"availability": "ready", "generation_id": generation.id, "store_revision": snapshot.built_from_store_revision, "snapshot_id": snapshot.id}}); + "surface": {"availability": "ready", "generation_id": generation.id, "memory_fingerprint": snapshot.built_from_memory_fingerprint, "snapshot_id": snapshot.id}}); assert!( validate_result(&store, &subject.id, &batch, "job", "attempt", &ready) .unwrap_err() @@ -170,11 +170,11 @@ fn result_checks_durable_dispositions_and_exact_job_surface_outcome() { let failure = store .prepare_job_surface_generation(&subject.id, Some(("job", "attempt"))) .unwrap(); - let revision = store + let memory_fingerprint = store .fail_surface_generation(&subject.id, &failure.id, "model_failure") .unwrap(); let failed = serde_json::json!({"subject_id": subject.id, "candidate_ids": batch, - "surface": {"availability": "failed", "generation_id": failure.id, "store_revision": revision, "reason_code": "model_failure"}}); + "surface": {"availability": "failed", "generation_id": failure.id, "memory_fingerprint": memory_fingerprint, "reason_code": "model_failure"}}); validate_result(&store, &subject.id, &batch, "job", "attempt", &failed).unwrap(); assert!(validate_result(&store, &subject.id, &batch, "job", "attempt", &ready).is_err()); let mut false_reason = failed; diff --git a/crates/subjektiv/src/legacy_migrations.rs b/crates/subjektiv/src/legacy_migrations.rs new file mode 100644 index 000000000..783179bff --- /dev/null +++ b/crates/subjektiv/src/legacy_migrations.rs @@ -0,0 +1,1227 @@ +//! Frozen migrations 1–6 and the one-time conversion of their data. +//! Do not expose these old fields through current domain operations. +use super::*; + +pub(super) fn create_schema(transaction: &Transaction<'_>) -> feature_storage::Result<()> { + transaction.execute_batch( + r#" +CREATE TABLE store_scope ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + workspace_id TEXT NOT NULL UNIQUE +); + +CREATE TABLE subjects ( + subject_id TEXT PRIMARY KEY, + role TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('active', 'retired')), + store_revision INTEGER NOT NULL CHECK (store_revision >= 0), + record_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE staging_records ( + subject_id TEXT NOT NULL, + candidate_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK ( + kind IN ( + 'preference', 'working_assumption', 'constraint', + 'decision', 'open_question', 'lesson' + ) + ), + record_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (subject_id, candidate_id), + FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT +); + +CREATE TABLE memory_records ( + subject_id TEXT NOT NULL, + memory_id TEXT NOT NULL, + current_revision INTEGER NOT NULL CHECK (current_revision > 0), + kind TEXT NOT NULL CHECK ( + kind IN ( + 'preference', 'working_assumption', 'constraint', + 'decision', 'open_question', 'lesson' + ) + ), + state TEXT NOT NULL CHECK (state IN ('active', 'resolved', 'retracted')), + record_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (subject_id, memory_id), + FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT, + FOREIGN KEY (subject_id, memory_id, current_revision) + REFERENCES memory_revisions(subject_id, memory_id, revision) + DEFERRABLE INITIALLY DEFERRED +); + +CREATE TABLE memory_revisions ( + subject_id TEXT NOT NULL, + memory_id TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision > 0), + kind TEXT NOT NULL CHECK ( + kind IN ( + 'preference', 'working_assumption', 'constraint', + 'decision', 'open_question', 'lesson' + ) + ), + state TEXT NOT NULL CHECK (state IN ('active', 'resolved', 'retracted')), + record_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (subject_id, memory_id, revision), + FOREIGN KEY (subject_id, memory_id) + REFERENCES memory_records(subject_id, memory_id) + ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED +); + +CREATE TABLE memory_revision_candidates ( + subject_id TEXT NOT NULL, + memory_id TEXT NOT NULL, + revision INTEGER NOT NULL, + candidate_id TEXT NOT NULL, + PRIMARY KEY (subject_id, memory_id, revision, candidate_id), + FOREIGN KEY (subject_id, memory_id, revision) + REFERENCES memory_revisions(subject_id, memory_id, revision) ON DELETE RESTRICT, + FOREIGN KEY (subject_id, candidate_id) + REFERENCES staging_records(subject_id, candidate_id) ON DELETE RESTRICT +); + +CREATE TABLE memory_revision_derivations ( + subject_id TEXT NOT NULL, + memory_id TEXT NOT NULL, + revision INTEGER NOT NULL, + source_memory_id TEXT NOT NULL, + source_revision INTEGER NOT NULL, + PRIMARY KEY ( + subject_id, memory_id, revision, source_memory_id, source_revision + ), + FOREIGN KEY (subject_id, memory_id, revision) + REFERENCES memory_revisions(subject_id, memory_id, revision) ON DELETE RESTRICT, + FOREIGN KEY (subject_id, source_memory_id, source_revision) + REFERENCES memory_revisions(subject_id, memory_id, revision) ON DELETE RESTRICT +); + +CREATE TABLE staging_resolutions ( + subject_id TEXT NOT NULL, + candidate_id TEXT NOT NULL, + resolution_id TEXT NOT NULL, + action TEXT NOT NULL CHECK ( + action IN ('applied', 'discarded', 'invalid', 'duplicate', 'already_covered') + ), + reason TEXT NOT NULL, + affected_refs_json TEXT NOT NULL, + staging_raw_json TEXT NOT NULL, + resolution_json TEXT NOT NULL, + resolved_at TEXT NOT NULL, + PRIMARY KEY (subject_id, candidate_id), + UNIQUE (resolution_id), + FOREIGN KEY (subject_id, candidate_id) + REFERENCES staging_records(subject_id, candidate_id) ON DELETE RESTRICT +); + +CREATE TABLE staging_resolution_targets ( + subject_id TEXT NOT NULL, + candidate_id TEXT NOT NULL, + memory_id TEXT NOT NULL, + revision INTEGER NOT NULL, + PRIMARY KEY (subject_id, candidate_id, memory_id, revision), + FOREIGN KEY (subject_id, candidate_id) + REFERENCES staging_resolutions(subject_id, candidate_id) ON DELETE RESTRICT, + FOREIGN KEY (subject_id, memory_id, revision) + REFERENCES memory_revisions(subject_id, memory_id, revision) ON DELETE RESTRICT +); + +CREATE TABLE surface_snapshots ( + subject_id TEXT NOT NULL, + snapshot_id TEXT NOT NULL, + built_from_store_revision INTEGER NOT NULL CHECK (built_from_store_revision >= 0), + snapshot_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (subject_id, snapshot_id), + FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT +); + +CREATE TABLE surface_snapshot_refs ( + subject_id TEXT NOT NULL, + snapshot_id TEXT NOT NULL, + memory_id TEXT NOT NULL, + revision INTEGER NOT NULL, + PRIMARY KEY (subject_id, snapshot_id, memory_id), + FOREIGN KEY (subject_id, snapshot_id) + REFERENCES surface_snapshots(subject_id, snapshot_id) ON DELETE RESTRICT, + FOREIGN KEY (subject_id, memory_id, revision) + REFERENCES memory_revisions(subject_id, memory_id, revision) ON DELETE RESTRICT +); + +CREATE TABLE memory_revision_seals ( + subject_id TEXT NOT NULL, + memory_id TEXT NOT NULL, + revision INTEGER NOT NULL, + PRIMARY KEY (subject_id, memory_id, revision), + FOREIGN KEY (subject_id, memory_id, revision) + REFERENCES memory_revisions(subject_id, memory_id, revision) ON DELETE RESTRICT +); + +CREATE TABLE staging_resolution_seals ( + subject_id TEXT NOT NULL, + candidate_id TEXT NOT NULL, + PRIMARY KEY (subject_id, candidate_id), + FOREIGN KEY (subject_id, candidate_id) + REFERENCES staging_resolutions(subject_id, candidate_id) ON DELETE RESTRICT +); + +CREATE TABLE surface_snapshot_seals ( + subject_id TEXT NOT NULL, + snapshot_id TEXT NOT NULL, + PRIMARY KEY (subject_id, snapshot_id), + FOREIGN KEY (subject_id, snapshot_id) + REFERENCES surface_snapshots(subject_id, snapshot_id) ON DELETE RESTRICT +); + +CREATE TRIGGER memory_revision_candidates_no_late_insert +BEFORE INSERT ON memory_revision_candidates +WHEN EXISTS ( + SELECT 1 FROM memory_revision_seals + WHERE subject_id = NEW.subject_id + AND memory_id = NEW.memory_id + AND revision = NEW.revision +) BEGIN + SELECT RAISE(ABORT, 'subjektiv revision evidence is sealed'); +END; +CREATE TRIGGER memory_revision_derivations_no_late_insert +BEFORE INSERT ON memory_revision_derivations +WHEN EXISTS ( + SELECT 1 FROM memory_revision_seals + WHERE subject_id = NEW.subject_id + AND memory_id = NEW.memory_id + AND revision = NEW.revision +) BEGIN + SELECT RAISE(ABORT, 'subjektiv derivations are sealed'); +END; +CREATE TRIGGER staging_resolution_targets_no_late_insert +BEFORE INSERT ON staging_resolution_targets +WHEN EXISTS ( + SELECT 1 FROM staging_resolution_seals + WHERE subject_id = NEW.subject_id + AND candidate_id = NEW.candidate_id +) BEGIN + SELECT RAISE(ABORT, 'subjektiv resolution targets are sealed'); +END; +CREATE TRIGGER surface_snapshot_refs_no_late_insert +BEFORE INSERT ON surface_snapshot_refs +WHEN EXISTS ( + SELECT 1 FROM surface_snapshot_seals + WHERE subject_id = NEW.subject_id + AND snapshot_id = NEW.snapshot_id +) BEGIN + SELECT RAISE(ABORT, 'subjektiv surface references are sealed'); +END; + +CREATE TRIGGER memory_revision_seals_no_update +BEFORE UPDATE ON memory_revision_seals BEGIN + SELECT RAISE(ABORT, 'subjektiv revision seals are immutable'); +END; +CREATE TRIGGER memory_revision_seals_no_delete +BEFORE DELETE ON memory_revision_seals BEGIN + SELECT RAISE(ABORT, 'subjektiv revision seals are retained'); +END; +CREATE TRIGGER staging_resolution_seals_no_update +BEFORE UPDATE ON staging_resolution_seals BEGIN + SELECT RAISE(ABORT, 'subjektiv resolution seals are immutable'); +END; +CREATE TRIGGER staging_resolution_seals_no_delete +BEFORE DELETE ON staging_resolution_seals BEGIN + SELECT RAISE(ABORT, 'subjektiv resolution seals are retained'); +END; +CREATE TRIGGER surface_snapshot_seals_no_update +BEFORE UPDATE ON surface_snapshot_seals BEGIN + SELECT RAISE(ABORT, 'subjektiv snapshot seals are immutable'); +END; +CREATE TRIGGER surface_snapshot_seals_no_delete +BEFORE DELETE ON surface_snapshot_seals BEGIN + SELECT RAISE(ABORT, 'subjektiv snapshot seals are retained'); +END; + +CREATE TRIGGER memory_revisions_no_update +BEFORE UPDATE ON memory_revisions BEGIN + SELECT RAISE(ABORT, 'subjektiv memory revisions are immutable'); +END; +CREATE TRIGGER memory_revisions_no_delete +BEFORE DELETE ON memory_revisions BEGIN + SELECT RAISE(ABORT, 'subjektiv memory revisions are retained'); +END; +CREATE TRIGGER memory_revision_candidates_no_update +BEFORE UPDATE ON memory_revision_candidates BEGIN + SELECT RAISE(ABORT, 'subjektiv revision evidence is immutable'); +END; +CREATE TRIGGER memory_revision_candidates_no_delete +BEFORE DELETE ON memory_revision_candidates BEGIN + SELECT RAISE(ABORT, 'subjektiv revision evidence is retained'); +END; +CREATE TRIGGER memory_revision_derivations_no_update +BEFORE UPDATE ON memory_revision_derivations BEGIN + SELECT RAISE(ABORT, 'subjektiv derivations are immutable'); +END; +CREATE TRIGGER memory_revision_derivations_no_delete +BEFORE DELETE ON memory_revision_derivations BEGIN + SELECT RAISE(ABORT, 'subjektiv derivations are retained'); +END; +CREATE TRIGGER staging_records_no_update +BEFORE UPDATE ON staging_records BEGIN + SELECT RAISE(ABORT, 'subjektiv staging records are immutable'); +END; +CREATE TRIGGER staging_records_no_delete +BEFORE DELETE ON staging_records BEGIN + SELECT RAISE(ABORT, 'subjektiv staging records are retained'); +END; +CREATE TRIGGER staging_resolutions_no_update +BEFORE UPDATE ON staging_resolutions BEGIN + SELECT RAISE(ABORT, 'subjektiv staging resolutions are immutable'); +END; +CREATE TRIGGER staging_resolutions_no_delete +BEFORE DELETE ON staging_resolutions BEGIN + SELECT RAISE(ABORT, 'subjektiv staging resolutions are retained'); +END; +CREATE TRIGGER staging_resolution_targets_no_update +BEFORE UPDATE ON staging_resolution_targets BEGIN + SELECT RAISE(ABORT, 'subjektiv resolution targets are immutable'); +END; +CREATE TRIGGER staging_resolution_targets_no_delete +BEFORE DELETE ON staging_resolution_targets BEGIN + SELECT RAISE(ABORT, 'subjektiv resolution targets are retained'); +END; +CREATE TRIGGER surface_snapshots_no_update +BEFORE UPDATE ON surface_snapshots BEGIN + SELECT RAISE(ABORT, 'subjektiv surface snapshots are immutable'); +END; +CREATE TRIGGER surface_snapshots_no_delete +BEFORE DELETE ON surface_snapshots BEGIN + SELECT RAISE(ABORT, 'subjektiv surface snapshots are retained'); +END; +CREATE TRIGGER surface_snapshot_refs_no_update +BEFORE UPDATE ON surface_snapshot_refs BEGIN + SELECT RAISE(ABORT, 'subjektiv surface references are immutable'); +END; +CREATE TRIGGER surface_snapshot_refs_no_delete +BEFORE DELETE ON surface_snapshot_refs BEGIN + SELECT RAISE(ABORT, 'subjektiv surface references are retained'); +END; +"#, + )?; + Ok(()) +} + +pub(super) fn add_subject_session_attribution( + transaction: &Transaction<'_>, +) -> feature_storage::Result<()> { + transaction.execute_batch( + r#" +CREATE TABLE subject_session_attributions ( + session_id TEXT PRIMARY KEY, + subject_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + record_json TEXT NOT NULL, + attributed_at TEXT NOT NULL, + FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT +); + +CREATE INDEX subject_session_attributions_by_subject +ON subject_session_attributions(subject_id, attributed_at, session_id); + +CREATE TRIGGER subject_session_attributions_no_update +BEFORE UPDATE ON subject_session_attributions BEGIN + SELECT RAISE(ABORT, 'subjektiv session attribution is immutable'); +END; +CREATE TRIGGER subject_session_attributions_no_delete +BEFORE DELETE ON subject_session_attributions BEGIN + SELECT RAISE(ABORT, 'subjektiv session attribution is retained'); +END; +"#, + )?; + Ok(()) +} + +pub(super) fn add_candidate_decision_receipts( + transaction: &Transaction<'_>, +) -> feature_storage::Result<()> { + transaction.execute_batch( + r#" +CREATE TABLE candidate_decision_receipts ( + subject_id TEXT NOT NULL, + request_id TEXT NOT NULL, + candidate_id TEXT NOT NULL, + request_json TEXT NOT NULL, + receipt_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (subject_id, request_id), + UNIQUE (subject_id, candidate_id), + FOREIGN KEY (subject_id, candidate_id) + REFERENCES staging_resolutions(subject_id, candidate_id) ON DELETE RESTRICT +); + +CREATE TRIGGER candidate_decision_receipts_no_update +BEFORE UPDATE ON candidate_decision_receipts BEGIN + SELECT RAISE(ABORT, 'subjektiv candidate decision receipts are immutable'); +END; +CREATE TRIGGER candidate_decision_receipts_no_delete +BEFORE DELETE ON candidate_decision_receipts BEGIN + SELECT RAISE(ABORT, 'subjektiv candidate decision receipts are retained'); +END; +"#, + )?; + Ok(()) +} + +pub(super) fn add_surface_generation_state( + transaction: &Transaction<'_>, +) -> feature_storage::Result<()> { + transaction.execute_batch( + r#" +CREATE TABLE surface_generation_state ( + subject_id TEXT PRIMARY KEY, + store_revision INTEGER NOT NULL CHECK (store_revision >= 0), + status TEXT NOT NULL CHECK (status IN ('dirty', 'failed', 'ready')), + snapshot_id TEXT, + reason_code TEXT, + updated_at TEXT NOT NULL, + FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT, + FOREIGN KEY (subject_id, snapshot_id) + REFERENCES surface_snapshots(subject_id, snapshot_id) ON DELETE RESTRICT +); + +INSERT INTO surface_generation_state ( + subject_id, store_revision, status, snapshot_id, reason_code, updated_at +) +SELECT subjects.subject_id, + subjects.store_revision, + 'dirty', + NULL, + 'legacy_snapshot_requires_regeneration', + subjects.updated_at +FROM subjects +WHERE EXISTS ( + SELECT 1 FROM surface_snapshots any_snapshot + WHERE any_snapshot.subject_id = subjects.subject_id +); + +CREATE TABLE surface_generation_runs ( + subject_id TEXT NOT NULL, + generation_id TEXT NOT NULL, + store_revision INTEGER NOT NULL CHECK (store_revision >= 0), + active_memory_count INTEGER NOT NULL CHECK (active_memory_count >= 0), + materials_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (subject_id, generation_id), + FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT +); + +CREATE INDEX surface_generation_runs_by_revision +ON surface_generation_runs(subject_id, store_revision, created_at, generation_id); + +CREATE TRIGGER surface_generation_runs_no_update +BEFORE UPDATE ON surface_generation_runs BEGIN + SELECT RAISE(ABORT, 'subjektiv surface generation inputs are immutable'); +END; +CREATE TRIGGER surface_generation_runs_no_delete +BEFORE DELETE ON surface_generation_runs BEGIN + SELECT RAISE(ABORT, 'subjektiv surface generation inputs are retained'); +END; +"#, + )?; + Ok(()) +} + +pub(super) fn add_subject_behavior(transaction: &Transaction<'_>) -> feature_storage::Result<()> { + transaction.execute_batch( + r#" +ALTER TABLE subjects ADD COLUMN behavior_md TEXT NOT NULL DEFAULT ''; +ALTER TABLE subjects ADD COLUMN behavior_revision INTEGER NOT NULL DEFAULT 0 + CHECK (behavior_revision >= 0); +"#, + )?; + Ok(()) +} + +pub(super) fn add_surface_job_provenance( + transaction: &Transaction<'_>, +) -> feature_storage::Result<()> { + transaction.execute_batch("ALTER TABLE surface_generation_runs ADD COLUMN job_id TEXT; ALTER TABLE surface_generation_runs ADD COLUMN attempt_id TEXT;")?; + Ok(()) +} + +/// Rebuilds the domain schema atomically, giving every retained historical +/// Memory change a fresh opaque identity and an explicit predecessor. Original +/// serialized records are separately archived verbatim for audit/backup fidelity. +pub(super) fn remove_state_numbers(tx: &Transaction<'_>) -> feature_storage::Result<()> { + use rusqlite::types::Value as SqlValue; + use std::collections::HashMap; + type Key = (String, String, i64); + let mut changes = HashMap::::new(); + { + let mut statement = + tx.prepare("SELECT subject_id, memory_id, revision FROM memory_revisions")?; + for row in statement.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + )) + })? { + changes.insert(row?, issued_id("memory-change")); + } + } + let mut inputs = HashMap::<(String, i64), String>::new(); + // Old counters cannot establish a complete historical input set. Retain + // runs/snapshots for audit, but never advertise them as fresh after conversion. + let mut legacy_input = |subject: &str, number: i64| -> String { + inputs + .entry((subject.into(), number)) + .or_insert_with(|| issued_id("archived-input")) + .clone() + }; + const TABLES: &[&str] = &[ + "store_scope", + "subjects", + "staging_records", + "memory_records", + "memory_revisions", + "memory_revision_candidates", + "memory_revision_derivations", + "staging_resolutions", + "staging_resolution_targets", + "surface_snapshots", + "surface_snapshot_refs", + "memory_revision_seals", + "staging_resolution_seals", + "surface_snapshot_seals", + "subject_session_attributions", + "candidate_decision_receipts", + "surface_generation_state", + "surface_generation_runs", + ]; + let mut tables = Vec::new(); + for table in TABLES { + let mut statement = tx.prepare(&format!("SELECT * FROM {table}"))?; + let columns = statement + .column_names() + .into_iter() + .map(str::to_owned) + .collect::>(); + let rows = statement + .query_map([], |row| { + (0..columns.len()) + .map(|i| row.get::<_, SqlValue>(i)) + .collect::, _>>() + })? + .collect::, _>>()?; + tables.push((*table, columns, rows)); + } + let objects = { + let mut statement = tx.prepare("SELECT type, name FROM sqlite_master WHERE type IN ('trigger','index') AND sql IS NOT NULL")?; + statement + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })? + .collect::, _>>()? + }; + for (kind, name) in objects { + // Feature-storage owns its migration metadata; never rebuild its indexes. + if !name.starts_with("__yoi_") { + tx.execute_batch(&format!("DROP {kind} \"{name}\";"))?; + } + } + // Deferring all constraints allows the cyclic current/history pair to be + // rebuilt in this transaction. Verify the full replacement before commit. + tx.execute_batch("PRAGMA defer_foreign_keys = ON;")?; + for table in TABLES.iter().rev() { + tx.execute_batch(&format!("DROP TABLE {table};"))?; + } + schema::create(tx)?; + tx.execute_batch("CREATE TABLE legacy_record_archive (source_table TEXT NOT NULL, row_position INTEGER NOT NULL, row_json TEXT NOT NULL, PRIMARY KEY(source_table, row_position));")?; + for (table, columns, rows) in tables { + for (position, values) in rows.into_iter().enumerate() { + let archive = columns + .iter() + .zip(&values) + .map(|(column, value)| { + let value = match value { + SqlValue::Null => serde_json::Value::Null, + SqlValue::Integer(value) => serde_json::json!(value), + SqlValue::Real(value) => serde_json::json!(value), + SqlValue::Text(value) => serde_json::json!(value), + SqlValue::Blob(value) => serde_json::json!(value), + }; + (column.clone(), value) + }) + .collect::>(); + tx.execute( + "INSERT INTO legacy_record_archive VALUES (?1, ?2, ?3)", + params![ + table, + position as i64, + serde_json::Value::Object(archive).to_string() + ], + )?; + let text = |column: &str| -> String { + columns + .iter() + .position(|name| name == column) + .and_then(|i| match &values[i] { + SqlValue::Text(value) => Some(value.clone()), + _ => None, + }) + .unwrap_or_default() + }; + let subject = text("subject_id"); + let memory = text("memory_id"); + let mut output_columns = Vec::new(); + let mut output_values = Vec::new(); + for (column, value) in columns.iter().zip(&values) { + if table == "subjects" + && matches!(column.as_str(), "store_revision" | "behavior_revision") + { + continue; + } + let (name, value) = match column.as_str() { + "revision" | "current_revision" | "source_revision" => { + let number = match value { + SqlValue::Integer(value) => *value, + _ => { + return Err(FeatureStorageError::operation( + "invalid old Memory identity", + )); + } + }; + let target = if column == "source_revision" { + text("source_memory_id") + } else { + memory.clone() + }; + let id = changes + .get(&(subject.clone(), target, number)) + .ok_or_else(|| { + FeatureStorageError::operation("dangling old Memory reference") + })? + .clone(); + let name = match column.as_str() { + "current_revision" => "current_change_id", + "source_revision" => "source_change_id", + _ => "change_id", + }; + (name.to_string(), SqlValue::Text(id)) + } + "store_revision" | "built_from_store_revision" => { + let number = match value { + SqlValue::Integer(value) => *value, + _ => { + return Err(FeatureStorageError::operation( + "invalid old input identity", + )); + } + }; + let name = if column == "store_revision" { + "memory_fingerprint" + } else { + "built_from_memory_fingerprint" + }; + ( + name.to_string(), + SqlValue::Text(legacy_input(&subject, number)), + ) + } + "record_json" | "affected_refs_json" | "staging_raw_json" + | "resolution_json" | "snapshot_json" | "request_json" | "receipt_json" + | "materials_json" => { + let raw = match value { + SqlValue::Text(value) => value, + _ => { + return Err(FeatureStorageError::operation( + "invalid old JSON record", + )); + } + }; + let mut json: serde_json::Value = serde_json::from_str(raw) + .map_err(|e| FeatureStorageError::operation(e.to_string()))?; + convert_json(&mut json, &subject, &memory, &changes, &mut legacy_input)?; + if table == "memory_records" || table == "memory_revisions" { + let old_number = match &values[columns + .iter() + .position(|name| { + name == if table == "memory_records" { + "current_revision" + } else { + "revision" + } + }) + .unwrap()] + { + SqlValue::Integer(value) => *value, + _ => unreachable!(), + }; + let previous = if old_number == 1 { + None + } else { + Some( + changes + .get(&(subject.clone(), memory.clone(), old_number - 1)) + .ok_or_else(|| { + FeatureStorageError::operation( + "broken old Memory history", + ) + })? + .clone(), + ) + }; + json["previous_change_id"] = serde_json::json!(previous); + } + // Preserve canonical request bytes for exact decision replay. + let raw = if column == "request_json" { + let request: CandidateDecisionRequest = serde_json::from_value(json) + .map_err(|e| FeatureStorageError::operation(e.to_string()))?; + serde_json::to_string(&request) + .map_err(|e| FeatureStorageError::operation(e.to_string()))? + } else { + json.to_string() + }; + (column.clone(), SqlValue::Text(raw)) + } + "status" if table == "surface_generation_state" => { + (column.clone(), SqlValue::Text("dirty".into())) + } + "snapshot_id" if table == "surface_generation_state" => { + (column.clone(), SqlValue::Null) + } + "reason_code" if table == "surface_generation_state" => ( + column.clone(), + SqlValue::Text("legacy_inputs_require_regeneration".into()), + ), + _ => (column.clone(), value.clone()), + }; + output_columns.push(name); + output_values.push(value); + } + if table == "memory_revisions" { + let number = + match &values[columns.iter().position(|name| name == "revision").unwrap()] { + SqlValue::Integer(value) => *value, + _ => unreachable!(), + }; + output_columns.push("previous_change_id".into()); + output_values.push(if number == 1 { + SqlValue::Null + } else { + SqlValue::Text(changes[&(subject.clone(), memory.clone(), number - 1)].clone()) + }); + } + let target_table = table + .replace("memory_revisions", "memory_changes") + .replace("memory_revision", "memory_change"); + let placeholders = (1..=output_values.len()) + .map(|i| format!("?{i}")) + .collect::>() + .join(","); + tx.execute( + &format!( + "INSERT INTO {target_table} ({}) VALUES ({placeholders})", + output_columns.join(",") + ), + rusqlite::params_from_iter(output_values), + )?; + } + } + tx.execute_batch("CREATE TRIGGER legacy_record_archive_no_update BEFORE UPDATE ON legacy_record_archive BEGIN SELECT RAISE(ABORT, 'legacy records are immutable'); END; CREATE TRIGGER legacy_record_archive_no_delete BEFORE DELETE ON legacy_record_archive BEGIN SELECT RAISE(ABORT, 'legacy records are retained'); END;")?; + let violation = tx + .prepare("PRAGMA foreign_key_check")? + .query([])? + .next()? + .is_some(); + if violation { + return Err(FeatureStorageError::operation( + "converted Memory references violate foreign keys", + )); + } + Ok(()) +} + +fn convert_json( + value: &mut serde_json::Value, + subject: &str, + memory: &str, + changes: &std::collections::HashMap<(String, String, i64), String>, + legacy_input: &mut impl FnMut(&str, i64) -> String, +) -> feature_storage::Result<()> { + match value { + serde_json::Value::Array(values) => { + for value in values { + convert_json(value, subject, memory, changes, legacy_input)?; + } + } + serde_json::Value::Object(object) => { + let subject = object + .get("subject_id") + .and_then(|v| v.as_str()) + .unwrap_or(subject) + .to_string(); + let memory = object + .get("memory_id") + .or_else(|| { + if object.contains_key("revision") && object.contains_key("body_md") { + object.get("id") + } else { + None + } + }) + .and_then(|v| v.as_str()) + .unwrap_or(memory) + .to_string(); + object.remove("behavior_revision"); + // Evidence provenance keeps the exact old origin in the archive. A + // counter does not reveal its definition content, so do not invent it. + object.remove("flow_definition_revision"); + for (old, new) in [ + ("revision", "change_id"), + ("expected_revision", "expected_change_id"), + ] { + if let Some(number) = object.remove(old) { + let number = number.as_i64().ok_or_else(|| { + FeatureStorageError::operation("invalid old Memory reference") + })?; + let id = changes + .get(&(subject.clone(), memory.clone(), number)) + .ok_or_else(|| { + FeatureStorageError::operation( + "old JSON has a dangling Memory reference", + ) + })? + .clone(); + object.insert(new.into(), serde_json::json!(id)); + if old == "revision" + && object.contains_key("body_md") + && object.contains_key("state") + { + let previous = if number == 1 { + None + } else { + Some( + changes + .get(&(subject.clone(), memory.clone(), number - 1)) + .ok_or_else(|| { + FeatureStorageError::operation("broken old JSON history") + })? + .clone(), + ) + }; + object.insert("previous_change_id".into(), serde_json::json!(previous)); + } + } + } + if let Some(proposal) = object.remove("revision_proposal") { + object.insert("change_proposal".into(), proposal); + } + for (old, new) in [ + ("store_revision", "memory_fingerprint"), + ("built_from_store_revision", "built_from_memory_fingerprint"), + ] { + if let Some(number) = object.remove(old) { + if object.contains_key("role") { + continue; + } + let number = number.as_i64().ok_or_else(|| { + FeatureStorageError::operation("invalid old input reference") + })?; + object.insert( + new.into(), + serde_json::json!(legacy_input(&subject, number)), + ); + } + } + for value in object.values_mut() { + convert_json(value, &subject, &memory, changes, legacy_input)?; + } + } + _ => {} + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tests::{attribution, candidate, open_store, role}; + use feature_storage::FeatureStorage; + #[derive(Serialize)] + struct LegacySnapshot { + schema_version: u32, + id: String, + subject_id: String, + body_md: String, + memory_refs: Vec, + built_from_store_revision: u64, + created_at: String, + } + static V1_MIGRATIONS: &[FeatureMigration] = &[FeatureMigration::new( + 1, + "create subjektiv subject memory store", + create_schema, + )]; + static V3_MIGRATIONS: &[FeatureMigration] = &[ + FeatureMigration::new(1, "create subjektiv subject memory store", create_schema), + FeatureMigration::new( + 2, + "add immutable subject session attribution", + add_subject_session_attribution, + ), + FeatureMigration::new( + 3, + "add idempotent atomic candidate decision receipts", + add_candidate_decision_receipts, + ), + ]; + + #[test] + fn v1_store_migrates_before_attributed_staging() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("storage"); + let subject_id = { + let manager = FeatureStorage::new(&root); + let workspace = manager.workspace("workspace-a").unwrap(); + let registration = workspace + .register(FeatureRegistration::new( + SUBJEKTIV_FEATURE_ID, + V1_MIGRATIONS, + )) + .unwrap(); + let store = SubjektivStore::open(&workspace, ®istration).unwrap(); + let timestamp = now(); + let subject_id = issued_id("subject"); + let mut raw = serde_json::json!({ + "schema_version": SUBJEKTIV_SCHEMA_VERSION, + "id": subject_id, + "role": role(), + "state": SubjectState::Active, + "store_revision": 0, + "created_at": timestamp.clone(), + "updated_at": timestamp.clone(), + }); + let raw = serde_json::to_string(&raw.take()).unwrap(); + store + .database + .try_transaction(|transaction| -> Result<()> { + transaction.execute( + "INSERT INTO subjects ( + subject_id, role, state, store_revision, + record_json, created_at, updated_at + ) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)", + params![subject_id, role().as_str(), raw, timestamp, timestamp], + )?; + Ok(()) + }) + .unwrap(); + subject_id + }; + + let (_manager, _workspace, store) = open_store(&root, "workspace-a"); + let migrated_subject = store.subject(&subject_id).unwrap().unwrap(); + assert!(migrated_subject.behavior_md.is_empty()); + assert!(migrated_subject.behavior_md.is_empty()); + let lifecycle_attribution = attribution( + &subject_id, + "runtime-1", + "worker-1", + "session-1", + "2026-09-28T10:00:00.000Z", + ); + store + .record_session_attribution(lifecycle_attribution.clone()) + .unwrap(); + let (staged, stored_attribution) = store + .stage_candidate_with_attribution( + candidate(&subject_id, "candidate-after-migration", "workspace-a"), + lifecycle_attribution, + ) + .unwrap(); + + assert_eq!(staged.subject_id, subject_id); + assert_eq!(stored_attribution.session_id, "session-1"); + assert_eq!( + store.session_attribution("session-1").unwrap().unwrap(), + stored_attribution + ); + } + + #[test] + fn legacy_surface_snapshot_migrates_as_stale_until_regenerated() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("storage"); + let (subject_id, snapshot_id) = { + let manager = FeatureStorage::new(&root); + let workspace = manager.workspace("workspace-a").unwrap(); + let registration = workspace + .register(FeatureRegistration::new( + SUBJEKTIV_FEATURE_ID, + V3_MIGRATIONS, + )) + .unwrap(); + let store = SubjektivStore::open(&workspace, ®istration).unwrap(); + let subject_id = issued_id("subject"); + let timestamp = now(); + let subject_json = serde_json::to_string(&serde_json::json!({ + "schema_version": SUBJEKTIV_SCHEMA_VERSION, + "id": subject_id, + "role": role(), + "state": SubjectState::Active, + "store_revision": 0, + "created_at": timestamp, + "updated_at": timestamp, + })) + .unwrap(); + let snapshot = LegacySnapshot { + schema_version: SUBJEKTIV_SCHEMA_VERSION, + id: "legacy-surface".into(), + subject_id: subject_id.clone(), + body_md: "Legacy summary without generation-policy evidence.".into(), + memory_refs: Vec::new(), + built_from_store_revision: 0, + created_at: now(), + }; + store + .database + .try_transaction(|transaction| -> Result<()> { + transaction.execute( + "INSERT INTO subjects ( + subject_id, role, state, store_revision, + record_json, created_at, updated_at + ) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)", + params![ + subject_id, + role().as_str(), + subject_json, + timestamp, + timestamp + ], + )?; + transaction.execute( + "INSERT INTO surface_snapshots ( + subject_id, snapshot_id, built_from_store_revision, + snapshot_json, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + subject_id, + snapshot.id, + to_i64(snapshot.built_from_store_revision)?, + serde_json::to_string(&snapshot)?, + snapshot.created_at + ], + )?; + transaction.execute( + "INSERT INTO surface_snapshot_seals (subject_id, snapshot_id) + VALUES (?1, ?2)", + params![subject_id, snapshot.id], + )?; + Ok(()) + }) + .unwrap(); + (subject_id, snapshot.id) + }; + + let (_manager, _workspace, store) = open_store(&root, "workspace-a"); + let resident = store.resident_surface(&subject_id).unwrap(); + assert_eq!(resident.availability, SurfaceAvailability::Stale); + assert!(resident.snapshot.is_none()); + assert!( + store + .surface_snapshot(&subject_id, &snapshot_id) + .unwrap() + .is_some(), + "migration retains the legacy snapshot as history without injecting it" + ); + + let generation = store.prepare_surface_generation(&subject_id).unwrap(); + let regenerated = store + .publish_surface_generation(&subject_id, &generation.id, Vec::new()) + .unwrap(); + assert_ne!(regenerated.id, snapshot_id); + let ready = store.resident_surface(&subject_id).unwrap(); + assert_eq!(ready.availability, SurfaceAvailability::Ready); + assert_eq!(ready.snapshot.unwrap().id, regenerated.id); + assert!( + store + .surface_snapshot(&subject_id, &snapshot_id) + .unwrap() + .is_some(), + "regeneration appends instead of mutating legacy history" + ); + let retried = store + .publish_surface_generation(&subject_id, &generation.id, Vec::new()) + .unwrap(); + assert_eq!(retried.id, regenerated.id); + } +} + +#[cfg(test)] +mod conversion_tests { + use super::*; + use crate::tests::{open_store, role}; + use feature_storage::FeatureStorage; + use serde_json::json; + static V6: &[FeatureMigration] = &[ + FeatureMigration::new(1, "create subjektiv subject memory store", create_schema), + FeatureMigration::new( + 2, + "add immutable subject session attribution", + add_subject_session_attribution, + ), + FeatureMigration::new( + 3, + "add idempotent atomic candidate decision receipts", + add_candidate_decision_receipts, + ), + FeatureMigration::new( + 4, + "add bounded surface generation state and runs", + add_surface_generation_state, + ), + FeatureMigration::new(5, "add user-managed subject behavior", add_subject_behavior), + FeatureMigration::new( + 6, + "bind surface generation provenance to Job attempts", + add_surface_job_provenance, + ), + ]; + + #[test] + fn populated_v6_conversion_preserves_history_edges_receipts_and_raw_evidence() { + let temp = tempfile::tempdir().unwrap(); + let at = "2026-10-01T10:00:00.000Z"; + let subject = json!({"schema_version":1,"id":"subject","role":role(),"behavior_md":"Preserve me.","behavior_revision":7,"state":"active","store_revision":2,"created_at":at,"updated_at":at}); + let candidate = json!({"schema_version":2,"id":"candidate","subject_id":"subject","extract_run_id":"extract","source":{"segment_id":"segment","range":[1,1]},"kind":"constraint","claim":"original","why_useful":"useful","created_at":at,"evidence":[{"id":"e","kind":"message","origin":{"kind":"flow_instruction","flow_definition_id":"flow","flow_definition_revision":9}}],"source_refs":[]}); + let first = json!({"schema_version":1,"id":"memory","subject_id":"subject","revision":1,"kind":"constraint","state":"active","claim":"original","body_md":"original body","why_useful":"useful","source_candidate_ids":["candidate"],"derived_from":[],"change_reason":"accepted","created_at":at,"updated_at":at}); + let mut second = first.clone(); + second["revision"] = json!(2); + second["claim"] = json!("corrected"); + second["body_md"] = json!("corrected body"); + second["source_candidate_ids"] = json!([]); + second["derived_from"] = json!([{"memory_id":"memory","revision":1}]); + let affected = json!([{"memory_id":"memory","revision":1}]); + let resolution = json!({"schema_version":1,"id":"resolution","subject_id":"subject","candidate_id":"candidate","action":"applied","reason":"accepted","affected_memory":affected,"candidate":candidate,"resolved_at":at}); + let request = json!({"request_id":"decision","candidate_id":"candidate","reason":"accepted","decision":{"kind":"apply","target":{"kind":"create"},"draft":{"kind":"constraint","state":"active","claim":"original","body_md":"original body","why_useful":"useful","staleness":null,"source_candidate_ids":[],"derived_from":[],"change_reason":"accepted"}}}); + let receipt = json!({"request_id":"decision","candidate_id":"candidate","resolution":resolution,"memory":first,"operation":"create","store_revision":1,"surface_dirty":true}); + let snapshot = json!({"schema_version":1,"id":"snapshot","subject_id":"subject","body_md":"summary","memory_refs":[{"memory_id":"memory","revision":2}],"built_from_store_revision":2,"created_at":at}); + let materials = json!([{"memory_id":"memory","revision":2,"kind":"constraint","body_md":"corrected body","why_useful":"useful","staleness":null}]); + let original_candidate_bytes = format!(" {}\n", candidate); + { + let manager = FeatureStorage::new(temp.path()); + let scope = manager.scope("scope").unwrap(); + let registration = scope + .register(FeatureRegistration::new(SUBJEKTIV_FEATURE_ID, V6)) + .unwrap(); + let store = SubjektivStore::open(&scope, ®istration).unwrap(); + store.database.try_transaction::<_, SubjektivError>(|tx| { + tx.execute("INSERT INTO subjects (subject_id,role,state,store_revision,record_json,created_at,updated_at,behavior_md,behavior_revision) VALUES ('subject',?1,'active',2,?2,?3,?3,'Preserve me.',7)",params![role().as_str(),subject.to_string(),at])?; + tx.execute("INSERT INTO staging_records VALUES ('subject','candidate','constraint',?1,?2)",params![original_candidate_bytes,at])?; + tx.execute("INSERT INTO memory_records VALUES ('subject','memory',2,'constraint','active',?1,?2,?2)",params![second.to_string(),at])?; + for (number, record) in [(1,&first),(2,&second)] { + tx.execute("INSERT INTO memory_revisions VALUES ('subject','memory',?1,'constraint','active',?2,?3)",params![number,record.to_string(),at])?; + } + tx.execute("INSERT INTO memory_revision_candidates VALUES ('subject','memory',1,'candidate')",[])?; + tx.execute("INSERT INTO memory_revision_derivations VALUES ('subject','memory',2,'memory',1)",[])?; + tx.execute("INSERT INTO staging_resolutions VALUES ('subject','candidate','resolution','applied','accepted',?1,?2,?3,?4)",params![affected.to_string(),original_candidate_bytes,resolution.to_string(),at])?; + tx.execute("INSERT INTO staging_resolution_targets VALUES ('subject','candidate','memory',1)",[])?; + tx.execute("INSERT INTO surface_snapshots VALUES ('subject','snapshot',2,?1,?2)",params![snapshot.to_string(),at])?; + tx.execute("INSERT INTO surface_snapshot_refs VALUES ('subject','snapshot','memory',2)",[])?; + tx.execute("INSERT INTO memory_revision_seals VALUES ('subject','memory',1),('subject','memory',2)",[])?; + tx.execute("INSERT INTO staging_resolution_seals VALUES ('subject','candidate')",[])?; + tx.execute("INSERT INTO surface_snapshot_seals VALUES ('subject','snapshot')",[])?; + tx.execute("INSERT INTO candidate_decision_receipts VALUES ('subject','decision','candidate',?1,?2,?3)",params![request.to_string(),receipt.to_string(),at])?; + tx.execute("INSERT INTO surface_generation_runs VALUES ('subject','generation',2,1,?1,?2,'job','attempt')",params![materials.to_string(),at])?; + tx.execute("INSERT INTO surface_generation_state VALUES ('subject',2,'ready','snapshot',NULL,?1)",[at])?; + Ok(()) + }).unwrap(); + } + let (_manager, _scope, store) = open_store(temp.path(), "scope"); + assert_eq!( + store.subject("subject").unwrap().unwrap().behavior_md, + "Preserve me." + ); + let history = store.list_memory_changes("subject", "memory").unwrap(); + assert_eq!(history.len(), 2); + assert_eq!(history[0].body_md, "corrected body"); + assert_eq!(history[1].body_md, "original body"); + assert_eq!( + history[0].previous_change_id.as_deref(), + Some(history[1].change_id.as_str()) + ); + assert_eq!(history[0].derived_from[0].change_id, history[1].change_id); + assert_eq!(history[1].source_candidate_ids, ["candidate"]); + let staged = store + .staging_candidate("subject", "candidate") + .unwrap() + .unwrap(); + assert_eq!( + staged.evidence[0] + .origin + .as_ref() + .unwrap() + .flow_definition_id + .as_deref(), + Some("flow") + ); + assert_eq!( + staged.evidence[0] + .origin + .as_ref() + .unwrap() + .flow_definition_fingerprint, + None + ); + assert_eq!( + store + .surface_snapshot("subject", "snapshot") + .unwrap() + .unwrap() + .memory_refs[0] + .change_id, + history[0].change_id + ); + assert_eq!( + store.resident_surface("subject").unwrap().availability, + SurfaceAvailability::Stale + ); + let request_raw: String = store + .database + .try_with_connection::<_, SubjektivError>(|conn| { + Ok(conn.query_row( + "SELECT request_json FROM candidate_decision_receipts", + [], + |row| row.get(0), + )?) + }) + .unwrap(); + let replay = store + .decide_candidate("subject", serde_json::from_str(&request_raw).unwrap()) + .unwrap(); + assert_eq!(replay.memory.unwrap().change_id, history[1].change_id); + assert_eq!( + replay.resolution.affected_memory[0].change_id, + history[1].change_id + ); + let archived: String = store.database.try_with_connection::<_, SubjektivError>(|conn| Ok(conn.query_row("SELECT row_json FROM legacy_record_archive WHERE source_table='staging_records'",[],|row|row.get(0))?)).unwrap(); + assert_eq!( + serde_json::from_str::(&archived).unwrap()["record_json"], + original_candidate_bytes + ); + let violations: i64 = store + .database + .try_with_connection::<_, SubjektivError>(|conn| { + Ok( + conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + })?, + ) + }) + .unwrap(); + assert_eq!(violations, 0); + } +} diff --git a/crates/subjektiv/src/lib.rs b/crates/subjektiv/src/lib.rs index 9865a770d..136ef7266 100644 --- a/crates/subjektiv/src/lib.rs +++ b/crates/subjektiv/src/lib.rs @@ -1,4 +1,4 @@ -//! Subject-scoped, revisioned Memory storage for the `subjektiv` Feature. +//! Subject-scoped Memory storage with immutable change history. //! //! This module owns subjektiv's domain schema and typed repository. Physical //! SQLite placement, connection configuration, migration execution, backup, @@ -8,6 +8,12 @@ pub mod api; pub mod job; +mod legacy_migrations; +mod schema; +use legacy_migrations::{ + add_candidate_decision_receipts, add_subject_behavior, add_subject_session_attribution, + add_surface_generation_state, add_surface_job_provenance, create_schema, +}; use std::collections::HashSet; @@ -73,20 +79,14 @@ pub enum SubjektivError { SessionAttributionConflict(String), #[error("memory `{0}` was not found for this subject")] MemoryNotFound(String), - #[error("memory `{memory_id}` revision conflict: expected {expected}, current {actual}")] - RevisionConflict { + #[error("memory `{memory_id}` change_id conflict: expected {expected}, current {actual}")] + MemoryChangeConflict { memory_id: String, - expected: u64, - actual: u64, - }, - #[error( - "subject `{subject_id}` behavior revision conflict: expected {expected}, current {actual}" - )] - SubjectBehaviorConflict { - subject_id: String, - expected: u64, - actual: u64, + expected: String, + actual: String, }, + #[error("subject `{subject_id}` behavior content changed")] + SubjectBehaviorConflict { subject_id: String }, #[error("surface generation conflict: {0}")] SurfaceGenerationConflict(String), #[error("memory `{memory_id}` cannot transition from {from:?} to {to:?}")] @@ -153,14 +153,7 @@ pub struct SubjectRecord { /// Memory lifecycle operations must preserve it byte-for-byte. #[serde(default)] pub behavior_md: String, - /// Monotonic CAS generation for behavior updates, independent of Memory's - /// `store_revision`. - #[serde(default)] - pub behavior_revision: u64, pub state: SubjectState, - /// Monotonic generation incremented exactly once for every committed Memory - /// creation or revision for this subject. - pub store_revision: u64, pub created_at: String, pub updated_at: String, } @@ -230,7 +223,7 @@ pub struct SubjectSessionAttributionPage { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub enum RevisionProposalIntent { +pub enum ChangeProposalIntent { Revise, Resolve, Retract, @@ -240,35 +233,35 @@ pub enum RevisionProposalIntent { /// Host-owned metadata describing how a staged candidate is expected to change /// one existing Memory. It is validated against the current scoped Memory when /// the candidate is staged; it is not model-authored and does not itself apply -/// the revision. +/// the change. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(deny_unknown_fields)] -pub struct RevisionProposal { - pub intent: RevisionProposalIntent, +pub struct ChangeProposal { + pub intent: ChangeProposalIntent, pub memory_id: String, - pub expected_revision: u64, + pub expected_change_id: String, pub change_reason: String, } -impl RevisionProposal { +impl ChangeProposal { pub fn new( - intent: RevisionProposalIntent, + intent: ChangeProposalIntent, memory_id: impl Into, - expected_revision: u64, + expected_change_id: String, change_reason: impl Into, ) -> Result { let proposal = Self { intent, memory_id: memory_id.into(), - expected_revision, + expected_change_id, change_reason: change_reason.into(), }; - validate_revision_proposal_metadata(&proposal)?; + validate_change_proposal_metadata(&proposal)?; Ok(proposal) } } -impl<'de> Deserialize<'de> for RevisionProposal { +impl<'de> Deserialize<'de> for ChangeProposal { fn deserialize(deserializer: D) -> std::result::Result where D: Deserializer<'de>, @@ -276,17 +269,17 @@ impl<'de> Deserialize<'de> for RevisionProposal { #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct WireProposal { - intent: RevisionProposalIntent, + intent: ChangeProposalIntent, memory_id: String, - expected_revision: u64, + expected_change_id: String, change_reason: String, } let proposal = WireProposal::deserialize(deserializer)?; - RevisionProposal::new( + ChangeProposal::new( proposal.intent, proposal.memory_id, - proposal.expected_revision, + proposal.expected_change_id, proposal.change_reason, ) .map_err(serde::de::Error::custom) @@ -294,7 +287,7 @@ impl<'de> Deserialize<'de> for RevisionProposal { } /// The existing extraction schema with only host-owned subject scope, -/// persistence time, and optional revision proposal metadata added. Model input +/// persistence time, and optional change proposal metadata added. Model input /// still contains none of those fields. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -314,7 +307,7 @@ pub struct SubjectStagingRecord { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub source_refs: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] - pub revision_proposal: Option, + pub change_proposal: Option, pub created_at: String, } @@ -338,22 +331,22 @@ impl SubjectStagingRecord { staleness: record.staleness, evidence: record.evidence, source_refs: record.source_refs, - revision_proposal: None, + change_proposal: None, created_at, } } - /// Attaches host-owned revision proposal metadata to this candidate. - pub fn with_revision_proposal(mut self, proposal: RevisionProposal) -> Result { - validate_revision_proposal_metadata(&proposal)?; - self.revision_proposal = Some(proposal); + /// Attaches host-owned change proposal metadata to this candidate. + pub fn with_change_proposal(mut self, proposal: ChangeProposal) -> Result { + validate_change_proposal_metadata(&proposal)?; + self.change_proposal = Some(proposal); Ok(self) } - /// Mutably attaches host-owned revision proposal metadata to this candidate. - pub fn attach_revision_proposal(&mut self, proposal: RevisionProposal) -> Result<()> { - validate_revision_proposal_metadata(&proposal)?; - self.revision_proposal = Some(proposal); + /// Mutably attaches host-owned change proposal metadata to this candidate. + pub fn attach_change_proposal(&mut self, proposal: ChangeProposal) -> Result<()> { + validate_change_proposal_metadata(&proposal)?; + self.change_proposal = Some(proposal); Ok(()) } } @@ -365,7 +358,7 @@ pub enum MemoryState { /// grants no authority. Active, /// No longer current because its question, condition, or applicability has - /// ended. A later revision may reopen it when circumstances change. + /// ended. A later change may reopen it when circumstances change. Resolved, /// Invalidated and no longer eligible for recall. Retraction is terminal; /// corrected follow-up experience is a separate Memory linked by derivation. @@ -374,9 +367,9 @@ pub enum MemoryState { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct MemoryRevisionRef { +pub struct MemoryChangeRef { pub memory_id: String, - pub revision: u64, + pub change_id: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -385,7 +378,8 @@ pub struct MemoryRecord { pub schema_version: u32, pub id: String, pub subject_id: String, - pub revision: u64, + pub change_id: String, + pub previous_change_id: Option, pub kind: CandidateKind, pub state: MemoryState, pub claim: String, @@ -396,7 +390,7 @@ pub struct MemoryRecord { #[serde(default)] pub source_candidate_ids: Vec, #[serde(default)] - pub derived_from: Vec, + pub derived_from: Vec, pub change_reason: String, pub created_at: String, pub updated_at: String, @@ -412,7 +406,7 @@ pub struct MemoryDraft { pub why_useful: String, pub staleness: Option, pub source_candidate_ids: Vec, - pub derived_from: Vec, + pub derived_from: Vec, pub change_reason: String, } @@ -458,7 +452,7 @@ pub struct StagingResolution { pub action: StagingResolutionAction, pub reason: String, #[serde(default)] - pub affected_memory: Vec, + pub affected_memory: Vec, /// Immutable copy of the candidate as it was staged. The database also /// retains the exact serialized bytes independently of this projection. pub candidate: SubjectStagingRecord, @@ -473,8 +467,8 @@ pub struct SurfaceSnapshot { pub subject_id: String, pub body_md: String, #[serde(default)] - pub memory_refs: Vec, - pub built_from_store_revision: u64, + pub memory_refs: Vec, + pub built_from_memory_fingerprint: String, pub created_at: String, } @@ -482,7 +476,7 @@ pub struct SurfaceSnapshot { #[serde(deny_unknown_fields)] pub struct SurfaceMaterial { pub memory_id: String, - pub revision: u64, + pub change_id: String, pub kind: CandidateKind, pub body_md: String, pub why_useful: String, @@ -494,7 +488,7 @@ pub struct SurfaceMaterial { pub struct SurfaceGeneration { pub id: String, pub subject_id: String, - pub store_revision: u64, + pub memory_fingerprint: String, pub active_memory_count: usize, pub materials: Vec, pub created_at: String, @@ -504,7 +498,7 @@ pub struct SurfaceGeneration { #[serde(deny_unknown_fields)] pub struct SurfacePoint { pub body_md: String, - pub memory_refs: Vec, + pub memory_refs: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -529,11 +523,11 @@ pub struct SubjectResidentContext { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] -pub enum MemoryRevisionTarget { +pub enum MemoryChangeTarget { Create, Revise { memory_id: String, - expected_revision: u64, + expected_change_id: String, }, } @@ -549,13 +543,13 @@ pub enum MemoryDecisionOperation { #[serde(tag = "kind", rename_all = "snake_case")] pub enum CandidateDecision { Apply { - target: MemoryRevisionTarget, + target: MemoryChangeTarget, draft: MemoryDraft, }, Close { action: StagingResolutionAction, #[serde(default)] - affected_memory: Vec, + affected_memory: Vec, }, } @@ -578,451 +572,10 @@ pub struct CandidateDecisionReceipt { pub memory: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub operation: Option, - pub store_revision: u64, + pub memory_fingerprint: String, pub surface_dirty: bool, } -fn create_schema(transaction: &Transaction<'_>) -> feature_storage::Result<()> { - transaction.execute_batch( - r#" -CREATE TABLE store_scope ( - singleton INTEGER PRIMARY KEY CHECK (singleton = 1), - workspace_id TEXT NOT NULL UNIQUE -); - -CREATE TABLE subjects ( - subject_id TEXT PRIMARY KEY, - role TEXT NOT NULL, - state TEXT NOT NULL CHECK (state IN ('active', 'retired')), - store_revision INTEGER NOT NULL CHECK (store_revision >= 0), - record_json TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE staging_records ( - subject_id TEXT NOT NULL, - candidate_id TEXT NOT NULL, - kind TEXT NOT NULL CHECK ( - kind IN ( - 'preference', 'working_assumption', 'constraint', - 'decision', 'open_question', 'lesson' - ) - ), - record_json TEXT NOT NULL, - created_at TEXT NOT NULL, - PRIMARY KEY (subject_id, candidate_id), - FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT -); - -CREATE TABLE memory_records ( - subject_id TEXT NOT NULL, - memory_id TEXT NOT NULL, - current_revision INTEGER NOT NULL CHECK (current_revision > 0), - kind TEXT NOT NULL CHECK ( - kind IN ( - 'preference', 'working_assumption', 'constraint', - 'decision', 'open_question', 'lesson' - ) - ), - state TEXT NOT NULL CHECK (state IN ('active', 'resolved', 'retracted')), - record_json TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (subject_id, memory_id), - FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT, - FOREIGN KEY (subject_id, memory_id, current_revision) - REFERENCES memory_revisions(subject_id, memory_id, revision) - DEFERRABLE INITIALLY DEFERRED -); - -CREATE TABLE memory_revisions ( - subject_id TEXT NOT NULL, - memory_id TEXT NOT NULL, - revision INTEGER NOT NULL CHECK (revision > 0), - kind TEXT NOT NULL CHECK ( - kind IN ( - 'preference', 'working_assumption', 'constraint', - 'decision', 'open_question', 'lesson' - ) - ), - state TEXT NOT NULL CHECK (state IN ('active', 'resolved', 'retracted')), - record_json TEXT NOT NULL, - created_at TEXT NOT NULL, - PRIMARY KEY (subject_id, memory_id, revision), - FOREIGN KEY (subject_id, memory_id) - REFERENCES memory_records(subject_id, memory_id) - ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED -); - -CREATE TABLE memory_revision_candidates ( - subject_id TEXT NOT NULL, - memory_id TEXT NOT NULL, - revision INTEGER NOT NULL, - candidate_id TEXT NOT NULL, - PRIMARY KEY (subject_id, memory_id, revision, candidate_id), - FOREIGN KEY (subject_id, memory_id, revision) - REFERENCES memory_revisions(subject_id, memory_id, revision) ON DELETE RESTRICT, - FOREIGN KEY (subject_id, candidate_id) - REFERENCES staging_records(subject_id, candidate_id) ON DELETE RESTRICT -); - -CREATE TABLE memory_revision_derivations ( - subject_id TEXT NOT NULL, - memory_id TEXT NOT NULL, - revision INTEGER NOT NULL, - source_memory_id TEXT NOT NULL, - source_revision INTEGER NOT NULL, - PRIMARY KEY ( - subject_id, memory_id, revision, source_memory_id, source_revision - ), - FOREIGN KEY (subject_id, memory_id, revision) - REFERENCES memory_revisions(subject_id, memory_id, revision) ON DELETE RESTRICT, - FOREIGN KEY (subject_id, source_memory_id, source_revision) - REFERENCES memory_revisions(subject_id, memory_id, revision) ON DELETE RESTRICT -); - -CREATE TABLE staging_resolutions ( - subject_id TEXT NOT NULL, - candidate_id TEXT NOT NULL, - resolution_id TEXT NOT NULL, - action TEXT NOT NULL CHECK ( - action IN ('applied', 'discarded', 'invalid', 'duplicate', 'already_covered') - ), - reason TEXT NOT NULL, - affected_refs_json TEXT NOT NULL, - staging_raw_json TEXT NOT NULL, - resolution_json TEXT NOT NULL, - resolved_at TEXT NOT NULL, - PRIMARY KEY (subject_id, candidate_id), - UNIQUE (resolution_id), - FOREIGN KEY (subject_id, candidate_id) - REFERENCES staging_records(subject_id, candidate_id) ON DELETE RESTRICT -); - -CREATE TABLE staging_resolution_targets ( - subject_id TEXT NOT NULL, - candidate_id TEXT NOT NULL, - memory_id TEXT NOT NULL, - revision INTEGER NOT NULL, - PRIMARY KEY (subject_id, candidate_id, memory_id, revision), - FOREIGN KEY (subject_id, candidate_id) - REFERENCES staging_resolutions(subject_id, candidate_id) ON DELETE RESTRICT, - FOREIGN KEY (subject_id, memory_id, revision) - REFERENCES memory_revisions(subject_id, memory_id, revision) ON DELETE RESTRICT -); - -CREATE TABLE surface_snapshots ( - subject_id TEXT NOT NULL, - snapshot_id TEXT NOT NULL, - built_from_store_revision INTEGER NOT NULL CHECK (built_from_store_revision >= 0), - snapshot_json TEXT NOT NULL, - created_at TEXT NOT NULL, - PRIMARY KEY (subject_id, snapshot_id), - FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT -); - -CREATE TABLE surface_snapshot_refs ( - subject_id TEXT NOT NULL, - snapshot_id TEXT NOT NULL, - memory_id TEXT NOT NULL, - revision INTEGER NOT NULL, - PRIMARY KEY (subject_id, snapshot_id, memory_id), - FOREIGN KEY (subject_id, snapshot_id) - REFERENCES surface_snapshots(subject_id, snapshot_id) ON DELETE RESTRICT, - FOREIGN KEY (subject_id, memory_id, revision) - REFERENCES memory_revisions(subject_id, memory_id, revision) ON DELETE RESTRICT -); - -CREATE TABLE memory_revision_seals ( - subject_id TEXT NOT NULL, - memory_id TEXT NOT NULL, - revision INTEGER NOT NULL, - PRIMARY KEY (subject_id, memory_id, revision), - FOREIGN KEY (subject_id, memory_id, revision) - REFERENCES memory_revisions(subject_id, memory_id, revision) ON DELETE RESTRICT -); - -CREATE TABLE staging_resolution_seals ( - subject_id TEXT NOT NULL, - candidate_id TEXT NOT NULL, - PRIMARY KEY (subject_id, candidate_id), - FOREIGN KEY (subject_id, candidate_id) - REFERENCES staging_resolutions(subject_id, candidate_id) ON DELETE RESTRICT -); - -CREATE TABLE surface_snapshot_seals ( - subject_id TEXT NOT NULL, - snapshot_id TEXT NOT NULL, - PRIMARY KEY (subject_id, snapshot_id), - FOREIGN KEY (subject_id, snapshot_id) - REFERENCES surface_snapshots(subject_id, snapshot_id) ON DELETE RESTRICT -); - -CREATE TRIGGER memory_revision_candidates_no_late_insert -BEFORE INSERT ON memory_revision_candidates -WHEN EXISTS ( - SELECT 1 FROM memory_revision_seals - WHERE subject_id = NEW.subject_id - AND memory_id = NEW.memory_id - AND revision = NEW.revision -) BEGIN - SELECT RAISE(ABORT, 'subjektiv revision evidence is sealed'); -END; -CREATE TRIGGER memory_revision_derivations_no_late_insert -BEFORE INSERT ON memory_revision_derivations -WHEN EXISTS ( - SELECT 1 FROM memory_revision_seals - WHERE subject_id = NEW.subject_id - AND memory_id = NEW.memory_id - AND revision = NEW.revision -) BEGIN - SELECT RAISE(ABORT, 'subjektiv derivations are sealed'); -END; -CREATE TRIGGER staging_resolution_targets_no_late_insert -BEFORE INSERT ON staging_resolution_targets -WHEN EXISTS ( - SELECT 1 FROM staging_resolution_seals - WHERE subject_id = NEW.subject_id - AND candidate_id = NEW.candidate_id -) BEGIN - SELECT RAISE(ABORT, 'subjektiv resolution targets are sealed'); -END; -CREATE TRIGGER surface_snapshot_refs_no_late_insert -BEFORE INSERT ON surface_snapshot_refs -WHEN EXISTS ( - SELECT 1 FROM surface_snapshot_seals - WHERE subject_id = NEW.subject_id - AND snapshot_id = NEW.snapshot_id -) BEGIN - SELECT RAISE(ABORT, 'subjektiv surface references are sealed'); -END; - -CREATE TRIGGER memory_revision_seals_no_update -BEFORE UPDATE ON memory_revision_seals BEGIN - SELECT RAISE(ABORT, 'subjektiv revision seals are immutable'); -END; -CREATE TRIGGER memory_revision_seals_no_delete -BEFORE DELETE ON memory_revision_seals BEGIN - SELECT RAISE(ABORT, 'subjektiv revision seals are retained'); -END; -CREATE TRIGGER staging_resolution_seals_no_update -BEFORE UPDATE ON staging_resolution_seals BEGIN - SELECT RAISE(ABORT, 'subjektiv resolution seals are immutable'); -END; -CREATE TRIGGER staging_resolution_seals_no_delete -BEFORE DELETE ON staging_resolution_seals BEGIN - SELECT RAISE(ABORT, 'subjektiv resolution seals are retained'); -END; -CREATE TRIGGER surface_snapshot_seals_no_update -BEFORE UPDATE ON surface_snapshot_seals BEGIN - SELECT RAISE(ABORT, 'subjektiv snapshot seals are immutable'); -END; -CREATE TRIGGER surface_snapshot_seals_no_delete -BEFORE DELETE ON surface_snapshot_seals BEGIN - SELECT RAISE(ABORT, 'subjektiv snapshot seals are retained'); -END; - -CREATE TRIGGER memory_revisions_no_update -BEFORE UPDATE ON memory_revisions BEGIN - SELECT RAISE(ABORT, 'subjektiv memory revisions are immutable'); -END; -CREATE TRIGGER memory_revisions_no_delete -BEFORE DELETE ON memory_revisions BEGIN - SELECT RAISE(ABORT, 'subjektiv memory revisions are retained'); -END; -CREATE TRIGGER memory_revision_candidates_no_update -BEFORE UPDATE ON memory_revision_candidates BEGIN - SELECT RAISE(ABORT, 'subjektiv revision evidence is immutable'); -END; -CREATE TRIGGER memory_revision_candidates_no_delete -BEFORE DELETE ON memory_revision_candidates BEGIN - SELECT RAISE(ABORT, 'subjektiv revision evidence is retained'); -END; -CREATE TRIGGER memory_revision_derivations_no_update -BEFORE UPDATE ON memory_revision_derivations BEGIN - SELECT RAISE(ABORT, 'subjektiv derivations are immutable'); -END; -CREATE TRIGGER memory_revision_derivations_no_delete -BEFORE DELETE ON memory_revision_derivations BEGIN - SELECT RAISE(ABORT, 'subjektiv derivations are retained'); -END; -CREATE TRIGGER staging_records_no_update -BEFORE UPDATE ON staging_records BEGIN - SELECT RAISE(ABORT, 'subjektiv staging records are immutable'); -END; -CREATE TRIGGER staging_records_no_delete -BEFORE DELETE ON staging_records BEGIN - SELECT RAISE(ABORT, 'subjektiv staging records are retained'); -END; -CREATE TRIGGER staging_resolutions_no_update -BEFORE UPDATE ON staging_resolutions BEGIN - SELECT RAISE(ABORT, 'subjektiv staging resolutions are immutable'); -END; -CREATE TRIGGER staging_resolutions_no_delete -BEFORE DELETE ON staging_resolutions BEGIN - SELECT RAISE(ABORT, 'subjektiv staging resolutions are retained'); -END; -CREATE TRIGGER staging_resolution_targets_no_update -BEFORE UPDATE ON staging_resolution_targets BEGIN - SELECT RAISE(ABORT, 'subjektiv resolution targets are immutable'); -END; -CREATE TRIGGER staging_resolution_targets_no_delete -BEFORE DELETE ON staging_resolution_targets BEGIN - SELECT RAISE(ABORT, 'subjektiv resolution targets are retained'); -END; -CREATE TRIGGER surface_snapshots_no_update -BEFORE UPDATE ON surface_snapshots BEGIN - SELECT RAISE(ABORT, 'subjektiv surface snapshots are immutable'); -END; -CREATE TRIGGER surface_snapshots_no_delete -BEFORE DELETE ON surface_snapshots BEGIN - SELECT RAISE(ABORT, 'subjektiv surface snapshots are retained'); -END; -CREATE TRIGGER surface_snapshot_refs_no_update -BEFORE UPDATE ON surface_snapshot_refs BEGIN - SELECT RAISE(ABORT, 'subjektiv surface references are immutable'); -END; -CREATE TRIGGER surface_snapshot_refs_no_delete -BEFORE DELETE ON surface_snapshot_refs BEGIN - SELECT RAISE(ABORT, 'subjektiv surface references are retained'); -END; -"#, - )?; - Ok(()) -} - -fn add_subject_session_attribution(transaction: &Transaction<'_>) -> feature_storage::Result<()> { - transaction.execute_batch( - r#" -CREATE TABLE subject_session_attributions ( - session_id TEXT PRIMARY KEY, - subject_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - worker_id TEXT NOT NULL, - record_json TEXT NOT NULL, - attributed_at TEXT NOT NULL, - FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT -); - -CREATE INDEX subject_session_attributions_by_subject -ON subject_session_attributions(subject_id, attributed_at, session_id); - -CREATE TRIGGER subject_session_attributions_no_update -BEFORE UPDATE ON subject_session_attributions BEGIN - SELECT RAISE(ABORT, 'subjektiv session attribution is immutable'); -END; -CREATE TRIGGER subject_session_attributions_no_delete -BEFORE DELETE ON subject_session_attributions BEGIN - SELECT RAISE(ABORT, 'subjektiv session attribution is retained'); -END; -"#, - )?; - Ok(()) -} - -fn add_candidate_decision_receipts(transaction: &Transaction<'_>) -> feature_storage::Result<()> { - transaction.execute_batch( - r#" -CREATE TABLE candidate_decision_receipts ( - subject_id TEXT NOT NULL, - request_id TEXT NOT NULL, - candidate_id TEXT NOT NULL, - request_json TEXT NOT NULL, - receipt_json TEXT NOT NULL, - created_at TEXT NOT NULL, - PRIMARY KEY (subject_id, request_id), - UNIQUE (subject_id, candidate_id), - FOREIGN KEY (subject_id, candidate_id) - REFERENCES staging_resolutions(subject_id, candidate_id) ON DELETE RESTRICT -); - -CREATE TRIGGER candidate_decision_receipts_no_update -BEFORE UPDATE ON candidate_decision_receipts BEGIN - SELECT RAISE(ABORT, 'subjektiv candidate decision receipts are immutable'); -END; -CREATE TRIGGER candidate_decision_receipts_no_delete -BEFORE DELETE ON candidate_decision_receipts BEGIN - SELECT RAISE(ABORT, 'subjektiv candidate decision receipts are retained'); -END; -"#, - )?; - Ok(()) -} - -fn add_surface_generation_state(transaction: &Transaction<'_>) -> feature_storage::Result<()> { - transaction.execute_batch( - r#" -CREATE TABLE surface_generation_state ( - subject_id TEXT PRIMARY KEY, - store_revision INTEGER NOT NULL CHECK (store_revision >= 0), - status TEXT NOT NULL CHECK (status IN ('dirty', 'failed', 'ready')), - snapshot_id TEXT, - reason_code TEXT, - updated_at TEXT NOT NULL, - FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT, - FOREIGN KEY (subject_id, snapshot_id) - REFERENCES surface_snapshots(subject_id, snapshot_id) ON DELETE RESTRICT -); - -INSERT INTO surface_generation_state ( - subject_id, store_revision, status, snapshot_id, reason_code, updated_at -) -SELECT subjects.subject_id, - subjects.store_revision, - 'dirty', - NULL, - 'legacy_snapshot_requires_regeneration', - subjects.updated_at -FROM subjects -WHERE EXISTS ( - SELECT 1 FROM surface_snapshots any_snapshot - WHERE any_snapshot.subject_id = subjects.subject_id -); - -CREATE TABLE surface_generation_runs ( - subject_id TEXT NOT NULL, - generation_id TEXT NOT NULL, - store_revision INTEGER NOT NULL CHECK (store_revision >= 0), - active_memory_count INTEGER NOT NULL CHECK (active_memory_count >= 0), - materials_json TEXT NOT NULL, - created_at TEXT NOT NULL, - PRIMARY KEY (subject_id, generation_id), - FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT -); - -CREATE INDEX surface_generation_runs_by_revision -ON surface_generation_runs(subject_id, store_revision, created_at, generation_id); - -CREATE TRIGGER surface_generation_runs_no_update -BEFORE UPDATE ON surface_generation_runs BEGIN - SELECT RAISE(ABORT, 'subjektiv surface generation inputs are immutable'); -END; -CREATE TRIGGER surface_generation_runs_no_delete -BEFORE DELETE ON surface_generation_runs BEGIN - SELECT RAISE(ABORT, 'subjektiv surface generation inputs are retained'); -END; -"#, - )?; - Ok(()) -} - -fn add_subject_behavior(transaction: &Transaction<'_>) -> feature_storage::Result<()> { - transaction.execute_batch( - r#" -ALTER TABLE subjects ADD COLUMN behavior_md TEXT NOT NULL DEFAULT ''; -ALTER TABLE subjects ADD COLUMN behavior_revision INTEGER NOT NULL DEFAULT 0 - CHECK (behavior_revision >= 0); -"#, - )?; - Ok(()) -} - -fn add_surface_job_provenance(transaction: &Transaction<'_>) -> feature_storage::Result<()> { - transaction.execute_batch("ALTER TABLE surface_generation_runs ADD COLUMN job_id TEXT; ALTER TABLE surface_generation_runs ADD COLUMN attempt_id TEXT;")?; - Ok(()) -} - static MIGRATIONS: &[FeatureMigration] = &[ FeatureMigration::new(1, "create subjektiv subject memory store", create_schema), FeatureMigration::new( @@ -1046,6 +599,11 @@ static MIGRATIONS: &[FeatureMigration] = &[ "bind surface generation provenance to Job attempts", add_surface_job_provenance, ), + FeatureMigration::new( + 7, + "replace counters with immutable changes and content conditions", + schema::migrate, + ), ]; pub const REGISTRATION: FeatureRegistration = @@ -1142,9 +700,7 @@ impl SubjektivStore { id: issued_id("subject"), role, behavior_md, - behavior_revision: 0, state: SubjectState::Active, - store_revision: 0, created_at: timestamp.clone(), updated_at: timestamp, }; @@ -1152,9 +708,8 @@ impl SubjektivStore { self.database.try_transaction(|transaction| { transaction.execute( "INSERT INTO subjects ( - subject_id, role, behavior_md, behavior_revision, state, - store_revision, record_json, created_at, updated_at - ) VALUES (?1, ?2, ?3, 0, 'active', 0, ?4, ?5, ?6)", + 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(), @@ -1181,51 +736,38 @@ impl SubjektivStore { }) } - /// Replaces or clears the user-managed behavior document using its own CAS - /// generation. Memory revisions do not participate in this generation. + /// Replaces behavior only if its exact previously-read content still matches. + /// Memory changes do not participate in this condition. pub fn update_subject_behavior( &self, subject_id: &str, - expected_behavior_revision: u64, + expected_behavior_md: &str, behavior_md: String, ) -> Result { validate_subject_behavior(&behavior_md)?; - self.database.try_transaction(|transaction| { - let mut subject = require_subject(transaction, subject_id)?; - if subject.behavior_revision != expected_behavior_revision { - return Err(SubjektivError::SubjectBehaviorConflict { - subject_id: subject_id.to_string(), - expected: expected_behavior_revision, - actual: subject.behavior_revision, - }); - } - if subject.behavior_md == behavior_md { - return Ok(subject); + self.database.try_transaction(|tx| { + let mut subject = require_subject(tx, subject_id)?; + if subject.behavior_md != expected_behavior_md { + return Err(SubjektivError::SubjectBehaviorConflict { subject_id: subject_id.into() }); } + if subject.behavior_md == behavior_md { return Ok(subject); } subject.behavior_md = behavior_md; - subject.behavior_revision = - subject.behavior_revision.checked_add(1).ok_or_else(|| { - SubjektivError::InvalidRecord("subject behavior revision overflow".to_string()) - })?; subject.updated_at = now(); - let raw = serde_json::to_string(&subject)?; - transaction.execute( - "UPDATE subjects - SET behavior_md = ?2, behavior_revision = ?3, - record_json = ?4, updated_at = ?5 - WHERE subject_id = ?1", - params![ - subject.id, - subject.behavior_md, - to_i64(subject.behavior_revision)?, - raw, - subject.updated_at - ], - )?; + tx.execute("UPDATE subjects SET behavior_md = ?2, record_json = ?3, updated_at = ?4 WHERE subject_id = ?1", + params![subject.id, subject.behavior_md, serde_json::to_string(&subject)?, subject.updated_at])?; Ok(subject) }) } + /// Derived content condition over all current Memory identities, not stored + /// on the Subject and never incremented. + pub fn memory_fingerprint(&self, subject_id: &str) -> Result { + self.database.try_transaction(|tx| { + require_subject(tx, subject_id)?; + memory_fingerprint_in_connection(tx, subject_id) + }) + } + /// Validates an explicit product launch connection against this Workspace's /// subject store and the already-resolved Profile policy. Subject knowledge /// alone is insufficient: only an active local record produces host launch material. @@ -1608,7 +1150,7 @@ impl SubjektivStore { } let memory_id = issued_id("memory"); self.database.try_transaction(|transaction| { - write_memory_revision(transaction, subject_id, &memory_id, None, draft) + write_memory_change(transaction, subject_id, &memory_id, None, draft) }) } @@ -1616,28 +1158,28 @@ impl SubjektivStore { &self, subject_id: &str, memory_id: &str, - expected_revision: u64, + expected_change_id: String, draft: MemoryDraft, ) -> Result { validate_direct_memory_draft(&draft)?; self.database.try_transaction(|transaction| { - write_memory_revision( + write_memory_change( transaction, subject_id, memory_id, - Some(expected_revision), + Some(expected_change_id), draft, ) }) } - /// Atomically writes a Memory revision and one candidate's immutable + /// Atomically writes a Memory change and one candidate's immutable /// resolution. Any conflict or invalid reference rolls both changes back. pub fn apply_candidate( &self, subject_id: &str, candidate_id: &str, - target: MemoryRevisionTarget, + target: MemoryChangeTarget, draft: MemoryDraft, reason: impl Into, ) -> Result<(MemoryRecord, StagingResolution)> { @@ -1648,13 +1190,13 @@ impl SubjektivStore { } /// Atomically applies one or more candidates to exactly one new Memory - /// revision. The repository derives `source_candidate_ids` from this list so + /// change_id. The repository derives `source_candidate_ids` from this list so /// every applied resolution has a reciprocal immutable provenance edge. pub fn apply_candidates( &self, subject_id: &str, candidate_ids: &[String], - target: MemoryRevisionTarget, + target: MemoryChangeTarget, draft: MemoryDraft, reason: impl Into, ) -> Result<(MemoryRecord, Vec)> { @@ -1726,8 +1268,8 @@ impl SubjektivStore { let (memory, operation, resolution) = match request.decision.clone() { CandidateDecision::Apply { target, draft } => { let operation = match &target { - MemoryRevisionTarget::Create => MemoryDecisionOperation::Create, - MemoryRevisionTarget::Revise { .. } => MemoryDecisionOperation::Revise, + MemoryChangeTarget::Create => MemoryDecisionOperation::Create, + MemoryChangeTarget::Revise { .. } => MemoryDecisionOperation::Revise, }; let candidate_ids = [request.candidate_id.clone()]; let (memory, mut resolutions) = apply_candidates_in_transaction( @@ -1762,7 +1304,7 @@ impl SubjektivStore { (None, operation, resolution) } }; - let store_revision = require_subject(transaction, subject_id)?.store_revision; + let memory_fingerprint = memory_fingerprint_in_connection(transaction, subject_id)?; let receipt = CandidateDecisionReceipt { request_id: request.request_id.clone(), candidate_id: request.candidate_id.clone(), @@ -1770,7 +1312,7 @@ impl SubjektivStore { memory, operation, resolution, - store_revision, + memory_fingerprint, }; let receipt_raw = serde_json::to_string(&receipt)?; transaction.execute( @@ -1797,7 +1339,7 @@ impl SubjektivStore { candidate_id: &str, action: StagingResolutionAction, reason: impl Into, - affected_memory: Vec, + affected_memory: Vec, ) -> Result { let reason = reason.into(); validate_nonempty("resolution reason", &reason)?; @@ -1851,18 +1393,18 @@ impl SubjektivStore { }) } - pub fn memory_revision( + pub fn memory_change( &self, subject_id: &str, memory_id: &str, - revision: u64, + change_id: String, ) -> Result> { self.database.try_with_connection(|connection| { let raw = connection .query_row( - "SELECT record_json FROM memory_revisions - WHERE subject_id = ?1 AND memory_id = ?2 AND revision = ?3", - params![subject_id, memory_id, to_i64(revision)?], + "SELECT record_json FROM memory_changes + WHERE subject_id = ?1 AND memory_id = ?2 AND change_id = ?3", + params![subject_id, memory_id, change_id], |row| row.get::<_, String>(0), ) .optional()?; @@ -1881,20 +1423,20 @@ impl SubjektivStore { }) } - /// Reads one immutable revision after verifying that the Memory belongs to - /// the requested subject. A missing revision of a valid scoped Memory is + /// Reads one immutable change_id after verifying that the Memory belongs to + /// the requested subject. A missing change_id of a valid scoped Memory is /// returned as `None`. - pub fn scoped_memory_revision( + pub fn scoped_memory_change( &self, subject_id: &str, memory_id: &str, - revision: u64, + change_id: String, ) -> Result> { validate_label("subject id", subject_id)?; validate_label("Memory id", memory_id)?; - if revision == 0 { + if change_id.is_empty() { return Err(SubjektivError::InvalidRecord( - "Memory revision must be greater than zero".into(), + "Memory change must be a nonempty change identity".into(), )); } self.database.try_with_connection(|connection| { @@ -1902,9 +1444,9 @@ impl SubjektivStore { scoped_memory_in_connection(connection, subject_id, memory_id)?; let raw = connection .query_row( - "SELECT record_json FROM memory_revisions - WHERE subject_id = ?1 AND memory_id = ?2 AND revision = ?3", - params![subject_id, memory_id, to_i64(revision)?], + "SELECT record_json FROM memory_changes + WHERE subject_id = ?1 AND memory_id = ?2 AND change_id = ?3", + params![subject_id, memory_id, change_id], |row| row.get::<_, String>(0), ) .optional()?; @@ -1932,35 +1474,36 @@ impl SubjektivStore { }) } - /// Lists every immutable revision of one scoped Memory, newest first. - pub fn list_memory_revisions( + /// Walks the immutable predecessor chain, newest first. Identity is opaque; + /// clock time and UUID lexical order do not determine ancestry. + pub fn list_memory_changes( &self, subject_id: &str, memory_id: &str, ) -> Result> { - validate_label("subject id", subject_id)?; - validate_label("Memory id", memory_id)?; - self.database.try_with_connection(|connection| { - require_subject_in_connection(connection, subject_id)?; - scoped_memory_in_connection(connection, subject_id, memory_id)?; - let mut statement = connection.prepare( - "SELECT record_json FROM memory_revisions - WHERE subject_id = ?1 AND memory_id = ?2 - ORDER BY revision DESC", - )?; - let rows = statement.query_map(params![subject_id, memory_id], |row| { - row.get::<_, String>(0) - })?; + self.database.try_transaction(|tx| { + require_subject(tx, subject_id)?; + let mut record = scoped_memory_in_connection(tx, subject_id, memory_id)?; + let mut seen = HashSet::new(); let mut records = Vec::new(); - for row in rows { - records.push(parse_memory(&row?)?); + loop { + if !seen.insert(record.change_id.clone()) { + return Err(SubjektivError::InvalidRecord("cyclic Memory history".into())); + } + let previous = record.previous_change_id.clone(); + records.push(record); + let Some(previous) = previous else { break; }; + let raw: String = tx.query_row( + "SELECT record_json FROM memory_changes WHERE subject_id = ?1 AND memory_id = ?2 AND change_id = ?3", + params![subject_id, memory_id, previous], |row| row.get(0))?; + record = parse_memory(&raw)?; } Ok(records) }) } /// Captures one deterministic, bounded generation input from only current - /// active Memory revisions. Kind order is fixed and selection is round-robin + /// active Memory changes. Kind order is fixed and selection is round-robin /// across kinds; within each kind, updated_at desc then memory id asc. pub fn prepare_surface_generation(&self, subject_id: &str) -> Result { self.prepare_job_surface_generation(subject_id, None) @@ -1972,26 +1515,27 @@ impl SubjektivStore { job_attempt: Option<(&str, &str)>, ) -> Result { self.database.try_transaction(|transaction| { - let subject = require_active_subject(transaction, subject_id)?; + require_active_subject(transaction, subject_id)?; + let memory_fingerprint = memory_fingerprint_in_connection(transaction, subject_id)?; let (active_memory_count, materials) = select_surface_materials(transaction, subject_id)?; let generation = SurfaceGeneration { id: issued_id("surface-generation"), subject_id: subject_id.to_string(), - store_revision: subject.store_revision, + memory_fingerprint, active_memory_count, materials, created_at: now(), }; transaction.execute( "INSERT INTO surface_generation_runs ( - subject_id, generation_id, store_revision, active_memory_count, + subject_id, generation_id, memory_fingerprint, active_memory_count, materials_json, created_at, job_id, attempt_id ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![ subject_id, generation.id, - to_i64(generation.store_revision)?, + generation.memory_fingerprint, to_i64(generation.active_memory_count as u64)?, serde_json::to_string(&generation.materials)?, generation.created_at, @@ -2011,7 +1555,7 @@ impl SubjektivStore { &self, subject_id: &str, generation_id: &str, - store_revision: u64, + memory_fingerprint: String, availability: &str, snapshot_id: Option<&str>, reason_code: Option<&str>, @@ -2019,17 +1563,18 @@ impl SubjektivStore { attempt_id: &str, ) -> Result<()> { self.database.try_with_connection(|connection| { - let subject = require_subject_in_connection(connection, subject_id)?; - let generation_revision = require_job_generation(connection, subject_id, generation_id, job_id, attempt_id)?; - if generation_revision != store_revision || subject.store_revision != store_revision { + require_subject_in_connection(connection, subject_id)?; + let current_fingerprint = memory_fingerprint_in_connection(connection, subject_id)?; + let generation_change = require_job_generation(connection, subject_id, generation_id, job_id, attempt_id)?; + if generation_change != memory_fingerprint || current_fingerprint != memory_fingerprint { return Err(SubjektivError::InvalidRecord("surface generation is stale".into())); } let state = connection.query_row( - "SELECT store_revision, status, snapshot_id, reason_code FROM surface_generation_state WHERE subject_id = ?1", + "SELECT memory_fingerprint, status, snapshot_id, reason_code FROM surface_generation_state WHERE subject_id = ?1", [subject_id], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, Option>(2)?, row.get::<_, Option>(3)?)), + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, Option>(2)?, row.get::<_, Option>(3)?)), ).optional()?.ok_or_else(|| SubjektivError::InvalidRecord("surface generation has no terminal outcome".into()))?; - if state.0 != to_i64(store_revision)? || state.1 != availability + if state.0 != memory_fingerprint || state.1 != availability || !matches!(availability, "ready" | "failed") || state.2.as_deref() != snapshot_id || state.3.as_deref() != reason_code { return Err(SubjektivError::InvalidRecord("surface claim differs from the persisted outcome".into())); @@ -2061,16 +1606,17 @@ impl SubjektivStore { ) -> Result { validate_label("surface generation id", generation_id)?; self.database.try_transaction(|transaction| { - let subject = require_active_subject(transaction, subject_id)?; - let (store_revision, active_memory_count, materials_json) = transaction + require_active_subject(transaction, subject_id)?; + let current_fingerprint = memory_fingerprint_in_connection(transaction, subject_id)?; + let (memory_fingerprint, active_memory_count, materials_json) = transaction .query_row( - "SELECT store_revision, active_memory_count, materials_json + "SELECT memory_fingerprint, active_memory_count, materials_json FROM surface_generation_runs WHERE subject_id = ?1 AND generation_id = ?2", params![subject_id, generation_id], |row| { Ok(( - row.get::<_, i64>(0)?, + row.get::<_, String>(0)?, row.get::<_, i64>(1)?, row.get::<_, String>(2)?, )) @@ -2082,13 +1628,10 @@ impl SubjektivStore { "unknown generation `{generation_id}` for subject `{subject_id}`" )) })?; - let store_revision = u64::try_from(store_revision).map_err(|_| { - SubjektivError::InvalidRecord("surface generation revision is negative".into()) - })?; - if subject.store_revision != store_revision { + if current_fingerprint != memory_fingerprint { return Err(SubjektivError::SurfaceGenerationConflict(format!( - "generation {store_revision} is stale; current store revision is {}", - subject.store_revision + "generation input {memory_fingerprint} is stale; current Memory input fingerprint is {}", + current_fingerprint ))); } let materials: Vec = serde_json::from_str(&materials_json)?; @@ -2099,13 +1642,13 @@ impl SubjektivStore { } let (body_md, memory_refs) = validate_surface_points(&materials, &points)?; if let Some(existing) = - ready_surface_snapshot_for_revision(transaction, subject_id, store_revision)? + ready_surface_snapshot_for_fingerprint(transaction, subject_id, &memory_fingerprint)? { if existing.body_md == body_md && existing.memory_refs == memory_refs { return Ok(existing); } return Err(SubjektivError::SurfaceGenerationConflict(format!( - "store revision {store_revision} already has a different published surface" + "Memory input fingerprint {memory_fingerprint} already has a different published surface" ))); } insert_surface_snapshot( @@ -2113,7 +1656,7 @@ impl SubjektivStore { subject_id, body_md, memory_refs, - store_revision, + memory_fingerprint, ) }) } @@ -2125,17 +1668,18 @@ impl SubjektivStore { subject_id: &str, generation_id: &str, reason_code: &str, - ) -> Result { + ) -> Result { validate_label("surface generation id", generation_id)?; validate_label("surface failure reason", reason_code)?; self.database.try_transaction(|transaction| { - let subject = require_active_subject(transaction, subject_id)?; - let generation_revision = transaction + require_active_subject(transaction, subject_id)?; + let memory_fingerprint = memory_fingerprint_in_connection(transaction, subject_id)?; + let generation_change = transaction .query_row( - "SELECT store_revision FROM surface_generation_runs + "SELECT memory_fingerprint FROM surface_generation_runs WHERE subject_id = ?1 AND generation_id = ?2", params![subject_id, generation_id], - |row| row.get::<_, i64>(0), + |row| row.get::<_, String>(0), ) .optional()? .ok_or_else(|| { @@ -2143,10 +1687,7 @@ impl SubjektivStore { "unknown generation `{generation_id}` for subject `{subject_id}`" )) })?; - let generation_revision = u64::try_from(generation_revision).map_err(|_| { - SubjektivError::InvalidRecord("surface generation revision is negative".into()) - })?; - if generation_revision == subject.store_revision { + if generation_change == memory_fingerprint { let ready = transaction .query_row( "SELECT status FROM surface_generation_state WHERE subject_id = ?1", @@ -2158,18 +1699,18 @@ impl SubjektivStore { if !ready { transaction.execute( "INSERT INTO surface_generation_state ( - subject_id, store_revision, status, snapshot_id, reason_code, updated_at + subject_id, memory_fingerprint, status, snapshot_id, reason_code, updated_at ) VALUES (?1, ?2, 'failed', NULL, ?3, ?4) ON CONFLICT(subject_id) DO UPDATE SET - store_revision = excluded.store_revision, + memory_fingerprint = excluded.memory_fingerprint, status = 'failed', snapshot_id = NULL, reason_code = excluded.reason_code, updated_at = excluded.updated_at", - params![subject_id, to_i64(generation_revision)?, reason_code, now()], + params![subject_id, generation_change, reason_code, now()], )?; } } - Ok(subject.store_revision) + Ok(memory_fingerprint) }) } @@ -2197,18 +1738,19 @@ impl SubjektivStore { &self, subject_id: &str, body_md: impl Into, - memory_refs: Vec, - built_from_store_revision: u64, + memory_refs: Vec, + built_from_memory_fingerprint: String, ) -> Result { reject_duplicate_refs("surface snapshot", &memory_refs)?; reject_duplicate_memory_ids("surface snapshot", &memory_refs)?; let body_md = body_md.into(); self.database.try_transaction(|transaction| { - let subject = require_active_subject(transaction, subject_id)?; - if subject.store_revision != built_from_store_revision { + require_active_subject(transaction, subject_id)?; + let memory_fingerprint = memory_fingerprint_in_connection(transaction, subject_id)?; + if memory_fingerprint != built_from_memory_fingerprint { return Err(SubjektivError::InvalidRecord(format!( - "surface snapshot generation {built_from_store_revision} does not match current subject store revision {}", - subject.store_revision + "surface snapshot generation {built_from_memory_fingerprint} does not match current subject Memory input fingerprint {}", + memory_fingerprint ))); } validate_memory_refs(transaction, subject_id, &memory_refs)?; @@ -2217,16 +1759,16 @@ impl SubjektivStore { "surface snapshot body exceeds {SURFACE_BODY_TOKEN_BUDGET} token estimate" ))); } - if let Some(existing) = surface_snapshot_for_revision( + if let Some(existing) = surface_snapshot_for_fingerprint( transaction, subject_id, - built_from_store_revision, + &built_from_memory_fingerprint, )? { if existing.body_md == body_md && existing.memory_refs == memory_refs { return Ok(existing); } return Err(SubjektivError::SurfaceGenerationConflict(format!( - "store revision {built_from_store_revision} already has a different published surface" + "Memory input fingerprint {built_from_memory_fingerprint} already has a different published surface" ))); } insert_surface_snapshot( @@ -2234,7 +1776,7 @@ impl SubjektivStore { subject_id, body_md, memory_refs, - built_from_store_revision, + built_from_memory_fingerprint, ) }) } @@ -2277,7 +1819,7 @@ fn select_surface_materials( if buckets[bucket].len() < SURFACE_PER_KIND_LIMIT { buckets[bucket].push(SurfaceMaterial { memory_id: memory.id, - revision: memory.revision, + change_id: memory.change_id.clone(), kind: memory.kind, body_md: memory.body_md, why_useful: memory.why_useful, @@ -2379,7 +1921,7 @@ fn surface_kind_index(kind: &CandidateKind) -> usize { fn validate_surface_points( materials: &[SurfaceMaterial], points: &[SurfacePoint], -) -> Result<(String, Vec)> { +) -> Result<(String, Vec)> { if materials.is_empty() { if points.is_empty() { return Ok((String::new(), Vec::new())); @@ -2400,7 +1942,7 @@ fn validate_surface_points( } let allowed = materials .iter() - .map(|material| (material.memory_id.as_str(), material.revision)) + .map(|material| (material.memory_id.as_str(), material.change_id.as_str())) .collect::>(); let mut bodies = Vec::with_capacity(points.len()); let mut seen = HashSet::new(); @@ -2415,13 +1957,13 @@ fn validate_surface_points( } reject_duplicate_refs("surface point", &point.memory_refs)?; for reference in &point.memory_refs { - if !allowed.contains(&(reference.memory_id.as_str(), reference.revision)) { + if !allowed.contains(&(reference.memory_id.as_str(), reference.change_id.as_str())) { return Err(SubjektivError::SubjectScopeMismatch { subject_id: "surface generation materials".into(), - reference: format!("{}@{}", reference.memory_id, reference.revision), + reference: format!("{}@{}", reference.memory_id, reference.change_id), }); } - if seen.insert((reference.memory_id.clone(), reference.revision)) { + if seen.insert((reference.memory_id.clone(), reference.change_id.clone())) { refs.push(reference.clone()); } } @@ -2436,10 +1978,10 @@ fn validate_surface_points( Ok((body_md, refs)) } -fn ready_surface_snapshot_for_revision( +fn ready_surface_snapshot_for_fingerprint( transaction: &Transaction<'_>, subject_id: &str, - store_revision: u64, + memory_fingerprint: &str, ) -> Result> { let raw = transaction .query_row( @@ -2449,26 +1991,26 @@ fn ready_surface_snapshot_for_revision( ON snapshots.subject_id = state.subject_id AND snapshots.snapshot_id = state.snapshot_id WHERE state.subject_id = ?1 - AND state.store_revision = ?2 + AND state.memory_fingerprint = ?2 AND state.status = 'ready'", - params![subject_id, to_i64(store_revision)?], + params![subject_id, memory_fingerprint], |row| row.get::<_, String>(0), ) .optional()?; raw.map(|raw| parse_surface_snapshot(&raw)).transpose() } -fn surface_snapshot_for_revision( +fn surface_snapshot_for_fingerprint( transaction: &Transaction<'_>, subject_id: &str, - store_revision: u64, + memory_fingerprint: &str, ) -> Result> { let raw = transaction .query_row( "SELECT snapshot_json FROM surface_snapshots - WHERE subject_id = ?1 AND built_from_store_revision = ?2 + WHERE subject_id = ?1 AND built_from_memory_fingerprint = ?2 ORDER BY created_at ASC, snapshot_id ASC LIMIT 1", - params![subject_id, to_i64(store_revision)?], + params![subject_id, memory_fingerprint], |row| row.get::<_, String>(0), ) .optional()?; @@ -2479,8 +2021,8 @@ fn insert_surface_snapshot( transaction: &Transaction<'_>, subject_id: &str, body_md: String, - memory_refs: Vec, - built_from_store_revision: u64, + memory_refs: Vec, + built_from_memory_fingerprint: String, ) -> Result { validate_memory_refs(transaction, subject_id, &memory_refs)?; let snapshot = SurfaceSnapshot { @@ -2489,18 +2031,18 @@ fn insert_surface_snapshot( subject_id: subject_id.to_string(), body_md, memory_refs, - built_from_store_revision, + built_from_memory_fingerprint: built_from_memory_fingerprint.clone(), created_at: now(), }; let raw = serde_json::to_string(&snapshot)?; transaction.execute( "INSERT INTO surface_snapshots ( - subject_id, snapshot_id, built_from_store_revision, snapshot_json, created_at + subject_id, snapshot_id, built_from_memory_fingerprint, snapshot_json, created_at ) VALUES (?1, ?2, ?3, ?4, ?5)", params![ subject_id, snapshot.id, - to_i64(snapshot.built_from_store_revision)?, + snapshot.built_from_memory_fingerprint, raw, snapshot.created_at ], @@ -2508,13 +2050,13 @@ fn insert_surface_snapshot( for reference in &snapshot.memory_refs { transaction.execute( "INSERT INTO surface_snapshot_refs ( - subject_id, snapshot_id, memory_id, revision + subject_id, snapshot_id, memory_id, change_id ) VALUES (?1, ?2, ?3, ?4)", params![ subject_id, snapshot.id, reference.memory_id, - to_i64(reference.revision)? + reference.change_id ], )?; } @@ -2524,15 +2066,15 @@ fn insert_surface_snapshot( )?; transaction.execute( "INSERT INTO surface_generation_state ( - subject_id, store_revision, status, snapshot_id, reason_code, updated_at + subject_id, memory_fingerprint, status, snapshot_id, reason_code, updated_at ) VALUES (?1, ?2, 'ready', ?3, NULL, ?4) ON CONFLICT(subject_id) DO UPDATE SET - store_revision = excluded.store_revision, + memory_fingerprint = excluded.memory_fingerprint, status = 'ready', snapshot_id = excluded.snapshot_id, reason_code = NULL, updated_at = excluded.updated_at", params![ subject_id, - to_i64(built_from_store_revision)?, + built_from_memory_fingerprint, snapshot.id, snapshot.created_at ], @@ -2552,7 +2094,7 @@ fn apply_candidates_in_transaction( transaction: &Transaction<'_>, subject_id: &str, candidate_ids: &[String], - target: &MemoryRevisionTarget, + target: &MemoryChangeTarget, mut draft: MemoryDraft, reason: &str, ) -> Result<(MemoryRecord, Vec)> { @@ -2567,28 +2109,28 @@ fn apply_candidates_in_transaction( draft.source_candidate_ids = candidate_ids.to_vec(); let new_memory_id = issued_id("memory"); let memory = match target { - MemoryRevisionTarget::Create => { + MemoryChangeTarget::Create => { if draft.state != MemoryState::Active { return Err(SubjektivError::InvalidRecord( "a new Memory must start in active state".into(), )); } - write_memory_revision(transaction, subject_id, &new_memory_id, None, draft)? + write_memory_change(transaction, subject_id, &new_memory_id, None, draft)? } - MemoryRevisionTarget::Revise { + MemoryChangeTarget::Revise { memory_id, - expected_revision, - } => write_memory_revision( + expected_change_id, + } => write_memory_change( transaction, subject_id, memory_id, - Some(*expected_revision), + Some(expected_change_id.clone()), draft, )?, }; - let reference = MemoryRevisionRef { + let reference = MemoryChangeRef { memory_id: memory.id.clone(), - revision: memory.revision, + change_id: memory.change_id.clone(), }; let resolutions = candidate_ids .iter() @@ -2606,11 +2148,11 @@ fn apply_candidates_in_transaction( Ok((memory, resolutions)) } -fn write_memory_revision( +fn write_memory_change( transaction: &Transaction<'_>, subject_id: &str, memory_id: &str, - expected_revision: Option, + expected_change_id: Option, draft: MemoryDraft, ) -> Result { validate_memory_draft(&draft)?; @@ -2625,24 +2167,24 @@ fn write_memory_revision( .optional()?; let current = current_raw.map(|raw| parse_memory(&raw)).transpose()?; - let (revision, created_at) = match (current.as_ref(), expected_revision) { - (None, None) => (1, now()), + let (change_id, created_at) = match (current.as_ref(), expected_change_id) { + (None, None) => (issued_id("memory-change"), now()), (None, Some(_)) => return Err(SubjektivError::MemoryNotFound(memory_id.to_string())), (Some(_), None) => { return Err(SubjektivError::InvalidRecord(format!( "memory `{memory_id}` already exists" ))); } - (Some(current), Some(expected)) if current.revision != expected => { - return Err(SubjektivError::RevisionConflict { + (Some(current), Some(expected)) if current.change_id != expected => { + return Err(SubjektivError::MemoryChangeConflict { memory_id: memory_id.to_string(), expected, - actual: current.revision, + actual: current.change_id.clone(), }); } (Some(current), Some(_)) => { validate_state_transition(memory_id, current.state, draft.state)?; - (current.revision + 1, current.created_at.clone()) + (issued_id("memory-change"), current.created_at.clone()) } }; @@ -2656,7 +2198,8 @@ fn write_memory_revision( schema_version: SUBJEKTIV_SCHEMA_VERSION, id: memory_id.to_string(), subject_id: subject_id.to_string(), - revision, + change_id: change_id.clone(), + previous_change_id: current.as_ref().map(|record| record.change_id.clone()), kind: draft.kind, state: draft.state, claim: draft.claim, @@ -2674,13 +2217,13 @@ fn write_memory_revision( if current.is_none() { transaction.execute( "INSERT INTO memory_records ( - subject_id, memory_id, current_revision, kind, state, + subject_id, memory_id, current_change_id, kind, state, record_json, created_at, updated_at ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![ subject_id, memory_id, - to_i64(revision)?, + change_id, candidate_kind_name(&record.kind), memory_state_name(record.state), raw, @@ -2690,29 +2233,30 @@ fn write_memory_revision( )?; } transaction.execute( - "INSERT INTO memory_revisions ( - subject_id, memory_id, revision, kind, state, record_json, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + "INSERT INTO memory_changes ( + subject_id, memory_id, change_id, kind, state, record_json, created_at, previous_change_id + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![ subject_id, memory_id, - to_i64(revision)?, + change_id, candidate_kind_name(&record.kind), memory_state_name(record.state), raw, - record.updated_at + record.updated_at, + record.previous_change_id ], )?; if current.is_some() { transaction.execute( "UPDATE memory_records - SET current_revision = ?3, kind = ?4, state = ?5, + SET current_change_id = ?3, kind = ?4, state = ?5, record_json = ?6, updated_at = ?7 WHERE subject_id = ?1 AND memory_id = ?2", params![ subject_id, memory_id, - to_i64(revision)?, + change_id, candidate_kind_name(&record.kind), memory_state_name(record.state), raw, @@ -2722,62 +2266,51 @@ fn write_memory_revision( } for candidate_id in &record.source_candidate_ids { transaction.execute( - "INSERT INTO memory_revision_candidates ( - subject_id, memory_id, revision, candidate_id + "INSERT INTO memory_change_candidates ( + subject_id, memory_id, change_id, candidate_id ) VALUES (?1, ?2, ?3, ?4)", - params![subject_id, memory_id, to_i64(revision)?, candidate_id], + params![subject_id, memory_id, change_id, candidate_id], )?; } for source in &record.derived_from { transaction.execute( - "INSERT INTO memory_revision_derivations ( - subject_id, memory_id, revision, source_memory_id, source_revision + "INSERT INTO memory_change_derivations ( + subject_id, memory_id, change_id, source_memory_id, source_change_id ) VALUES (?1, ?2, ?3, ?4, ?5)", params![ subject_id, memory_id, - to_i64(revision)?, + change_id, source.memory_id, - to_i64(source.revision)? + source.change_id ], )?; } transaction.execute( - "INSERT INTO memory_revision_seals (subject_id, memory_id, revision) + "INSERT INTO memory_change_seals (subject_id, memory_id, change_id) VALUES (?1, ?2, ?3)", - params![subject_id, memory_id, to_i64(revision)?], + params![subject_id, memory_id, change_id], )?; - subject.store_revision = subject - .store_revision - .checked_add(1) - .ok_or_else(|| SubjektivError::InvalidRecord("subject store revision overflow".into()))?; subject.updated_at = record.updated_at.clone(); - let subject_raw = serde_json::to_string(&subject)?; transaction.execute( - "UPDATE subjects - SET store_revision = ?2, record_json = ?3, updated_at = ?4 - WHERE subject_id = ?1", + "UPDATE subjects SET record_json = ?2, updated_at = ?3 WHERE subject_id = ?1", params![ subject_id, - to_i64(subject.store_revision)?, - subject_raw, + serde_json::to_string(&subject)?, subject.updated_at ], )?; + let memory_fingerprint = memory_fingerprint_in_connection(transaction, subject_id)?; transaction.execute( "INSERT INTO surface_generation_state ( - subject_id, store_revision, status, snapshot_id, reason_code, updated_at + subject_id, memory_fingerprint, status, snapshot_id, reason_code, updated_at ) VALUES (?1, ?2, 'dirty', NULL, NULL, ?3) ON CONFLICT(subject_id) DO UPDATE SET - store_revision = excluded.store_revision, + memory_fingerprint = excluded.memory_fingerprint, status = 'dirty', snapshot_id = NULL, reason_code = NULL, updated_at = excluded.updated_at", - params![ - subject_id, - to_i64(subject.store_revision)?, - subject.updated_at - ], + params![subject_id, memory_fingerprint, subject.updated_at], )?; Ok(record) } @@ -2802,7 +2335,7 @@ fn write_staging_candidate( } return Err(SubjektivError::CandidateConflict(record.id.clone())); } - validate_staged_revision_proposal(transaction, &record)?; + validate_staged_change_proposal(transaction, &record)?; transaction.execute( "INSERT INTO staging_records ( subject_id, candidate_id, kind, record_json, created_at @@ -2868,7 +2401,7 @@ fn insert_resolution( candidate_id: &str, action: StagingResolutionAction, reason: &str, - affected_memory: &[MemoryRevisionRef], + affected_memory: &[MemoryChangeRef], ) -> Result { require_active_subject(transaction, subject_id)?; reject_duplicate_refs("staging resolution", affected_memory)?; @@ -2876,19 +2409,19 @@ fn insert_resolution( if action == StagingResolutionAction::Applied { if affected_memory.is_empty() { return Err(SubjektivError::InvalidRecord( - "an applied candidate requires an affected Memory revision".into(), + "an applied candidate requires an affected Memory change".into(), )); } for reference in affected_memory { let reciprocal = transaction .query_row( - "SELECT 1 FROM memory_revision_candidates + "SELECT 1 FROM memory_change_candidates WHERE subject_id = ?1 AND memory_id = ?2 - AND revision = ?3 AND candidate_id = ?4", + AND change_id = ?3 AND candidate_id = ?4", params![ subject_id, reference.memory_id, - to_i64(reference.revision)?, + reference.change_id, candidate_id ], |_| Ok(()), @@ -2898,7 +2431,7 @@ fn insert_resolution( if !reciprocal { return Err(SubjektivError::InvalidRecord(format!( "applied candidate `{candidate_id}` has no reciprocal provenance edge to `{}@{}`", - reference.memory_id, reference.revision + reference.memory_id, reference.change_id ))); } } @@ -2944,13 +2477,13 @@ fn insert_resolution( for reference in affected_memory { transaction.execute( "INSERT INTO staging_resolution_targets ( - subject_id, candidate_id, memory_id, revision + subject_id, candidate_id, memory_id, change_id ) VALUES (?1, ?2, ?3, ?4)", params![ subject_id, candidate_id, reference.memory_id, - to_i64(reference.revision)? + reference.change_id ], )?; } @@ -3067,20 +2600,20 @@ fn validate_candidate_refs( fn validate_memory_refs( transaction: &Transaction<'_>, subject_id: &str, - references: &[MemoryRevisionRef], + references: &[MemoryChangeRef], ) -> Result<()> { for reference in references { - if reference.revision == 0 { + if reference.change_id.is_empty() { return Err(SubjektivError::InvalidRecord(format!( - "Memory `{}` revision must be greater than zero", + "Memory `{}` change_id must be a nonempty change identity", reference.memory_id ))); } let exists = transaction .query_row( - "SELECT 1 FROM memory_revisions - WHERE subject_id = ?1 AND memory_id = ?2 AND revision = ?3", - params![subject_id, reference.memory_id, to_i64(reference.revision)?], + "SELECT 1 FROM memory_changes + WHERE subject_id = ?1 AND memory_id = ?2 AND change_id = ?3", + params![subject_id, reference.memory_id, reference.change_id], |_| Ok(()), ) .optional()? @@ -3088,7 +2621,7 @@ fn validate_memory_refs( if !exists { return Err(SubjektivError::SubjectScopeMismatch { subject_id: subject_id.to_string(), - reference: format!("{}@{}", reference.memory_id, reference.revision), + reference: format!("{}@{}", reference.memory_id, reference.change_id), }); } } @@ -3220,8 +2753,8 @@ fn validate_staging_record(workspace_id: &str, record: &SubjectStagingRecord) -> }); } } - if let Some(proposal) = &record.revision_proposal { - validate_revision_proposal_metadata(proposal)?; + if let Some(proposal) = &record.change_proposal { + validate_change_proposal_metadata(proposal)?; } Ok(()) } @@ -3239,63 +2772,63 @@ fn validate_staging_admission(record: &SubjectStagingRecord) -> Result<()> { Ok(()) } -fn validate_revision_proposal_metadata(proposal: &RevisionProposal) -> Result<()> { - validate_label("revision proposal Memory id", &proposal.memory_id)?; - if proposal.expected_revision == 0 { +fn validate_change_proposal_metadata(proposal: &ChangeProposal) -> Result<()> { + validate_label("change proposal Memory id", &proposal.memory_id)?; + if proposal.expected_change_id.is_empty() { return Err(SubjektivError::InvalidRecord( - "revision proposal expected_revision must be greater than zero".into(), + "change proposal expected_change_id must be a nonempty change identity".into(), )); } - validate_nonempty("revision proposal change_reason", &proposal.change_reason) + validate_nonempty("change proposal change_reason", &proposal.change_reason) } fn validate_candidate_application( transaction: &Transaction<'_>, subject_id: &str, candidate: &SubjectStagingRecord, - target: &MemoryRevisionTarget, + target: &MemoryChangeTarget, draft: &MemoryDraft, ) -> Result<()> { - let Some(proposal) = &candidate.revision_proposal else { + let Some(proposal) = &candidate.change_proposal else { return Ok(()); }; - let MemoryRevisionTarget::Revise { + let MemoryChangeTarget::Revise { memory_id, - expected_revision, + expected_change_id, } = target else { return Err(SubjektivError::InvalidRecord( - "a revision proposal cannot create a different Memory".into(), + "a change proposal cannot create a different Memory".into(), )); }; - if memory_id != &proposal.memory_id || expected_revision != &proposal.expected_revision { + if memory_id != &proposal.memory_id || expected_change_id != &proposal.expected_change_id { return Err(SubjektivError::InvalidRecord( - "candidate application target must exactly match revision proposal metadata".into(), + "candidate application target must exactly match change proposal metadata".into(), )); } let current = scoped_memory_in_connection(transaction, subject_id, memory_id)?; - if current.revision != *expected_revision { - return Err(SubjektivError::RevisionConflict { + if current.change_id != *expected_change_id { + return Err(SubjektivError::MemoryChangeConflict { memory_id: memory_id.clone(), - expected: *expected_revision, - actual: current.revision, + expected: expected_change_id.clone(), + actual: current.change_id.clone(), }); } let expected_state = match proposal.intent { - RevisionProposalIntent::Revise => current.state, - RevisionProposalIntent::Resolve => MemoryState::Resolved, - RevisionProposalIntent::Retract => MemoryState::Retracted, - RevisionProposalIntent::Reopen => MemoryState::Active, + ChangeProposalIntent::Revise => current.state, + ChangeProposalIntent::Resolve => MemoryState::Resolved, + ChangeProposalIntent::Retract => MemoryState::Retracted, + ChangeProposalIntent::Reopen => MemoryState::Active, }; let intent_allowed = match proposal.intent { - RevisionProposalIntent::Revise => { + ChangeProposalIntent::Revise => { matches!(current.state, MemoryState::Active | MemoryState::Resolved) } - RevisionProposalIntent::Resolve => current.state == MemoryState::Active, - RevisionProposalIntent::Retract => { + ChangeProposalIntent::Resolve => current.state == MemoryState::Active, + ChangeProposalIntent::Retract => { matches!(current.state, MemoryState::Active | MemoryState::Resolved) } - RevisionProposalIntent::Reopen => current.state == MemoryState::Resolved, + ChangeProposalIntent::Reopen => current.state == MemoryState::Resolved, }; if !intent_allowed || draft.state != expected_state { return Err(SubjektivError::InvalidStateTransition { @@ -3306,48 +2839,48 @@ fn validate_candidate_application( } if draft.kind != current.kind || draft.kind != candidate.kind { return Err(SubjektivError::InvalidRecord( - "revision proposal application must preserve the target Memory kind".into(), + "change proposal application must preserve the target Memory kind".into(), )); } if draft.change_reason != proposal.change_reason { return Err(SubjektivError::InvalidRecord( - "revision proposal application must preserve change_reason".into(), + "change proposal application must preserve change_reason".into(), )); } Ok(()) } -fn validate_staged_revision_proposal( +fn validate_staged_change_proposal( transaction: &Transaction<'_>, record: &SubjectStagingRecord, ) -> Result<()> { - let Some(proposal) = &record.revision_proposal else { + let Some(proposal) = &record.change_proposal else { return Ok(()); }; let current = scoped_memory_in_connection(transaction, &record.subject_id, &proposal.memory_id)?; - if current.revision != proposal.expected_revision { - return Err(SubjektivError::RevisionConflict { + if current.change_id != proposal.expected_change_id { + return Err(SubjektivError::MemoryChangeConflict { memory_id: proposal.memory_id.clone(), - expected: proposal.expected_revision, - actual: current.revision, + expected: proposal.expected_change_id.clone(), + actual: current.change_id.clone(), }); } let to = match proposal.intent { - RevisionProposalIntent::Revise => current.state, - RevisionProposalIntent::Resolve => MemoryState::Resolved, - RevisionProposalIntent::Retract => MemoryState::Retracted, - RevisionProposalIntent::Reopen => MemoryState::Active, + ChangeProposalIntent::Revise => current.state, + ChangeProposalIntent::Resolve => MemoryState::Resolved, + ChangeProposalIntent::Retract => MemoryState::Retracted, + ChangeProposalIntent::Reopen => MemoryState::Active, }; let allowed = match proposal.intent { - RevisionProposalIntent::Revise => { + ChangeProposalIntent::Revise => { matches!(current.state, MemoryState::Active | MemoryState::Resolved) } - RevisionProposalIntent::Resolve => current.state == MemoryState::Active, - RevisionProposalIntent::Retract => { + ChangeProposalIntent::Resolve => current.state == MemoryState::Active, + ChangeProposalIntent::Retract => { matches!(current.state, MemoryState::Active | MemoryState::Resolved) } - RevisionProposalIntent::Reopen => current.state == MemoryState::Resolved, + ChangeProposalIntent::Reopen => current.state == MemoryState::Resolved, }; if allowed { Ok(()) @@ -3489,9 +3022,9 @@ fn parse_staging_without_workspace(raw: &str) -> Result { fn parse_memory(raw: &str) -> Result { let record: MemoryRecord = serde_json::from_str(raw)?; validate_schema_version(record.schema_version, "Memory")?; - if record.revision == 0 { + if record.change_id.is_empty() { return Err(SubjektivError::InvalidRecord( - "Memory revision must be greater than zero".to_string(), + "Memory change must be a nonempty change identity".to_string(), )); } Ok(record) @@ -3535,27 +3068,26 @@ fn resident_surface_in_connection( ) -> Result { let state = connection .query_row( - "SELECT store_revision, status, snapshot_id + "SELECT memory_fingerprint, status, snapshot_id FROM surface_generation_state WHERE subject_id = ?1", [&subject.id], |row| { Ok(( - row.get::<_, i64>(0)?, + row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, Option>(2)?, )) }, ) .optional()?; - let Some((revision, status, snapshot_id)) = state else { + let Some((change_id, status, snapshot_id)) = state else { return Ok(ResidentSurface { availability: SurfaceAvailability::Ungenerated, snapshot: None, }); }; - let revision = u64::try_from(revision) - .map_err(|_| SubjektivError::InvalidRecord("surface state revision is negative".into()))?; - if revision != subject.store_revision || status == "dirty" { + let memory_fingerprint = memory_fingerprint_in_connection(connection, &subject.id)?; + if change_id != memory_fingerprint || status == "dirty" { return Ok(ResidentSurface { availability: SurfaceAvailability::Stale, snapshot: None, @@ -3577,7 +3109,7 @@ fn resident_surface_in_connection( |row| row.get::<_, String>(0), )?; let snapshot = parse_surface_snapshot(&raw)?; - if snapshot.built_from_store_revision != subject.store_revision { + if snapshot.built_from_memory_fingerprint != memory_fingerprint { return Err(SubjektivError::InvalidRecord( "ready surface snapshot generation does not match subject".into(), )); @@ -3640,21 +3172,21 @@ fn reject_duplicates(kind: &str, values: &[String]) -> Result<()> { Ok(()) } -fn reject_duplicate_refs(kind: &str, values: &[MemoryRevisionRef]) -> Result<()> { +fn reject_duplicate_refs(kind: &str, values: &[MemoryChangeRef]) -> Result<()> { let mut seen = HashSet::new(); if let Some(duplicate) = values .iter() - .find(|value| !seen.insert((value.memory_id.as_str(), value.revision))) + .find(|value| !seen.insert((value.memory_id.as_str(), value.change_id.as_str()))) { return Err(SubjektivError::InvalidRecord(format!( "duplicate {kind} `{}@{}`", - duplicate.memory_id, duplicate.revision + duplicate.memory_id, duplicate.change_id ))); } Ok(()) } -fn reject_duplicate_memory_ids(kind: &str, values: &[MemoryRevisionRef]) -> Result<()> { +fn reject_duplicate_memory_ids(kind: &str, values: &[MemoryChangeRef]) -> Result<()> { let mut seen = HashSet::new(); if let Some(duplicate) = values .iter() @@ -3674,23 +3206,52 @@ fn require_job_generation( generation_id: &str, job_id: &str, attempt_id: &str, -) -> Result { - let revision: Option = connection.query_row( - "SELECT store_revision FROM surface_generation_runs WHERE subject_id = ?1 AND generation_id = ?2 AND job_id = ?3 AND attempt_id = ?4", +) -> Result { + let change_id: Option = connection.query_row( + "SELECT memory_fingerprint FROM surface_generation_runs WHERE subject_id = ?1 AND generation_id = ?2 AND job_id = ?3 AND attempt_id = ?4", params![subject_id, generation_id, job_id, attempt_id], |row| row.get(0), ).optional()?; - revision - .and_then(|value| u64::try_from(value).ok()) - .ok_or_else(|| { - SubjektivError::InvalidRecord( - "surface generation is not owned by this Job attempt".into(), - ) - }) + change_id.ok_or_else(|| { + SubjektivError::InvalidRecord("surface generation is not owned by this Job attempt".into()) + }) +} + +fn memory_fingerprint_in_connection(connection: &Connection, subject_id: &str) -> Result { + let mut statement = connection.prepare( + "SELECT memory_id, current_change_id FROM memory_records WHERE subject_id = ?1 ORDER BY memory_id ASC", + )?; + let identities = statement + .query_map([subject_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })? + .collect::, _>>()?; + Ok(memory_identity_fingerprint(&identities)) +} + +fn memory_identity_fingerprint(identities: &[(String, String)]) -> String { + use sha2::{Digest, Sha256}; + let mut identities = identities.to_vec(); + identities.sort(); + // Length framing prevents delimiter collisions. Change identities include + // every state/body/provenance update and prevent ABA reuse. + let mut digest = Sha256::new(); + digest.update(b"subjektiv-memory-identities-v1\0"); + for (memory_id, change_id) in identities { + digest.update((memory_id.len() as u64).to_be_bytes()); + digest.update(memory_id.as_bytes()); + digest.update((change_id.len() as u64).to_be_bytes()); + digest.update(change_id.as_bytes()); + } + digest + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() } fn to_i64(value: u64) -> Result { i64::try_from(value).map_err(|_| { - SubjektivError::InvalidRecord(format!("revision value {value} exceeds SQLite range")) + SubjektivError::InvalidRecord(format!("change_id value {value} exceeds SQLite range")) }) } @@ -3738,30 +3299,15 @@ mod tests { use super::*; use feature_storage::FeatureStorage; - static V1_MIGRATIONS: &[FeatureMigration] = &[FeatureMigration::new( - 1, - "create subjektiv subject memory store", - create_schema, - )]; - static V3_MIGRATIONS: &[FeatureMigration] = &[ - FeatureMigration::new(1, "create subjektiv subject memory store", create_schema), - FeatureMigration::new( - 2, - "add immutable subject session attribution", - add_subject_session_attribution, - ), - FeatureMigration::new( - 3, - "add idempotent atomic candidate decision receipts", - add_candidate_decision_receipts, - ), - ]; - - fn role() -> SubjectRole { + pub(super) fn role() -> SubjectRole { SubjectRole::new("workspace_companion").unwrap() } - fn candidate(subject_id: &str, candidate_id: &str, workspace_id: &str) -> SubjectStagingRecord { + pub(super) fn candidate( + subject_id: &str, + candidate_id: &str, + workspace_id: &str, + ) -> SubjectStagingRecord { let origin = EvidenceOrigin { kind: EvidenceOriginKind::HumanInput, account_id: Some("account-1".into()), @@ -3770,7 +3316,7 @@ mod tests { worker_id: None, flow_selector: None, flow_definition_id: None, - flow_definition_revision: None, + flow_definition_fingerprint: None, }; let record = StagingRecord::from_candidate( candidate_id, @@ -3808,7 +3354,7 @@ mod tests { SubjectStagingRecord::attach_at(subject_id, record, "2026-09-28T10:00:00.000Z".into()) } - fn attribution( + pub(super) fn attribution( subject_id: &str, runtime_id: &str, worker_id: &str, @@ -3832,15 +3378,15 @@ mod tests { } fn proposal( - intent: RevisionProposalIntent, + intent: ChangeProposalIntent, memory_id: &str, - expected_revision: u64, + expected_change_id: String, change_reason: &str, - ) -> RevisionProposal { - RevisionProposal::new(intent, memory_id, expected_revision, change_reason).unwrap() + ) -> ChangeProposal { + ChangeProposal::new(intent, memory_id, expected_change_id, change_reason).unwrap() } - fn open_store( + pub(super) fn open_store( root: &std::path::Path, workspace_id: &str, ) -> (FeatureStorage, ScopedFeatureStorage, SubjektivStore) { @@ -3885,68 +3431,54 @@ mod tests { } #[test] - fn subject_behavior_is_bounded_revisioned_and_independent_from_memory_revision() { + fn behavior_content_cas_is_independent_of_memory() { let temp = tempfile::tempdir().unwrap(); - let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); let subject = store - .create_subject_with_behavior(role(), "Prefer explicit evidence.".to_string()) + .create_subject_with_behavior(role(), "Prefer explicit evidence.".into()) .unwrap(); - assert_eq!(subject.behavior_revision, 0); - assert_eq!(subject.store_revision, 0); - + let before = store.memory_fingerprint(&subject.id).unwrap(); let updated = store .update_subject_behavior( &subject.id, - 0, - "Prefer explicit evidence.\nAsk when uncertain.".to_string(), + &subject.behavior_md, + "Ask when uncertain.".into(), ) .unwrap(); - assert_eq!(updated.behavior_revision, 1); - assert_eq!(updated.store_revision, 0); - + assert_eq!(store.memory_fingerprint(&subject.id).unwrap(), before); store - .create_memory( + .create_memory(&subject.id, draft("confirmed", "isolation")) + .unwrap(); + let after = store.subject(&subject.id).unwrap().unwrap(); + assert_eq!(after.behavior_md, updated.behavior_md); + assert_ne!(store.memory_fingerprint(&subject.id).unwrap(), before); + let (_reopened_manager, _reopened_scope, reopened) = open_store(temp.path(), "workspace-a"); + assert_eq!( + reopened.subject(&subject.id).unwrap().unwrap().behavior_md, + updated.behavior_md + ); + assert!(matches!( + store.update_subject_behavior(&subject.id, &subject.behavior_md, String::new()), + Err(SubjektivError::SubjectBehaviorConflict { .. }) + )); + let unchanged = store + .update_subject_behavior( &subject.id, - draft( - "A confirmed observation", - "exercise Memory lifecycle isolation", - ), + &updated.behavior_md, + updated.behavior_md.clone(), ) .unwrap(); - let after_memory = store.subject(&subject.id).unwrap().unwrap(); - assert_eq!(after_memory.behavior_md, updated.behavior_md); - assert_eq!(after_memory.behavior_revision, 1); - assert_eq!(after_memory.store_revision, 1); - - let (_reopened_manager, _reopened_workspace, reopened) = - open_store(temp.path(), "workspace-a"); - let persisted = reopened.subject(&subject.id).unwrap().unwrap(); - assert_eq!(persisted.behavior_md, updated.behavior_md); - assert_eq!(persisted.behavior_revision, 1); - assert!(matches!( - store.update_subject_behavior(&subject.id, 0, String::new()), - Err(SubjektivError::SubjectBehaviorConflict { - expected: 0, - actual: 1, - .. - }) - )); - - let unchanged = store - .update_subject_behavior(&subject.id, 1, updated.behavior_md.clone()) - .unwrap(); - assert_eq!(unchanged.behavior_revision, 1); + assert_eq!(unchanged, after); let cleared = store - .update_subject_behavior(&subject.id, 1, String::new()) + .update_subject_behavior(&subject.id, &updated.behavior_md, String::new()) .unwrap(); - assert_eq!(cleared.behavior_revision, 2); assert!(cleared.behavior_md.is_empty()); assert!(validate_subject_behavior(&"x".repeat(MAX_SUBJECT_BEHAVIOR_BYTES + 1)).is_err()); assert!(validate_subject_behavior(" ").is_err()); } #[test] - fn attributed_candidates_preserve_revision_and_surface_reference_continuity() { + fn attributed_candidates_preserve_change_and_surface_reference_continuity() { let temp = tempfile::tempdir().unwrap(); let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); let subject = store.create_subject(role()).unwrap(); @@ -3976,7 +3508,7 @@ mod tests { candidate_id: first_candidate.id.clone(), reason: "adopt the attributed candidate".into(), decision: CandidateDecision::Apply { - target: MemoryRevisionTarget::Create, + target: MemoryChangeTarget::Create, draft: draft( "Keep the first confirmed rule", "initial attributed decision", @@ -3986,20 +3518,26 @@ mod tests { let created = store .decide_candidate(&subject.id, create_request.clone()) .unwrap(); - let first_revision = created.memory.unwrap(); - assert_eq!(first_revision.revision, 1); + let first_change = created.memory.unwrap(); + assert!(!first_change.change_id.is_empty()); assert_eq!( - first_revision.source_candidate_ids, + first_change.source_candidate_ids, [first_candidate.id.clone()] ); assert_eq!(created.resolution.affected_memory.len(), 1); - assert_eq!(created.resolution.affected_memory[0].revision, 1); + assert_eq!( + created.resolution.affected_memory[0].change_id, + first_change.change_id + ); // Exact decision retries return the original receipt rather than creating - // a second revision or resolution. + // a second change_id or resolution. let replayed = store.decide_candidate(&subject.id, create_request).unwrap(); - assert_eq!(replayed.store_revision, created.store_revision); - assert_eq!(replayed.memory.as_ref().unwrap().revision, 1); + assert_eq!(replayed.memory_fingerprint, created.memory_fingerprint); + assert_eq!( + replayed.memory.as_ref().unwrap().change_id, + first_change.change_id + ); let (second_candidate, _) = store .stage_candidate_with_attribution( @@ -4015,21 +3553,29 @@ mod tests { candidate_id: second_candidate.id.clone(), reason: "apply the attributed correction".into(), decision: CandidateDecision::Apply { - target: MemoryRevisionTarget::Revise { - memory_id: first_revision.id.clone(), - expected_revision: 1, + target: MemoryChangeTarget::Revise { + memory_id: first_change.id.clone(), + expected_change_id: first_change.change_id.clone(), }, draft: draft("Keep the corrected confirmed rule", "attributed correction"), }, }, ) .unwrap(); - let second_revision = revised.memory.unwrap(); - assert_eq!(second_revision.revision, 2); - assert_eq!(second_revision.source_candidate_ids, [second_candidate.id]); + let second_change = revised.memory.unwrap(); + assert_ne!(second_change.change_id, first_change.change_id); + assert_eq!( + second_change.previous_change_id.as_deref(), + Some(first_change.change_id.as_str()) + ); + assert_eq!(second_change.source_candidate_ids, [second_candidate.id]); let historical = store - .scoped_memory_revision(&subject.id, &first_revision.id, 1) + .scoped_memory_change( + &subject.id, + &first_change.id, + first_change.change_id.clone(), + ) .unwrap() .unwrap(); assert_eq!(historical.source_candidate_ids, [first_candidate.id]); @@ -4044,11 +3590,11 @@ mod tests { let generation = store.prepare_surface_generation(&subject.id).unwrap(); assert_eq!(generation.materials.len(), 1); - assert_eq!(generation.materials[0].memory_id, second_revision.id); - assert_eq!(generation.materials[0].revision, 2); - let expected_ref = MemoryRevisionRef { - memory_id: second_revision.id.clone(), - revision: second_revision.revision, + assert_eq!(generation.materials[0].memory_id, second_change.id); + assert_eq!(generation.materials[0].change_id, second_change.change_id); + let expected_ref = MemoryChangeRef { + memory_id: second_change.id.clone(), + change_id: second_change.change_id.clone(), }; let published = store .publish_surface_generation( @@ -4061,7 +3607,10 @@ mod tests { ) .unwrap(); assert_eq!(published.memory_refs, [expected_ref.clone()]); - assert_eq!(published.built_from_store_revision, revised.store_revision); + assert_eq!( + published.built_from_memory_fingerprint, + revised.memory_fingerprint + ); let resident = store.resident_surface(&subject.id).unwrap(); assert_eq!(resident.availability, SurfaceAvailability::Ready); @@ -4079,47 +3628,53 @@ mod tests { decoded.source_refs[0].session_id.as_deref(), Some("session-1") ); - assert!(decoded.revision_proposal.is_none()); - assert!(!raw.contains("revision_proposal")); + assert!(decoded.change_proposal.is_none()); + assert!(!raw.contains("change_proposal")); assert!(raw.contains("human_input")); let proposed = staging .clone() - .with_revision_proposal(proposal( - RevisionProposalIntent::Revise, + .with_change_proposal(proposal( + ChangeProposalIntent::Revise, "memory-1", - 3, + "fixture-change".into(), "Incorporate the new bounded evidence", )) .unwrap(); let proposed_raw = serde_json::to_string(&proposed).unwrap(); let proposed_decoded: SubjectStagingRecord = serde_json::from_str(&proposed_raw).unwrap(); assert_eq!( - proposed_decoded.revision_proposal, + proposed_decoded.change_proposal, Some(proposal( - RevisionProposalIntent::Revise, + ChangeProposalIntent::Revise, "memory-1", - 3, + "fixture-change".into(), "Incorporate the new bounded evidence", )) ); assert!( - RevisionProposal::new( - RevisionProposalIntent::Revise, + ChangeProposal::new( + ChangeProposalIntent::Revise, "memory-1", - 0, - "invalid zero revision" + String::new(), + "invalid zero change_id" ) .is_err() ); assert!( - RevisionProposal::new(RevisionProposalIntent::Revise, "memory-1", 1, " ").is_err() + ChangeProposal::new( + ChangeProposalIntent::Revise, + "memory-1", + "fixture-change".into(), + " " + ) + .is_err() ); assert!( - serde_json::from_value::(serde_json::json!({ + serde_json::from_value::(serde_json::json!({ "intent": "revise", "memory_id": "memory-1", - "expected_revision": 0, + "expected_change_id": "", "change_reason": "invalid" })) .is_err() @@ -4136,7 +3691,8 @@ mod tests { schema_version: SUBJEKTIV_SCHEMA_VERSION, id: "memory-1".into(), subject_id: "subject-1".into(), - revision: 1, + change_id: "fixture-change".into(), + previous_change_id: None, kind: CandidateKind::Constraint, state: MemoryState::Active, claim: "Memory is not authority".into(), @@ -4151,189 +3707,10 @@ mod tests { }; let raw = serde_json::to_string(&memory).unwrap(); let decoded: MemoryRecord = serde_json::from_str(&raw).unwrap(); - assert_eq!(decoded.revision, 1); + assert!(!decoded.change_id.is_empty()); assert!(serde_json::from_str::(&raw.replace("active", "unknown")).is_err()); } - #[test] - fn v1_store_migrates_before_attributed_staging() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().join("storage"); - let subject_id = { - let manager = FeatureStorage::new(&root); - let workspace = manager.workspace("workspace-a").unwrap(); - let registration = workspace - .register(FeatureRegistration::new( - SUBJEKTIV_FEATURE_ID, - V1_MIGRATIONS, - )) - .unwrap(); - let store = SubjektivStore::open(&workspace, ®istration).unwrap(); - let timestamp = now(); - let subject_id = issued_id("subject"); - let mut raw = serde_json::json!({ - "schema_version": SUBJEKTIV_SCHEMA_VERSION, - "id": subject_id, - "role": role(), - "state": SubjectState::Active, - "store_revision": 0, - "created_at": timestamp.clone(), - "updated_at": timestamp.clone(), - }); - let raw = serde_json::to_string(&raw.take()).unwrap(); - store - .database - .try_transaction(|transaction| -> Result<()> { - transaction.execute( - "INSERT INTO subjects ( - subject_id, role, state, store_revision, - record_json, created_at, updated_at - ) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)", - params![subject_id, role().as_str(), raw, timestamp, timestamp], - )?; - Ok(()) - }) - .unwrap(); - subject_id - }; - - let (_manager, _workspace, store) = open_store(&root, "workspace-a"); - let migrated_subject = store.subject(&subject_id).unwrap().unwrap(); - assert!(migrated_subject.behavior_md.is_empty()); - assert_eq!(migrated_subject.behavior_revision, 0); - let lifecycle_attribution = attribution( - &subject_id, - "runtime-1", - "worker-1", - "session-1", - "2026-09-28T10:00:00.000Z", - ); - store - .record_session_attribution(lifecycle_attribution.clone()) - .unwrap(); - let (staged, stored_attribution) = store - .stage_candidate_with_attribution( - candidate(&subject_id, "candidate-after-migration", "workspace-a"), - lifecycle_attribution, - ) - .unwrap(); - - assert_eq!(staged.subject_id, subject_id); - assert_eq!(stored_attribution.session_id, "session-1"); - assert_eq!( - store.session_attribution("session-1").unwrap().unwrap(), - stored_attribution - ); - } - - #[test] - fn legacy_surface_snapshot_migrates_as_stale_until_regenerated() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().join("storage"); - let (subject_id, snapshot_id) = { - let manager = FeatureStorage::new(&root); - let workspace = manager.workspace("workspace-a").unwrap(); - let registration = workspace - .register(FeatureRegistration::new( - SUBJEKTIV_FEATURE_ID, - V3_MIGRATIONS, - )) - .unwrap(); - let store = SubjektivStore::open(&workspace, ®istration).unwrap(); - let subject_id = issued_id("subject"); - let timestamp = now(); - let subject_json = serde_json::to_string(&serde_json::json!({ - "schema_version": SUBJEKTIV_SCHEMA_VERSION, - "id": subject_id, - "role": role(), - "state": SubjectState::Active, - "store_revision": 0, - "created_at": timestamp, - "updated_at": timestamp, - })) - .unwrap(); - let snapshot = SurfaceSnapshot { - schema_version: SUBJEKTIV_SCHEMA_VERSION, - id: "legacy-surface".into(), - subject_id: subject_id.clone(), - body_md: "Legacy summary without generation-policy evidence.".into(), - memory_refs: Vec::new(), - built_from_store_revision: 0, - created_at: now(), - }; - store - .database - .try_transaction(|transaction| -> Result<()> { - transaction.execute( - "INSERT INTO subjects ( - subject_id, role, state, store_revision, - record_json, created_at, updated_at - ) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)", - params![ - subject_id, - role().as_str(), - subject_json, - timestamp, - timestamp - ], - )?; - transaction.execute( - "INSERT INTO surface_snapshots ( - subject_id, snapshot_id, built_from_store_revision, - snapshot_json, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5)", - params![ - subject_id, - snapshot.id, - to_i64(snapshot.built_from_store_revision)?, - serde_json::to_string(&snapshot)?, - snapshot.created_at - ], - )?; - transaction.execute( - "INSERT INTO surface_snapshot_seals (subject_id, snapshot_id) - VALUES (?1, ?2)", - params![subject_id, snapshot.id], - )?; - Ok(()) - }) - .unwrap(); - (subject_id, snapshot.id) - }; - - let (_manager, _workspace, store) = open_store(&root, "workspace-a"); - let resident = store.resident_surface(&subject_id).unwrap(); - assert_eq!(resident.availability, SurfaceAvailability::Stale); - assert!(resident.snapshot.is_none()); - assert!( - store - .surface_snapshot(&subject_id, &snapshot_id) - .unwrap() - .is_some(), - "migration retains the legacy snapshot as history without injecting it" - ); - - let generation = store.prepare_surface_generation(&subject_id).unwrap(); - let regenerated = store - .publish_surface_generation(&subject_id, &generation.id, Vec::new()) - .unwrap(); - assert_ne!(regenerated.id, snapshot_id); - let ready = store.resident_surface(&subject_id).unwrap(); - assert_eq!(ready.availability, SurfaceAvailability::Ready); - assert_eq!(ready.snapshot.unwrap().id, regenerated.id); - assert!( - store - .surface_snapshot(&subject_id, &snapshot_id) - .unwrap() - .is_some(), - "regeneration appends instead of mutating legacy history" - ); - let retried = store - .publish_surface_generation(&subject_id, &generation.id, Vec::new()) - .unwrap(); - assert_eq!(retried.id, regenerated.id); - } - #[test] fn attributed_staging_requires_lifecycle_record_and_retry_is_idempotent() { let temp = tempfile::tempdir().unwrap(); @@ -4657,7 +4034,7 @@ mod tests { } #[test] - fn revision_proposal_roundtrips_through_staging_and_resolution() { + fn change_proposal_roundtrips_through_staging_and_resolution() { let temp = tempfile::tempdir().unwrap(); let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); let subject = store.create_subject(role()).unwrap(); @@ -4665,25 +4042,25 @@ mod tests { .create_memory(&subject.id, draft("Existing memory", "initial observation")) .unwrap(); let proposal = proposal( - RevisionProposalIntent::Revise, + ChangeProposalIntent::Revise, &memory.id, - 1, + memory.change_id.clone(), "Refine the wording from new evidence", ); let staged = store .stage_candidate( candidate(&subject.id, "candidate-proposed", "workspace-a") - .with_revision_proposal(proposal.clone()) + .with_change_proposal(proposal.clone()) .unwrap(), ) .unwrap(); - assert_eq!(staged.revision_proposal, Some(proposal.clone())); + assert_eq!(staged.change_proposal, Some(proposal.clone())); assert_eq!( store .staging_candidate(&subject.id, &staged.id) .unwrap() .unwrap() - .revision_proposal, + .change_proposal, Some(proposal.clone()) ); @@ -4696,17 +4073,14 @@ mod tests { vec![], ) .unwrap(); - assert_eq!( - resolution.candidate.revision_proposal, - Some(proposal.clone()) - ); + assert_eq!(resolution.candidate.change_proposal, Some(proposal.clone())); assert_eq!( store .staging_resolution(&subject.id, &staged.id) .unwrap() .unwrap() .candidate - .revision_proposal, + .change_proposal, Some(proposal) ); } @@ -4723,10 +4097,10 @@ mod tests { let staged = store .stage_candidate( candidate(&subject.id, "candidate-resolve-atomic", "workspace-a") - .with_revision_proposal(proposal( - RevisionProposalIntent::Resolve, + .with_change_proposal(proposal( + ChangeProposalIntent::Resolve, &memory.id, - memory.revision, + memory.change_id.clone(), change_reason, )) .unwrap(), @@ -4739,9 +4113,9 @@ mod tests { candidate_id: staged.id.clone(), reason: "Accepted exact resolution proposal".into(), decision: CandidateDecision::Apply { - target: MemoryRevisionTarget::Revise { + target: MemoryChangeTarget::Revise { memory_id: memory.id.clone(), - expected_revision: memory.revision, + expected_change_id: memory.change_id.clone(), }, draft: revised, }, @@ -4752,23 +4126,29 @@ mod tests { .unwrap(); let retried = store.decide_candidate(&subject.id, request).unwrap(); - assert_eq!(first.memory.as_ref().unwrap().revision, 2); - assert_eq!(retried.memory.as_ref().unwrap().revision, 2); + assert_eq!( + first.memory.as_ref().unwrap().previous_change_id.as_deref(), + Some(memory.change_id.as_str()) + ); + assert_eq!( + retried.memory.as_ref().unwrap().change_id, + first.memory.as_ref().unwrap().change_id + ); assert_eq!(first.resolution.id, retried.resolution.id); - assert_eq!(first.store_revision, 2); + assert_eq!(first.memory_fingerprint, retried.memory_fingerprint); assert!(first.surface_dirty); assert_eq!( first .resolution .candidate - .revision_proposal + .change_proposal .unwrap() .change_reason, change_reason ); assert_eq!( store - .list_memory_revisions(&subject.id, &memory.id) + .list_memory_changes(&subject.id, &memory.id) .unwrap() .len(), 2 @@ -4786,17 +4166,22 @@ mod tests { let stale = store .stage_candidate( candidate(&subject.id, "candidate-stale-apply", "workspace-a") - .with_revision_proposal(proposal( - RevisionProposalIntent::Revise, + .with_change_proposal(proposal( + ChangeProposalIntent::Revise, &memory.id, - 1, + memory.change_id.clone(), "stale correction", )) .unwrap(), ) .unwrap(); store - .revise_memory(&subject.id, &memory.id, 1, draft("Advanced", "advance")) + .revise_memory( + &subject.id, + &memory.id, + memory.change_id.clone(), + draft("Advanced", "advance"), + ) .unwrap(); let stale_result = store.decide_candidate( &subject.id, @@ -4805,9 +4190,9 @@ mod tests { candidate_id: stale.id.clone(), reason: "must not auto-rebase".into(), decision: CandidateDecision::Apply { - target: MemoryRevisionTarget::Revise { + target: MemoryChangeTarget::Revise { memory_id: memory.id.clone(), - expected_revision: 1, + expected_change_id: memory.change_id.clone(), }, draft: draft("Stale", "stale correction"), }, @@ -4815,11 +4200,7 @@ mod tests { ); assert!(matches!( stale_result, - Err(SubjektivError::RevisionConflict { - expected: 1, - actual: 2, - .. - }) + Err(SubjektivError::MemoryChangeConflict { .. }) )); assert!( store @@ -4830,17 +4211,17 @@ mod tests { let apply_intent = |store: &SubjektivStore, candidate_id: &str, - intent: RevisionProposalIntent, - expected_revision: u64, + intent: ChangeProposalIntent, + expected_change_id: String, state: MemoryState, reason: &str| { let staged = store .stage_candidate( candidate(&subject.id, candidate_id, "workspace-a") - .with_revision_proposal(proposal( + .with_change_proposal(proposal( intent, &memory.id, - expected_revision, + expected_change_id.clone(), reason, )) .unwrap(), @@ -4856,9 +4237,9 @@ mod tests { candidate_id: staged.id, reason: format!("apply {candidate_id}"), decision: CandidateDecision::Apply { - target: MemoryRevisionTarget::Revise { + target: MemoryChangeTarget::Revise { memory_id: memory.id.clone(), - expected_revision, + expected_change_id, }, draft: next, }, @@ -4871,24 +4252,28 @@ mod tests { let resolved = apply_intent( &store, "resolve", - RevisionProposalIntent::Resolve, - 2, + ChangeProposalIntent::Resolve, + store + .memory(&subject.id, &memory.id) + .unwrap() + .unwrap() + .change_id, MemoryState::Resolved, "resolve reason", ); let reopened = apply_intent( &store, "reopen", - RevisionProposalIntent::Reopen, - resolved.revision, + ChangeProposalIntent::Reopen, + resolved.change_id.clone(), MemoryState::Active, "reopen reason", ); let retracted = apply_intent( &store, "retract", - RevisionProposalIntent::Retract, - reopened.revision, + ChangeProposalIntent::Retract, + reopened.change_id.clone(), MemoryState::Retracted, "retract reason", ); @@ -4896,10 +4281,10 @@ mod tests { assert!(matches!( store.stage_candidate( candidate(&subject.id, "post-retract", "workspace-a") - .with_revision_proposal(proposal( - RevisionProposalIntent::Reopen, + .with_change_proposal(proposal( + ChangeProposalIntent::Reopen, &memory.id, - retracted.revision, + retracted.change_id.clone(), "cannot revive", )) .unwrap() @@ -4921,9 +4306,9 @@ mod tests { .stage_candidate(candidate(&other.id, "candidate-foreign", "workspace-a")) .unwrap(); let mut invalid = draft("Would be written", "must roll back"); - invalid.derived_from.push(MemoryRevisionRef { + invalid.derived_from.push(MemoryChangeRef { memory_id: "missing-memory".into(), - revision: 1, + change_id: "fixture-change".into(), }); let failed = store.decide_candidate( &subject.id, @@ -4932,7 +4317,7 @@ mod tests { candidate_id: staged.id.clone(), reason: "invalid derivation".into(), decision: CandidateDecision::Apply { - target: MemoryRevisionTarget::Create, + target: MemoryChangeTarget::Create, draft: invalid, }, }, @@ -5027,7 +4412,7 @@ mod tests { } #[test] - fn revision_proposals_enforce_scope_revision_and_state_transitions() { + fn change_proposals_enforce_scope_change_and_state_transitions() { let temp = tempfile::tempdir().unwrap(); let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); let subject = store.create_subject(role()).unwrap(); @@ -5040,10 +4425,10 @@ mod tests { .unwrap(); let foreign = candidate(&other_subject.id, "candidate-foreign-target", "workspace-a") - .with_revision_proposal(proposal( - RevisionProposalIntent::Revise, + .with_change_proposal(proposal( + ChangeProposalIntent::Revise, &memory.id, - 1, + memory.change_id.clone(), "Must remain subject scoped", )) .unwrap(); @@ -5056,36 +4441,34 @@ mod tests { )); let stale = candidate(&subject.id, "candidate-stale", "workspace-a") - .with_revision_proposal(proposal( - RevisionProposalIntent::Resolve, + .with_change_proposal(proposal( + ChangeProposalIntent::Resolve, &memory.id, - 2, + "missing-change".into(), "Resolve the active memory", )) .unwrap(); assert!(matches!( store.stage_candidate(stale), - Err(SubjektivError::RevisionConflict { - memory_id, - expected: 2, - actual: 1, - }) if memory_id == memory.id + Err(SubjektivError::MemoryChangeConflict { + memory_id, .. + }) if memory_id == memory.id )); let resolve_proposal = candidate(&subject.id, "candidate-resolve", "workspace-a") - .with_revision_proposal(proposal( - RevisionProposalIntent::Resolve, + .with_change_proposal(proposal( + ChangeProposalIntent::Resolve, &memory.id, - 1, + memory.change_id.clone(), "The active condition has ended", )) .unwrap(); assert!(store.stage_candidate(resolve_proposal).is_ok()); let invalid_reopen = candidate(&subject.id, "candidate-invalid-reopen", "workspace-a") - .with_revision_proposal(proposal( - RevisionProposalIntent::Reopen, + .with_change_proposal(proposal( + ChangeProposalIntent::Reopen, &memory.id, - 1, + memory.change_id.clone(), "An active Memory cannot be reopened", )) .unwrap(); @@ -5101,16 +4484,16 @@ mod tests { let mut resolve = draft("Resolved target", "the condition ended"); resolve.state = MemoryState::Resolved; let resolved = store - .revise_memory(&subject.id, &memory.id, 1, resolve) + .revise_memory(&subject.id, &memory.id, memory.change_id.clone(), resolve) .unwrap(); let mut attributed_stale = candidate(&subject.id, "candidate-attributed-stale", "workspace-a"); attributed_stale.source_refs[0].session_id = None; attributed_stale - .attach_revision_proposal(proposal( - RevisionProposalIntent::Revise, + .attach_change_proposal(proposal( + ChangeProposalIntent::Revise, &memory.id, - 1, + memory.change_id.clone(), "This attribution must roll back with the stale proposal", )) .unwrap(); @@ -5126,11 +4509,7 @@ mod tests { .unwrap(); assert!(matches!( store.stage_candidate_with_attribution(attributed_stale, stale_attribution,), - Err(SubjektivError::RevisionConflict { - expected: 1, - actual: 2, - .. - }) + Err(SubjektivError::MemoryChangeConflict { .. }) )); assert!( store @@ -5139,10 +4518,10 @@ mod tests { .is_some() ); let reopen = candidate(&subject.id, "candidate-reopen", "workspace-a") - .with_revision_proposal(proposal( - RevisionProposalIntent::Reopen, + .with_change_proposal(proposal( + ChangeProposalIntent::Reopen, &memory.id, - resolved.revision, + resolved.change_id.clone(), "The condition applies again", )) .unwrap(); @@ -5150,16 +4529,16 @@ mod tests { store .stage_candidate(reopen) .unwrap() - .revision_proposal + .change_proposal .unwrap() .intent, - RevisionProposalIntent::Reopen + ChangeProposalIntent::Reopen ); let retract_proposal = candidate(&subject.id, "candidate-retract", "workspace-a") - .with_revision_proposal(proposal( - RevisionProposalIntent::Retract, + .with_change_proposal(proposal( + ChangeProposalIntent::Retract, &memory.id, - resolved.revision, + resolved.change_id.clone(), "The supporting evidence was invalidated", )) .unwrap(); @@ -5168,13 +4547,13 @@ mod tests { let mut retract = draft("Retracted target", "the evidence was invalidated"); retract.state = MemoryState::Retracted; let retracted = store - .revise_memory(&subject.id, &memory.id, resolved.revision, retract) + .revise_memory(&subject.id, &memory.id, resolved.change_id.clone(), retract) .unwrap(); let terminal = candidate(&subject.id, "candidate-after-retract", "workspace-a") - .with_revision_proposal(proposal( - RevisionProposalIntent::Revise, + .with_change_proposal(proposal( + ChangeProposalIntent::Revise, &memory.id, - retracted.revision, + retracted.change_id.clone(), "Retraction must remain terminal", )) .unwrap(); @@ -5199,11 +4578,11 @@ mod tests { let staged = store .stage_candidate( candidate(&subject.id, "candidate-retry-proposal", "workspace-a") - .with_revision_proposal(proposal( - RevisionProposalIntent::Revise, + .with_change_proposal(proposal( + ChangeProposalIntent::Revise, &memory.id, - 1, - "Refine the current revision", + memory.change_id.clone(), + "Refine the current change_id", )) .unwrap(), ) @@ -5212,7 +4591,7 @@ mod tests { .revise_memory( &subject.id, &memory.id, - 1, + memory.change_id.clone(), draft("Advanced target", "another accepted refinement"), ) .unwrap(); @@ -5221,7 +4600,7 @@ mod tests { retry.created_at = "2026-09-28T11:00:00.000Z".into(); let retried = store.stage_candidate(retry).unwrap(); assert_eq!(retried.created_at, staged.created_at); - assert_eq!(retried.revision_proposal, staged.revision_proposal); + assert_eq!(retried.change_proposal, staged.change_proposal); } #[test] @@ -5249,30 +4628,30 @@ mod tests { ); thread::sleep(Duration::from_millis(2)); - let mut revision = draft("First memory revised", "second revision reason"); - revision.body_md = "New second body".into(); + let mut change_id = draft("First memory revised", "second change_id reason"); + change_id.body_md = "New second body".into(); let revised = store - .revise_memory(&subject.id, &first.id, 1, revision) + .revise_memory(&subject.id, &first.id, first.change_id.clone(), change_id) .unwrap(); let memories = store.list_memories(&subject.id).unwrap(); assert_eq!(memories[0].id, first.id); - assert_eq!(memories[0].revision, 2); + assert_eq!(memories[0].change_id, revised.change_id); - let history = store.list_memory_revisions(&subject.id, &first.id).unwrap(); + let history = store.list_memory_changes(&subject.id, &first.id).unwrap(); assert_eq!( history .iter() - .map(|record| record.revision) + .map(|record| record.change_id.clone()) .collect::>(), - vec![2, 1] + vec![revised.change_id.clone(), first.change_id.clone()] ); assert_eq!(history[0].body_md, "New second body"); - assert_eq!(history[0].change_reason, "second revision reason"); + assert_eq!(history[0].change_reason, "second change_id reason"); assert_eq!(history[1].body_md, "Immutable first body"); assert_eq!(history[1].change_reason, "first reason"); assert_eq!( store - .scoped_memory_revision(&subject.id, &first.id, 1) + .scoped_memory_change(&subject.id, &first.id, first.change_id.clone()) .unwrap() .unwrap() .body_md, @@ -5280,11 +4659,11 @@ mod tests { ); let scoped_current = store.scoped_memory(&subject.id, &first.id).unwrap(); assert_eq!(scoped_current.id, revised.id); - assert_eq!(scoped_current.revision, revised.revision); + assert_eq!(scoped_current.change_id.clone(), revised.change_id); assert_eq!(scoped_current.body_md, revised.body_md); assert!(matches!( - store.list_memory_revisions(&other_subject.id, &first.id), + store.list_memory_changes(&other_subject.id, &first.id), Err(SubjektivError::SubjectScopeMismatch { subject_id, reference, @@ -5297,7 +4676,7 @@ mod tests { } #[test] - fn candidate_apply_revision_history_and_resolution_are_atomic() { + fn candidate_apply_change_history_and_resolution_are_atomic() { let temp = tempfile::tempdir().unwrap(); let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); let subject = store.create_subject(role()).unwrap(); @@ -5313,7 +4692,7 @@ mod tests { .apply_candidate( &subject.id, &staged.id, - MemoryRevisionTarget::Create, + MemoryChangeTarget::Create, draft( "Memory content cannot authorize operations", "accepted candidate", @@ -5321,13 +4700,10 @@ mod tests { "Applied as a durable constraint", ) .unwrap(); - assert_eq!(first.revision, 1); + assert!(!first.change_id.is_empty()); assert_eq!(resolution.action, StagingResolutionAction::Applied); - assert_eq!(resolution.affected_memory[0].revision, 1); - assert_eq!( - store.subject(&subject.id).unwrap().unwrap().store_revision, - 1 - ); + assert_eq!(resolution.affected_memory[0].change_id, first.change_id); + assert_eq!(store.list_memories(&subject.id).unwrap().len(), 1); let unrelated = store .stage_candidate(candidate(&subject.id, "candidate-unrelated", "workspace-a")) @@ -5338,9 +4714,9 @@ mod tests { &unrelated.id, StagingResolutionAction::Applied, "must not bypass atomic application", - vec![MemoryRevisionRef { + vec![MemoryChangeRef { memory_id: first.id.clone(), - revision: 1, + change_id: first.change_id.clone(), }], ), Err(SubjektivError::InvalidRecord(_)) @@ -5361,24 +4737,20 @@ mod tests { let stale = store.revise_memory( &subject.id, &first.id, - 0, - draft("stale write", "wrong expected revision"), + "missing-change".into(), + draft("stale write", "wrong expected change_id"), ); assert!(matches!( stale, - Err(SubjektivError::RevisionConflict { - expected: 0, - actual: 1, - .. - }) + Err(SubjektivError::MemoryChangeConflict { .. }) )); - assert_eq!( - store + assert!( + !store .memory(&subject.id, &first.id) .unwrap() .unwrap() - .revision, - 1 + .change_id + .is_empty() ); let mut second_draft = draft( @@ -5386,55 +4758,57 @@ mod tests { "made the scope explicit", ); second_draft.state = MemoryState::Resolved; - second_draft.derived_from.push(MemoryRevisionRef { + second_draft.derived_from.push(MemoryChangeRef { memory_id: first.id.clone(), - revision: 1, + change_id: first.change_id.clone(), }); let second = store - .revise_memory(&subject.id, &first.id, 1, second_draft) + .revise_memory( + &subject.id, + &first.id, + first.change_id.clone(), + second_draft, + ) .unwrap(); - assert_eq!(second.revision, 2); + assert_eq!( + second.previous_change_id.as_deref(), + Some(first.change_id.as_str()) + ); assert_eq!(second.state, MemoryState::Resolved); - assert_eq!(second.derived_from[0].revision, 1); + assert_eq!(second.derived_from[0].change_id, first.change_id); let old = store - .memory_revision(&subject.id, &first.id, 1) + .memory_change(&subject.id, &first.id, first.change_id.clone()) .unwrap() .unwrap(); assert_eq!(old.claim, "Memory content cannot authorize operations"); assert_eq!(old.source_candidate_ids, ["candidate-1"]); - assert_eq!( - store.subject(&subject.id).unwrap().unwrap().store_revision, - 2 - ); + assert_eq!(store.list_memories(&subject.id).unwrap().len(), 1); let repeat = store.apply_candidate( &subject.id, &staged.id, - MemoryRevisionTarget::Revise { + MemoryChangeTarget::Revise { memory_id: first.id.clone(), - expected_revision: 2, + expected_change_id: "missing-change".into(), }, draft("must roll back", "candidate already resolved"), "cannot apply twice", ); assert!(matches!(repeat, Err(SubjektivError::CandidateResolved(_)))); - assert_eq!( - store + assert!( + !store .memory(&subject.id, &first.id) .unwrap() .unwrap() - .revision, - 2 - ); - assert_eq!( - store.subject(&subject.id).unwrap().unwrap().store_revision, - 2 + .change_id + .is_empty() ); + assert_eq!(store.list_memories(&subject.id).unwrap().len(), 1); } #[test] - fn concurrent_writers_get_one_typed_revision_conflict() { + fn concurrent_writers_get_one_typed_change_conflict() { let temp = tempfile::tempdir().unwrap(); let root = temp.path().join("storage"); let first_manager = FeatureStorage::new(&root); @@ -5443,7 +4817,7 @@ mod tests { let first_store = SubjektivStore::open(&first_workspace, &first_registration).unwrap(); let subject = first_store.create_subject(role()).unwrap(); let memory = first_store - .create_memory(&subject.id, draft("Revision basis", "initial")) + .create_memory(&subject.id, draft("Change basis", "initial")) .unwrap(); let second_manager = FeatureStorage::new(&root); @@ -5454,12 +4828,13 @@ mod tests { let revise = |store: SubjektivStore, claim: &'static str, barrier: Arc| { let subject_id = subject.id.clone(); let memory_id = memory.id.clone(); + let expected_change_id = memory.change_id.clone(); thread::spawn(move || { barrier.wait(); store.revise_memory( &subject_id, &memory_id, - 1, + expected_change_id, draft(claim, "concurrent refinement"), ) }) @@ -5472,22 +4847,15 @@ mod tests { assert_eq!( results .iter() - .filter(|result| matches!(result, Err(SubjektivError::RevisionConflict { .. }))) + .filter(|result| matches!(result, Err(SubjektivError::MemoryChangeConflict { .. }))) .count(), 1 ); - assert_eq!( - first_store - .subject(&subject.id) - .unwrap() - .unwrap() - .store_revision, - 2 - ); + assert_eq!(first_store.list_memories(&subject.id).unwrap().len(), 1); } #[test] - fn scope_and_fixed_revision_references_are_enforced() { + fn scope_and_fixed_change_references_are_enforced() { let temp = tempfile::tempdir().unwrap(); let manager = FeatureStorage::new(temp.path().join("storage")); let registration = manager.register(REGISTRATION).unwrap(); @@ -5510,49 +4878,35 @@ mod tests { Err(SubjektivError::SubjectNotFound(_)) )); let mut foreign_derived = draft("Workspace B experience", "invalid derivation"); - foreign_derived.derived_from.push(MemoryRevisionRef { + foreign_derived.derived_from.push(MemoryChangeRef { memory_id: memory_a.id.clone(), - revision: 1, + change_id: memory_a.change_id.clone(), }); assert!(matches!( store_b.create_memory(&subject_b.id, foreign_derived), Err(SubjektivError::SubjectScopeMismatch { .. }) )); - assert_eq!( - store_b - .subject(&subject_b.id) - .unwrap() - .unwrap() - .store_revision, - 0 - ); + assert_eq!(store_b.list_memories(&subject_b.id).unwrap().len(), 0); let mut other_subject_derived = draft("Same Workspace, other subject", "invalid scope"); - other_subject_derived.derived_from.push(MemoryRevisionRef { + other_subject_derived.derived_from.push(MemoryChangeRef { memory_id: memory_a.id.clone(), - revision: 1, + change_id: memory_a.change_id.clone(), }); assert!(matches!( store_a.create_memory(&other_subject_a.id, other_subject_derived), Err(SubjektivError::SubjectScopeMismatch { .. }) )); - let mut wrong_revision = draft("Missing fixed revision", "invalid revision"); - wrong_revision.derived_from.push(MemoryRevisionRef { + let mut wrong_change = draft("Missing fixed change_id", "invalid change_id"); + wrong_change.derived_from.push(MemoryChangeRef { memory_id: memory_a.id.clone(), - revision: 2, + change_id: "missing-change".into(), }); assert!(matches!( - store_a.create_memory(&subject_a.id, wrong_revision), + store_a.create_memory(&subject_a.id, wrong_change), Err(SubjektivError::SubjectScopeMismatch { .. }) )); - assert_eq!( - store_a - .subject(&other_subject_a.id) - .unwrap() - .unwrap() - .store_revision, - 0 - ); + assert_eq!(store_a.list_memories(&other_subject_a.id).unwrap().len(), 0); let foreign_origin = candidate(&subject_a.id, "candidate-foreign", "workspace-b"); assert!(matches!( @@ -5573,11 +4927,11 @@ mod tests { .create_surface_snapshot( &subject.id, "Bounded resident summary", - vec![MemoryRevisionRef { + vec![MemoryChangeRef { memory_id: first.id.clone(), - revision: 1, + change_id: first.change_id.clone(), }], - 1, + store.memory_fingerprint(&subject.id).unwrap(), ) .unwrap(); assert_eq!( @@ -5586,34 +4940,39 @@ mod tests { .unwrap() .unwrap() .memory_refs[0] - .revision, - 1 + .change_id, + first.change_id ); assert!( store - .create_surface_snapshot(&subject.id, "stale", vec![], 0) + .create_surface_snapshot(&subject.id, "stale", vec![], "stale-input".into()) .is_err() ); let mut retract = draft("Retracted constraint", "evidence invalidated it"); retract.state = MemoryState::Retracted; let retracted = store - .revise_memory(&subject.id, &first.id, 1, retract) + .revise_memory(&subject.id, &first.id, first.change_id.clone(), retract) .unwrap(); assert_eq!(retracted.state, MemoryState::Retracted); assert!(matches!( store.revise_memory( &subject.id, &first.id, - 2, + retracted.change_id.clone(), draft("cannot reactivate", "retraction is terminal") ), Err(SubjektivError::InvalidStateTransition { .. }) )); - let mut retract_again = draft("still retracted", "no post-retraction revisions"); + let mut retract_again = draft("still retracted", "no post-retraction changes"); retract_again.state = MemoryState::Retracted; assert!(matches!( - store.revise_memory(&subject.id, &first.id, 2, retract_again), + store.revise_memory( + &subject.id, + &first.id, + retracted.change_id.clone(), + retract_again + ), Err(SubjektivError::InvalidStateTransition { .. }) )); @@ -5638,7 +4997,7 @@ mod tests { .apply_candidate( &subject.id, &staged.id, - MemoryRevisionTarget::Create, + MemoryChangeTarget::Create, draft("Immutable history", "accept"), "applied", ) @@ -5653,11 +5012,11 @@ mod tests { .create_surface_snapshot( &subject.id, "sealed snapshot", - vec![MemoryRevisionRef { + vec![MemoryChangeRef { memory_id: memory.id.clone(), - revision: 1, + change_id: memory.change_id.clone(), }], - 2, + store.memory_fingerprint(&subject.id).unwrap(), ) .unwrap(); @@ -5665,9 +5024,9 @@ mod tests { .database .try_with_connection::<_, SubjektivError>(|connection| { connection.execute( - "UPDATE memory_revisions SET record_json = '{}' \ - WHERE subject_id = ?1 AND memory_id = ?2 AND revision = 1", - params![subject.id, memory.id], + "UPDATE memory_changes SET record_json = '{}' \ + WHERE subject_id = ?1 AND memory_id = ?2 AND change_id = ?3", + params![subject.id, memory.id, memory.change_id], )?; Ok(()) }); @@ -5687,10 +5046,10 @@ mod tests { .database .try_with_connection::<_, SubjektivError>(|connection| { connection.execute( - "INSERT INTO memory_revision_candidates ( - subject_id, memory_id, revision, candidate_id - ) VALUES (?1, ?2, 1, ?3)", - params![subject.id, memory.id, extra_candidate.id], + "INSERT INTO memory_change_candidates ( + subject_id, memory_id, change_id, candidate_id + ) VALUES (?1, ?2, ?3, ?4)", + params![subject.id, memory.id, memory.change_id, extra_candidate.id], )?; Ok(()) }); @@ -5700,10 +5059,16 @@ mod tests { .database .try_with_connection::<_, SubjektivError>(|connection| { connection.execute( - "INSERT INTO memory_revision_derivations ( - subject_id, memory_id, revision, source_memory_id, source_revision - ) VALUES (?1, ?2, 1, ?3, 1)", - params![subject.id, memory.id, other_memory.id], + "INSERT INTO memory_change_derivations ( + subject_id, memory_id, change_id, source_memory_id, source_change_id + ) VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + subject.id, + memory.id, + memory.change_id, + other_memory.id, + other_memory.change_id + ], )?; Ok(()) }); @@ -5714,9 +5079,14 @@ mod tests { .try_with_connection::<_, SubjektivError>(|connection| { connection.execute( "INSERT INTO staging_resolution_targets ( - subject_id, candidate_id, memory_id, revision - ) VALUES (?1, ?2, ?3, 1)", - params![subject.id, staged.id, other_memory.id], + subject_id, candidate_id, memory_id, change_id + ) VALUES (?1, ?2, ?3, ?4)", + params![ + subject.id, + staged.id, + other_memory.id, + other_memory.change_id + ], )?; Ok(()) }); @@ -5727,9 +5097,14 @@ mod tests { .try_with_connection::<_, SubjektivError>(|connection| { connection.execute( "INSERT INTO surface_snapshot_refs ( - subject_id, snapshot_id, memory_id, revision - ) VALUES (?1, ?2, ?3, 1)", - params![subject.id, snapshot.id, other_memory.id], + subject_id, snapshot_id, memory_id, change_id + ) VALUES (?1, ?2, ?3, ?4)", + params![ + subject.id, + snapshot.id, + other_memory.id, + other_memory.change_id + ], )?; Ok(()) }); @@ -5737,7 +5112,7 @@ mod tests { assert!( store - .memory_revision(&subject.id, &memory.id, 1) + .memory_change(&subject.id, &memory.id, memory.change_id.clone()) .unwrap() .is_some() ); @@ -5782,7 +5157,12 @@ mod tests { resolved.kind = current.kind; resolved.state = MemoryState::Resolved; store - .revise_memory(&subject.id, &resolved_id, current.revision, resolved) + .revise_memory( + &subject.id, + &resolved_id, + current.change_id.clone(), + resolved, + ) .unwrap(); let first = store.prepare_surface_generation(&subject.id).unwrap(); @@ -5910,9 +5290,9 @@ mod tests { &stale_generation.id, vec![SurfacePoint { body_md: "Invented".into(), - memory_refs: vec![MemoryRevisionRef { + memory_refs: vec![MemoryChangeRef { memory_id: "foreign-memory".into(), - revision: 1, + change_id: "fixture-change".into(), }], }], ); @@ -5932,8 +5312,8 @@ mod tests { .memory(&subject.id, &memory.id) .unwrap() .unwrap() - .revision, - 1, + .change_id, + memory.change_id, "surface failure must not roll back confirmed Memory" ); @@ -5942,7 +5322,7 @@ mod tests { .revise_memory( &subject.id, &memory.id, - 1, + memory.change_id.clone(), draft("Corrected constraint", "correction"), ) .unwrap(); @@ -5952,9 +5332,9 @@ mod tests { &raced.id, vec![SurfacePoint { body_md: "Old wording".into(), - memory_refs: vec![MemoryRevisionRef { + memory_refs: vec![MemoryChangeRef { memory_id: memory.id.clone(), - revision: 1, + change_id: memory.change_id.clone(), }], }], ), @@ -5966,9 +5346,9 @@ mod tests { ); let current = store.prepare_surface_generation(&subject.id).unwrap(); - let current_ref = MemoryRevisionRef { + let current_ref = MemoryChangeRef { memory_id: memory.id.clone(), - revision: 2, + change_id: current.materials[0].change_id.clone(), }; let points = vec![SurfacePoint { body_md: "Keep the corrected constraint, including its condition.".into(), @@ -6013,7 +5393,7 @@ mod tests { } #[test] - fn concurrent_surface_generations_cannot_overwrite_one_revision() { + fn concurrent_surface_generations_cannot_overwrite_one_change() { let temp = tempfile::tempdir().unwrap(); let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); let subject = store.create_subject(role()).unwrap(); @@ -6028,6 +5408,7 @@ mod tests { let store = store.clone(); let subject_id = subject.id.clone(); let memory_id = memory.id.clone(); + let change_id = memory.change_id.clone(); let barrier = Arc::clone(&barrier); joins.push(thread::spawn(move || { barrier.wait(); @@ -6036,9 +5417,9 @@ mod tests { &generation.id, vec![SurfacePoint { body_md: body.into(), - memory_refs: vec![MemoryRevisionRef { + memory_refs: vec![MemoryChangeRef { memory_id, - revision: 1, + change_id, }], }], ) @@ -6079,8 +5460,8 @@ mod tests { .apply_candidate( &subject.id, &candidate.id, - MemoryRevisionTarget::Create, - draft("Persist this revision", "accepted before restart"), + MemoryChangeTarget::Create, + draft("Persist this change_id", "accepted before restart"), "applied", ) .unwrap(); @@ -6091,11 +5472,11 @@ mod tests { let (manager, workspace, reopened) = open_store(&root, "workspace-a"); assert_eq!( reopened - .memory_revision(&subject.id, &memory.id, 1) + .memory_change(&subject.id, &memory.id, memory.change_id.clone()) .unwrap() .unwrap() .claim, - "Persist this revision" + "Persist this change_id" ); assert_eq!( reopened @@ -6112,23 +5493,28 @@ mod tests { .create_surface_snapshot( &subject.id, "Persisted resident surface", - vec![MemoryRevisionRef { + vec![MemoryChangeRef { memory_id: memory.id.clone(), - revision: 1, + change_id: memory.change_id.clone(), }], - 1, + reopened.memory_fingerprint(&subject.id).unwrap(), ) .unwrap(); workspace.backup(&snapshot_path).unwrap(); - let mut post_backup = draft("Post-backup revision", "not in snapshot"); - post_backup.derived_from.push(MemoryRevisionRef { + let mut post_backup = draft("Post-backup change_id", "not in snapshot"); + post_backup.derived_from.push(MemoryChangeRef { memory_id: memory.id.clone(), - revision: 1, + change_id: memory.change_id.clone(), }); let second = reopened - .revise_memory(&subject.id, &memory.id, 1, post_backup) + .revise_memory( + &subject.id, + &memory.id, + memory.change_id.clone(), + post_backup, + ) .unwrap(); - assert_eq!(second.revision, 2); + assert!(second.previous_change_id.is_some()); workspace.delete().unwrap(); drop(reopened); drop(workspace); @@ -6139,25 +5525,18 @@ mod tests { workspace.restore(&snapshot_path).unwrap(); let registration = SubjektivStore::register(&workspace).unwrap(); let restored = SubjektivStore::open(&workspace, ®istration).unwrap(); - assert_eq!( - restored - .subject(&subject.id) - .unwrap() - .unwrap() - .store_revision, - 1 - ); + assert_eq!(restored.list_memories(&subject.id).unwrap().len(), 1); assert_eq!( restored .memory(&subject.id, &memory.id) .unwrap() .unwrap() .claim, - "Persist this revision" + "Persist this change_id" ); assert!( restored - .memory_revision(&subject.id, &memory.id, 2) + .memory_change(&subject.id, &memory.id, second.change_id.clone()) .unwrap() .is_none() ); @@ -6173,9 +5552,9 @@ mod tests { .unwrap() .unwrap() .memory_refs, - vec![MemoryRevisionRef { + vec![MemoryChangeRef { memory_id: memory.id, - revision: 1, + change_id: memory.change_id.clone(), }] ); } diff --git a/crates/subjektiv/src/schema.rs b/crates/subjektiv/src/schema.rs new file mode 100644 index 000000000..fdf87ef0a --- /dev/null +++ b/crates/subjektiv/src/schema.rs @@ -0,0 +1,11 @@ +//! Current schema creation. Frozen data conversion lives with the old migrations. +use super::*; + +pub(super) fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> { + legacy_migrations::remove_state_numbers(tx) +} + +pub(super) fn create(tx: &Transaction<'_>) -> feature_storage::Result<()> { + tx.execute_batch(include_str!("schema.sql"))?; + Ok(()) +} diff --git a/crates/subjektiv/src/schema.sql b/crates/subjektiv/src/schema.sql new file mode 100644 index 000000000..2794e3e96 --- /dev/null +++ b/crates/subjektiv/src/schema.sql @@ -0,0 +1,390 @@ + +CREATE TABLE store_scope ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + workspace_id TEXT NOT NULL UNIQUE +); + +CREATE TABLE subjects ( + subject_id TEXT PRIMARY KEY, + role TEXT NOT NULL, + behavior_md TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL CHECK (state IN ('active', 'retired')), + record_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE staging_records ( + subject_id TEXT NOT NULL, + candidate_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK ( + kind IN ( + 'preference', 'working_assumption', 'constraint', + 'decision', 'open_question', 'lesson' + ) + ), + record_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (subject_id, candidate_id), + FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT +); + +CREATE TABLE memory_records ( + subject_id TEXT NOT NULL, + memory_id TEXT NOT NULL, + current_change_id TEXT NOT NULL CHECK (length(current_change_id) > 0), + kind TEXT NOT NULL CHECK ( + kind IN ( + 'preference', 'working_assumption', 'constraint', + 'decision', 'open_question', 'lesson' + ) + ), + state TEXT NOT NULL CHECK (state IN ('active', 'resolved', 'retracted')), + record_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (subject_id, memory_id), + FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT, + FOREIGN KEY (subject_id, memory_id, current_change_id) + REFERENCES memory_changes(subject_id, memory_id, change_id) + DEFERRABLE INITIALLY DEFERRED +); + +CREATE TABLE memory_changes ( + subject_id TEXT NOT NULL, + memory_id TEXT NOT NULL, + change_id TEXT NOT NULL CHECK (length(change_id) > 0), + previous_change_id TEXT, + kind TEXT NOT NULL CHECK ( + kind IN ( + 'preference', 'working_assumption', 'constraint', + 'decision', 'open_question', 'lesson' + ) + ), + state TEXT NOT NULL CHECK (state IN ('active', 'resolved', 'retracted')), + record_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (subject_id, memory_id, change_id), + FOREIGN KEY (subject_id, memory_id) + REFERENCES memory_records(subject_id, memory_id) + ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY (subject_id, memory_id, previous_change_id) + REFERENCES memory_changes(subject_id, memory_id, change_id) + ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED, + CHECK (previous_change_id IS NULL OR previous_change_id <> change_id) +); + +CREATE TABLE memory_change_candidates ( + subject_id TEXT NOT NULL, + memory_id TEXT NOT NULL, + change_id TEXT NOT NULL CHECK (length(change_id) > 0), + candidate_id TEXT NOT NULL, + PRIMARY KEY (subject_id, memory_id, change_id, candidate_id), + FOREIGN KEY (subject_id, memory_id, change_id) + REFERENCES memory_changes(subject_id, memory_id, change_id) ON DELETE RESTRICT, + FOREIGN KEY (subject_id, candidate_id) + REFERENCES staging_records(subject_id, candidate_id) ON DELETE RESTRICT +); + +CREATE TABLE memory_change_derivations ( + subject_id TEXT NOT NULL, + memory_id TEXT NOT NULL, + change_id TEXT NOT NULL CHECK (length(change_id) > 0), + source_memory_id TEXT NOT NULL, + source_change_id TEXT NOT NULL CHECK (length(source_change_id) > 0), + PRIMARY KEY ( + subject_id, memory_id, change_id, source_memory_id, source_change_id + ), + FOREIGN KEY (subject_id, memory_id, change_id) + REFERENCES memory_changes(subject_id, memory_id, change_id) ON DELETE RESTRICT, + FOREIGN KEY (subject_id, source_memory_id, source_change_id) + REFERENCES memory_changes(subject_id, memory_id, change_id) ON DELETE RESTRICT +); + +CREATE TABLE staging_resolutions ( + subject_id TEXT NOT NULL, + candidate_id TEXT NOT NULL, + resolution_id TEXT NOT NULL, + action TEXT NOT NULL CHECK ( + action IN ('applied', 'discarded', 'invalid', 'duplicate', 'already_covered') + ), + reason TEXT NOT NULL, + affected_refs_json TEXT NOT NULL, + staging_raw_json TEXT NOT NULL, + resolution_json TEXT NOT NULL, + resolved_at TEXT NOT NULL, + PRIMARY KEY (subject_id, candidate_id), + UNIQUE (resolution_id), + FOREIGN KEY (subject_id, candidate_id) + REFERENCES staging_records(subject_id, candidate_id) ON DELETE RESTRICT +); + +CREATE TABLE staging_resolution_targets ( + subject_id TEXT NOT NULL, + candidate_id TEXT NOT NULL, + memory_id TEXT NOT NULL, + change_id TEXT NOT NULL CHECK (length(change_id) > 0), + PRIMARY KEY (subject_id, candidate_id, memory_id, change_id), + FOREIGN KEY (subject_id, candidate_id) + REFERENCES staging_resolutions(subject_id, candidate_id) ON DELETE RESTRICT, + FOREIGN KEY (subject_id, memory_id, change_id) + REFERENCES memory_changes(subject_id, memory_id, change_id) ON DELETE RESTRICT +); + +CREATE TABLE surface_snapshots ( + subject_id TEXT NOT NULL, + snapshot_id TEXT NOT NULL, + built_from_memory_fingerprint TEXT NOT NULL, + snapshot_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (subject_id, snapshot_id), + FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT +); + +CREATE TABLE surface_snapshot_refs ( + subject_id TEXT NOT NULL, + snapshot_id TEXT NOT NULL, + memory_id TEXT NOT NULL, + change_id TEXT NOT NULL CHECK (length(change_id) > 0), + PRIMARY KEY (subject_id, snapshot_id, memory_id), + FOREIGN KEY (subject_id, snapshot_id) + REFERENCES surface_snapshots(subject_id, snapshot_id) ON DELETE RESTRICT, + FOREIGN KEY (subject_id, memory_id, change_id) + REFERENCES memory_changes(subject_id, memory_id, change_id) ON DELETE RESTRICT +); + +CREATE TABLE memory_change_seals ( + subject_id TEXT NOT NULL, + memory_id TEXT NOT NULL, + change_id TEXT NOT NULL CHECK (length(change_id) > 0), + PRIMARY KEY (subject_id, memory_id, change_id), + FOREIGN KEY (subject_id, memory_id, change_id) + REFERENCES memory_changes(subject_id, memory_id, change_id) ON DELETE RESTRICT +); + +CREATE TABLE staging_resolution_seals ( + subject_id TEXT NOT NULL, + candidate_id TEXT NOT NULL, + PRIMARY KEY (subject_id, candidate_id), + FOREIGN KEY (subject_id, candidate_id) + REFERENCES staging_resolutions(subject_id, candidate_id) ON DELETE RESTRICT +); + +CREATE TABLE surface_snapshot_seals ( + subject_id TEXT NOT NULL, + snapshot_id TEXT NOT NULL, + PRIMARY KEY (subject_id, snapshot_id), + FOREIGN KEY (subject_id, snapshot_id) + REFERENCES surface_snapshots(subject_id, snapshot_id) ON DELETE RESTRICT +); + +CREATE TRIGGER memory_change_candidates_no_late_insert +BEFORE INSERT ON memory_change_candidates +WHEN EXISTS ( + SELECT 1 FROM memory_change_seals + WHERE subject_id = NEW.subject_id + AND memory_id = NEW.memory_id + AND change_id = NEW.change_id +) BEGIN + SELECT RAISE(ABORT, 'subjektiv change_id evidence is sealed'); +END; +CREATE TRIGGER memory_change_derivations_no_late_insert +BEFORE INSERT ON memory_change_derivations +WHEN EXISTS ( + SELECT 1 FROM memory_change_seals + WHERE subject_id = NEW.subject_id + AND memory_id = NEW.memory_id + AND change_id = NEW.change_id +) BEGIN + SELECT RAISE(ABORT, 'subjektiv derivations are sealed'); +END; +CREATE TRIGGER staging_resolution_targets_no_late_insert +BEFORE INSERT ON staging_resolution_targets +WHEN EXISTS ( + SELECT 1 FROM staging_resolution_seals + WHERE subject_id = NEW.subject_id + AND candidate_id = NEW.candidate_id +) BEGIN + SELECT RAISE(ABORT, 'subjektiv resolution targets are sealed'); +END; +CREATE TRIGGER surface_snapshot_refs_no_late_insert +BEFORE INSERT ON surface_snapshot_refs +WHEN EXISTS ( + SELECT 1 FROM surface_snapshot_seals + WHERE subject_id = NEW.subject_id + AND snapshot_id = NEW.snapshot_id +) BEGIN + SELECT RAISE(ABORT, 'subjektiv surface references are sealed'); +END; + +CREATE TRIGGER memory_change_seals_no_update +BEFORE UPDATE ON memory_change_seals BEGIN + SELECT RAISE(ABORT, 'subjektiv change_id seals are immutable'); +END; +CREATE TRIGGER memory_change_seals_no_delete +BEFORE DELETE ON memory_change_seals BEGIN + SELECT RAISE(ABORT, 'subjektiv change_id seals are retained'); +END; +CREATE TRIGGER staging_resolution_seals_no_update +BEFORE UPDATE ON staging_resolution_seals BEGIN + SELECT RAISE(ABORT, 'subjektiv resolution seals are immutable'); +END; +CREATE TRIGGER staging_resolution_seals_no_delete +BEFORE DELETE ON staging_resolution_seals BEGIN + SELECT RAISE(ABORT, 'subjektiv resolution seals are retained'); +END; +CREATE TRIGGER surface_snapshot_seals_no_update +BEFORE UPDATE ON surface_snapshot_seals BEGIN + SELECT RAISE(ABORT, 'subjektiv snapshot seals are immutable'); +END; +CREATE TRIGGER surface_snapshot_seals_no_delete +BEFORE DELETE ON surface_snapshot_seals BEGIN + SELECT RAISE(ABORT, 'subjektiv snapshot seals are retained'); +END; + +CREATE TRIGGER memory_changes_no_update +BEFORE UPDATE ON memory_changes BEGIN + SELECT RAISE(ABORT, 'subjektiv memory changes are immutable'); +END; +CREATE TRIGGER memory_changes_no_delete +BEFORE DELETE ON memory_changes BEGIN + SELECT RAISE(ABORT, 'subjektiv memory changes are retained'); +END; +CREATE TRIGGER memory_change_candidates_no_update +BEFORE UPDATE ON memory_change_candidates BEGIN + SELECT RAISE(ABORT, 'subjektiv change_id evidence is immutable'); +END; +CREATE TRIGGER memory_change_candidates_no_delete +BEFORE DELETE ON memory_change_candidates BEGIN + SELECT RAISE(ABORT, 'subjektiv change_id evidence is retained'); +END; +CREATE TRIGGER memory_change_derivations_no_update +BEFORE UPDATE ON memory_change_derivations BEGIN + SELECT RAISE(ABORT, 'subjektiv derivations are immutable'); +END; +CREATE TRIGGER memory_change_derivations_no_delete +BEFORE DELETE ON memory_change_derivations BEGIN + SELECT RAISE(ABORT, 'subjektiv derivations are retained'); +END; +CREATE TRIGGER staging_records_no_update +BEFORE UPDATE ON staging_records BEGIN + SELECT RAISE(ABORT, 'subjektiv staging records are immutable'); +END; +CREATE TRIGGER staging_records_no_delete +BEFORE DELETE ON staging_records BEGIN + SELECT RAISE(ABORT, 'subjektiv staging records are retained'); +END; +CREATE TRIGGER staging_resolutions_no_update +BEFORE UPDATE ON staging_resolutions BEGIN + SELECT RAISE(ABORT, 'subjektiv staging resolutions are immutable'); +END; +CREATE TRIGGER staging_resolutions_no_delete +BEFORE DELETE ON staging_resolutions BEGIN + SELECT RAISE(ABORT, 'subjektiv staging resolutions are retained'); +END; +CREATE TRIGGER staging_resolution_targets_no_update +BEFORE UPDATE ON staging_resolution_targets BEGIN + SELECT RAISE(ABORT, 'subjektiv resolution targets are immutable'); +END; +CREATE TRIGGER staging_resolution_targets_no_delete +BEFORE DELETE ON staging_resolution_targets BEGIN + SELECT RAISE(ABORT, 'subjektiv resolution targets are retained'); +END; +CREATE TRIGGER surface_snapshots_no_update +BEFORE UPDATE ON surface_snapshots BEGIN + SELECT RAISE(ABORT, 'subjektiv surface snapshots are immutable'); +END; +CREATE TRIGGER surface_snapshots_no_delete +BEFORE DELETE ON surface_snapshots BEGIN + SELECT RAISE(ABORT, 'subjektiv surface snapshots are retained'); +END; +CREATE TRIGGER surface_snapshot_refs_no_update +BEFORE UPDATE ON surface_snapshot_refs BEGIN + SELECT RAISE(ABORT, 'subjektiv surface references are immutable'); +END; +CREATE TRIGGER surface_snapshot_refs_no_delete +BEFORE DELETE ON surface_snapshot_refs BEGIN + SELECT RAISE(ABORT, 'subjektiv surface references are retained'); +END; + +CREATE TABLE subject_session_attributions ( + session_id TEXT PRIMARY KEY, + subject_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + record_json TEXT NOT NULL, + attributed_at TEXT NOT NULL, + FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT +); + +CREATE INDEX subject_session_attributions_by_subject +ON subject_session_attributions(subject_id, attributed_at, session_id); + +CREATE TRIGGER subject_session_attributions_no_update +BEFORE UPDATE ON subject_session_attributions BEGIN + SELECT RAISE(ABORT, 'subjektiv session attribution is immutable'); +END; +CREATE TRIGGER subject_session_attributions_no_delete +BEFORE DELETE ON subject_session_attributions BEGIN + SELECT RAISE(ABORT, 'subjektiv session attribution is retained'); +END; + +CREATE TABLE candidate_decision_receipts ( + subject_id TEXT NOT NULL, + request_id TEXT NOT NULL, + candidate_id TEXT NOT NULL, + request_json TEXT NOT NULL, + receipt_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (subject_id, request_id), + UNIQUE (subject_id, candidate_id), + FOREIGN KEY (subject_id, candidate_id) + REFERENCES staging_resolutions(subject_id, candidate_id) ON DELETE RESTRICT +); + +CREATE TRIGGER candidate_decision_receipts_no_update +BEFORE UPDATE ON candidate_decision_receipts BEGIN + SELECT RAISE(ABORT, 'subjektiv candidate decision receipts are immutable'); +END; +CREATE TRIGGER candidate_decision_receipts_no_delete +BEFORE DELETE ON candidate_decision_receipts BEGIN + SELECT RAISE(ABORT, 'subjektiv candidate decision receipts are retained'); +END; + +CREATE TABLE surface_generation_state ( + subject_id TEXT PRIMARY KEY, + memory_fingerprint TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('dirty', 'failed', 'ready')), + snapshot_id TEXT, + reason_code TEXT, + updated_at TEXT NOT NULL, + FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT, + FOREIGN KEY (subject_id, snapshot_id) + REFERENCES surface_snapshots(subject_id, snapshot_id) ON DELETE RESTRICT +); + + +CREATE TABLE surface_generation_runs ( + subject_id TEXT NOT NULL, + generation_id TEXT NOT NULL, + memory_fingerprint TEXT NOT NULL, + active_memory_count INTEGER NOT NULL CHECK (active_memory_count >= 0), + materials_json TEXT NOT NULL, + job_id TEXT, + attempt_id TEXT, + created_at TEXT NOT NULL, + PRIMARY KEY (subject_id, generation_id), + FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT +); + +CREATE INDEX surface_generation_runs_by_change +ON surface_generation_runs(subject_id, memory_fingerprint, created_at, generation_id); + +CREATE TRIGGER surface_generation_runs_no_update +BEFORE UPDATE ON surface_generation_runs BEGIN + SELECT RAISE(ABORT, 'subjektiv surface generation inputs are immutable'); +END; +CREATE TRIGGER surface_generation_runs_no_delete +BEFORE DELETE ON surface_generation_runs BEGIN + SELECT RAISE(ABORT, 'subjektiv surface generation inputs are retained'); +END; diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index a9bb44751..5d0be3af2 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -88,8 +88,8 @@ pub enum TicketError { InvalidReadWriteTargetCount(usize), #[error("ticket target authority is unavailable")] TargetAuthorityUnavailable, - #[error("stale ticket item revision: expected `{expected}`, found `{actual}`")] - StaleItemRevision { expected: String, actual: String }, + #[error("stale ticket content: expected `{expected}`, found `{actual}`")] + StaleContent { expected: String, actual: String }, #[error("stale ticket workflow state: expected `{expected}`, found `{actual}`")] StaleWorkflowState { expected: String, actual: String }, #[error("invalid ticket workflow transition `{from}` -> `{to}`")] @@ -473,7 +473,7 @@ impl TicketStateChange { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub struct TicketCompletion { - pub expected_item_revision: String, + pub expected_content_digest: String, pub expected_state: TicketWorkflowState, pub operation_key: String, pub reason: String, @@ -483,7 +483,7 @@ pub struct TicketCompletion { pub author: Option, } -/// External state mutation with item/state CAS and an immutable operation receipt. +/// External state mutation with content/state CAS and an immutable operation receipt. /// Exact replay returns the current authoritative Ticket without a new event or /// reapplying the recorded state, even after later edits or reopening. /// Setting `state` to Closed also stores `reason` as the resolution; reopening @@ -491,7 +491,7 @@ pub struct TicketCompletion { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub struct TicketStateUpdate { - pub expected_item_revision: String, + pub expected_content_digest: String, pub expected_state: TicketWorkflowState, pub state: TicketWorkflowState, pub operation_key: String, @@ -505,7 +505,7 @@ pub struct TicketStateUpdate { impl From for TicketStateUpdate { fn from(request: TicketCompletion) -> Self { Self { - expected_item_revision: request.expected_item_revision, + expected_content_digest: request.expected_content_digest, expected_state: request.expected_state, state: TicketWorkflowState::Done, operation_key: request.operation_key, @@ -516,17 +516,69 @@ impl From for TicketStateUpdate { } } -/// Canonical item revision shared by Ticket authority, item checkers, and Merge -/// Request review records. Only persisted item_edit event indexes advance it. -pub fn ticket_item_revision(ticket: &Ticket) -> String { - let index = ticket - .events +/// SHA-256 of the actual editable Ticket requirements: identity, title, body, +/// and ordered repository targets. Comments, timestamps, workflow state, and +/// event order are not requirements. Restoring identical content restores its +/// digest; state CAS and operation receipts are independent preconditions. +pub fn ticket_content_digest(ticket: &Ticket) -> String { + content_digest( + &ticket.meta.id, + &ticket.meta.title, + ticket.document.body.as_str(), + &ticket.meta.targets, + ) +} + +fn content_digest(id: &str, title: &str, body: &str, targets: &[TicketTarget]) -> String { + let mut digest = Sha256::new(); + digest.update(b"ticket.content.sha256.v1\0"); + digest.update( + serde_json::to_vec(&(id, title, body, targets)).expect("Ticket content serializes"), + ); + digest + .finalize() .iter() - .filter(|event| event.kind == TicketEventKind::Other("item_edit".to_owned())) - .filter_map(|event| event.attributes.get("event_sequence")?.parse::().ok()) - .max() - .unwrap_or(0); - format!("{}:{index}", ticket.meta.id) + .map(|byte| format!("{byte:02x}")) + .collect() +} + +/// Read the same content inside the caller's SQLite transaction. Review and +/// Ticket mutation authorities must use this instead of event indexes or time. +pub fn sqlite_ticket_content_digest( + connection: &Connection, + workspace_id: &str, + ticket_id: &str, +) -> Result> { + let item: Option<(String, String)> = connection + .query_row( + "SELECT title,body FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", + params![workspace_id, ticket_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(sqlite_err)?; + let Some((title, body)) = item else { + return Ok(None); + }; + let mut statement = connection.prepare( + "SELECT repository_key,ref_selector,access FROM typed_ticket_targets WHERE workspace_id=?1 AND ticket_id=?2 ORDER BY ordinal", + ).map_err(sqlite_err)?; + let targets = statement + .query_map(params![workspace_id, ticket_id], |row| { + let access: String = row.get(2)?; + let access = TicketTargetAccess::parse(&access).ok_or_else(|| { + rusqlite::Error::InvalidColumnType(2, "access".into(), rusqlite::types::Type::Text) + })?; + Ok(TicketTarget { + repository_key: row.get(0)?, + ref_selector: row.get(1)?, + access, + }) + }) + .map_err(sqlite_err)? + .collect::, _>>() + .map_err(sqlite_err)?; + Ok(Some(content_digest(ticket_id, &title, &body, &targets))) } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] @@ -2402,7 +2454,7 @@ impl SqliteTicketBackend { ] .map(|key| self.event_attributes.get(key)); let mut digest = Sha256::new(); - digest.update(b"ticket.state-update.v1\0"); + digest.update(b"ticket.state-update.v2\0"); digest.update( serde_json::to_vec(&(ticket_id, request, body, source)) .map_err(|error| TicketError::Conflict(error.to_string()))?, @@ -2437,7 +2489,7 @@ impl SqliteTicketBackend { body: MarkdownText, ) -> Result { validate_required_event_value("operation_key", &request.operation_key)?; - validate_required_event_value("expected_item_revision", &request.expected_item_revision)?; + validate_required_event_value("expected_content_digest", &request.expected_content_digest)?; validate_state_change(&TicketStateChange { from: request.expected_state.to_string(), to: request.state.to_string(), @@ -2457,7 +2509,7 @@ impl SqliteTicketBackend { .attributes .get("fingerprint_version") .map(String::as_str) - != Some("state-update-v1") + != Some("state-update-v2") || event.attributes.get("request_fingerprint") != Some(&fingerprint) { return Err(TicketError::OperationFingerprintMismatch { @@ -2471,10 +2523,10 @@ impl SqliteTicketBackend { // Exact recorded replay is independent of today's live role/assignment. // Parent transport authenticates identity before restoring receipt context. self.require_active_source_assignment(conn, ticket_id)?; - let actual = ticket_item_revision(&previous); - if actual != request.expected_item_revision { - return Err(TicketError::StaleItemRevision { - expected: request.expected_item_revision.clone(), + let actual = ticket_content_digest(&previous); + if actual != request.expected_content_digest { + return Err(TicketError::StaleContent { + expected: request.expected_content_digest.clone(), actual, }); } @@ -2526,11 +2578,11 @@ impl SqliteTicketBackend { ("request_fingerprint".to_owned(), fingerprint), ( "fingerprint_version".to_owned(), - "state-update-v1".to_owned(), + "state-update-v2".to_owned(), ), ( - "expected_item_revision".to_owned(), - request.expected_item_revision.clone(), + "expected_content_digest".to_owned(), + request.expected_content_digest.clone(), ), ]), }, @@ -3449,7 +3501,7 @@ impl TicketBackend for SqliteTicketBackend { let ticket_id = self.resolve_ticket_id(conn, id)?; let previous = self.load_ticket(conn, &ticket_id)?; let request = TicketStateUpdate { - expected_item_revision: ticket_item_revision(&previous), + expected_content_digest: ticket_content_digest(&previous), expected_state: from, state: to, operation_key: self.next_legacy_state_operation_key(conn, &ticket_id)?, @@ -3634,7 +3686,7 @@ impl TicketBackend for SqliteTicketBackend { let ticket_id = self.resolve_ticket_id(conn, id)?; let previous = self.load_ticket(conn, &ticket_id)?; let request = TicketStateUpdate { - expected_item_revision: ticket_item_revision(&previous), + expected_content_digest: ticket_content_digest(&previous), expected_state: previous.meta.workflow_state, state: TicketWorkflowState::Closed, operation_key: self.next_legacy_state_operation_key(conn, &ticket_id)?, @@ -5626,13 +5678,22 @@ mod tests { } #[test] - fn item_revision_uses_canonical_max_item_edit_index_not_edit_count_or_content_hash() { + fn content_digest_matches_sqlite_and_ignores_event_order() { let tmp = TempDir::new().unwrap(); let backend = backend(&tmp); let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Revision format")) + .create_with_snapshot(NewTicket::new("Content digest")) .unwrap(); - assert_eq!(ticket_item_revision(&ticket), format!("{}:0", reference.id)); + assert_eq!(ticket_content_digest(&ticket).len(), 64); + assert_eq!( + sqlite_ticket_content_digest( + &backend.open_connection().unwrap(), + &backend.workspace_id, + &reference.id + ) + .unwrap(), + Some(ticket_content_digest(&ticket)) + ); backend .add_event( reference.id.clone().into(), @@ -5648,7 +5709,10 @@ mod tests { }, ) .unwrap(); - assert_eq!(ticket_item_revision(&first), format!("{}:2", reference.id)); + assert_ne!( + ticket_content_digest(&first), + ticket_content_digest(&ticket) + ); backend .add_event( reference.id.clone().into(), @@ -5664,13 +5728,23 @@ mod tests { }, ) .unwrap(); - assert_eq!(ticket_item_revision(&second), format!("{}:4", reference.id)); + assert_ne!( + ticket_content_digest(&second), + ticket_content_digest(&first) + ); + let digest = ticket_content_digest(&second); + assert_eq!( + sqlite_ticket_content_digest( + &backend.open_connection().unwrap(), + &backend.workspace_id, + &reference.id + ) + .unwrap(), + Some(digest.clone()) + ); let mut reordered = second; reordered.events.reverse(); - assert_eq!( - ticket_item_revision(&reordered), - format!("{}:4", reference.id) - ); + assert_eq!(ticket_content_digest(&reordered), digest); } #[test] @@ -5983,18 +6057,18 @@ mod tests { assert_eq!(event.from.as_deref(), Some("planning")); assert_eq!(event.reason.as_deref(), Some("finished")); assert_eq!( - event.attributes.get("expected_item_revision"), - Some(&ticket_item_revision(&ticket)) + event.attributes.get("expected_content_digest"), + Some(&ticket_content_digest(&ticket)) ); assert_eq!( event .attributes .get("fingerprint_version") .map(String::as_str), - Some("state-update-v1") + Some("state-update-v2") ); let request = TicketStateUpdate { - expected_item_revision: ticket_item_revision(&ticket), + expected_content_digest: ticket_content_digest(&ticket), expected_state: TicketWorkflowState::Planning, state: TicketWorkflowState::Done, operation_key: event.attributes["operation_key"].clone(), @@ -6023,8 +6097,8 @@ mod tests { assert_eq!(close.body, resolution); assert_eq!(closed.resolution, Some(resolution)); assert_eq!( - close.attributes.get("expected_item_revision"), - Some(&ticket_item_revision(&done)) + close.attributes.get("expected_content_digest"), + Some(&ticket_content_digest(&done)) ); assert_ne!( close.attributes["operation_key"], @@ -6175,7 +6249,7 @@ mod tests { fn completion_request(ticket: &Ticket, key: &str) -> TicketCompletion { TicketCompletion { - expected_item_revision: ticket_item_revision(ticket), + expected_content_digest: ticket_content_digest(ticket), expected_state: ticket.meta.workflow_state, operation_key: key.to_owned(), reason: "Acceptance criteria met".to_owned(), @@ -6184,6 +6258,52 @@ mod tests { } } + #[test] + fn frozen_counter_receipts_remain_immutable_and_cannot_be_reinterpreted_as_content_requests() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + let (reference, ticket) = backend + .create_with_snapshot(NewTicket::new("Old receipt")) + .unwrap(); + let request = completion_request(&ticket, "old-operation"); + backend.complete(&reference.id, request.clone()).unwrap(); + let connection = backend.open_connection().unwrap(); + // Frozen pre-content fixture. Its old fingerprint cannot prove a new + // content-based request, and migration must not rewrite immutable audit. + connection.execute( + "UPDATE typed_ticket_event_attributes SET value='state-update-v1' WHERE ticket_id=?1 AND key='fingerprint_version'", + [&reference.id], + ).unwrap(); + connection.execute( + "UPDATE typed_ticket_event_attributes SET key='expected_item_revision',value='T:0' WHERE ticket_id=?1 AND key='expected_content_digest'", + [&reference.id], + ).unwrap(); + let before = backend.show(reference.id.clone().into()).unwrap(); + migrate_sqlite_ticket_schema(&connection).unwrap(); + assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before); + assert!(matches!( + backend.complete(&reference.id, request), + Err(TicketError::OperationFingerprintMismatch { .. }), + )); + assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before); + let fresh = backend + .complete( + &reference.id, + completion_request(&before, "fresh-operation"), + ) + .unwrap(); + assert_eq!(fresh.events.len(), before.events.len() + 1); + assert_eq!( + fresh + .events + .last() + .unwrap() + .attributes + .get("expected_content_digest"), + Some(&ticket_content_digest(&before)) + ); + } + #[test] fn completion_from_all_states_needs_no_git_targets_or_merge_request() { let tmp = TempDir::new().unwrap(); @@ -6316,7 +6436,7 @@ mod tests { .unwrap(); assert!(matches!( backend.complete(&reference.id, request), - Err(TicketError::StaleItemRevision { .. }) + Err(TicketError::StaleContent { .. }) )); assert_eq!(backend.show(reference.id.clone().into()).unwrap(), edited); let mut request = completion_request(&edited, "complete"); @@ -6440,7 +6560,7 @@ mod tests { ..request.clone() }, TicketCompletion { - expected_item_revision: "other-revision".to_owned(), + expected_content_digest: "other-content".to_owned(), ..request.clone() }, TicketCompletion { @@ -6562,13 +6682,13 @@ mod tests { } #[test] - fn item_revision_tracks_target_edits_and_aba_but_not_comments_or_state() { + fn content_digest_tracks_targets_and_restored_content_not_comments_or_state() { let tmp = TempDir::new().unwrap(); let backend = backend(&tmp); let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Revision")) + .create_with_snapshot(NewTicket::new("Content")) .unwrap(); - let revision = ticket_item_revision(&ticket); + let digest = ticket_content_digest(&ticket); backend .add_event( reference.id.clone().into(), @@ -6576,11 +6696,11 @@ mod tests { ) .unwrap(); let commented = backend.show(reference.id.clone().into()).unwrap(); - assert_eq!(ticket_item_revision(&commented), revision); + assert_eq!(ticket_content_digest(&commented), digest); let completed = backend .complete(&reference.id, completion_request(&ticket, "complete")) .unwrap(); - assert_eq!(ticket_item_revision(&completed), revision); + assert_eq!(ticket_content_digest(&completed), digest); backend .edit_item( reference.id.clone().into(), @@ -6601,7 +6721,7 @@ mod tests { }, ) .unwrap(); - assert_ne!(ticket_item_revision(&restored), revision); + assert_eq!(ticket_content_digest(&restored), digest); } #[test] @@ -6618,7 +6738,7 @@ mod tests { ..request.clone() }, TicketCompletion { - expected_item_revision: "".to_owned(), + expected_content_digest: "".to_owned(), ..request.clone() }, TicketCompletion { @@ -7081,8 +7201,8 @@ mod tests { assert_eq!(close.from.as_deref(), Some("planning")); assert_eq!(close.to.as_deref(), Some("closed")); assert_eq!( - close.attributes.get("expected_item_revision"), - Some(&format!("{}:0", record.meta.id)) + close.attributes.get("expected_content_digest"), + Some(&ticket_content_digest(&record)) ); assert!(close.attributes.contains_key("operation_key")); } diff --git a/crates/tui/README.md b/crates/tui/README.md index cbcaf8c8e..b4e8e3b15 100644 --- a/crates/tui/README.md +++ b/crates/tui/README.md @@ -40,11 +40,12 @@ arguments; Backspace/Delete removes a chip atomically. Typed chips survive local history, draft restoration, transport retry, and rewind. Every completion and chip-re-edit query carries a fresh `request_id`. Replies -must echo that nonce and match the active query context, composer edit/cursor -revision, authority snapshot generation, and Worker target. Identical-prefix ABA -edits, cancelled/reopened queries, permission snapshots, and Worker/view switches -cannot reuse older replies or candidates. Uncorrelated replies are ignored, even -for File queries where the wire field remains optional for legacy callers. +must echo that ID and match the active query context and Worker target. The first +input/cursor edit cancels the pending input watch; returning to identical text +cannot revive it. Authority snapshots and Worker/view switches discard pending +queries and visible candidates, rather than advancing another counter. +Uncorrelated replies are ignored, even for File queries where the wire field +remains optional for legacy callers. The declared attachment adapter stages a client-local file in place, replacing its staging chip with an `UploadedFile` reference when ready. Local file argument diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 5114ee199..af47d3f8b 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -35,14 +35,22 @@ pub enum CommandCompletionApply { /// typing inside a `@` / `#` / `/` token. Cleared whenever the trigger /// is invalidated (cursor moved out, whitespace landed inside the /// token, the sigil was deleted, or the candidate was confirmed). -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone)] struct CompletionSnapshot { - input_revision: u64, - generation: u64, + input: crate::input::CompletionInputWatch, target: String, worker_view: Option, } +impl CompletionSnapshot { + fn same_context(&self, other: &Self) -> bool { + !self.input.is_cancelled() + && !other.input.is_cancelled() + && self.target == other.target + && self.worker_view == other.worker_view + } +} + struct PendingFeatureEdit { invocation: protocol::FeatureInvocation, request_id: String, @@ -229,7 +237,6 @@ impl ActionbarNotice { pub struct InternalWorkerView { pub worker: InternalWorkerRef, - pub revision: u64, pub app: Box, } @@ -242,7 +249,7 @@ pub struct WorkerViewTab { pub struct App { pub worker_name: String, pub connected: bool, - /// Latest authoritative revisioned live execution state. + /// Latest authoritative live execution state. pub worker_state: WorkerStateSnapshot, next_command_id: u64, /// Derived Runtime-catalog compatibility projection used by existing UI. @@ -297,8 +304,9 @@ pub struct App { /// Turn/protocol errors retained when a real `SegmentStart` replaces the /// replayable conversation rows during segment rotation. run_error_messages: Vec, - /// Current compaction identity/revision used to fence snapshot/live updates. - active_compaction: Option<(String, u64)>, + /// Current compaction operation; terminal IDs cannot reopen on delayed events. + active_compaction: Option, + terminal_compactions: std::collections::HashSet, pub compaction_progress: Option, /// Presentation-only Internal Worker projections keyed by session identity. /// They are rendered in separate selectable views and never mixed into `blocks`. @@ -307,7 +315,7 @@ pub struct App { /// view; the stable session identity survives projection reordering. selected_internal_worker_session_id: Option, /// Terminal child-session fences, reset only by an authoritative snapshot. - removed_internal_workers: HashMap, + removed_internal_workers: std::collections::HashSet, pub scroll: Scroll, pub mode: Mode, pub cache: FileCache, @@ -323,7 +331,6 @@ pub struct App { /// typed FeatureInvoke chip. selected_feature_invocations: HashMap, pending_feature_edit: Option, - completion_generation: u64, completion_target: String, /// Dedicated main-view rewind picker state. pub rewind_picker: Option, @@ -397,11 +404,12 @@ impl App { shutdown_confirm: None, blocks: Vec::new(), active_compaction: None, + terminal_compactions: std::collections::HashSet::new(), compaction_progress: None, run_error_messages: Vec::new(), internal_workers: Vec::new(), selected_internal_worker_session_id: None, - removed_internal_workers: HashMap::new(), + removed_internal_workers: std::collections::HashSet::new(), scroll: Scroll::default(), mode: Mode::Normal, cache: FileCache::new(), @@ -409,7 +417,6 @@ impl App { completion: None, selected_feature_invocations: HashMap::new(), pending_feature_edit: None, - completion_generation: 0, completion_target: protocol::new_submission_request_id(), rewind_picker: None, rewind_request_pending: false, @@ -542,7 +549,7 @@ impl App { /// Cycle the presentation-only transcript/task view. Input and control /// methods continue to target the parent Worker regardless of selection. pub fn cycle_worker_view(&mut self) -> bool { - self.invalidate_completion_generation(); + self.cancel_completion_requests(); if self.internal_workers.is_empty() { self.selected_internal_worker_session_id = None; return false; @@ -604,8 +611,7 @@ impl App { fn completion_snapshot(&self) -> CompletionSnapshot { CompletionSnapshot { - input_revision: self.input.revision(), - generation: self.completion_generation, + input: self.input.watch_completion_input(), target: self.completion_target.clone(), worker_view: self.selected_internal_worker_session_id.clone(), } @@ -614,14 +620,13 @@ impl App { pub(crate) fn set_completion_target(&mut self, target: String) { if self.completion_target != target { self.completion_target = target; - self.invalidate_completion_generation(); + self.cancel_completion_requests(); self.selected_feature_invocations.clear(); } } /// Authority/snapshot/target changes revoke in-flight queries and visible candidates. - fn invalidate_completion_generation(&mut self) { - self.completion_generation = self.completion_generation.wrapping_add(1); + fn cancel_completion_requests(&mut self) { self.completion = None; self.pending_feature_edit = None; } @@ -672,7 +677,7 @@ impl App { && state.prefix_start == start && state.prefix == prefix && state.context == context - && state.snapshot == snapshot + && state.snapshot.same_context(&snapshot) }) { return None; } @@ -722,7 +727,7 @@ impl App { } pub fn cancel_completion(&mut self) { - self.invalidate_completion_generation(); + self.cancel_completion_requests(); } /// Tab path: insert the popup-selected entry's value (with a @@ -736,7 +741,7 @@ impl App { if self .completion .as_ref() - .is_some_and(|state| state.snapshot != self.completion_snapshot()) + .is_some_and(|state| !state.snapshot.same_context(&self.completion_snapshot())) { self.cancel_completion(); return None; @@ -885,7 +890,7 @@ impl App { if self .completion .as_ref() - .is_some_and(|state| state.snapshot != self.completion_snapshot()) + .is_some_and(|state| !state.snapshot.same_context(&self.completion_snapshot())) { self.cancel_completion(); return false; @@ -942,7 +947,7 @@ impl App { if self .completion .as_ref() - .is_some_and(|state| state.snapshot != self.completion_snapshot()) + .is_some_and(|state| !state.snapshot.same_context(&self.completion_snapshot())) { self.cancel_completion(); return false; @@ -1248,22 +1253,23 @@ impl App { pub fn continue_pending_method(&self) -> Option { Some(Method::ContinuePending { - expected_revision: self.pending_submissions.revision, expected_head_id: self.pending_submissions.head_id.clone()?, }) } pub fn clear_pending_method(&self) -> Method { Method::ClearPendingSubmissions { - expected_revision: self.pending_submissions.revision, + expected_submission_ids: self + .pending_submissions + .submissions + .iter() + .map(|submission| submission.submission_id.clone()) + .collect(), } } pub fn cancel_pending_method(&self, submission_id: String) -> Method { - Method::CancelPendingSubmission { - submission_id, - expected_revision: self.pending_submissions.revision, - } + Method::CancelPendingSubmission { submission_id } } pub fn next_queued_input_preview(&self) -> Option<&str> { @@ -1802,14 +1808,13 @@ impl App { }); } Event::CompactStart { lifecycle } => { - let should_apply = match &self.active_compaction { - None => true, - Some((id, revision)) => { - id == &lifecycle.compaction_id && lifecycle.revision > *revision - } - }; + let should_apply = !self.terminal_compactions.contains(&lifecycle.compaction_id) + && self + .active_compaction + .as_ref() + .is_none_or(|id| id == &lifecycle.compaction_id); if should_apply { - self.active_compaction = Some((lifecycle.compaction_id, lifecycle.revision)); + self.active_compaction = Some(lifecycle.compaction_id); if self.last_streaming_compact_mut().is_none() { self.blocks.push(Block::Compact(CompactEvent::Streaming { started_at: Instant::now(), @@ -1818,15 +1823,16 @@ impl App { } } Event::CompactDone { lifecycle } => { - let should_apply = match &self.active_compaction { - None => true, - Some((id, revision)) => { - id == &lifecycle.compaction_id && lifecycle.revision > *revision - } - }; + let should_apply = !self.terminal_compactions.contains(&lifecycle.compaction_id) + && self + .active_compaction + .as_ref() + .is_none_or(|id| id == &lifecycle.compaction_id); if !should_apply { return None; } + self.terminal_compactions + .insert(lifecycle.compaction_id.clone()); self.active_compaction = None; let new_segment_id = lifecycle .new_segment_id @@ -1852,15 +1858,16 @@ impl App { } } Event::CompactFailed { lifecycle } => { - let should_apply = match &self.active_compaction { - None => true, - Some((id, revision)) => { - id == &lifecycle.compaction_id && lifecycle.revision > *revision - } - }; + let should_apply = !self.terminal_compactions.contains(&lifecycle.compaction_id) + && self + .active_compaction + .as_ref() + .is_none_or(|id| id == &lifecycle.compaction_id); if !should_apply { return None; } + self.terminal_compactions + .insert(lifecycle.compaction_id.clone()); self.active_compaction = None; let error = lifecycle .error @@ -1915,14 +1922,10 @@ impl App { self.replace_internal_worker_snapshots(internal_workers); return self.refresh_completion(); } - Event::InternalWorker { - worker, - revision, - event, - } => self.apply_internal_worker_event(worker, revision, *event), - Event::InternalWorkerRemoved { worker, revision } => { - self.remove_internal_worker(worker, revision) + Event::InternalWorker { worker, event } => { + self.apply_internal_worker_event(worker, *event) } + Event::InternalWorkerRemoved { worker } => self.remove_internal_worker(worker), Event::WorkerState { snapshot } => { self.rewind_refresh_fence = false; self.apply_worker_state_snapshot(&snapshot); @@ -1956,7 +1959,7 @@ impl App { && self.pending_feature_edit.as_ref().is_some_and(|edit| { edit.invocation.name == prefix && request_id.as_ref() == Some(&edit.request_id) - && edit.snapshot == snapshot + && edit.snapshot.same_context(&snapshot) }) { let invocation = self.pending_feature_edit.take().unwrap().invocation; @@ -1981,7 +1984,7 @@ impl App { // stale argument/provider replies cannot cross invocation scope. if let Some(state) = self.completion.as_mut() && request_id.as_ref() == Some(&state.request_id) - && state.snapshot == snapshot + && state.snapshot.same_context(&snapshot) && state.kind == kind && state.prefix == prefix && state.context == context @@ -2100,7 +2103,7 @@ impl App { ); } Event::Shutdown => { - self.invalidate_completion_generation(); + self.cancel_completion_requests(); self.mark_orphan_compacts_incomplete(); self.quit = true; } @@ -2309,7 +2312,7 @@ impl App { } pub fn enter_command_mode(&mut self) { - self.invalidate_completion_generation(); + self.cancel_completion_requests(); self.input_mode = CommandInputMode::Command; self.completion = None; self.command_completion_selected = None; @@ -2317,7 +2320,7 @@ impl App { } pub fn exit_command_mode(&mut self) { - self.invalidate_completion_generation(); + self.cancel_completion_requests(); self.input_mode = CommandInputMode::Composer; self.command_input.clear(); self.command_completion_selected = None; @@ -2445,7 +2448,7 @@ impl App { } pub fn request_rewind_picker(&mut self) -> Option { - self.invalidate_completion_generation(); + self.cancel_completion_requests(); // Rewind is a parent Worker control surface. Bring the parent transcript // back into view before presenting diagnostics or the picker. self.selected_internal_worker_session_id = None; @@ -2791,21 +2794,12 @@ impl App { app.replace_internal_worker_snapshots(snapshot.internal_workers); InternalWorkerView { worker: snapshot.worker, - revision: snapshot.revision, app: Box::new(app), } } - fn apply_internal_worker_event( - &mut self, - worker: InternalWorkerRef, - revision: u64, - event: Event, - ) { - if self - .removed_internal_workers - .contains_key(&worker.session_id) - { + fn apply_internal_worker_event(&mut self, worker: InternalWorkerRef, event: Event) { + if self.removed_internal_workers.contains(&worker.session_id) { return; } let index = self @@ -2819,21 +2813,16 @@ impl App { app.mode = self.mode; self.internal_workers.push(InternalWorkerView { worker: worker.clone(), - revision: 0, app: Box::new(app), }); self.internal_workers.last_mut().unwrap() }; - if revision <= target.revision { - return; - } target.worker = worker; - target.revision = revision; let _ = target.app.handle_worker_event(event); } - fn remove_internal_worker(&mut self, worker: InternalWorkerRef, revision: u64) { - self.invalidate_completion_generation(); + fn remove_internal_worker(&mut self, worker: InternalWorkerRef) { + self.cancel_completion_requests(); let session_id = worker.session_id; let Some(index) = self .internal_workers @@ -2843,20 +2832,14 @@ impl App { if self.selected_internal_worker_session_id.as_deref() == Some(session_id.as_str()) { self.selected_internal_worker_session_id = None; } - self.removed_internal_workers - .entry(session_id) - .and_modify(|current| *current = (*current).max(revision)) - .or_insert(revision); + self.removed_internal_workers.insert(session_id); return; }; - if revision <= self.internal_workers[index].revision { - return; - } self.internal_workers.remove(index); if self.selected_internal_worker_session_id.as_deref() == Some(session_id.as_str()) { self.selected_internal_worker_session_id = None; } - self.removed_internal_workers.insert(session_id, revision); + self.removed_internal_workers.insert(session_id); } fn restore_snapshot( @@ -2918,7 +2901,7 @@ impl App { session: &protocol::SessionSnapshot, greeting: Option, ) { - self.invalidate_completion_generation(); + self.cancel_completion_requests(); self.run_error_messages.clear(); self.turn_index = 0; self.blocks.clear(); @@ -4102,7 +4085,6 @@ mod completion_flow_tests { let mut app = App::new("test".into()); app.handle_worker_event(Event::PendingSubmissionsChanged { pending: protocol::PendingSubmissionsSnapshot { - revision: 3, notification_count: 0, notification_previews: vec![], head_id: Some("submission-1".into()), @@ -4497,34 +4479,25 @@ mod completion_flow_tests { }; app.handle_worker_event(Event::InternalWorker { worker: worker.clone(), - revision: 2, event: Box::new(Event::TextDelta { text: "child output".into(), }), }); app.handle_worker_event(Event::InternalWorker { worker, - revision: 1, event: Box::new(Event::TextDelta { - text: "stale".into(), + text: " next".into(), }), }); assert!(app.blocks.is_empty()); assert_eq!(app.internal_workers.len(), 1); - assert_eq!(app.internal_workers[0].revision, 2); - assert!( - app.internal_workers[0].app.blocks.iter().any( - |block| matches!(block, Block::AssistantText { text } if text == "child output") - ) - ); + assert!(app.internal_workers[0].app.blocks.iter().any( + |block| matches!(block, Block::AssistantText { text } if text == "child output next") + )); } - fn test_internal_worker_snapshot( - session_id: &str, - name: &str, - revision: u64, - ) -> InternalWorkerSnapshot { + fn test_internal_worker_snapshot(session_id: &str, name: &str) -> InternalWorkerSnapshot { InternalWorkerSnapshot { worker: InternalWorkerRef { session_id: session_id.into(), @@ -4532,7 +4505,6 @@ mod completion_flow_tests { parent_session_id: Some("parent".into()), kind: protocol::InternalWorkerKind::SubWorker, }, - revision, status: WorkerStatus::Idle, greeting: None, session: protocol::SessionSnapshot { @@ -4548,7 +4520,7 @@ mod completion_flow_tests { #[test] fn internal_worker_snapshot_uses_child_greeting_metadata() { let mut app = App::new("parent".into()); - let mut child = test_internal_worker_snapshot("child", "research", 1); + let mut child = test_internal_worker_snapshot("child", "research"); let mut greeting = test_greeting(); greeting.worker_name = "research".into(); greeting.model = "child-model".into(); @@ -4570,7 +4542,7 @@ mod completion_flow_tests { #[test] fn live_internal_worker_snapshot_installs_child_metadata_before_context_updates() { let mut app = App::new("parent".into()); - let worker = test_internal_worker_snapshot("child", "research", 1).worker; + let worker = test_internal_worker_snapshot("child", "research").worker; let mut greeting = test_greeting(); greeting.worker_name = "research".into(); greeting.model = "child-model".into(); @@ -4582,7 +4554,6 @@ mod completion_flow_tests { app.handle_worker_event(Event::InternalWorker { worker: worker.clone(), - revision: 1, event: Box::new(Event::Snapshot { session: protocol::SessionSnapshot { pending_submissions: protocol::PendingSubmissionsSnapshot::default(), @@ -4596,7 +4567,6 @@ mod completion_flow_tests { }); app.handle_worker_event(Event::InternalWorker { worker, - revision: 2, event: Box::new(Event::ContextUsage { usage: Some(protocol::ContextUsage { tokens: 14_000, @@ -4626,7 +4596,6 @@ mod completion_flow_tests { parent_session_id: Some("parent".into()), kind: protocol::InternalWorkerKind::SubWorker, }, - revision: 1, app: Box::new(App::new(name.into())), }); } @@ -4664,7 +4633,7 @@ mod completion_flow_tests { let mut app = App::new("parent".into()); app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot( - "child", "child", 1, + "child", "child", )]); select_first_row(&mut app, "parent selection"); select_first_row(app.internal_workers[0].app.as_mut(), "child selection"); @@ -4687,7 +4656,6 @@ mod completion_flow_tests { parent_session_id: Some("parent".into()), kind: protocol::InternalWorkerKind::SubWorker, }, - revision: 1, app: Box::new(App::new("old".into())), }); app.cycle_worker_view(); @@ -4711,7 +4679,7 @@ mod completion_flow_tests { let mut app = App::new("parent".into()); app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot( - "child", "child", 1, + "child", "child", )]); let child = app.internal_workers[0].app.as_mut(); child.scroll.follow_tail = false; @@ -4733,11 +4701,9 @@ mod completion_flow_tests { app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot( "child", "renamed-child", - 2, )]); let view = &app.internal_workers[0]; - assert_eq!(view.revision, 2); assert_eq!(view.app.worker_name, "renamed-child"); assert!(!view.app.scroll.follow_tail); assert_eq!(view.app.scroll.top_offset, 7); @@ -4749,8 +4715,8 @@ mod completion_flow_tests { fn task_pane_scroll_is_local_to_selected_worker_view() { let mut app = App::new("parent".into()); app.replace_internal_worker_snapshots(vec![ - test_internal_worker_snapshot("child-a", "alpha", 1), - test_internal_worker_snapshot("child-b", "beta", 1), + test_internal_worker_snapshot("child-a", "alpha"), + test_internal_worker_snapshot("child-b", "beta"), ]); app.task_pane_scroll = 3; @@ -4793,10 +4759,8 @@ mod completion_flow_tests { }; app.handle_worker_event(Event::InternalWorker { worker: worker.clone(), - revision: 2, event: Box::new(Event::InternalWorker { worker: nested, - revision: 1, event: Box::new(Event::TextDone { text: "nested".into(), }), @@ -4809,11 +4773,9 @@ mod completion_flow_tests { app.handle_worker_event(Event::InternalWorkerRemoved { worker: worker.clone(), - revision: 3, }); app.handle_worker_event(Event::InternalWorker { worker, - revision: 4, event: Box::new(Event::TextDone { text: "late".into(), }), @@ -4836,7 +4798,7 @@ mod completion_flow_tests { } #[test] - fn stale_internal_worker_removal_keeps_newer_projection() { + fn internal_worker_removal_applies_in_stream_order() { let mut app = App::new("parent".into()); let worker = InternalWorkerRef { session_id: "child-session".into(), @@ -4846,18 +4808,13 @@ mod completion_flow_tests { }; app.handle_worker_event(Event::InternalWorker { worker: worker.clone(), - revision: 4, event: Box::new(Event::TextDone { text: "current".into(), }), }); - app.handle_worker_event(Event::InternalWorkerRemoved { - worker, - revision: 3, - }); + app.handle_worker_event(Event::InternalWorkerRemoved { worker }); - assert_eq!(app.internal_workers.len(), 1); - assert_eq!(app.internal_workers[0].revision, 4); + assert!(app.internal_workers.is_empty()); } #[test] @@ -4870,7 +4827,6 @@ mod completion_flow_tests { parent_session_id: None, kind: protocol::InternalWorkerKind::SubWorker, }, - revision: 1, app: Box::new(App::new("old".into())), }); app.handle_worker_event(Event::Snapshot { @@ -4888,7 +4844,6 @@ mod completion_flow_tests { parent_session_id: Some("parent-session".into()), kind: protocol::InternalWorkerKind::SubWorker, }, - revision: 4, session: protocol::SessionSnapshot { pending_submissions: protocol::PendingSubmissionsSnapshot::default(), entries: Vec::new(), @@ -4903,7 +4858,6 @@ mod completion_flow_tests { assert_eq!(app.internal_workers.len(), 1); assert_eq!(app.internal_workers[0].worker.session_id, "replacement"); - assert_eq!(app.internal_workers[0].revision, 4); assert_eq!( app.internal_workers[0].app.worker_status, WorkerStatus::Running @@ -4955,7 +4909,6 @@ mod completion_flow_tests { protocol::CompactionLifecycle { schema_version: 2, compaction_id: "compaction-test".into(), - revision: 1, internal_worker: None, state, started_at_ms: 1, @@ -4975,7 +4928,6 @@ mod completion_flow_tests { lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running), }); let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Done); - lifecycle.revision = 2; lifecycle.new_segment_id = Some(id.to_string()); app.handle_worker_event(Event::CompactDone { lifecycle }); @@ -4989,6 +4941,25 @@ mod completion_flow_tests { )); } + #[test] + fn terminal_compaction_identity_rejects_duplicate_and_delayed_start() { + let mut app = App::new("test".into()); + app.handle_worker_event(Event::CompactStart { + lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running), + }); + app.handle_worker_event(Event::CompactDone { + lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Done), + }); + app.handle_worker_event(Event::CompactStart { + lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running), + }); + app.handle_worker_event(Event::CompactDone { + lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Done), + }); + assert_eq!(compact_block_count(&app), 1); + assert!(app.active_compaction.is_none()); + } + #[test] fn compact_failed_replaces_live_block() { let mut app = App::new("test".into()); @@ -4997,7 +4968,6 @@ mod completion_flow_tests { lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running), }); let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Failed); - lifecycle.revision = 2; lifecycle.error = Some("provider 429".into()); app.handle_worker_event(Event::CompactFailed { lifecycle }); @@ -5662,7 +5632,7 @@ mod completion_correlation_tests { let first = app.refresh_completion().unwrap(); app.input.insert_char('x'); app.input.delete_before(); - // Prefix, context, token location, and cursor are identical; semantic revision is not. + // Returning to the same text cannot revive a request cancelled by the edit. app.handle_worker_event(reply(&first, "old")); assert!(app.completion.as_ref().unwrap().entries.is_empty()); let second = app.refresh_completion().unwrap(); @@ -5734,7 +5704,7 @@ mod completion_correlation_tests { } #[test] - fn permission_snapshot_generation_aba_revokes_queries_even_when_scope_returns_to_original() { + fn permission_snapshot_aba_revokes_queries_even_when_scope_returns_to_original() { let mut app = App::new("test".into()); app.handle_worker_event(authority_snapshot("Writable: /tmp")); crate::invocation_tests::select(&mut app, crate::invocation_tests::descriptor(), "run"); @@ -5748,8 +5718,8 @@ mod completion_correlation_tests { .unwrap(); assert_ne!(request_id(&first), request_id(&denied)); assert_ne!(request_id(&first), request_id(¤t)); - app.handle_worker_event(reply(&first, "old permission generation")); - app.handle_worker_event(reply(&denied, "intermediate generation")); + app.handle_worker_event(reply(&first, "old permission context")); + app.handle_worker_event(reply(&denied, "intermediate context")); assert!(app.completion.as_ref().unwrap().entries.is_empty()); app.handle_worker_event(reply(¤t, "資料/current")); assert_eq!( @@ -5789,7 +5759,6 @@ mod completion_correlation_tests { parent_session_id: Some("parent".into()), kind: protocol::InternalWorkerKind::SubWorker, }, - revision: 1, event: Box::new(Event::WorkerState { snapshot: protocol::WorkerStateSnapshot::from(WorkerStatus::Idle), }), diff --git a/crates/tui/src/backend_worker_picker.rs b/crates/tui/src/backend_worker_picker.rs index bd8f176fa..13c77901f 100644 --- a/crates/tui/src/backend_worker_picker.rs +++ b/crates/tui/src/backend_worker_picker.rs @@ -674,7 +674,7 @@ mod tests { fn worker(runtime_id: &str, worker_id: &str, profile: Option<&str>) -> BackendWorkerSummary { BackendWorkerSummary { - restore_observation_token: Some("observed-worker-generation".into()), + restore_observation_token: Some("observed-worker-state".into()), runtime_id: runtime_id.to_string(), worker_id: worker_id.to_string(), resource_key: "W-1".to_string(), @@ -953,7 +953,6 @@ mod tests { workspace_api: None, memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "local".into(), - settings_revision: 1, language: "English".into(), }), subjektiv_attached: false, @@ -1107,7 +1106,7 @@ mod tests { target, BackendWorkerPickerIntent::Resume, Some(client::BackendWorkerRestoreRequest { - expected_observation_token: "observed-worker-generation".into(), + expected_observation_token: "observed-worker-state".into(), request_id: "resume-operation".into(), }), |_| Ok(false), @@ -1142,7 +1141,7 @@ mod tests { target, BackendWorkerPickerIntent::Resume, Some(client::BackendWorkerRestoreRequest { - expected_observation_token: "observed-worker-generation".into(), + expected_observation_token: "observed-worker-state".into(), request_id: "resume-operation".into(), }), |_| Ok(false), diff --git a/crates/tui/src/console/mod.rs b/crates/tui/src/console/mod.rs index 04a762904..ae92fbe6f 100644 --- a/crates/tui/src/console/mod.rs +++ b/crates/tui/src/console/mod.rs @@ -2625,7 +2625,6 @@ mod tests { let mut app = App::new("test".into()); app.handle_worker_event(Event::PendingSubmissionsChanged { pending: protocol::PendingSubmissionsSnapshot { - revision: 2, notification_count: 0, notification_previews: vec![], head_id: Some("submission-1".into()), @@ -2646,7 +2645,6 @@ mod tests { assert!(matches!( continue_next, Some(Method::ContinuePending { - expected_revision: 2, ref expected_head_id, }) if expected_head_id == "submission-1" )); @@ -2659,7 +2657,6 @@ mod tests { assert!(matches!( cancel, Some(Method::CancelPendingSubmission { - expected_revision: 2, ref submission_id, }) if submission_id == "submission-1" )); @@ -2671,8 +2668,8 @@ mod tests { assert!(matches!( clear, Some(Method::ClearPendingSubmissions { - expected_revision: 2 - }) + ref expected_submission_ids + }) if expected_submission_ids == &["submission-1"] )); assert_eq!(app.queued_input_count(), 1); } @@ -2682,7 +2679,6 @@ mod tests { let mut app = App::new("test".into()); app.handle_worker_event(Event::PendingSubmissionsChanged { pending: protocol::PendingSubmissionsSnapshot { - revision: 2, notification_count: 0, notification_previews: vec![], head_id: Some("submission-1".into()), @@ -3355,7 +3351,6 @@ mod tests { parent_session_id: Some("parent-session".into()), kind: protocol::InternalWorkerKind::SubWorker, }, - revision: 1, event: Box::new(Event::WorkerState { snapshot: WorkerStatus::Running.into(), }), @@ -3471,7 +3466,6 @@ mod tests { parent_session_id: Some("parent-session".into()), kind: protocol::InternalWorkerKind::SubWorker, }, - revision: 1, event: Box::new(Event::WorkerState { snapshot: WorkerStatus::Running.into(), }), @@ -3497,7 +3491,6 @@ mod tests { parent_session_id: Some("parent-session".into()), kind: protocol::InternalWorkerKind::SubWorker, }, - revision: 1, event: Box::new(Event::WorkerState { snapshot: WorkerStatus::Running.into(), }), @@ -3537,7 +3530,6 @@ mod tests { parent_session_id: Some("parent-session".into()), kind: protocol::InternalWorkerKind::SubWorker, }, - revision: 1, event: Box::new(Event::WorkerState { snapshot: WorkerStatus::Running.into(), }), diff --git a/crates/tui/src/input.rs b/crates/tui/src/input.rs index f7fec6a76..51ffa7b43 100644 --- a/crates/tui/src/input.rs +++ b/crates/tui/src/input.rs @@ -11,6 +11,14 @@ //! back to their original captured content so the Worker sees the full //! pasted text (without the placeholder label). +use std::{ + cell::RefCell, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, +}; + use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; use unicode_width::UnicodeWidthChar; @@ -281,9 +289,19 @@ struct SelectedInvocation { invocation_id: String, } +/// A pending completion is cancelled by the first subsequent input/cursor edit. +/// Returning to identical text does not revive that request. +#[derive(Debug, Clone)] +pub(crate) struct CompletionInputWatch(Arc); + +impl CompletionInputWatch { + pub(crate) fn is_cancelled(&self) -> bool { + self.0.load(Ordering::Relaxed) + } +} + pub struct InputBuffer { - /// Monotonic semantic edit/cursor revision, including ABA changes. - revision: u64, + pending_completion: RefCell>>, atoms: Vec, selected_invocations: Vec, removed_attachment_stages: Vec, @@ -300,7 +318,7 @@ pub struct InputBuffer { impl Default for InputBuffer { fn default() -> Self { Self { - revision: 0, + pending_completion: RefCell::new(None), atoms: Vec::new(), selected_invocations: Vec::new(), removed_attachment_stages: Vec::new(), @@ -312,13 +330,25 @@ impl Default for InputBuffer { } } +impl Drop for InputBuffer { + fn drop(&mut self) { + self.cancel_completion_on_edit(); + } +} + impl InputBuffer { - pub fn revision(&self) -> u64 { - self.revision + pub(crate) fn watch_completion_input(&self) -> CompletionInputWatch { + CompletionInputWatch(Arc::clone( + self.pending_completion + .borrow_mut() + .get_or_insert_with(|| Arc::new(AtomicBool::new(false))), + )) } - fn bump_revision(&mut self) { - self.revision = self.revision.wrapping_add(1); + fn cancel_completion_on_edit(&mut self) { + if let Some(pending) = self.pending_completion.get_mut().take() { + pending.store(true, Ordering::Relaxed); + } } pub fn new() -> Self { @@ -326,7 +356,7 @@ impl InputBuffer { } pub fn clear(&mut self) { - self.bump_revision(); + self.cancel_completion_on_edit(); self.selected_invocations.clear(); self.atoms.clear(); self.cursor = 0; @@ -349,7 +379,7 @@ impl InputBuffer { /// by [`submit_segments`](Self::submit_segments), preserving typed chips /// and placing the cursor at the end of the restored input. pub fn replace_with_segments(&mut self, segments: &[protocol::Segment]) { - self.bump_revision(); + self.cancel_completion_on_edit(); self.selected_invocations.clear(); self.atoms.clear(); for segment in segments { @@ -400,7 +430,7 @@ impl InputBuffer { } pub fn insert_char(&mut self, c: char) { - self.bump_revision(); + self.cancel_completion_on_edit(); self.adjust_selection(self.cursor, self.cursor, 1); self.atoms.insert(self.cursor, Atom::Char(c)); self.cursor += 1; @@ -445,7 +475,7 @@ impl InputBuffer { } pub fn insert_paste(&mut self, content: String) { - self.bump_revision(); + self.cancel_completion_on_edit(); let measurement = measure_paste(&content); if measurement.presentation() == PastePresentation::Text { self.insert_str(&content); @@ -469,7 +499,7 @@ impl InputBuffer { #[cfg(test)] pub fn insert_uploaded_file(&mut self, file: protocol::UploadedFileRef) { - self.bump_revision(); + self.cancel_completion_on_edit(); self.adjust_selection(self.cursor, self.cursor, 1); self.atoms.insert(self.cursor, Atom::UploadedFile(file)); self.cursor += 1; @@ -493,7 +523,7 @@ impl InputBuffer { start: usize, descriptor: protocol::FeatureInvocationDescriptor, ) { - self.bump_revision(); + self.cancel_completion_on_edit(); self.selected_invocations .retain(|selection| selection.start != start); self.selected_invocations.push(SelectedInvocation { @@ -508,7 +538,7 @@ impl InputBuffer { } pub fn retry_attachment_stage(&mut self, id: &str) { - self.bump_revision(); + self.cancel_completion_on_edit(); for atom in &mut self.atoms { if let Atom::AttachmentStage { id: stage_id, @@ -563,7 +593,7 @@ impl InputBuffer { } fn replace_atoms(&mut self, start: usize, end: usize, atoms: Vec) { - self.bump_revision(); + self.cancel_completion_on_edit(); let inserted = atoms.len(); self.adjust_selection(start, end, inserted); let removed = self.atoms.splice(start..end, atoms).collect::>(); @@ -669,7 +699,7 @@ impl InputBuffer { id: &str, file: Option, ) -> bool { - self.bump_revision(); + self.cancel_completion_on_edit(); let Some(index) = self.atoms.iter().position( |atom| matches!(atom, Atom::AttachmentStage { id: stage_id, .. } if stage_id == id), ) else { @@ -965,12 +995,12 @@ impl InputBuffer { } pub fn move_left(&mut self) { - self.bump_revision(); + self.cancel_completion_on_edit(); self.cursor = self.cursor.saturating_sub(1); } pub fn move_right(&mut self) { - self.bump_revision(); + self.cancel_completion_on_edit(); self.cursor = (self.cursor + 1).min(self.atoms.len()); } @@ -978,7 +1008,7 @@ impl InputBuffer { /// atoms sharing the same [`AtomClass`] — so `Word(Hiragana)` next to /// `Word(Han)` are separate blocks, and a `Paste` atom is its own block. pub fn move_word_left(&mut self) { - self.bump_revision(); + self.cancel_completion_on_edit(); while self.cursor > 0 && atom_class(&self.atoms[self.cursor - 1]) == AtomClass::Sep { self.cursor -= 1; } @@ -993,7 +1023,7 @@ impl InputBuffer { /// Move forward by one word. Mirror of [`move_word_left`]. pub fn move_word_right(&mut self) { - self.bump_revision(); + self.cancel_completion_on_edit(); while self.cursor < self.atoms.len() && atom_class(&self.atoms[self.cursor]) == AtomClass::Sep { @@ -1009,7 +1039,7 @@ impl InputBuffer { } pub fn move_start(&mut self) { - self.bump_revision(); + self.cancel_completion_on_edit(); self.cursor = 0; } @@ -1058,13 +1088,13 @@ impl InputBuffer { } pub fn move_home(&mut self) { - self.bump_revision(); + self.cancel_completion_on_edit(); let (ranges, line, _) = self.logical_line_and_col(); self.cursor = ranges[line].0; } pub fn move_end(&mut self) { - self.bump_revision(); + self.cancel_completion_on_edit(); let (ranges, line, _) = self.logical_line_and_col(); self.cursor = ranges[line].1; } @@ -1072,7 +1102,7 @@ impl InputBuffer { /// Move one logical line up, preserving column (atom count from /// current line start). No-op if already on the first line. pub fn move_up(&mut self) { - self.bump_revision(); + self.cancel_completion_on_edit(); let (ranges, line, col) = self.logical_line_and_col(); if line == 0 { return; @@ -1083,7 +1113,7 @@ impl InputBuffer { /// Move one logical line down, preserving column. pub fn move_down(&mut self) { - self.bump_revision(); + self.cancel_completion_on_edit(); let (ranges, line, col) = self.logical_line_and_col(); let Some(&(start, end)) = ranges.get(line + 1) else { return; diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index de5492ac3..f3fdb1b59 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -2187,7 +2187,6 @@ mod tests { fn set_pending_submission(app: &mut App, id: &str) { app.handle_worker_event(Event::PendingSubmissionsChanged { pending: protocol::PendingSubmissionsSnapshot { - revision: 1, notification_count: 0, notification_previews: vec![], head_id: Some(id.into()), @@ -2320,7 +2319,6 @@ mod tests { parent_session_id: Some("parent-session".into()), kind: protocol::InternalWorkerKind::SubWorker, }, - revision: 1, event: Box::new(protocol::Event::TextDelta { text: "child transcript".into(), }), diff --git a/crates/worker-runtime/src/catalog.rs b/crates/worker-runtime/src/catalog.rs index 2b00cac60..5e9c883ec 100644 --- a/crates/worker-runtime/src/catalog.rs +++ b/crates/worker-runtime/src/catalog.rs @@ -75,7 +75,6 @@ pub struct WorkingDirectoryRepository { pub id: String, pub provider: String, pub source: server_api::RepositorySource, - pub source_revision: u64, pub source_fingerprint: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub selector: Option, @@ -121,7 +120,7 @@ impl std::fmt::Debug for SensitiveString { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RepositorySshCredentialCandidate { pub credential_id: String, - pub credential_revision: u64, + pub public_key_fingerprint: String, #[serde(skip, default)] pub private_key: SensitiveString, } @@ -130,7 +129,7 @@ pub struct RepositorySshCredentialCandidate { pub struct RepositorySshMaterializationAccess { pub credential_candidates: Vec, pub host_trust_id: String, - pub host_trust_revision: u64, + pub host_key_fingerprint: String, pub access: server_api::RepositoryAccessMode, pub expires_at_epoch_seconds: u64, pub repository_id: String, @@ -146,7 +145,6 @@ pub struct RepositoryMaterializationContext { pub workspace_id: String, pub runtime_id: String, pub operation_id: String, - pub config_revision: u64, pub config_projection_digest: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub ssh: Option, @@ -191,10 +189,9 @@ pub struct RepositoryRefObservationRequest { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RepositoryRefObservation { pub repository_id: String, - pub source_revision: u64, pub source_fingerprint: String, pub selector: String, - pub revision_ref: String, + pub resolved_ref: String, pub observed_at_epoch_seconds: u64, } diff --git a/crates/worker-runtime/src/config_bundle.rs b/crates/worker-runtime/src/config_bundle.rs index dd2b227d5..e67f08292 100644 --- a/crates/worker-runtime/src/config_bundle.rs +++ b/crates/worker-runtime/src/config_bundle.rs @@ -43,7 +43,6 @@ impl ConfigBundle { pub fn computed_digest(&self) -> String { let mut lines = Vec::new(); lines.push(format!("id\0{}", self.metadata.id)); - lines.push(format!("revision\0{}", self.metadata.revision)); lines.push(format!("workspace_id\0{}", self.metadata.workspace_id)); lines.push(format!("created_at\0{}", self.metadata.created_at)); lines.push(format!( @@ -82,7 +81,7 @@ impl ConfigBundle { if let Some(prompt_catalog) = &self.prompt_catalog { lines.push(format!( "prompt_catalog\0{}\0{}\0{}\0{}", - prompt_catalog.config_revision, + prompt_catalog.source_digest, prompt_catalog.schema_fingerprint, prompt_catalog.toolchain_fingerprint, prompt_catalog.catalog_digest @@ -100,12 +99,8 @@ impl ConfigBundle { } if let Some(handle) = &self.profile_source_archive_handle { lines.push(format!( - "profile_archive_handle\0{}\0{}\0{}\0{}\0{}", - handle.workspace_id, - handle.resource_id, - handle.digest, - handle.revision, - handle.max_bytes + "profile_archive_handle\0{}\0{}\0{}\0{}", + handle.workspace_id, handle.resource_id, handle.digest, handle.max_bytes )); for (selector, path) in handle .profile_source_graph @@ -140,7 +135,6 @@ impl ConfigBundle { id: self.metadata.id.clone(), digest: self.metadata.digest.clone(), digest_algorithm: CONFIG_BUNDLE_DIGEST_ALGORITHM.to_string(), - revision: self.metadata.revision.clone(), workspace_id: self.metadata.workspace_id.clone(), created_at: self.metadata.created_at.clone(), provenance: self.metadata.provenance.clone(), @@ -182,7 +176,6 @@ impl From for server_api::WorkspaceRuntimeConfigResponse { prompt_catalog: prompt_catalog.map(|catalog| { server_api::WorkspaceRuntimePromptCatalog { templates: catalog.templates, - config_revision: catalog.config_revision, source_digest: catalog.source_digest, schema_fingerprint: catalog.schema_fingerprint, toolchain_fingerprint: catalog.toolchain_fingerprint, @@ -200,7 +193,6 @@ impl From for server_api::WorkspaceRuntimeConfigMetadata { Self { id: metadata.id, digest: metadata.digest, - revision: metadata.revision, workspace_id: metadata.workspace_id, created_at: metadata.created_at, provenance: metadata.provenance.into(), @@ -303,8 +295,6 @@ impl From for server_api::WorkspaceRuntimeResourceHandle operation: handle.operation.into(), expires_at_unix_seconds: handle.expires_at_unix_seconds, nonce: handle.nonce, - revision: handle.revision, - generation: handle.generation, max_bytes: handle.max_bytes, content_type: handle.content_type, redaction: handle.redaction.into(), @@ -344,7 +334,6 @@ impl From for server_api::WorkspaceRuntimeResourceRedac pub struct ConfigBundleMetadata { pub id: String, pub digest: String, - pub revision: String, pub workspace_id: String, pub created_at: String, pub provenance: ConfigBundleProvenance, @@ -402,7 +391,6 @@ pub struct ConfigBundleSummary { pub id: String, pub digest: String, pub digest_algorithm: String, - pub revision: String, pub workspace_id: String, pub created_at: String, pub provenance: ConfigBundleProvenance, @@ -422,7 +410,6 @@ pub(crate) fn validate_config_bundle(bundle: &ConfigBundle) -> Result<(), Runtim validate_config_bundle_id(&bundle.metadata.id)?; validate_non_empty("config bundle digest", &bundle.metadata.digest)?; validate_digest("config bundle digest", &bundle.metadata.digest)?; - validate_non_empty("config bundle revision", &bundle.metadata.revision)?; validate_non_empty("config bundle workspace id", &bundle.metadata.workspace_id)?; validate_non_empty("config bundle created_at", &bundle.metadata.created_at)?; validate_non_empty( @@ -430,7 +417,6 @@ pub(crate) fn validate_config_bundle(bundle: &ConfigBundle) -> Result<(), Runtim &bundle.metadata.provenance.source, )?; validate_boundary_text("config bundle id", &bundle.metadata.id)?; - validate_boundary_text("config bundle revision", &bundle.metadata.revision)?; validate_boundary_text("config bundle workspace id", &bundle.metadata.workspace_id)?; validate_boundary_text( "config bundle provenance source", @@ -489,7 +475,6 @@ pub(crate) fn validate_config_bundle(bundle: &ConfigBundle) -> Result<(), Runtim ("resource handle resource id", handle.resource_id.as_str()), ("resource handle digest", handle.digest.as_str()), ("resource handle nonce", handle.nonce.as_str()), - ("resource handle revision", handle.revision.as_str()), ("resource handle content type", handle.content_type.as_str()), ( "resource handle audit correlation id", @@ -766,7 +751,6 @@ mod tests { metadata: ConfigBundleMetadata { id: "bundle-1".to_string(), digest: String::new(), - revision: "rev-1".to_string(), workspace_id: "workspace-1".to_string(), created_at: "2026-06-26T00:00:00Z".to_string(), provenance: ConfigBundleProvenance { @@ -823,7 +807,6 @@ mod tests { bundle.prompt_catalog = Some( worker::EffectivePromptCatalog::new( std::collections::BTreeMap::from([("default".to_string(), "hello".to_string())]), - 7, "schema", "toolchain", ) @@ -866,8 +849,6 @@ mod tests { operation: crate::resource::BackendResourceOperation::FetchArchive, expires_at_unix_seconds: 4_102_444_800, nonce: "nonce-not-for-browser".to_string(), - revision: "rev-1".to_string(), - generation: Some(1), max_bytes: 128, content_type: crate::resource::PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly, diff --git a/crates/worker-runtime/src/execution.rs b/crates/worker-runtime/src/execution.rs index 86af999e1..42b82e216 100644 --- a/crates/worker-runtime/src/execution.rs +++ b/crates/worker-runtime/src/execution.rs @@ -693,7 +693,7 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static { } /// Observe a newer immutable Workspace Prompt projection. Profile-backed - /// execution uses this as a revision notification; other backends may + /// execution uses this as a content projection notification; other backends may /// safely ignore it. fn observe_workspace_prompt_projection( &self, diff --git a/crates/worker-runtime/src/fs_store.rs b/crates/worker-runtime/src/fs_store.rs index 0e5c8b5c5..c0fcdec54 100644 --- a/crates/worker-runtime/src/fs_store.rs +++ b/crates/worker-runtime/src/fs_store.rs @@ -21,7 +21,9 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicU8, AtomicU64, Ordering}; -const SCHEMA_VERSION: u32 = 9; +const SCHEMA_VERSION: u32 = 10; +// Frozen source schemas: do not move their meaning when adding a migration. +const AGGREGATE_SCHEMA_VERSION: u32 = 9; const PREVIOUS_SCHEMA_VERSION: u32 = 8; const LEGACY_SCHEMA_VERSION: u32 = 7; const OLDEST_SCHEMA_VERSION: u32 = 6; @@ -609,8 +611,8 @@ fn plan_runtime_store_migration( format!("Runtime store schema version {schema_version} is out of range"), ) })?; - let staging = migration_sibling(root, "schema-v9-staging")?; - let backup = migration_sibling(root, "pre-schema-v9-backup")?; + let staging = migration_sibling(root, "schema-v10-staging")?; + let backup = migration_sibling(root, "pre-schema-v10-backup")?; if staging.exists() || backup.exists() { return Err(runtime_store_corrupt( root, @@ -639,12 +641,15 @@ fn plan_runtime_store_migration( } if !matches!( current_schema_version, - OLDEST_SCHEMA_VERSION | LEGACY_SCHEMA_VERSION | PREVIOUS_SCHEMA_VERSION + OLDEST_SCHEMA_VERSION + | LEGACY_SCHEMA_VERSION + | PREVIOUS_SCHEMA_VERSION + | AGGREGATE_SCHEMA_VERSION ) { return Err(runtime_store_corrupt( &runtime_path, format!( - "unsupported Runtime store schema version {schema_version}; expected {OLDEST_SCHEMA_VERSION}, {LEGACY_SCHEMA_VERSION}, {PREVIOUS_SCHEMA_VERSION}, or {SCHEMA_VERSION}" + "unsupported Runtime store schema version {schema_version}; expected {OLDEST_SCHEMA_VERSION}, {LEGACY_SCHEMA_VERSION}, {PREVIOUS_SCHEMA_VERSION}, {AGGREGATE_SCHEMA_VERSION}, or {SCHEMA_VERSION}" ), )); } @@ -969,11 +974,30 @@ fn validate_migrated_worker_documents( } fn migrate_worker_record( - identity_document: serde_json::Value, + mut identity_document: serde_json::Value, source_schema_version: u32, mapping: Option<&LegacyWorkerIdentityMapping>, identity_path: &Path, ) -> Result { + if source_schema_version == AGGREGATE_SCHEMA_VERSION { + migrate_v9_restore_guard(&mut identity_document, identity_path)?; + remove_legacy_job_input_counter(&mut identity_document); + migrate_legacy_request_evidence(&mut identity_document) + .map_err(|message| runtime_store_corrupt(identity_path, message.into()))?; + identity_document["schema_version"] = serde_json::Value::from(SCHEMA_VERSION); + let aggregate: WorkerAggregateRecord = + serde_json::from_value(identity_document).map_err(|error| { + runtime_store_corrupt( + identity_path, + format!("decode migrated schema-v9 Worker aggregate: {error}"), + ) + })?; + aggregate.clone().validate(identity_path)?; + return Ok(aggregate); + } + remove_legacy_job_input_counter(&mut identity_document); + migrate_legacy_request_evidence(&mut identity_document) + .map_err(|message| runtime_store_corrupt(identity_path, message.into()))?; if source_schema_version == PREVIOUS_SCHEMA_VERSION { let identity: WorkerIdentityRecord = serde_json::from_value(identity_document).map_err(|error| { @@ -984,8 +1008,18 @@ fn migrate_worker_record( })?; identity.validate_for_schema(identity_path, PREVIOUS_SCHEMA_VERSION)?; let execution_path = identity_path.with_file_name(WORKER_EXECUTION_FILE); - let execution: WorkerExecutionRecord = + let mut execution_document: serde_json::Value = read_bounded_json(&execution_path, "read schema-v8 Worker execution record")?; + remove_legacy_job_input_counter(&mut execution_document); + migrate_legacy_request_evidence(&mut execution_document) + .map_err(|message| runtime_store_corrupt(&execution_path, message.into()))?; + let execution: WorkerExecutionRecord = + serde_json::from_value(execution_document).map_err(|error| { + runtime_store_corrupt( + &execution_path, + format!("decode migrated schema-v8 execution: {error}"), + ) + })?; execution.validate_for_schema(&identity, &execution_path, PREVIOUS_SCHEMA_VERSION)?; return Ok(WorkerAggregateRecord::from_v8(identity, execution)); } @@ -1016,6 +1050,114 @@ fn migrate_worker_record( Ok(WorkerAggregateRecord::from_v8(identity, execution)) } +// Frozen schema-v9 conversion. Preserve the exact token and all Restore owners +// and receipts; generating a fresh token here would lose retry/admission authority. +fn migrate_v9_restore_guard( + document: &mut serde_json::Value, + path: &Path, +) -> Result<(), RuntimeError> { + let Some(guard) = document.get_mut("restore_guard") else { + return Ok(()); + }; + let guard = guard.as_object_mut().ok_or_else(|| { + runtime_store_corrupt(path, "schema-v9 restore_guard must be an object".into()) + })?; + let token = guard.remove("generation").ok_or_else(|| { + runtime_store_corrupt(path, "schema-v9 restore_guard is missing generation".into()) + })?; + if !token.as_str().is_some_and(|token| !token.is_empty()) + || guard.contains_key("observation_token") + { + return Err(runtime_store_corrupt( + path, + "schema-v9 Restore observation token is invalid or ambiguous".into(), + )); + } + guard.insert("observation_token".into(), token); + Ok(()) +} + +// Legacy Runtime Job bindings never held input JSON, so the old caller-selected +// counter cannot become a content digest. Retain Job/attempt identity and leave +// content identity unknown until the host supplies its immutable intent. +fn remove_legacy_job_input_counter(value: &mut serde_json::Value) { + match value { + serde_json::Value::Object(object) => { + if let Some(job) = object + .get_mut("backend_job") + .and_then(serde_json::Value::as_object_mut) + { + job.remove("input_revision"); + } + for value in object.values_mut() { + remove_legacy_job_input_counter(value); + } + } + serde_json::Value::Array(values) => { + for value in values { + remove_legacy_job_input_counter(value); + } + } + _ => {} + } +} + +// Frozen schema 6..9 request migration. Preserve concrete source/config digests, +// contents, operation ownership and fingerprints. Unknown legacy SSH key identity +// stays empty and cannot pass the materializer's live-authority validation. +fn migrate_legacy_request_evidence(value: &mut serde_json::Value) -> Result<(), &'static str> { + match value { + serde_json::Value::Object(object) => { + if object.contains_key("credential_id") && object.contains_key("credential_revision") { + let counter = object.remove("credential_revision").expect("checked field"); + if !counter.as_u64().is_some_and(|value| value > 0) { + return Err("invalid legacy SSH credential evidence"); + } + object + .entry("public_key_fingerprint") + .or_insert_with(|| serde_json::json!("")); + } + if object.contains_key("host_trust_id") && object.contains_key("host_trust_revision") { + let counter = object.remove("host_trust_revision").expect("checked field"); + if !counter.as_u64().is_some_and(|value| value > 0) { + return Err("invalid legacy SSH host trust evidence"); + } + object + .entry("host_key_fingerprint") + .or_insert_with(|| serde_json::json!("")); + } + if let Some(settings) = object + .get_mut("memory_settings") + .and_then(serde_json::Value::as_object_mut) + && let Some(counter) = settings.remove("settings_revision") + && !counter.as_u64().is_some_and(|value| value > 0) + { + return Err("invalid legacy Memory settings evidence"); + } + if object.contains_key("source_fingerprint") { + object.remove("source_revision"); + } + if object.contains_key("config_projection_digest") { + object.remove("config_revision"); + } + if object.contains_key("resource_id") && object.contains_key("nonce") { + object.remove("revision"); + object.remove("generation"); + } + for value in object.values_mut() { + migrate_legacy_request_evidence(value)?; + } + } + serde_json::Value::Array(values) => { + for value in values { + migrate_legacy_request_evidence(value)?; + } + } + _ => {} + } + Ok(()) +} + fn runtime_worker_name(worker_id: WorkerId) -> String { format!("worker-runtime-{worker_id}") } @@ -1216,6 +1358,9 @@ fn migrate_runtime_document( serde_json::Value::from(SCHEMA_VERSION), ); object.remove("workers"); + // This legacy cache is not Runtime lifecycle or restore authority. Bundles + // are reacquired from Workspace content authority, never renumbered/rehash-pinned here. + object.remove("config_bundles"); object.remove("next_worker_sequence"); let snapshot: RuntimeSnapshot = serde_json::from_value(document.clone()).map_err(|error| { runtime_store_corrupt( @@ -1325,8 +1470,14 @@ fn remove_runtime_migration_directory(path: &Path) -> Result<(), RuntimeError> { } fn recover_runtime_store_migration(root: &Path) -> Result<(), RuntimeError> { - let staging = migration_sibling(root, "schema-v9-staging")?; - let backup = migration_sibling(root, "pre-schema-v9-backup")?; + for suffix in ["schema-v9-staging", "pre-schema-v9-backup"] { + let earlier = migration_sibling(root, suffix)?; + if earlier.exists() { + return Err(runtime_store_corrupt(root, "unfinished schema-v9 migration requires explicit recovery before schema-v10 startup".into())); + } + } + let staging = migration_sibling(root, "schema-v10-staging")?; + let backup = migration_sibling(root, "pre-schema-v10-backup")?; let root_exists = migration_directory_exists(root)?; let staging_exists = migration_directory_exists(&staging)?; let backup_exists = migration_directory_exists(&backup)?; @@ -1528,8 +1679,8 @@ fn migrate_runtime_store( if !plan.migration_required { return Ok(plan); } - let staging = migration_sibling(root, "schema-v9-staging")?; - let backup = migration_sibling(root, "pre-schema-v9-backup")?; + let staging = migration_sibling(root, "schema-v10-staging")?; + let backup = migration_sibling(root, "pre-schema-v10-backup")?; if staging.exists() || backup.exists() { return Err(runtime_store_corrupt( root, @@ -2875,7 +3026,7 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let root = temp.path().join("runtime-store"); write_empty_schema_v8_store(&root); - let staging = migration_sibling(&root, "schema-v9-staging").unwrap(); + let staging = migration_sibling(&root, "schema-v10-staging").unwrap(); fs::create_dir_all(&staging).unwrap(); fs::write(staging.join("partial"), b"partial").unwrap(); let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap(); @@ -2890,7 +3041,7 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let root = temp.path().join("runtime-store"); write_empty_schema_v8_store(&root); - let backup = migration_sibling(&root, "pre-schema-v9-backup").unwrap(); + let backup = migration_sibling(&root, "pre-schema-v10-backup").unwrap(); fs::rename(&root, &backup).unwrap(); let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap(); @@ -2904,10 +3055,10 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let root = temp.path().join("runtime-store"); write_empty_schema_v8_store(&root); - let staging = migration_sibling(&root, "schema-v9-staging").unwrap(); + let staging = migration_sibling(&root, "schema-v10-staging").unwrap(); copy_runtime_tree(&root, &root, &staging).unwrap(); migrate_runtime_store_in_place(&staging, "test-runtime").unwrap(); - let backup = migration_sibling(&root, "pre-schema-v9-backup").unwrap(); + let backup = migration_sibling(&root, "pre-schema-v10-backup").unwrap(); fs::rename(&root, &backup).unwrap(); let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap(); @@ -2922,10 +3073,10 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let root = temp.path().join("runtime-store"); write_empty_schema_v8_store(&root); - let staging = migration_sibling(&root, "schema-v9-staging").unwrap(); + let staging = migration_sibling(&root, "schema-v10-staging").unwrap(); fs::create_dir_all(&staging).unwrap(); fs::write(staging.join(RUNTIME_FILE), b"{\"schema_version\":8}").unwrap(); - let backup = migration_sibling(&root, "pre-schema-v9-backup").unwrap(); + let backup = migration_sibling(&root, "pre-schema-v10-backup").unwrap(); fs::rename(&root, &backup).unwrap(); let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap(); @@ -2942,7 +3093,7 @@ mod tests { let root = temp.path().join("runtime-store"); write_empty_schema_v8_store(&root); migrate_runtime_store_in_place(&root, "test-runtime").unwrap(); - let backup = migration_sibling(&root, "pre-schema-v9-backup").unwrap(); + let backup = migration_sibling(&root, "pre-schema-v10-backup").unwrap(); write_empty_schema_v8_store(&backup); let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap(); @@ -2950,6 +3101,120 @@ mod tests { assert!(!backup.exists()); } + #[test] + fn earlier_migration_backup_cannot_be_mistaken_for_a_fresh_runtime() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("runtime-store"); + let backup = migration_sibling(&root, "pre-schema-v9-backup").unwrap(); + write_empty_schema_v8_store(&backup); + let error = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap_err(); + assert!(error.to_string().contains("explicit recovery")); + assert!(!root.exists()); + assert!(backup.join(RUNTIME_FILE).exists()); + } + + #[test] + fn legacy_request_migration_preserves_content_and_never_invents_key_identity() { + let mut document = serde_json::json!({"request": { + "memory_settings": {"workspace_id":"workspace-a", "language":"English", "settings_revision":2}, + "repository": {"source_revision":1, "source_fingerprint":"source-content"}, + "materialization": {"operation_id":"op-a", "config_revision":2, "config_projection_digest":"projection-content", "ssh": { + "host_trust_id":"host-a", "host_trust_revision":3, + "credential_candidates":[{"credential_id":"credential-a", "credential_revision":4}] + }} + }}); + migrate_legacy_request_evidence(&mut document).unwrap(); + let request = &document["request"]; + assert_eq!(request["memory_settings"]["language"], "English"); + assert!( + request["memory_settings"] + .get("settings_revision") + .is_none() + ); + assert_eq!( + request["repository"]["source_fingerprint"], + "source-content" + ); + assert_eq!( + request["materialization"]["config_projection_digest"], + "projection-content" + ); + assert_eq!( + request["materialization"]["ssh"]["host_key_fingerprint"], + "" + ); + assert_eq!( + request["materialization"]["ssh"]["credential_candidates"][0]["public_key_fingerprint"], + "" + ); + } + + #[test] + fn schema_v9_restore_token_migrates_durably_without_rotating_admission_identity() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("runtime-store"); + write_empty_schema_v8_store(&root); + let runtime_path = root.join(RUNTIME_FILE); + let mut runtime: serde_json::Value = read_json(&runtime_path, "read test Runtime").unwrap(); + runtime["schema_version"] = serde_json::json!(AGGREGATE_SCHEMA_VERSION); + fs::write(&runtime_path, serde_json::to_vec(&runtime).unwrap()).unwrap(); + let worker_id = WorkerId::now_v7(); + let path = root + .join(WORKERS_DIR) + .join(worker_id.to_string()) + .join(WORKER_FILE); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let aggregate = migrate_worker_record( + schema_v7_worker_document(worker_id), + LEGACY_SCHEMA_VERSION, + None, + &path, + ) + .unwrap(); + let mut legacy = serde_json::to_value(&aggregate).unwrap(); + let token = legacy["restore_guard"]["observation_token"].take(); + legacy["restore_guard"] + .as_object_mut() + .unwrap() + .remove("observation_token"); + legacy["restore_guard"]["generation"] = token.clone(); + legacy["schema_version"] = serde_json::json!(AGGREGATE_SCHEMA_VERSION); + fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap(); + let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap(); + store.store.load_runtime_state().unwrap(); + drop(store); + let migrated: serde_json::Value = read_json(&path, "read migrated Worker").unwrap(); + assert_eq!(migrated["schema_version"], SCHEMA_VERSION); + assert_eq!(migrated["restore_guard"]["observation_token"], token); + assert!(migrated["restore_guard"].get("generation").is_none()); + let store = FsRuntimeStore::open_or_create(root, "test-runtime").unwrap(); + store.store.load_runtime_state().unwrap(); + assert_eq!( + read_json::(&path, "read restarted Worker").unwrap(), + migrated + ); + } + + #[test] + fn frozen_restore_guard_conversion_preserves_receipts_and_rejects_ambiguous_identity() { + let path = Path::new("worker.json"); + let mut document = serde_json::json!({"restore_guard": { + "generation": "opaque-admission-token", "active_request_id": "restore-a", + "owners": {"restore-a": {"fingerprint": "intent", "receipt": {"accepted": true}}} + }}); + let owners = document["restore_guard"]["owners"].clone(); + migrate_v9_restore_guard(&mut document, path).unwrap(); + assert_eq!(document["restore_guard"]["owners"], owners); + assert_eq!(document["restore_guard"]["active_request_id"], "restore-a"); + assert_eq!( + document["restore_guard"]["observation_token"], + "opaque-admission-token" + ); + let mut ambiguous = + serde_json::json!({"restore_guard": {"generation": "old", "observation_token": "new"}}); + assert!(migrate_v9_restore_guard(&mut ambiguous, path).is_err()); + } + #[test] fn bounded_worker_record_read_rejects_oversize_before_parse() { let root = tempfile::tempdir().unwrap(); @@ -3047,7 +3312,7 @@ mod tests { )); assert!(!worker_dir.join(WORKER_EXECUTION_FILE).exists()); let aggregate: WorkerAggregateRecord = - read_bounded_json(&identity_path, "read schema-v9 aggregate").unwrap(); + read_bounded_json(&identity_path, "read schema-v10 aggregate").unwrap(); assert_eq!(aggregate.schema_version, SCHEMA_VERSION); } diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index d5d97c67f..381afeb0c 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -413,7 +413,8 @@ pub struct RuntimeHttpWorkspacePromptProjectionRequest { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RuntimeHttpWorkspacePromptProjectionResponse { pub workspace_id: String, - pub config_revision: u64, + pub source_digest: String, + pub projection_digest: String, } /// Config bundle availability response used by sync/check endpoints. @@ -576,10 +577,10 @@ async fn post_workspace_verification_challenge( })?; if challenge.workspace_id != verified.workspace_id || challenge.runtime_id != verified.runtime_id - || challenge.binding_revision != verified.binding_revision + || challenge.binding_id != verified.binding_id || challenge.workspace_key_id != verified.issuer_key_id - || challenge.workspace_identity_revision != verified.issuer_identity_revision - || challenge.workspace_trust_generation != verified.trust_generation + || challenge.workspace_public_key_fingerprint != verified.issuer_public_key_fingerprint + || challenge.workspace_trust_id != verified.trust_id || challenge.runtime_id != auth.signer.runtime_id() || challenge.runtime_public_key_fingerprint != auth.signer.public_key_fingerprint() { @@ -599,6 +600,13 @@ async fn post_workspace_verification_challenge( error.to_string(), ) })?; + auth.verifications.begin(challenge).map_err(|error| { + RuntimeHttpRestError::new( + StatusCode::SERVICE_UNAVAILABLE, + "workspace_runtime_verification_unavailable", + error.to_string(), + ) + })?; Ok(Json(response)) } @@ -616,17 +624,17 @@ async fn post_workspace_verification_acknowledgement( })?; if acknowledgement.workspace_id != verified.workspace_id || acknowledgement.runtime_id != verified.runtime_id - || acknowledgement.binding_revision != verified.binding_revision + || acknowledgement.binding_id != verified.binding_id || acknowledgement.workspace_key_id != verified.issuer_key_id - || acknowledgement.workspace_identity_revision != verified.issuer_identity_revision - || acknowledgement.workspace_trust_generation != verified.trust_generation + || acknowledgement.workspace_public_key_fingerprint + != verified.issuer_public_key_fingerprint + || acknowledgement.workspace_trust_id != verified.trust_id || acknowledgement.runtime_id != auth.signer.runtime_id() || acknowledgement.runtime_public_key_fingerprint != auth.signer.public_key_fingerprint() || acknowledgement.expires_at <= unix_now_i64() - || acknowledgement.binding_revision == 0 - || acknowledgement.runtime_identity_revision == 0 - || acknowledgement.workspace_identity_revision == 0 - || acknowledgement.workspace_trust_generation == 0 + || acknowledgement.binding_id.is_empty() + || acknowledgement.workspace_public_key_fingerprint.is_empty() + || acknowledgement.workspace_trust_id.is_empty() || acknowledgement.workspace_nonce.is_empty() || acknowledgement.runtime_nonce.is_empty() || acknowledgement.response_digest.len() != workspace_request_body_digest(&[]).len() @@ -641,12 +649,11 @@ async fn post_workspace_verification_acknowledgement( challenge_id: acknowledgement.challenge_id.clone(), workspace_id: acknowledgement.workspace_id.clone(), runtime_id: acknowledgement.runtime_id.clone(), - binding_revision: acknowledgement.binding_revision, + binding_id: acknowledgement.binding_id.clone(), workspace_key_id: acknowledgement.workspace_key_id.clone(), - workspace_identity_revision: acknowledgement.workspace_identity_revision, - workspace_trust_generation: acknowledgement.workspace_trust_generation, + workspace_public_key_fingerprint: acknowledgement.workspace_public_key_fingerprint.clone(), + workspace_trust_id: acknowledgement.workspace_trust_id.clone(), runtime_public_key_fingerprint: acknowledgement.runtime_public_key_fingerprint.clone(), - runtime_identity_revision: acknowledgement.runtime_identity_revision, workspace_nonce: acknowledgement.workspace_nonce.clone(), expires_at: acknowledgement.expires_at, }; @@ -689,17 +696,23 @@ async fn post_workspace_verification_acknowledgement( ) })?; auth.verifications - .record(WorkspaceRuntimeVerificationRecord { - workspace_id: acknowledgement.workspace_id.clone(), - runtime_id: acknowledgement.runtime_id.clone(), - binding_revision: acknowledgement.binding_revision, - workspace_key_id: acknowledgement.workspace_key_id.clone(), - workspace_identity_revision: acknowledgement.workspace_identity_revision, - workspace_trust_generation: acknowledgement.workspace_trust_generation, - runtime_public_key_fingerprint: acknowledgement.runtime_public_key_fingerprint.clone(), - runtime_identity_revision: acknowledgement.runtime_identity_revision, - verified_at: unix_now_i64(), - }) + .acknowledge( + &acknowledgement.challenge_id, + WorkspaceRuntimeVerificationRecord { + workspace_id: acknowledgement.workspace_id.clone(), + runtime_id: acknowledgement.runtime_id.clone(), + binding_id: acknowledgement.binding_id.clone(), + workspace_key_id: acknowledgement.workspace_key_id.clone(), + workspace_public_key_fingerprint: acknowledgement + .workspace_public_key_fingerprint + .clone(), + workspace_trust_id: acknowledgement.workspace_trust_id.clone(), + runtime_public_key_fingerprint: acknowledgement + .runtime_public_key_fingerprint + .clone(), + verified_at: unix_now_i64(), + }, + ) .map_err(|error| { RuntimeHttpRestError::new( StatusCode::SERVICE_UNAVAILABLE, @@ -711,7 +724,7 @@ async fn post_workspace_verification_acknowledgement( challenge_id: acknowledgement.challenge_id, workspace_id: acknowledgement.workspace_id, runtime_id: acknowledgement.runtime_id, - binding_revision: acknowledgement.binding_revision, + binding_id: acknowledgement.binding_id.clone(), accepted_at: unix_now_i64(), })) } @@ -810,14 +823,16 @@ async fn observe_workspace_prompt_projection( )); } let workspace_id = request.projection.workspace_id.clone(); - let config_revision = request.projection.config_revision; + let source_digest = request.projection.source_digest.clone(); + let projection_digest = request.projection.projection_digest.clone(); state .runtime .observe_workspace_prompt_projection(request.projection) .map_err(RuntimeHttpRestError::runtime)?; Ok(Json(RuntimeHttpWorkspacePromptProjectionResponse { workspace_id, - config_revision, + source_digest, + projection_digest, })) } @@ -2108,7 +2123,7 @@ async fn require_runtime_auth( let body_digest = workspace_request_body_digest(&body); let expected = WorkspaceCapabilityExpectation { workspace_id: &claims.issuer_workspace_id, - binding_revision: claims.binding_revision, + binding_id: &claims.binding_id, runtime_id: workspace_auth.signer.runtime_id(), worker_id: expected_worker_id.as_deref(), operation: required_permission, @@ -2145,13 +2160,13 @@ async fn require_runtime_auth( .into_response(); } }; - if record.binding_revision != verified.binding_revision + if record.binding_id != verified.binding_id || record.workspace_key_id != verified.issuer_key_id - || record.workspace_identity_revision != verified.issuer_identity_revision - || record.workspace_trust_generation != verified.trust_generation + || record.workspace_public_key_fingerprint + != verified.issuer_public_key_fingerprint + || record.workspace_trust_id != verified.trust_id || record.runtime_public_key_fingerprint != workspace_auth.signer.public_key_fingerprint() - || record.runtime_identity_revision == 0 { return RuntimeHttpRestError::new( StatusCode::FORBIDDEN, @@ -2782,9 +2797,8 @@ mod tests { key_id: "workspace-key".to_string(), algorithm: "ed25519".to_string(), public_key: workspace_identity.public_key.clone(), - public_key_fingerprint: workspace_fingerprint, - identity_revision: 1, - trust_generation: 1, + public_key_fingerprint: workspace_fingerprint.clone(), + trust_id: "trust-test".into(), state: WorkspaceIssuerTrustState::Active, registered_at_unix: 1, updated_at_unix: 1, @@ -2805,12 +2819,11 @@ mod tests { challenge_id: "challenge-1".to_string(), workspace_id: "workspace-a".to_string(), runtime_id: "runtime-test".to_string(), - binding_revision: 4, + binding_id: "binding-test".into(), workspace_key_id: "workspace-key".to_string(), - workspace_identity_revision: 1, - workspace_trust_generation: 1, + workspace_public_key_fingerprint: workspace_fingerprint.clone(), + workspace_trust_id: "trust-test".into(), runtime_public_key_fingerprint: runtime_fingerprint, - runtime_identity_revision: 1, workspace_nonce: "workspace-nonce".to_string(), expires_at: unix_now_i64() + 60, }; @@ -2819,9 +2832,9 @@ mod tests { issuer: "https://backend.test".to_string(), issuer_workspace_id: "workspace-a".to_string(), issuer_key_id: "workspace-key".to_string(), - issuer_identity_revision: 1, - trust_generation: 1, - binding_revision: 4, + issuer_public_key_fingerprint: workspace_fingerprint.clone(), + trust_id: "trust-test".into(), + binding_id: "binding-test".into(), runtime_id: "runtime-test".to_string(), worker_id: None, operation: WORKSPACE_VERIFICATION_OPERATION.to_string(), @@ -2870,14 +2883,15 @@ mod tests { challenge_id: verification_response.challenge_id.clone(), workspace_id: verification_response.workspace_id.clone(), runtime_id: verification_response.runtime_id.clone(), - binding_revision: verification_response.binding_revision, + binding_id: verification_response.binding_id.clone(), workspace_key_id: verification_response.workspace_key_id.clone(), - workspace_identity_revision: verification_response.workspace_identity_revision, - workspace_trust_generation: verification_response.workspace_trust_generation, + workspace_public_key_fingerprint: verification_response + .workspace_public_key_fingerprint + .clone(), + workspace_trust_id: verification_response.workspace_trust_id.clone(), runtime_public_key_fingerprint: verification_response .runtime_public_key_fingerprint .clone(), - runtime_identity_revision: verification_response.runtime_identity_revision, workspace_nonce: verification_response.workspace_nonce.clone(), runtime_nonce: verification_response.runtime_nonce.clone(), response_digest: workspace_request_body_digest(&response_body), @@ -2974,13 +2988,11 @@ mod tests { workspace_id: "workspace-b".to_string(), source_runtime_id: "runtime-a".to_string(), worker_id: retention_worker_id, - expected_worker_revision: "revision".to_string(), source_created_at: "2025-01-01T00:00:00Z".to_string(), removed_at: "2025-01-02T00:00:00Z".to_string(), effective_profile: None, retention_class: None, policy_id: "policy".to_string(), - policy_revision: 1, session_disposition: SessionDisposition::Purge, diagnostics_disposition: DiagnosticsDisposition::Retain, }; @@ -3018,6 +3030,141 @@ mod tests { assert_eq!(error.error.code, "worker_not_found"); } + #[tokio::test] + async fn reenrolled_binding_trust_or_keys_require_fresh_signed_verification() { + let workspace_identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap(); + let runtime_identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap(); + let fingerprint = |key: &str| { + format!( + "sha256:{}", + crate::workspace_issuer::hex_lower(&sha2::Sha256::digest( + crate::auth::decode_public_key(key).unwrap() + )) + ) + }; + let old_trust = WorkspaceIssuerTrustRecord::from_bundle( + server_api::WorkspacePublicIdentityBundle { + workspace_id: "workspace-a".into(), + backend_url: "https://backend.test".into(), + key_id: "workspace-key".into(), + algorithm: "ed25519".into(), + public_key: workspace_identity.public_key.clone(), + public_key_fingerprint: fingerprint(&workspace_identity.public_key), + }, + "old-trust".into(), + 1, + ) + .unwrap(); + let old_verification = WorkspaceRuntimeVerificationRecord { + workspace_id: old_trust.workspace_id.clone(), + runtime_id: "runtime-test".into(), + binding_id: "old-binding".into(), + workspace_key_id: old_trust.key_id.clone(), + workspace_public_key_fingerprint: old_trust.public_key_fingerprint.clone(), + workspace_trust_id: old_trust.trust_id.clone(), + runtime_public_key_fingerprint: fingerprint(&runtime_identity.public_key), + verified_at: 1, + }; + for changed in ["binding", "trust", "workspace_key", "runtime_key"] { + let mut current = old_verification.clone(); + let mut trust = old_trust.clone(); + let mut workspace_key = workspace_identity.clone(); + let mut runtime_key = runtime_identity.clone(); + match changed { + "binding" => current.binding_id = "reenrolled-binding".into(), + "trust" => { + trust.trust_id = "reaccepted-trust".into(); + current.workspace_trust_id = trust.trust_id.clone(); + } + "workspace_key" => { + workspace_key = RuntimeIdentityMaterial::generate("new-workspace-key").unwrap(); + trust.key_id = workspace_key.identity_id.clone(); + trust.public_key = workspace_key.public_key.clone(); + trust.public_key_fingerprint = fingerprint(&workspace_key.public_key); + current.workspace_key_id = trust.key_id.clone(); + current.workspace_public_key_fingerprint = trust.public_key_fingerprint.clone(); + } + "runtime_key" => { + runtime_key = RuntimeIdentityMaterial::generate("runtime-test").unwrap(); + current.runtime_public_key_fingerprint = fingerprint(&runtime_key.public_key); + } + _ => unreachable!(), + } + let authority = Arc::new(InMemoryWorkspaceRuntimeVerificationAuthority::default()); + authority.record(old_verification.clone()).unwrap(); + let app = runtime_http_router_with_workspace_auth( + Runtime::new_memory(), + None, + WorkspaceRuntimeHttpAuth { + verifier: WorkspaceCapabilityVerifier::new( + vec![trust.clone()], + Arc::new(InMemoryWorkspaceClaimReplayProtection::default()), + ) + .unwrap(), + signer: RuntimeVerificationSigner::from_identity(&runtime_key).unwrap(), + verifications: authority.clone(), + }, + ); + let claims = WorkspaceCapabilityClaims { + issuer: trust.backend_url.clone(), + issuer_workspace_id: trust.workspace_id.clone(), + issuer_key_id: trust.key_id.clone(), + issuer_public_key_fingerprint: trust.public_key_fingerprint.clone(), + trust_id: trust.trust_id.clone(), + binding_id: current.binding_id.clone(), + runtime_id: current.runtime_id.clone(), + worker_id: None, + operation: RUNTIME_PING_PERMISSION.into(), + method: "GET".into(), + path_and_query: "/v1/ping".into(), + body_digest: workspace_request_body_digest(&[]), + iat: unix_now_i64(), + exp: unix_now_i64() + 60, + jti: "stale-verification".into(), + }; + for (expected, token_id) in [ + (StatusCode::FORBIDDEN, "stale-verification"), + (StatusCode::OK, "fresh-verification"), + ] { + if expected == StatusCode::OK { + authority.record(current.clone()).unwrap(); + } + let token = issue_workspace_capability_token( + &workspace_key.signing_key().unwrap(), + &WorkspaceCapabilityClaims { + jti: token_id.into(), + ..claims.clone() + }, + ) + .unwrap(); + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/v1/ping") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-a") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + status, + expected, + "{changed}: {}", + String::from_utf8_lossy(&body) + ); + if expected == StatusCode::FORBIDDEN { + let error: RuntimeHttpErrorResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(error.error.code, "workspace_runtime_verification_stale"); + } + } + } + } + #[cfg(feature = "ws-server")] #[test] fn runtime_protocol_replaces_serialized_tracked_source() { @@ -3107,7 +3254,6 @@ mod tests { metadata: ConfigBundleMetadata { id: "http-test-bundle".to_string(), digest: String::new(), - revision: "test".to_string(), workspace_id: "test-workspace".to_string(), created_at: "test".to_string(), provenance: ConfigBundleProvenance { @@ -3164,7 +3310,6 @@ mod tests { workspace_api: None, memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "local".to_string(), - settings_revision: 1, language: "English".to_string(), }), subjektiv_attached: false, @@ -4477,8 +4622,7 @@ mod tests { algorithm: "ed25519".to_string(), public_key: workspace_identity.public_key, public_key_fingerprint: workspace_fingerprint, - identity_revision: 1, - trust_generation: 1, + trust_id: "trust-cleanup-test".to_string(), state: WorkspaceIssuerTrustState::Active, registered_at_unix: 1, updated_at_unix: 1, @@ -5041,7 +5185,6 @@ mod ws_tests { metadata: ConfigBundleMetadata { id: "ws-test-bundle".to_string(), digest: String::new(), - revision: "test".to_string(), workspace_id: "test".to_string(), created_at: "test".to_string(), provenance: ConfigBundleProvenance { @@ -5097,7 +5240,6 @@ mod ws_tests { workspace_api: None, memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "local".to_string(), - settings_revision: 1, language: "English".to_string(), }), subjektiv_attached: false, diff --git a/crates/worker-runtime/src/main.rs b/crates/worker-runtime/src/main.rs index c09b0f149..cad90cdbd 100644 --- a/crates/worker-runtime/src/main.rs +++ b/crates/worker-runtime/src/main.rs @@ -905,12 +905,11 @@ fn print_workspace_trust_mutation( WorkspaceIssuerTrustMutation::Unchanged => "unchanged", }; println!( - "Workspace issuer trust {action}: workspace={} key={} fingerprint={} identity_revision={} trust_generation={} state={:?}", + "Workspace issuer trust {action}: workspace={} key={} fingerprint={} trust_id={} state={:?}", record.workspace_id, record.key_id, record.public_key_fingerprint, - record.identity_revision, - record.trust_generation, + record.trust_id, record.state, ); } @@ -936,10 +935,38 @@ fn read_runtime_auth_file(path: &Path) -> Result } let contents = String::from_utf8(contents) .map_err(|_| ProcessError::auth("runtime auth store is corrupt"))?; - let auth: RuntimeAuthFile = toml::from_str(&contents) + let mut document: toml::Value = toml::from_str(&contents) + .map_err(|_| ProcessError::auth("runtime auth store is corrupt"))?; + let mut migrated = false; + if let Some(records) = document + .get_mut("workspace_issuers") + .and_then(toml::Value::as_array_mut) + { + for value in records { + if value.get("identity_revision").is_some() || value.get("trust_generation").is_some() { + let legacy = serde_json::to_value(&*value) + .map_err(|_| ProcessError::auth("runtime auth store is corrupt"))?; + let record = + worker_runtime::workspace_issuer::migrate_legacy_workspace_issuer_trust_record( + legacy, + ) + .map_err(|_| ProcessError::auth("runtime legacy trust store is corrupt"))?; + *value = toml::Value::try_from(record).map_err(|_| { + ProcessError::auth("runtime auth migration serialization failed") + })?; + migrated = true; + } + } + } + let auth: RuntimeAuthFile = document + .try_into() .map_err(|_| ProcessError::auth("runtime auth store is corrupt"))?; validate_workspace_issuer_trust_records(&auth.workspace_issuers) .map_err(|_| ProcessError::auth("runtime Workspace issuer trust store is corrupt"))?; + if migrated { + // Commit the revoked migration before exposing any issuer authority. + write_runtime_auth_file(path, &auth)?; + } Ok(auth) } @@ -1398,7 +1425,6 @@ mod tests { algorithm: "ed25519".to_string(), public_key: identity.public_key, public_key_fingerprint: fingerprint, - revision: 1, }) .unwrap(), ) @@ -1419,7 +1445,38 @@ mod tests { let path = runtime_auth_path(&config); let first = read_runtime_auth_file(&path).unwrap(); assert_eq!(first.workspace_issuers.len(), 1); - assert_eq!(first.workspace_issuers[0].trust_generation, 1); + assert!(!first.workspace_issuers[0].trust_id.is_empty()); + // `trust-workspace show` serializes this persisted record. Owner + // registration must forward that exact enrollment ID, not derive one + // from the key fingerprint or an initial counter. + let shown = serde_json::to_value(&first.workspace_issuers[0]).unwrap(); + let runtime_identity = RuntimeIdentityMaterial::generate("remote-runtime").unwrap(); + let mut registration_wire = serde_json::json!({ + "public_bundle": { + "identity_id": runtime_identity.identity_id, + "public_key": runtime_identity.public_key, + }, + "endpoint": "https://runtime.example.test", + "workspace_trust_id": shown["trust_id"], + }); + let registration: server_api::CreateRemoteRuntimeRequest = + serde_json::from_value(registration_wire.clone()).unwrap(); + assert_eq!( + registration.workspace_trust_id, + first.workspace_issuers[0].trust_id + ); + assert_ne!( + registration.workspace_trust_id, + first.workspace_issuers[0].public_key_fingerprint + ); + registration_wire + .as_object_mut() + .unwrap() + .remove("workspace_trust_id"); + assert!( + serde_json::from_value::(registration_wire) + .is_err() + ); run_trust_workspace_command(VecDeque::from([ "add".to_string(), @@ -1430,7 +1487,10 @@ mod tests { ])) .unwrap(); let replay = read_runtime_auth_file(&path).unwrap(); - assert_eq!(replay.workspace_issuers[0].trust_generation, 1); + assert_eq!( + replay.workspace_issuers[0].trust_id, + first.workspace_issuers[0].trust_id + ); let replacement = RuntimeIdentityMaterial::generate("WK-2").unwrap(); let public_key = decode_public_key(&replacement.public_key).unwrap(); @@ -1451,7 +1511,6 @@ mod tests { algorithm: "ed25519".to_string(), public_key: replacement.public_key, public_key_fingerprint: fingerprint, - revision: 2, }) .unwrap(), ) @@ -1483,7 +1542,10 @@ mod tests { ])) .unwrap(); let revoked = read_runtime_auth_file(&path).unwrap(); - assert_eq!(revoked.workspace_issuers[0].trust_generation, 3); + assert_ne!( + revoked.workspace_issuers[0].trust_id, + first.workspace_issuers[0].trust_id + ); assert_eq!( revoked.workspace_issuers[0].state, worker_runtime::workspace_issuer::WorkspaceIssuerTrustState::Revoked diff --git a/crates/worker-runtime/src/resource.rs b/crates/worker-runtime/src/resource.rs index 8c38f5760..c9a4dc0db 100644 --- a/crates/worker-runtime/src/resource.rs +++ b/crates/worker-runtime/src/resource.rs @@ -17,7 +17,7 @@ pub const DEFAULT_BACKEND_RESOURCE_FETCH_TIMEOUT: std::time::Duration = #[derive(Clone, Serialize, Deserialize)] pub struct RepositorySshAccessSecretCandidate { pub credential_id: String, - pub credential_revision: u64, + pub public_key_fingerprint: String, pub private_key: String, } @@ -32,7 +32,7 @@ impl std::fmt::Debug for RepositorySshAccessSecretCandidate { formatter .debug_struct("RepositorySshAccessSecretCandidate") .field("credential_id", &self.credential_id) - .field("credential_revision", &self.credential_revision) + .field("public_key_fingerprint", &self.public_key_fingerprint) .field("private_key", &"[REDACTED]") .finish() } @@ -89,9 +89,6 @@ pub struct BackendResourceHandle { pub operation: BackendResourceOperation, pub expires_at_unix_seconds: i64, pub nonce: String, - pub revision: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub generation: Option, pub max_bytes: u64, pub content_type: String, pub redaction: ResourceRedactionPolicy, @@ -425,8 +422,6 @@ mod tests { operation: BackendResourceOperation::FetchArchive, expires_at_unix_seconds: 4_102_444_800, nonce: "nonce-test".to_string(), - revision: sha256_hex(bytes), - generation: Some(1), max_bytes: 1024, content_type: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), redaction: ResourceRedactionPolicy::RuntimeInternalOnly, @@ -477,7 +472,7 @@ mod tests { let secret = RepositorySshAccessSecret { credential_candidates: vec![RepositorySshAccessSecretCandidate { credential_id: "credential-1".to_string(), - credential_revision: 2, + public_key_fingerprint: "key-2".into(), private_key: "PRIVATE KEY secret bytes".to_string(), }], known_hosts_entry: "host key secret bytes".to_string(), diff --git a/crates/worker-runtime/src/retention.rs b/crates/worker-runtime/src/retention.rs index a956ac1bf..ae86a2ee5 100644 --- a/crates/worker-runtime/src/retention.rs +++ b/crates/worker-runtime/src/retention.rs @@ -116,13 +116,11 @@ pub struct WorkerRetentionExecutionRequest { pub workspace_id: String, pub source_runtime_id: String, pub worker_id: WorkerId, - pub expected_worker_revision: String, pub source_created_at: String, pub removed_at: String, pub effective_profile: Option, pub retention_class: Option, pub policy_id: String, - pub policy_revision: u64, pub session_disposition: SessionDisposition, pub diagnostics_disposition: DiagnosticsDisposition, } @@ -145,7 +143,6 @@ pub struct WorkerSessionArchiveManifest { pub content_bytes: u64, pub content_file_count: u64, pub policy_id: String, - pub policy_revision: u64, pub operation_id: String, pub input_fingerprint: String, } @@ -154,7 +151,6 @@ pub struct WorkerSessionArchiveManifest { pub struct WorkerRetentionExecutionResult { pub operation_id: String, pub input_fingerprint: String, - pub expected_worker_revision: String, pub worker_id: WorkerId, pub session_disposition: SessionDisposition, pub diagnostics_disposition: DiagnosticsDisposition, @@ -524,7 +520,6 @@ impl WorkerRetentionProvider for FsWorkerRetentionProvider { let mut result = WorkerRetentionExecutionResult { operation_id: request.operation_id.clone(), input_fingerprint: request.input_fingerprint.clone(), - expected_worker_revision: request.expected_worker_revision.clone(), worker_id: request.worker_id, session_disposition: request.session_disposition, diagnostics_disposition: request.diagnostics_disposition, @@ -749,7 +744,6 @@ fn commit_session_archive( content_bytes: bytes, content_file_count: count, policy_id: request.policy_id.clone(), - policy_revision: request.policy_revision, operation_id: request.operation_id.clone(), input_fingerprint: request.input_fingerprint.clone(), }; @@ -1290,7 +1284,6 @@ mod tests { WorkerRetentionExecutionRequest { operation_id: "operation-a".to_string(), input_fingerprint: "fingerprint-a".to_string(), - expected_worker_revision: "revision-a".to_string(), archive_id: (disposition == SessionDisposition::Archive) .then(|| "archive-a".to_string()), workspace_id: "workspace-a".to_string(), @@ -1301,7 +1294,6 @@ mod tests { effective_profile: Some("builtin:coder".to_string()), retention_class: None, policy_id: "policy-a".to_string(), - policy_revision: 3, session_disposition: disposition, diagnostics_disposition: DiagnosticsDisposition::Purge, } diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 1c812321c..73937df1b 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -457,7 +457,7 @@ impl Runtime { state }; state.execution_backend = execution_backend; - // Initialize legacy generations durably before exposing an observation. + // Initialize legacy observation tokens durably before exposing an observation. state.persist_workers()?; let runtime = Self { inner: Arc::new(Mutex::new(state)), @@ -761,7 +761,7 @@ impl Runtime { .zip(&ssh.credential_candidates) .any(|(secret, metadata)| { secret.credential_id != metadata.credential_id - || secret.credential_revision != metadata.credential_revision + || secret.public_key_fingerprint != metadata.public_key_fingerprint }) { return Err(RuntimeError::InvalidRequest( @@ -2157,7 +2157,8 @@ impl Runtime { && candidate.pending_stop.is_none() && candidate.restore_guard.active_request_id.is_none(); if coordinate && request.preparation.is_none() && !recovering_pending { - if candidate.restore_guard.generation != request.expected_observation_token + if candidate.restore_guard.observation_token + != request.expected_observation_token || candidate.restore_guard.active_request_id.is_some() || candidate.has_pending_lifecycle_operation() { @@ -2176,7 +2177,7 @@ impl Runtime { )); } state.ensure_running()?; - if candidate.restore_guard.generation != request.expected_observation_token + if candidate.restore_guard.observation_token != request.expected_observation_token || candidate.restore_guard.active_request_id.is_some() || (candidate.has_pending_lifecycle_operation() && !recovering_pending) { @@ -2283,7 +2284,7 @@ impl Runtime { return worker.restore_guard.owners[id].request.clone(); } runtime_api::WorkerRestoreRequest { - expected_observation_token: worker.restore_guard.generation.clone(), + expected_observation_token: worker.restore_guard.observation_token.clone(), request_id: uuid::Uuid::now_v7().to_string(), preparation: worker .workspace_id @@ -5531,7 +5532,7 @@ impl RuntimeState { ProfileSelector::Builtin(name) | ProfileSelector::Named(name) => Some(name.clone()), }; Ok(SubscriptionWorker { - restore_observation_token: Some(worker.restore_guard.generation.clone()), + restore_observation_token: Some(worker.restore_guard.observation_token.clone()), worker_id, runtime_id: None, resource_key: None, @@ -5806,7 +5807,6 @@ impl RuntimeState { InternalWorkerActivity { status: Some(snapshot.status), parent_session_id: snapshot.worker.parent_session_id.clone(), - revision: snapshot.revision, }, ); for child in &snapshot.internal_workers { @@ -5854,42 +5854,22 @@ impl RuntimeState { fn remove_internal_worker_subtree( activity: &mut InternalWorkerActivityProjection, worker: &protocol::InternalWorkerRef, - revision: u64, ) { - let known_revision = activity - .workers - .get(&worker.session_id) - .map(|worker| worker.revision) - .into_iter() - .chain( - activity - .removed_workers - .get(&worker.session_id) - .map(|worker| worker.revision), - ) - .max(); - if known_revision.is_some_and(|known_revision| revision <= known_revision) { - return; - } - for session_id in Self::internal_worker_subtree_ids(activity, &worker.session_id) { let active = activity.workers.remove(&session_id); let removed = activity.removed_workers.remove(&session_id); - let (worker_revision, parent_session_id) = if session_id == worker.session_id { - (revision, worker.parent_session_id.clone()) + let parent_session_id = if session_id == worker.session_id { + worker.parent_session_id.clone() } else if let Some(active) = active { - (active.revision, active.parent_session_id) + active.parent_session_id } else if let Some(removed) = removed { - (removed.revision, removed.parent_session_id) + removed.parent_session_id } else { continue; }; activity.removed_workers.insert( session_id, - RemovedInternalWorkerActivity { - revision: worker_revision, - parent_session_id, - }, + RemovedInternalWorkerActivity { parent_session_id }, ); } } @@ -5897,37 +5877,25 @@ impl RuntimeState { fn touch_internal_worker_activity( activity: &mut InternalWorkerActivityProjection, worker: &protocol::InternalWorkerRef, - revision: u64, ) { activity .workers .entry(worker.session_id.clone()) - .and_modify(|activity| { - activity.parent_session_id = worker.parent_session_id.clone(); - activity.revision = revision; - }) + .and_modify(|activity| activity.parent_session_id = worker.parent_session_id.clone()) .or_insert_with(|| InternalWorkerActivity { status: None, parent_session_id: worker.parent_session_id.clone(), - revision, }); } fn project_internal_worker_event( activity: &mut InternalWorkerActivityProjection, worker: &protocol::InternalWorkerRef, - revision: u64, event: &protocol::Event, ) { - if activity.removed_workers.contains_key(&worker.session_id) - || activity - .workers - .get(&worker.session_id) - .is_some_and(|current| revision <= current.revision) - { + if activity.removed_workers.contains_key(&worker.session_id) { return; } - match event { protocol::Event::Snapshot { state, @@ -5940,7 +5908,6 @@ impl RuntimeState { InternalWorkerActivity { status: Some(state.catalog_status()), parent_session_id: worker.parent_session_id.clone(), - revision, }, ); for child in internal_workers { @@ -5949,23 +5916,16 @@ impl RuntimeState { } protocol::Event::InternalWorker { worker: nested_worker, - revision: nested_revision, event, } => { - Self::touch_internal_worker_activity(activity, worker, revision); - Self::project_internal_worker_event( - activity, - nested_worker, - *nested_revision, - event, - ); + Self::touch_internal_worker_activity(activity, worker); + Self::project_internal_worker_event(activity, nested_worker, event); } protocol::Event::InternalWorkerRemoved { worker: removed_worker, - revision: removed_revision, } => { - Self::touch_internal_worker_activity(activity, worker, revision); - Self::remove_internal_worker_subtree(activity, removed_worker, *removed_revision); + Self::touch_internal_worker_activity(activity, worker); + Self::remove_internal_worker_subtree(activity, removed_worker); } protocol::Event::WorkerState { snapshot } | protocol::Event::CommandAcknowledged { @@ -5979,11 +5939,10 @@ impl RuntimeState { InternalWorkerActivity { status: Some(snapshot.catalog_status()), parent_session_id: worker.parent_session_id.clone(), - revision, }, ); } - _ => Self::touch_internal_worker_activity(activity, worker, revision), + _ => Self::touch_internal_worker_activity(activity, worker), } } @@ -6001,15 +5960,11 @@ impl RuntimeState { Self::internal_worker_snapshot_statuses(activity, child); } } - protocol::Event::InternalWorker { - worker, - revision, - event, - } => { - Self::project_internal_worker_event(activity, worker, *revision, event); + protocol::Event::InternalWorker { worker, event } => { + Self::project_internal_worker_event(activity, worker, event); } - protocol::Event::InternalWorkerRemoved { worker, revision } => { - Self::remove_internal_worker_subtree(activity, worker, *revision); + protocol::Event::InternalWorkerRemoved { worker } => { + Self::remove_internal_worker_subtree(activity, worker); } _ => {} } @@ -6057,12 +6012,10 @@ impl RuntimeState { struct InternalWorkerActivity { status: Option, parent_session_id: Option, - revision: u64, } #[derive(Debug, Clone)] struct RemovedInternalWorkerActivity { - revision: u64, parent_session_id: Option, } @@ -6111,8 +6064,9 @@ pub(crate) struct RestoreRequestOwner { } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] pub(crate) struct RestoreGuard { - generation: String, + observation_token: String, active_request_id: Option, owners: BTreeMap, } @@ -6120,7 +6074,7 @@ pub(crate) struct RestoreGuard { impl Default for RestoreGuard { fn default() -> Self { Self { - generation: uuid::Uuid::now_v7().to_string(), + observation_token: uuid::Uuid::now_v7().to_string(), active_request_id: None, owners: BTreeMap::new(), } @@ -6129,7 +6083,7 @@ impl Default for RestoreGuard { impl RestoreGuard { fn rotate(&mut self) { - self.generation = uuid::Uuid::now_v7().to_string(); + self.observation_token = uuid::Uuid::now_v7().to_string(); } fn finish(&mut self, result: RuntimeWorkerRestoreResult) { @@ -6199,7 +6153,7 @@ impl WorkerRecord { fn summary(&self) -> WorkerSummary { WorkerSummary { - restore_observation_token: Some(self.restore_guard.generation.clone()), + restore_observation_token: Some(self.restore_guard.observation_token.clone()), worker_ref: self.worker_ref.clone(), worker_id: self.worker_id, status: self.status, @@ -6217,7 +6171,7 @@ impl WorkerRecord { fn detail(&self) -> WorkerDetail { WorkerDetail { - restore_observation_token: Some(self.restore_guard.generation.clone()), + restore_observation_token: Some(self.restore_guard.observation_token.clone()), worker_ref: self.worker_ref.clone(), worker_id: self.worker_id, status: self.status, @@ -6740,11 +6694,6 @@ fn validate_create_workspace_scope( snapshot.workspace_id ))); } - if snapshot.settings_revision == 0 { - return Err(RuntimeError::InvalidRequest( - "Memory settings revision must be at least 1".to_string(), - )); - } if !manifest::is_normalized_workspace_memory_language(&snapshot.language) { return Err(RuntimeError::InvalidRequest( "Memory settings language must be a normalized bounded UTF-8 value".to_string(), @@ -6953,12 +6902,10 @@ mod tests { fn internal_worker_status_event( worker: protocol::InternalWorkerRef, - revision: u64, status: protocol::WorkerStatus, ) -> protocol::Event { protocol::Event::InternalWorker { worker, - revision, event: Box::new(protocol::Event::WorkerState { snapshot: status.into(), }), @@ -6967,13 +6914,11 @@ mod tests { fn internal_worker_snapshot( worker: protocol::InternalWorkerRef, - revision: u64, status: protocol::WorkerStatus, internal_workers: Vec, ) -> protocol::InternalWorkerSnapshot { protocol::InternalWorkerSnapshot { worker, - revision, status, greeting: None, session: protocol::SessionSnapshot { @@ -7017,7 +6962,6 @@ mod tests { &mut activity, &internal_worker_status_event( internal_worker_ref("child-a", None), - 1, protocol::WorkerStatus::Running, ), )); @@ -7025,7 +6969,6 @@ mod tests { &mut activity, &internal_worker_status_event( internal_worker_ref("child-b", None), - 1, protocol::WorkerStatus::Running, ), )); @@ -7033,7 +6976,6 @@ mod tests { &mut activity, &internal_worker_status_event( internal_worker_ref("child-a", None), - 2, protocol::WorkerStatus::Idle, ), )); @@ -7041,7 +6983,6 @@ mod tests { &mut activity, &internal_worker_status_event( internal_worker_ref("child-b", None), - 2, protocol::WorkerStatus::Stopped, ), )); @@ -7053,10 +6994,8 @@ mod tests { let direct_child = internal_worker_ref("child", None); let nested_running = protocol::Event::InternalWorker { worker: direct_child.clone(), - revision: 1, event: Box::new(internal_worker_status_event( internal_worker_ref("grandchild", Some("child")), - 1, protocol::WorkerStatus::Running, )), }; @@ -7067,10 +7006,8 @@ mod tests { let nested_idle = protocol::Event::InternalWorker { worker: direct_child, - revision: 2, event: Box::new(internal_worker_status_event( internal_worker_ref("grandchild", Some("child")), - 2, protocol::WorkerStatus::Idle, )), }; @@ -7087,7 +7024,6 @@ mod tests { &mut activity, &internal_worker_status_event( internal_worker_ref("child-a", None), - 1, protocol::WorkerStatus::Running, ), ); @@ -7101,44 +7037,23 @@ mod tests { } #[test] - fn internal_worker_removal_is_revision_fenced_until_parent_snapshot() { + fn internal_worker_removal_is_session_fenced_until_parent_snapshot() { let mut activity = InternalWorkerActivityProjection::default(); let child_a = internal_worker_ref("child-a", None); let child_b = internal_worker_ref("child-b", None); assert!(RuntimeState::update_internal_worker_activity( &mut activity, - &internal_worker_status_event(child_a.clone(), 2, protocol::WorkerStatus::Running), + &internal_worker_status_event(child_a.clone(), protocol::WorkerStatus::Running), )); assert!(!RuntimeState::update_internal_worker_activity( &mut activity, - &internal_worker_status_event(child_b.clone(), 1, protocol::WorkerStatus::Running), + &internal_worker_status_event(child_b.clone(), protocol::WorkerStatus::Running), )); assert!(!RuntimeState::update_internal_worker_activity( &mut activity, - &protocol::Event::InternalWorkerRemoved { - worker: child_a.clone(), - revision: 1, - }, - )); - assert!(activity.workers.contains_key("child-a")); - - assert!(!RuntimeState::update_internal_worker_activity( - &mut activity, - &protocol::Event::InternalWorkerRemoved { - worker: child_a.clone(), - revision: 2, - }, - )); - assert!(activity.workers.contains_key("child-a")); - - assert!(!RuntimeState::update_internal_worker_activity( - &mut activity, - &protocol::Event::InternalWorkerRemoved { - worker: child_a, - revision: 3, - }, + &protocol::Event::InternalWorkerRemoved { worker: child_a }, )); assert!(!activity.workers.contains_key("child-a")); assert!(activity.removed_workers.contains_key("child-a")); @@ -7148,14 +7063,13 @@ mod tests { &mut activity, &protocol::Event::InternalWorkerRemoved { worker: child_b.clone(), - revision: 2, }, )); assert!(!activity.has_running_worker()); assert!(!RuntimeState::update_internal_worker_activity( &mut activity, - &internal_worker_status_event(child_b.clone(), 3, protocol::WorkerStatus::Running), + &internal_worker_status_event(child_b.clone(), protocol::WorkerStatus::Running), )); assert!(!activity.workers.contains_key("child-b")); assert!(!activity.has_running_worker()); @@ -7164,7 +7078,6 @@ mod tests { &mut activity, &parent_snapshot(vec![internal_worker_snapshot( child_b, - 4, protocol::WorkerStatus::Running, Vec::new(), )]), @@ -7182,16 +7095,14 @@ mod tests { assert!(!RuntimeState::update_internal_worker_activity( &mut activity, - &internal_worker_status_event(child.clone(), 1, protocol::WorkerStatus::Idle), + &internal_worker_status_event(child.clone(), protocol::WorkerStatus::Idle), )); assert!(RuntimeState::update_internal_worker_activity( &mut activity, &protocol::Event::InternalWorker { worker: child.clone(), - revision: 2, event: Box::new(internal_worker_status_event( grandchild.clone(), - 1, protocol::WorkerStatus::Running, )), }, @@ -7200,13 +7111,10 @@ mod tests { &mut activity, &protocol::Event::InternalWorker { worker: child.clone(), - revision: 3, event: Box::new(protocol::Event::InternalWorker { worker: grandchild.clone(), - revision: 2, event: Box::new(internal_worker_status_event( great_grandchild, - 1, protocol::WorkerStatus::Running, )), }), @@ -7217,10 +7125,8 @@ mod tests { &mut activity, &protocol::Event::InternalWorker { worker: child.clone(), - revision: 4, event: Box::new(protocol::Event::InternalWorkerRemoved { worker: grandchild.clone(), - revision: 3, }), }, )); @@ -7235,10 +7141,8 @@ mod tests { &mut activity, &protocol::Event::InternalWorker { worker: child, - revision: 5, event: Box::new(internal_worker_status_event( grandchild, - 4, protocol::WorkerStatus::Running, )), }, @@ -7338,7 +7242,6 @@ mod tests { workspace_api: None, memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "local".to_string(), - settings_revision: 1, language: "English".to_string(), }), subjektiv_attached: false, @@ -7367,7 +7270,7 @@ mod tests { request.backend_job = Some(crate::catalog::BackendJobExecutionBinding { job_id: "job-1".into(), attempt_id: "attempt-1".into(), - input_revision: Some("revision-1".into()), + input_digest: Some(format!("sha256:{}", "a".repeat(64))), subjektiv_consolidation: false, }); let owner = scope("local", "server-job-test"); @@ -7416,7 +7319,7 @@ mod tests { request.backend_job = Some(crate::catalog::BackendJobExecutionBinding { job_id: "job-1".into(), attempt_id: "attempt-1".into(), - input_revision: None, + input_digest: None, subjektiv_consolidation: true, }); assert!(validate_create_worker_request(&request).is_err()); @@ -7444,7 +7347,6 @@ mod tests { kind: server_api::RepositorySourceKind::Ssh, uri: "ssh://git@example.test/repo.git".to_string(), }, - source_revision: 1, source_fingerprint: "sha256:source".to_string(), selector: None, }, @@ -7455,16 +7357,15 @@ mod tests { workspace_id: "workspace-1".to_string(), runtime_id: "runtime-1".to_string(), operation_id: "operation-1".to_string(), - config_revision: 1, config_projection_digest: "sha256:projection".to_string(), ssh: Some(RepositorySshMaterializationAccess { credential_candidates: vec![RepositorySshCredentialCandidate { credential_id: "credential-1".to_string(), - credential_revision: 1, + public_key_fingerprint: "key-1".into(), private_key: SensitiveString::new("private-key-bytes"), }], host_trust_id: "host-trust-1".to_string(), - host_trust_revision: 1, + host_key_fingerprint: "key-1".into(), access: server_api::RepositoryAccessMode::ReadOnly, expires_at_epoch_seconds: u64::MAX, repository_id: "repository-1".to_string(), @@ -7512,8 +7413,6 @@ mod tests { operation: crate::resource::BackendResourceOperation::FetchOnce, expires_at_unix_seconds: i64::MAX, nonce: "repository-access-1".to_string(), - revision: "1".to_string(), - generation: None, max_bytes: crate::resource::DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES, content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(), redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly, @@ -7544,12 +7443,12 @@ mod tests { credential_candidates: vec![ crate::resource::RepositorySshAccessSecretCandidate { credential_id: "credential-1".to_string(), - credential_revision: 1, + public_key_fingerprint: "key-1".into(), private_key: "private-key-bytes-1".to_string(), }, crate::resource::RepositorySshAccessSecretCandidate { credential_id: "credential-2".to_string(), - credential_revision: 3, + public_key_fingerprint: "key-3".into(), private_key: "private-key-bytes-2".to_string(), }, ], @@ -7567,23 +7466,22 @@ mod tests { workspace_id: "workspace-1".to_string(), runtime_id: "runtime-1".to_string(), operation_id: "operation-1".to_string(), - config_revision: 1, config_projection_digest: "sha256:projection".to_string(), ssh: Some(RepositorySshMaterializationAccess { credential_candidates: vec![ RepositorySshCredentialCandidate { credential_id: "credential-1".to_string(), - credential_revision: 1, + public_key_fingerprint: "key-1".into(), private_key: SensitiveString::default(), }, RepositorySshCredentialCandidate { credential_id: "credential-2".to_string(), - credential_revision: 3, + public_key_fingerprint: "key-3".into(), private_key: SensitiveString::default(), }, ], host_trust_id: "host-trust-1".to_string(), - host_trust_revision: 1, + host_key_fingerprint: "key-1".into(), access: server_api::RepositoryAccessMode::ReadOnly, expires_at_epoch_seconds: u64::MAX, repository_id: "repository-1".to_string(), @@ -7652,12 +7550,12 @@ mod tests { credential_candidates: vec![ crate::resource::RepositorySshAccessSecretCandidate { credential_id: "credential-1".to_string(), - credential_revision: 1, + public_key_fingerprint: "key-1".into(), private_key: "create-private-key-bytes-1".to_string(), }, crate::resource::RepositorySshAccessSecretCandidate { credential_id: "credential-2".to_string(), - credential_revision: 3, + public_key_fingerprint: "key-3".into(), private_key: "create-private-key-bytes-2".to_string(), }, ], @@ -7677,7 +7575,6 @@ mod tests { kind: server_api::RepositorySourceKind::Ssh, uri: "ssh://git@example.test/repo.git".to_string(), }, - source_revision: 1, source_fingerprint: "sha256:source".to_string(), selector: None, }, @@ -7688,23 +7585,22 @@ mod tests { workspace_id: "workspace-1".to_string(), runtime_id: "runtime-1".to_string(), operation_id: "operation-create".to_string(), - config_revision: 1, config_projection_digest: "sha256:projection".to_string(), ssh: Some(RepositorySshMaterializationAccess { credential_candidates: vec![ RepositorySshCredentialCandidate { credential_id: "credential-1".to_string(), - credential_revision: 1, + public_key_fingerprint: "key-1".into(), private_key: SensitiveString::default(), }, RepositorySshCredentialCandidate { credential_id: "credential-2".to_string(), - credential_revision: 3, + public_key_fingerprint: "key-3".into(), private_key: SensitiveString::default(), }, ], host_trust_id: "host-trust-1".to_string(), - host_trust_revision: 1, + host_key_fingerprint: "key-1".into(), access: server_api::RepositoryAccessMode::ReadOnly, expires_at_epoch_seconds: u64::MAX, repository_id: "repository-1".to_string(), @@ -7751,7 +7647,6 @@ mod tests { }); request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: workspace_id.to_string(), - settings_revision: 1, language: "English".to_string(), }); request @@ -7769,14 +7664,12 @@ mod tests { request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "workspace-b".to_string(), - settings_revision: 1, language: "English".to_string(), }); assert!(validate_create_workspace_scope(&request, Some("workspace-a")).is_err()); request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "workspace-a".to_string(), - settings_revision: 2, language: " english ".to_string(), }); assert!(validate_create_workspace_scope(&request, Some("workspace-a")).is_err()); @@ -7791,7 +7684,6 @@ mod tests { metadata: ConfigBundleMetadata { id: "bundle-1".to_string(), digest: String::new(), - revision: "rev-1".to_string(), workspace_id: "workspace-1".to_string(), created_at: "2026-06-26T00:00:00Z".to_string(), provenance: ConfigBundleProvenance { @@ -8350,7 +8242,6 @@ mod tests { kind: server_api::RepositorySourceKind::LocalPath, uri: "/unused-test-repository".into(), }, - source_revision: 1, source_fingerprint: "sha256:test-source".into(), selector: None, }, @@ -8368,7 +8259,6 @@ mod tests { workspace_id: "workspace-a".into(), runtime_id: "runtime-1".into(), operation_id: "operation-1".into(), - config_revision: 1, config_projection_digest: "sha256:test-projection".into(), ssh: None, }, @@ -8842,7 +8732,6 @@ mod tests { &created.worker_ref, internal_worker_status_event( internal_worker_ref("child-live", None), - 1, protocol::WorkerStatus::Running, ), ) @@ -8861,7 +8750,6 @@ mod tests { &created.worker_ref, protocol::Event::InternalWorkerRemoved { worker: internal_worker_ref("child-live", None), - revision: 2, }, ) .unwrap(); @@ -8879,7 +8767,6 @@ mod tests { &created.worker_ref, internal_worker_status_event( internal_worker_ref("child-live", None), - 3, protocol::WorkerStatus::Running, ), ) @@ -8900,7 +8787,6 @@ mod tests { &created.worker_ref, parent_snapshot(vec![internal_worker_snapshot( internal_worker_ref("child-live", None), - 4, protocol::WorkerStatus::Running, Vec::new(), )]), @@ -9427,7 +9313,6 @@ mod tests { bundle.prompt_catalog = Some( worker::EffectivePromptCatalog::new( BTreeMap::from([("default".to_string(), "workspace prompt".to_string())]), - 7, "schema", "toolchain", ) @@ -10287,7 +10172,6 @@ mod tests { workspace_id: "workspace-a".into(), runtime_id: "peer-runtime".into(), operation_id: format!("worker-restore:{}", request.request_id), - config_revision: 1, config_projection_digest: "snapshot".into(), ssh: None, }, @@ -10405,7 +10289,7 @@ mod tests { } #[test] - fn restore_guard_protocol_state_aba_rotates_generation() { + fn restore_guard_protocol_state_aba_rotates_observation_token() { let (runtime, backend) = runtime_and_backend(); let worker = runtime.create_worker(task_request("state ABA")).unwrap(); let request = runtime.test_restore_request(&worker.worker_ref); @@ -11160,7 +11044,7 @@ mod tests { #[cfg(feature = "fs-store")] #[test] - fn restore_guard_generation_and_terminal_receipt_survive_restart() { + fn restore_guard_observation_token_and_terminal_receipt_survive_restart() { let dir = tempfile::tempdir().unwrap(); let options = FsRuntimeStoreOptions::new(dir.path().join("runtime")).with_runtime_id("guard-test"); @@ -11177,7 +11061,7 @@ mod tests { .restore_worker_operation(&worker.worker_ref, request.clone()) .unwrap(); runtime.stop_worker(&worker.worker_ref, None).unwrap(); - let generation = runtime + let observation_token = runtime .worker_detail(&worker.worker_ref) .unwrap() .restore_observation_token; @@ -11188,7 +11072,7 @@ mod tests { .worker_detail(&worker.worker_ref) .unwrap() .restore_observation_token, - generation + observation_token ); assert_eq!( restarted @@ -12680,7 +12564,7 @@ mod tests { assert!( error .to_string() - .contains("unsupported Runtime store schema version 2; expected 6, 7, 8, or 9") + .contains("unsupported Runtime store schema version 2; expected 6, 7, 8, 9, or 10") ); let _ = std::fs::remove_dir_all(root); @@ -12722,7 +12606,7 @@ mod tests { let worker_aggregate: serde_json::Value = serde_json::from_slice(&std::fs::read(worker_store_dir.join("worker.json")).unwrap()) .unwrap(); - assert_eq!(worker_aggregate["schema_version"], serde_json::json!(9)); + assert_eq!(worker_aggregate["schema_version"], serde_json::json!(10)); assert_eq!(worker_aggregate["status"], serde_json::json!("stopped")); assert_eq!( worker_aggregate["execution_state"]["state"], @@ -13072,7 +12956,7 @@ mod tests { .unwrap() .remove("restore_guard") .unwrap(); - assert_ne!(guard["generation"], old_guard["generation"]); + assert_ne!(guard["observation_token"], old_guard["observation_token"]); assert!(guard["active_request_id"].is_null()); assert_eq!( guard["owners"] @@ -13669,7 +13553,7 @@ mod tests { ); let migrated_aggregate: serde_json::Value = serde_json::from_slice(&std::fs::read(&worker_path).unwrap()).unwrap(); - assert_eq!(migrated_aggregate["schema_version"], serde_json::json!(9)); + assert_eq!(migrated_aggregate["schema_version"], serde_json::json!(10)); assert!(migrated_aggregate.get("request").is_none()); assert!(migrated_aggregate.get("execution").is_none()); assert_eq!( diff --git a/crates/worker-runtime/src/session_public.rs b/crates/worker-runtime/src/session_public.rs index 60b154ac9..451fe9fc7 100644 --- a/crates/worker-runtime/src/session_public.rs +++ b/crates/worker-runtime/src/session_public.rs @@ -374,7 +374,6 @@ fn api_archive_manifest(manifest: &DomainArchiveManifest) -> WorkerSessionArchiv content_bytes: manifest.content_bytes, content_file_count: manifest.content_file_count, policy_id: manifest.policy_id.clone(), - policy_revision: manifest.policy_revision, operation_id: manifest.operation_id.clone(), input_fingerprint: manifest.input_fingerprint.clone(), } diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 801838b70..63a4e8163 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -133,7 +133,7 @@ use worker::feature::builtin::{ WorkspaceClientWorkerObservationProvider, }; #[cfg(feature = "ws-server")] -use worker::ipc::protocol_session::{live_log_entry_event, subscribe_worker_protocol_session}; +use worker::ipc::protocol_session::{WorkerProtocolSessionStreams, live_log_entry_event}; use worker::{ PreparedWorker, PromptCatalogSource, SegmentLogSink, SubjektivSessionAttributionLifecycle, Worker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout, @@ -142,6 +142,15 @@ use worker::{ bash_output_dir_for_worker_id, }; +#[cfg(feature = "ws-server")] +fn subscribe_worker_protocol_session(handle: &WorkerHandle) -> WorkerProtocolSessionStreams { + // Child state changes and their live events share this publication gate. + // Acquire it before subscribing, and retain it through the parent snapshot + // capture, so a pre-snapshot child event cannot replay after newer state. + let _publish_guard = handle.protocol_snapshot_publish_guard(); + worker::ipc::protocol_session::subscribe_worker_protocol_session(handle) +} + const DEFAULT_BACKEND_ID: &str = "worker-crate"; const RUNTIME_TASK_TIMEOUT: Duration = Duration::from_secs(10); const SPAWN_RESTORE_TASK_TIMEOUT: Duration = Duration::from_secs(60); @@ -403,26 +412,12 @@ impl WorkspacePromptProjectionCache { .active .lock() .map_err(|_| "Workspace Prompt projection cache lock was poisoned".to_string())?; - if let Some(current) = active.get(&workspace_id) { - if current.projection.config_revision > resolution.projection.config_revision { - return Ok(current.clone()); - } - if current.projection.config_revision == resolution.projection.config_revision - && (current.projection.source_digest != resolution.projection.source_digest - || current.projection.projection_digest - != resolution.projection.projection_digest - || current.projection.catalog.catalog_digest - != resolution.projection.catalog.catalog_digest - || current.projection.catalog.schema_fingerprint - != resolution.projection.catalog.schema_fingerprint - || current.projection.catalog.toolchain_fingerprint - != resolution.projection.catalog.toolchain_fingerprint) - { - return Err(format!( - "Workspace Prompt projection identity changed without a config revision transition: workspace={workspace_id} revision={}", - resolution.projection.config_revision - )); - } + // Runtime receives authoritative projections in operation/arrival order. + // Equal content reuses the compiled catalog; counters are not authority. + if let Some(current) = active.get(&workspace_id) + && current.projection == resolution.projection + { + return Ok(current.clone()); } active.insert(workspace_id, resolution.clone()); Ok(resolution) @@ -688,7 +683,7 @@ impl ProfileRuntimeWorkerFactory { None => fallback, }; // Saved policy is execution authority. Rebinding current settings here - // would hide revision and attachment mismatches. + // would hide content and attachment mismatches. validate_worker_memory_settings(&manifest, request)?; Ok((manifest, loader)) } @@ -1195,11 +1190,8 @@ fn validate_worker_memory_settings( "Workspace Worker restored without its bound Memory settings snapshot".to_string() })?; return Err(format!( - "Workspace Worker Memory settings snapshot mismatch: expected {} revision {}, restored {} revision {}", - expected.workspace_id, - expected.settings_revision, - actual.workspace_id, - actual.settings_revision + "Workspace Worker Memory settings snapshot mismatch: expected {} language {}, restored {} language {}", + expected.workspace_id, expected.language, actual.workspace_id, actual.language )); } let subjektiv_settings = manifest.feature.subjektiv.workspace_settings(); @@ -1216,11 +1208,8 @@ fn validate_worker_memory_settings( .to_string() })?; return Err(format!( - "Workspace Worker subjektiv settings snapshot mismatch: expected {} revision {}, restored {} revision {}", - expected.workspace_id, - expected.settings_revision, - actual.workspace_id, - actual.settings_revision + "Workspace Worker subjektiv settings snapshot mismatch: expected {} language {}, restored {} language {}", + expected.workspace_id, expected.language, actual.workspace_id, actual.language )); } } else if subjektiv_settings.is_some() { @@ -4789,7 +4778,6 @@ mod tests { request.subjektiv_attached = true; request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "workspace-saved-subjektiv".to_string(), - settings_revision: 17, language: "English".to_string(), }); bind_workspace_memory_settings(&mut saved, &request).unwrap(); @@ -5982,7 +5970,6 @@ mod tests { ); let mut catalog = worker::EffectivePromptCatalog::new( templates, - 17, projection.schema_fingerprint.clone(), projection.toolchain_fingerprint.clone(), ) @@ -6005,7 +5992,6 @@ mod tests { missing_instruction_bundle.prompt_catalog = Some( worker::EffectivePromptCatalog::new( templates, - 17, projection.schema_fingerprint.clone(), projection.toolchain_fingerprint.clone(), ) @@ -6115,19 +6101,19 @@ mod tests { .with_runtime_store_dir(&runtime_store) .with_runtime_id("runtime-subjektiv-test"); let before = persisted_files(&runtime_store); - let mut wrong_revision = request.clone(); - wrong_revision + let mut wrong_settings = request.clone(); + wrong_settings .request .memory_settings .as_mut() .unwrap() - .settings_revision = 18; + .language = "different language".into(); let mut detached = request.clone(); detached.request.subjektiv_attached = false; let mut missing_settings = request.clone(); missing_settings.request.memory_settings = None; for (invalid, expected) in [ - (wrong_revision, "subjektiv settings snapshot mismatch"), + (wrong_settings, "subjektiv settings snapshot mismatch"), (detached, "unauthorized subjektiv attachment"), (missing_settings, "missing its trusted settings snapshot"), ] { @@ -6660,7 +6646,6 @@ mod tests { let cache = WorkspacePromptProjectionCache::default(); let catalog_v1 = worker::EffectivePromptCatalog::new( BTreeMap::from([("default".to_string(), "prompt-v1".to_string())]), - 8, "schema", "toolchain", ) @@ -6674,7 +6659,6 @@ mod tests { .unwrap(); let catalog_v2 = worker::EffectivePromptCatalog::new( BTreeMap::from([("default".to_string(), "prompt-v2".to_string())]), - 9, "schema", "toolchain", ) @@ -6691,7 +6675,7 @@ mod tests { cache.observe(projection_v2).unwrap(); let active = cache.active("workspace-a").unwrap().unwrap(); - assert_eq!(active.projection.config_revision, 9); + assert_eq!(active.projection.source_digest, "source-v2"); assert_eq!( active.projection.catalog.catalog_digest, catalog_v2.catalog_digest @@ -6699,10 +6683,9 @@ mod tests { } #[test] - fn workspace_prompt_projection_cache_rejects_same_revision_source_drift() { + fn workspace_prompt_projection_cache_accepts_authoritative_source_change() { let catalog = worker::EffectivePromptCatalog::new( BTreeMap::from([("default".to_string(), "prompt".to_string())]), - 8, "schema", "toolchain", ) @@ -6724,22 +6707,20 @@ mod tests { let cache = WorkspacePromptProjectionCache::default(); cache.observe(first).unwrap(); - let error = cache.observe(drifted).unwrap_err(); - assert!( - error - .to_string() - .contains("without a config revision transition") + assert_eq!( + cache.observe(drifted).unwrap().projection.source_digest, + "source-b" ); } #[test] - fn workspace_prompt_projection_cache_rejects_same_revision_schema_drift() { + fn workspace_prompt_projection_cache_accepts_authoritative_toolchain_change() { let templates = BTreeMap::from([("default".to_string(), "prompt".to_string())]); let first_catalog = - worker::EffectivePromptCatalog::new(templates.clone(), 8, "schema-a", "toolchain") + worker::EffectivePromptCatalog::new(templates.clone(), "schema-a", "toolchain") .unwrap(); let drifted_catalog = - worker::EffectivePromptCatalog::new(templates, 8, "schema-b", "toolchain").unwrap(); + worker::EffectivePromptCatalog::new(templates, "schema-b", "toolchain").unwrap(); let first = worker::WorkspacePromptProjection::new( "workspace-a", "source-a", @@ -6757,8 +6738,14 @@ mod tests { let cache = WorkspacePromptProjectionCache::default(); cache.observe(first).unwrap(); - let error = cache.observe(drifted).unwrap_err(); - assert!(error.contains("without a config revision transition")); + assert_eq!( + cache + .observe(drifted) + .unwrap() + .projection + .schema_fingerprint, + "schema-b" + ); } #[test] @@ -7165,7 +7152,6 @@ mod tests { metadata: crate::config_bundle::ConfigBundleMetadata { id: "adapter-test-bundle".to_string(), digest: String::new(), - revision: "test".to_string(), workspace_id: "adapter-test".to_string(), created_at: "test".to_string(), provenance: crate::config_bundle::ConfigBundleProvenance { @@ -7273,7 +7259,7 @@ mod tests { request.backend_job = Some(crate::catalog::BackendJobExecutionBinding { job_id: "job-1".into(), attempt_id: "attempt-1".into(), - input_revision: None, + input_digest: None, subjektiv_consolidation: granted, }); for tools in [false, true] { @@ -7329,7 +7315,6 @@ mod tests { }; let settings = manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "workspace-subject".to_string(), - settings_revision: 3, language: "English".to_string(), }; let mut ordinary_request = create_request("ordinary"); @@ -7987,7 +7972,6 @@ mod tests { kind: server_api::RepositorySourceKind::LocalPath, uri: repo.display().to_string(), }, - source_revision: 1, source_fingerprint: "sha256:test".to_string(), selector: Some(RepositorySelector::from("HEAD")), }, @@ -8197,7 +8181,6 @@ mod tests { ); let mut effective = worker::EffectivePromptCatalog::new( templates, - 7, projection.schema_fingerprint.clone(), projection.toolchain_fingerprint.clone(), ) @@ -8213,7 +8196,7 @@ mod tests { .unwrap() .unwrap(); - assert_eq!(resolution.projection.config_revision, 7); + assert_eq!(resolution.projection.source_digest, "source-7"); assert_eq!( resolution.catalog.notify_wrapper("restored").unwrap(), "PENDING-LAUNCH restored" @@ -8250,7 +8233,6 @@ mod tests { }); request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "workspace-restore".to_string(), - settings_revision: 1, language: "English".to_string(), }); let identity = RuntimeIdentityMaterial::generate("runtime-restore").unwrap(); diff --git a/crates/worker-runtime/src/worker_source.rs b/crates/worker-runtime/src/worker_source.rs index 43bda3fb0..d847f2103 100644 --- a/crates/worker-runtime/src/worker_source.rs +++ b/crates/worker-runtime/src/worker_source.rs @@ -736,7 +736,6 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient { fn current_prompt_projection( &self, - minimum_revision: Option, ) -> Result, WorkspaceClientError> { let Some(cache) = self.prompt_projection_cache.as_ref() else { return Ok(None); @@ -744,11 +743,6 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient { if let Some(resolution) = cache .active(&self.workspace_id) .map_err(WorkspaceClientError::Request)? - .filter(|resolution| { - minimum_revision - .map(|minimum| resolution.projection.config_revision >= minimum) - .unwrap_or(true) - }) { return Ok(Some((*resolution).clone())); } @@ -763,11 +757,6 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient { if let Some(resolution) = cache .active(&self.workspace_id) .map_err(WorkspaceClientError::Request)? - .filter(|resolution| { - minimum_revision - .map(|minimum| resolution.projection.config_revision >= minimum) - .unwrap_or(true) - }) { return Ok(Some((*resolution).clone())); } @@ -807,14 +796,6 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient { let resolution = cache .observe(projection) .map_err(WorkspaceClientError::Request)?; - if let Some(minimum_revision) = minimum_revision - && resolution.projection.config_revision < minimum_revision - { - return Err(WorkspaceClientError::Request(format!( - "active Workspace Prompt projection is stale: required revision {minimum_revision}, got {}", - resolution.projection.config_revision - ))); - } Ok(Some((*resolution).clone())) } @@ -1082,7 +1063,6 @@ mod tests { "default".to_string(), "workspace prompt".to_string(), )]), - 3, "schema", "toolchain", ) @@ -1103,22 +1083,20 @@ mod tests { ) .with_prompt_projection_cache(cache); - let projection = client.current_prompt_projection(None).unwrap().unwrap(); - let second = client.current_prompt_projection(None).unwrap().unwrap(); + let projection = client.current_prompt_projection().unwrap().unwrap(); + let second = client.current_prompt_projection().unwrap().unwrap(); - assert_eq!(projection.projection.config_revision, 3); assert_eq!(projection.projection.source_digest, "source-3"); assert!(Arc::ptr_eq(&projection.catalog, &second.catalog)); } #[test] - fn prompt_projection_minimum_revision_rejects_stale_server_response() { + fn prompt_projection_cache_miss_fetches_content_identity() { use std::io::{Read, Write}; use std::net::TcpListener; let catalog = worker::EffectivePromptCatalog::new( std::collections::BTreeMap::from([("default".to_string(), "stale prompt".to_string())]), - 3, "schema", "toolchain", ) @@ -1132,7 +1110,6 @@ mod tests { .unwrap(); let body = serde_json::to_string(&projection).unwrap(); let cache = Arc::new(WorkspacePromptProjectionCache::default()); - cache.observe(projection).unwrap(); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap()); let server = std::thread::spawn(move || { @@ -1151,10 +1128,10 @@ mod tests { RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a") .with_prompt_projection_cache(cache); - let error = client.current_prompt_projection(Some(4)).unwrap_err(); + let fetched = client.current_prompt_projection().unwrap().unwrap(); server.join().unwrap(); - assert!(error.to_string().contains("required revision 4, got 3")); + assert_eq!(fetched.projection.source_digest, "source-3"); } #[test] @@ -1168,7 +1145,6 @@ mod tests { "default".to_string(), "shared prompt".to_string(), )]), - 5, "schema", "toolchain", ) @@ -1211,7 +1187,7 @@ mod tests { let barrier = barrier.clone(); std::thread::spawn(move || { barrier.wait(); - client.current_prompt_projection(None).unwrap().unwrap() + client.current_prompt_projection().unwrap().unwrap() }) }) .collect::>(); @@ -1239,7 +1215,6 @@ mod tests { "default".to_string(), "foreign prompt".to_string(), )]), - 4, "schema", "toolchain", ) @@ -1270,7 +1245,7 @@ mod tests { RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a") .with_prompt_projection_cache(Arc::new(WorkspacePromptProjectionCache::default())); - let error = client.current_prompt_projection(None).unwrap_err(); + let error = client.current_prompt_projection().unwrap_err(); server.join().unwrap(); assert!(error.to_string().contains("scope mismatch")); diff --git a/crates/worker-runtime/src/working_directory.rs b/crates/worker-runtime/src/working_directory.rs index dc27551a2..3e7781d3c 100644 --- a/crates/worker-runtime/src/working_directory.rs +++ b/crates/worker-runtime/src/working_directory.rs @@ -40,6 +40,7 @@ const REPOSITORY_MAX_BYTES: u64 = 5 * 1024 * 1024 * 1024; static NEXT_WORKING_DIRECTORY_SEQUENCE: AtomicU64 = AtomicU64::new(0); #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "FrozenEvidenceRead")] pub struct WorkingDirectoryEvidence { pub repository_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -48,16 +49,61 @@ pub struct WorkingDirectoryEvidence { #[serde(default, skip_serializing_if = "Option::is_none")] pub resolved_tree: Option, pub materializer_kind: MaterializerKind, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub repository_source_revision: Option, + /// SSH authority must be reacquired before opening a session, even if an old + /// record has no concrete key fingerprint. Never inferred as false from that omission. + pub repository_access_required: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub repository_source_fingerprint: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub operation_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub credential_revision: Option, + pub public_key_fingerprint: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub host_trust_revision: Option, + pub host_key_fingerprint: Option, +} + +// Frozen persisted evidence reader. Legacy counters only mark that SSH +// access is required; they are never converted to key identity or grants. +#[derive(Deserialize)] +struct FrozenEvidenceRead { + repository_id: String, + requested_selector: Option, + resolved_commit: String, + resolved_tree: Option, + materializer_kind: MaterializerKind, + repository_source_fingerprint: Option, + operation_id: Option, + public_key_fingerprint: Option, + host_key_fingerprint: Option, + repository_access_required: Option, + credential_revision: Option, + host_trust_revision: Option, +} + +impl TryFrom for WorkingDirectoryEvidence { + type Error = &'static str; + fn try_from(wire: FrozenEvidenceRead) -> Result { + if wire.credential_revision == Some(0) || wire.host_trust_revision == Some(0) { + return Err("legacy SSH evidence contains an invalid authority marker"); + } + let repository_access_required = wire.repository_access_required.unwrap_or(false) + || wire.credential_revision.is_some() + || wire.host_trust_revision.is_some() + || wire.public_key_fingerprint.is_some() + || wire.host_key_fingerprint.is_some(); + Ok(Self { + repository_id: wire.repository_id, + requested_selector: wire.requested_selector, + resolved_commit: wire.resolved_commit, + resolved_tree: wire.resolved_tree, + materializer_kind: wire.materializer_kind, + repository_access_required, + repository_source_fingerprint: wire.repository_source_fingerprint, + operation_id: wire.operation_id, + public_key_fingerprint: wire.public_key_fingerprint, + host_key_fingerprint: wire.host_key_fingerprint, + }) + } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -156,7 +202,7 @@ impl WorkingDirectoryBinding { WorkingDirectoryStatusKind::Active | WorkingDirectoryStatusKind::CleanupPending ) && binding_paths_are_available(self) { - let (current_selector, current_ref, current_tree) = binding_current_revision(self); + let (current_selector, current_ref, current_tree) = binding_current_commit(self); summary.current_selector = current_selector; summary.current_ref = current_ref; summary.current_tree = current_tree; @@ -274,7 +320,7 @@ fn binding_paths_are_available(binding: &WorkingDirectoryBinding) -> bool { root.join(".git").is_dir() } -fn binding_current_revision( +fn binding_current_commit( binding: &WorkingDirectoryBinding, ) -> (Option, Option, Option) { let current_ref = git_stdout(binding.root(), ["rev-parse", "HEAD"]) @@ -305,13 +351,12 @@ fn binding_cleanliness(binding: &WorkingDirectoryBinding) -> String { #[derive(Debug)] struct PendingRepositoryAccessLease { - generation: u64, + lease_id: uuid::Uuid, access: RepositorySshMaterializationAccess, } #[derive(Debug, Default)] struct RepositoryAccessExpiryState { - next_generation: u64, pending: HashMap, shutdown: bool, } @@ -360,23 +405,11 @@ impl RepositoryAccessExpiryScheduler { } } - fn next_generation( - state: &mut RepositoryAccessExpiryState, - ) -> Result { - state.next_generation = state.next_generation.checked_add(1).ok_or_else(|| { - WorkingDirectoryDiagnostic::new( - "working_directory_repository_access_generation_exhausted", - "Runtime Repository access generation is exhausted", - ) - })?; - Ok(state.next_generation) - } - fn store_pending( &self, working_directory_id: &str, access: RepositorySshMaterializationAccess, - ) -> Result { + ) -> Result { let mut state = self.inner.state.lock().map_err(|_| { WorkingDirectoryDiagnostic::new( "working_directory_repository_access_unavailable", @@ -389,14 +422,14 @@ impl RepositoryAccessExpiryScheduler { "Runtime Repository access state is shutting down", )); } - let generation = Self::next_generation(&mut state)?; + let lease_id = uuid::Uuid::now_v7(); state.pending.insert( working_directory_id.to_string(), - PendingRepositoryAccessLease { generation, access }, + PendingRepositoryAccessLease { lease_id, access }, ); drop(state); self.inner.wake.notify_one(); - Ok(generation) + Ok(lease_id) } fn pending( @@ -452,14 +485,14 @@ impl RepositoryAccessExpiryScheduler { Ok(()) } - fn next_expiry(state: &RepositoryAccessExpiryState) -> Option<(String, u64, u64)> { + fn next_expiry(state: &RepositoryAccessExpiryState) -> Option<(String, uuid::Uuid, u64)> { state .pending .iter() .map(|(working_directory_id, lease)| { ( working_directory_id.clone(), - lease.generation, + lease.lease_id, lease.access.expires_at_epoch_seconds, ) }) @@ -469,14 +502,14 @@ impl RepositoryAccessExpiryScheduler { fn expire_if_current( state: &mut RepositoryAccessExpiryState, working_directory_id: &str, - generation: u64, + lease_id: uuid::Uuid, now_epoch_seconds: u64, ) { let should_remove = state .pending .get(working_directory_id) .is_some_and(|lease| { - lease.generation == generation + lease.lease_id == lease_id && lease.access.expires_at_epoch_seconds <= now_epoch_seconds }); if should_remove { @@ -494,7 +527,7 @@ impl RepositoryAccessExpiryScheduler { if state.shutdown { return; } - let Some((working_directory_id, generation, expires_at_epoch_seconds)) = + let Some((working_directory_id, lease_id, expires_at_epoch_seconds)) = Self::next_expiry(&state) else { state = match inner.wake.wait(state) { @@ -518,7 +551,7 @@ impl RepositoryAccessExpiryScheduler { Self::expire_if_current( &mut state, &working_directory_id, - generation, + lease_id, now_epoch_seconds, ); break; @@ -527,21 +560,21 @@ impl RepositoryAccessExpiryScheduler { } #[cfg(test)] - fn pending_generation(&self, working_directory_id: &str) -> Option { + fn pending_lease_id(&self, working_directory_id: &str) -> Option { self.inner .state .lock() .ok()? .pending .get(working_directory_id) - .map(|lease| lease.generation) + .map(|lease| lease.lease_id) } #[cfg(test)] fn expire_pending_for_test( &self, working_directory_id: &str, - generation: u64, + lease_id: uuid::Uuid, now_epoch_seconds: u64, ) { let Ok(mut state) = self.inner.state.lock() else { @@ -550,7 +583,7 @@ impl RepositoryAccessExpiryScheduler { Self::expire_if_current( &mut state, working_directory_id, - generation, + lease_id, now_epoch_seconds, ); } @@ -701,6 +734,26 @@ impl RuntimeGitMaterializer { fn read_binding( &self, working_directory_id: &str, + ) -> Result { + // Frozen evidence normalization can write the record. Keep it inside + // use admission so observational reads cannot race physical cleanup. + let _use_lease = self.occupancy.acquire_use(working_directory_id)?; + self.read_binding_record(working_directory_id, true) + } + + fn read_binding_for_cleanup( + &self, + working_directory_id: &str, + ) -> Result { + // Cleanup already owns exclusive admission and validates the original + // bytes. Its witnessed status write performs any canonicalization. + self.read_binding_record(working_directory_id, false) + } + + fn read_binding_record( + &self, + working_directory_id: &str, + migrate: bool, ) -> Result { let working_directory_root = self.working_directory_root(working_directory_id); let path = working_directory_root.join(MATERIALIZATION_RECORD); @@ -716,7 +769,7 @@ impl RuntimeGitMaterializer { "working directory working_directory record is invalid; backend-private path details were omitted", ) })?; - Ok(WorkingDirectoryBinding { + let binding = WorkingDirectoryBinding { working_directory: record.working_directory, root: record.root.clone(), cwd: record.root, @@ -726,7 +779,39 @@ impl RuntimeGitMaterializer { occupancy: self.occupancy.clone(), occupancy_id: working_directory_id.to_string(), generation: self.occupancy.generation(&working_directory_id), + }; + let canonical = serde_json::to_vec_pretty(&WorkingDirectoryMaterializationRecord { + working_directory: binding.working_directory.clone(), + root: binding.root.clone(), }) + .map_err(|_| { + WorkingDirectoryDiagnostic::new( + "working_directory_record_invalid", + "materialization evidence is invalid", + ) + })?; + if migrate && canonical != raw { + let temporary = path.with_extension(format!("migration-{}.tmp", uuid::Uuid::now_v7())); + let result = (|| -> std::io::Result<()> { + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary)?; + file.write_all(&canonical)?; + file.sync_all()?; + fs::rename(&temporary, &path)?; + fs::File::open(&binding.working_directory_root)?.sync_all()?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + return Err(WorkingDirectoryDiagnostic::new( + "working_directory_record_write_failed", + "materialization evidence migration could not be committed", + )); + } + } + Ok(binding) } fn store_repository_access( @@ -750,8 +835,7 @@ impl RuntimeGitMaterializer { if binding .working_directory .evidence - .credential_revision - .is_some() + .repository_access_required { return Err(WorkingDirectoryDiagnostic::new( "working_directory_remote_repository_access_required", @@ -832,7 +916,6 @@ impl RuntimeGitMaterializer { if materialization.workspace_id.trim().is_empty() || materialization.runtime_id.trim().is_empty() || materialization.operation_id.trim().is_empty() - || materialization.config_revision == 0 || materialization.config_projection_digest.trim().is_empty() { return Err(WorkingDirectoryDiagnostic::new( @@ -1032,16 +1115,17 @@ impl RuntimeGitMaterializer { resolved_commit, resolved_tree, materializer_kind: MaterializerKind::RuntimeGitClone, - repository_source_revision: Some(request.repository.source_revision), + repository_access_required: request.repository.source.kind + == server_api::RepositorySourceKind::Ssh, repository_source_fingerprint: Some(request.repository.source_fingerprint.clone()), operation_id: context.map(|value| value.operation_id.clone()), - credential_revision: context + public_key_fingerprint: context .and_then(|value| value.ssh.as_ref()) .and_then(|value| value.credential_candidates.first()) - .map(|candidate| candidate.credential_revision), - host_trust_revision: context + .map(|candidate| candidate.public_key_fingerprint.clone()), + host_key_fingerprint: context .and_then(|value| value.ssh.as_ref()) - .map(|value| value.host_trust_revision), + .map(|value| value.host_key_fingerprint.clone()), }, cleanup_target: WorkingDirectoryCleanupTarget { kind: "runtime_git_clone".to_string(), @@ -1122,11 +1206,10 @@ impl WorkingDirectoryMaterializer for RuntimeGitMaterializer { } Err(error) => return Err(error), }; - if binding + if !binding .working_directory .evidence - .credential_revision - .is_none() + .repository_access_required { return Err(WorkingDirectoryDiagnostic::new( "working_directory_repository_access_not_applicable", @@ -1146,13 +1229,15 @@ impl WorkingDirectoryMaterializer for RuntimeGitMaterializer { )?; binding.working_directory.evidence.operation_id = Some(request.materialization.operation_id.clone()); - binding.working_directory.evidence.credential_revision = Some( + binding.working_directory.evidence.public_key_fingerprint = Some( ssh.credential_candidates .first() .expect("validated SSH credential candidate") - .credential_revision, + .public_key_fingerprint + .clone(), ); - binding.working_directory.evidence.host_trust_revision = Some(ssh.host_trust_revision); + binding.working_directory.evidence.host_key_fingerprint = + Some(ssh.host_key_fingerprint.clone()); self.write_record(&binding)?; self.store_repository_access(&request.working_directory_id, ssh) } @@ -1195,15 +1280,15 @@ impl WorkingDirectoryMaterializer for RuntimeGitMaterializer { "Repository provider returned an ambiguous ref observation", )); } - let (revision_ref, observed_selector) = line.split_once('\t').ok_or_else(|| { + let (resolved_ref, observed_selector) = line.split_once('\t').ok_or_else(|| { WorkingDirectoryDiagnostic::new( "repository_ref_response_invalid", "Repository provider returned an invalid ref observation", ) })?; if observed_selector != selector - || !matches!(revision_ref.len(), 40 | 64) - || !revision_ref.bytes().all(|byte| byte.is_ascii_hexdigit()) + || !matches!(resolved_ref.len(), 40 | 64) + || !resolved_ref.bytes().all(|byte| byte.is_ascii_hexdigit()) { return Err(WorkingDirectoryDiagnostic::new( "repository_ref_response_invalid", @@ -1213,10 +1298,9 @@ impl WorkingDirectoryMaterializer for RuntimeGitMaterializer { Ok(RepositoryRefObservation { repository_id: request.repository.id.clone(), - source_revision: request.repository.source_revision, source_fingerprint: request.repository.source_fingerprint.clone(), selector: selector.to_string(), - revision_ref: revision_ref.to_ascii_lowercase(), + resolved_ref: resolved_ref.to_ascii_lowercase(), observed_at_epoch_seconds: SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() @@ -2294,11 +2378,11 @@ fn validate_ssh_materialization_access( if access.credential_candidates.is_empty() || access.credential_candidates.iter().any(|candidate| { candidate.credential_id.trim().is_empty() - || candidate.credential_revision == 0 + || candidate.public_key_fingerprint.trim().is_empty() || !candidate.private_key.expose().contains("PRIVATE KEY") }) || access.host_trust_id.trim().is_empty() - || access.host_trust_revision == 0 + || access.host_key_fingerprint.trim().is_empty() || access.known_hosts_entry.expose().trim().is_empty() { return Err(WorkingDirectoryDiagnostic::new( @@ -3013,8 +3097,6 @@ mod tests { operation: crate::resource::BackendResourceOperation::FetchOnce, expires_at_unix_seconds: i64::MAX, nonce: "repository-access-1".to_string(), - revision: "1".to_string(), - generation: None, max_bytes: crate::resource::DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES, content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(), redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly, @@ -3027,11 +3109,11 @@ mod tests { crate::catalog::RepositorySshMaterializationAccess { credential_candidates: vec![crate::catalog::RepositorySshCredentialCandidate { credential_id: "credential-1".to_string(), - credential_revision: 2, + public_key_fingerprint: "key-2".into(), private_key: crate::catalog::SensitiveString::new("PRIVATE KEY secret bytes"), }], host_trust_id: "trust-1".to_string(), - host_trust_revision: 4, + host_key_fingerprint: "key-4".into(), access: server_api::RepositoryAccessMode::ReadOnly, expires_at_epoch_seconds: u64::MAX, repository_id: "repo-main".to_string(), @@ -3043,7 +3125,7 @@ mod tests { } #[test] - fn repository_access_expiry_fences_a_stale_pending_generation_after_refresh() { + fn repository_access_expiry_fences_a_stale_pending_lease_id_after_refresh() { let scheduler = RepositoryAccessExpiryScheduler::new(); let working_directory_id = "working-directory-refresh"; let old_expiry = repository_access_now_epoch_seconds() + 60; @@ -3052,9 +3134,9 @@ mod tests { scheduler .store_pending(working_directory_id, old_access) .unwrap(); - let old_generation = scheduler - .pending_generation(working_directory_id) - .expect("old pending generation"); + let old_lease_id = scheduler + .pending_lease_id(working_directory_id) + .expect("old pending lease_id"); let barrier = Arc::new(std::sync::Barrier::new(2)); let refresh_barrier = Arc::clone(&barrier); @@ -3069,12 +3151,12 @@ mod tests { }); barrier.wait(); refresh.join().unwrap(); - let refreshed_generation = scheduler - .pending_generation(working_directory_id) - .expect("refreshed pending generation"); - assert_ne!(old_generation, refreshed_generation); + let refreshed_lease_id = scheduler + .pending_lease_id(working_directory_id) + .expect("refreshed pending lease_id"); + assert_ne!(old_lease_id, refreshed_lease_id); - scheduler.expire_pending_for_test(working_directory_id, old_generation, old_expiry); + scheduler.expire_pending_for_test(working_directory_id, old_lease_id, old_expiry); let refreshed = scheduler .pending(working_directory_id) .unwrap() @@ -3175,7 +3257,6 @@ mod tests { kind: server_api::RepositorySourceKind::LocalPath, uri: repo.display().to_string(), }, - source_revision: 1, source_fingerprint: "sha256:test".to_string(), selector: Some(RepositorySelector::from("HEAD")), }, @@ -3190,6 +3271,48 @@ mod tests { WorkerRef::new(WorkerId::from_legacy_u64(sequence)) } + #[test] + fn legacy_ssh_evidence_requires_fresh_access_and_is_rewritten_without_counters() { + let repository = create_clean_repo(); + let runtime = tempfile::tempdir().unwrap(); + let materializer = RuntimeGitMaterializer::new(runtime.path()); + let binding = materializer.create(&request(repository.path())).unwrap(); + let id = binding.working_directory.id.clone(); + let record_path = binding.working_directory_root.join(MATERIALIZATION_RECORD); + let mut record: serde_json::Value = + serde_json::from_slice(&fs::read(&record_path).unwrap()).unwrap(); + let evidence = record["working_directory"]["evidence"] + .as_object_mut() + .unwrap(); + evidence.remove("repository_access_required"); + evidence.insert("credential_revision".into(), serde_json::json!(3)); + evidence.insert("host_trust_revision".into(), serde_json::json!(2)); + fs::write(&record_path, serde_json::to_vec(&record).unwrap()).unwrap(); + drop(binding); + assert_eq!( + materializer + .bind_working_directory(&id, None) + .unwrap_err() + .code, + "working_directory_remote_repository_access_required" + ); + let migrated: serde_json::Value = + serde_json::from_slice(&fs::read(&record_path).unwrap()).unwrap(); + let evidence = &migrated["working_directory"]["evidence"]; + assert_eq!(evidence["repository_access_required"], true); + assert!(evidence.get("credential_revision").is_none()); + assert!(evidence.get("host_trust_revision").is_none()); + assert!(evidence.get("public_key_fingerprint").is_none()); + drop(materializer); + assert_eq!( + RuntimeGitMaterializer::new(runtime.path()) + .bind_working_directory(&id, None) + .unwrap_err() + .code, + "working_directory_remote_repository_access_required" + ); + } + #[test] fn repository_ref_observation_reads_the_provider_fresh() { let repo = create_clean_repo(); @@ -3207,7 +3330,7 @@ mod tests { .observe_repository_ref(&observation_request) .unwrap(); assert_eq!( - first.revision_ref, + first.resolved_ref, git_stdout(repo.path(), ["rev-parse", "published"]).unwrap() ); fs::write(repo.path().join("second.txt"), "second\n").unwrap(); @@ -3218,9 +3341,9 @@ mod tests { let second = materializer .observe_repository_ref(&observation_request) .unwrap(); - assert_ne!(first.revision_ref, second.revision_ref); + assert_ne!(first.resolved_ref, second.resolved_ref); assert_eq!( - second.revision_ref, + second.resolved_ref, git_stdout(repo.path(), ["rev-parse", "published"]).unwrap() ); } @@ -3290,8 +3413,8 @@ mod tests { let still_first = materializer .observe_repository_ref(&observation_request) .unwrap(); - assert_eq!(still_first.revision_ref, first.revision_ref); - assert_ne!(still_first.revision_ref, unpublished_second); + assert_eq!(still_first.resolved_ref, first.resolved_ref); + assert_ne!(still_first.resolved_ref, unpublished_second); git( &workdir, @@ -3300,11 +3423,11 @@ mod tests { let second = materializer .observe_repository_ref(&observation_request) .unwrap(); - assert_eq!(second.revision_ref, unpublished_second); - assert_ne!(second.revision_ref, first.revision_ref); + assert_eq!(second.resolved_ref, unpublished_second); + assert_ne!(second.resolved_ref, first.resolved_ref); assert_ne!( git_stdout(&cache, ["rev-parse", "HEAD"]).unwrap(), - second.revision_ref + second.resolved_ref ); } @@ -3458,7 +3581,6 @@ mod tests { let mut file_request = request(repo.path()); file_request.repository.source.kind = server_api::RepositorySourceKind::File; file_request.repository.source.uri = format!("file://{}", repo.path().display()); - file_request.repository.source_revision = 2; file_request.repository.source_fingerprint = "sha256:file-source".to_string(); let file = materializer .materialize(&worker_ref(3), &file_request) @@ -3478,7 +3600,6 @@ mod tests { workspace_id: "workspace-1".to_string(), runtime_id: "runtime-1".to_string(), operation_id: "operation-1".to_string(), - config_revision: 7, config_projection_digest: "sha256:projection".to_string(), ssh: None, }); @@ -3511,8 +3632,8 @@ mod tests { "credential-1" ); assert_eq!( - serialized["credential_candidates"][0]["credential_revision"], - 2 + serialized["credential_candidates"][0]["public_key_fingerprint"], + "key-2" ); assert!( serialized["credential_candidates"][0] @@ -3564,27 +3685,26 @@ mod tests { workspace_id: "workspace-1".to_string(), runtime_id: "runtime-1".to_string(), operation_id: "operation-agent".to_string(), - config_revision: 2, config_projection_digest: "sha256:projection".to_string(), ssh: Some(crate::catalog::RepositorySshMaterializationAccess { credential_candidates: vec![ crate::catalog::RepositorySshCredentialCandidate { credential_id: "credential-1".to_string(), - credential_revision: 1, + public_key_fingerprint: "key-1".into(), private_key: crate::catalog::SensitiveString::new( fs::read_to_string(&key_path).unwrap(), ), }, crate::catalog::RepositorySshCredentialCandidate { credential_id: "credential-2".to_string(), - credential_revision: 3, + public_key_fingerprint: "key-3".into(), private_key: crate::catalog::SensitiveString::new( fs::read_to_string(&fallback_key_path).unwrap(), ), }, ], host_trust_id: "trust-1".to_string(), - host_trust_revision: 1, + host_key_fingerprint: "key-1".into(), access: server_api::RepositoryAccessMode::ReadWrite, expires_at_epoch_seconds: u64::MAX, repository_id: "repo-main".to_string(), @@ -3720,11 +3840,11 @@ mod tests { ssh_backed_binding .working_directory .evidence - .credential_revision = Some(1); + .public_key_fingerprint = Some("key-1".into()); ssh_backed_binding .working_directory .evidence - .host_trust_revision = Some(1); + .host_key_fingerprint = Some("key-1".into()); materializer.write_record(&ssh_backed_binding).unwrap(); #[cfg(target_os = "linux")] fail_workdir_cleanup_once(&materializer, &id); @@ -3809,7 +3929,8 @@ mod tests { ); let mut rotated = initial_materialization.clone(); rotated.operation_id = "operation-agent-rotated".to_string(); - rotated.ssh.as_mut().unwrap().credential_candidates[0].credential_revision = 2; + rotated.ssh.as_mut().unwrap().credential_candidates[0].public_key_fingerprint = + "key-2".into(); rotated.ssh.as_mut().unwrap().access = server_api::RepositoryAccessMode::ReadOnly; materializer .authorize_repository_access(&WorkingDirectoryRepositoryAccessRequest { @@ -3819,8 +3940,8 @@ mod tests { .unwrap(); let rebound = materializer.bind_working_directory(&id, None).unwrap(); assert_eq!( - rebound.working_directory.evidence.credential_revision, - Some(2) + rebound.working_directory.evidence.public_key_fingerprint, + Some("key-2".into()) ); let rebound_environment = rebound.command_environment(); assert_eq!(rebound_environment["YOI_REPOSITORY_ACCESS"], "read_only"); @@ -3927,7 +4048,8 @@ mod tests { let mut read_write = rotated; read_write.operation_id = "operation-agent-read-write".to_string(); - read_write.ssh.as_mut().unwrap().credential_candidates[0].credential_revision = 3; + read_write.ssh.as_mut().unwrap().credential_candidates[0].public_key_fingerprint = + "key-3".into(); read_write.ssh.as_mut().unwrap().access = server_api::RepositoryAccessMode::ReadWrite; let read_write_command_policy = RepositorySshCommandPolicy::from_access(read_write.ssh.as_ref().unwrap()).unwrap(); @@ -4006,16 +4128,15 @@ mod tests { workspace_id: "workspace-1".to_string(), runtime_id: "runtime-1".to_string(), operation_id: "operation-read-only".to_string(), - config_revision: 2, config_projection_digest: "sha256:projection".to_string(), ssh: Some(crate::catalog::RepositorySshMaterializationAccess { credential_candidates: vec![crate::catalog::RepositorySshCredentialCandidate { credential_id: "credential-1".to_string(), - credential_revision: 1, + public_key_fingerprint: "key-1".into(), private_key: crate::catalog::SensitiveString::new("PRIVATE KEY placeholder"), }], host_trust_id: "trust-1".to_string(), - host_trust_revision: 1, + host_key_fingerprint: "key-1".into(), access: server_api::RepositoryAccessMode::ReadOnly, expires_at_epoch_seconds: u64::MAX, repository_id: "repo-main".to_string(), @@ -4176,7 +4297,6 @@ mod tests { workspace_id: "workspace-1".to_string(), runtime_id: "runtime-1".to_string(), operation_id: "operation-1".to_string(), - config_revision: 1, config_projection_digest: "sha256:projection".to_string(), ssh: Some(access), }); @@ -4205,7 +4325,6 @@ mod tests { workspace_id: "workspace-1".to_string(), runtime_id: "runtime-1".to_string(), operation_id: "operation-1".to_string(), - config_revision: 1, config_projection_digest: "sha256:projection".to_string(), ssh, }; @@ -4283,11 +4402,11 @@ mod tests { crate::catalog::RepositorySshMaterializationAccess { credential_candidates: vec![crate::catalog::RepositorySshCredentialCandidate { credential_id: "credential-1".to_string(), - credential_revision: 1, + public_key_fingerprint: "key-1".into(), private_key: crate::catalog::SensitiveString::new("PRIVATE KEY placeholder"), }], host_trust_id: "trust-1".to_string(), - host_trust_revision: 1, + host_key_fingerprint: "key-1".into(), access: server_api::RepositoryAccessMode::ReadOnly, expires_at_epoch_seconds: u64::MAX, repository_id: "repo-main".to_string(), @@ -4321,7 +4440,6 @@ mod tests { workspace_id: "workspace-1".to_string(), runtime_id: "runtime-1".to_string(), operation_id: "operation-1".to_string(), - config_revision: 1, config_projection_digest: "sha256:projection".to_string(), ssh: None, }); @@ -4498,7 +4616,6 @@ mod tests { workspace_id: "workspace-1".to_string(), runtime_id: "runtime-1".to_string(), operation_id: "operation-1".to_string(), - config_revision: 7, config_projection_digest: "sha256:projection".to_string(), ssh, } @@ -4641,6 +4758,45 @@ mod tests { .unwrap(); } + #[cfg(target_os = "linux")] + #[test] + fn legacy_evidence_normalization_is_excluded_by_cleanup_and_cleanup_reads_do_not_rewrite() { + let repo = create_clean_repo(); + let runtime_root = tempfile::tempdir().unwrap(); + let materializer = RuntimeGitMaterializer::new(runtime_root.path()); + let binding = materializer.create(&request(repo.path())).unwrap(); + let id = &binding.working_directory.id; + let path = binding + .working_directory_root() + .join(MATERIALIZATION_RECORD); + let mut legacy: serde_json::Value = + serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + legacy["working_directory"]["evidence"]["repository_source_revision"] = + serde_json::json!(7); + let original = serde_json::to_vec_pretty(&legacy).unwrap(); + fs::write(&path, &original).unwrap(); + + let cleanup = materializer.occupancy.acquire_cleanup(id).unwrap(); + assert_eq!( + materializer.read_binding(id).unwrap_err().code, + "working_directory_cleanup_resource_busy" + ); + materializer.read_binding_for_cleanup(id).unwrap(); + assert_eq!(fs::read(&path).unwrap(), original); + drop(cleanup); + + materializer.read_binding(id).unwrap(); + let canonical: serde_json::Value = + serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + assert!( + canonical["working_directory"]["evidence"] + .get("repository_source_revision") + .is_none() + ); + materializer.cleanup_working_directory(id).unwrap(); + assert!(!binding.working_directory_root().exists()); + } + #[cfg(target_os = "linux")] #[test] fn removed_binding_cannot_activate_against_recreated_same_id() { diff --git a/crates/worker-runtime/src/working_directory/cleanup.rs b/crates/worker-runtime/src/working_directory/cleanup.rs index a9a2c3a50..e13906edf 100644 --- a/crates/worker-runtime/src/working_directory/cleanup.rs +++ b/crates/worker-runtime/src/working_directory/cleanup.rs @@ -190,7 +190,7 @@ fn remove_with_checks( } } else { let mut binding = materializer - .read_binding(id) + .read_binding_for_cleanup(id) .map_err(|_| cause("ownership_unknown"))?; validate_binding(materializer, id, &binding)?; // Preflight the entire tree without following links or crossing mounts, @@ -278,8 +278,8 @@ fn same_materialization(saved: &WorkingDirectory, current: &WorkingDirectory) -> // These fields are refreshed by authorize_repository_access, not a new // materialization. All repository, creation and cleanup identity stays. directory.evidence.operation_id = None; - directory.evidence.credential_revision = None; - directory.evidence.host_trust_revision = None; + directory.evidence.public_key_fingerprint = None; + directory.evidence.host_key_fingerprint = None; directory }; immutable(saved) == immutable(current) @@ -298,7 +298,7 @@ fn retry_inventory( return Ok(before); } let binding = materializer - .read_binding(id) + .read_binding_for_cleanup(id) .map_err(|_| cause("changes_present"))?; validate_binding(materializer, id, &binding)?; if !same_materialization(&saved.working_directory, &binding.working_directory) @@ -1211,6 +1211,13 @@ pub(super) mod tests { for update in ["access", "repository", "creation"] { let (_runtime, materializer, binding) = fixture(); let id = &binding.working_directory.id; + // Model an existing SSH materialization before taking the witness. + // Refresh replaces its access proof, not its requirement for SSH authority. + let mut record = materializer.read_binding(id).unwrap(); + record.working_directory.evidence.repository_access_required = true; + record.working_directory.evidence.public_key_fingerprint = Some("key-1".into()); + record.working_directory.evidence.host_key_fingerprint = Some("host-key-1".into()); + materializer.write_record(&record).unwrap(); fail_once(&materializer, id); let mut record = materializer.read_binding(id).unwrap(); match update { @@ -1218,8 +1225,8 @@ pub(super) mod tests { // The same serialized fields changed by authorize_repository_access. record.working_directory.evidence.operation_id = Some("reattach-operation".into()); - record.working_directory.evidence.credential_revision = Some(2); - record.working_directory.evidence.host_trust_revision = Some(2); + record.working_directory.evidence.public_key_fingerprint = Some("key-2".into()); + record.working_directory.evidence.host_key_fingerprint = Some("key-2".into()); } "repository" => { record.working_directory.repository_id = "substitute".into(); diff --git a/crates/worker-runtime/src/workspace_issuer.rs b/crates/worker-runtime/src/workspace_issuer.rs index 5e5bdaaec..f0204dc49 100644 --- a/crates/worker-runtime/src/workspace_issuer.rs +++ b/crates/worker-runtime/src/workspace_issuer.rs @@ -13,8 +13,8 @@ use sha2::{Digest, Sha256}; use crate::auth::RuntimeAuthError; -const WORKSPACE_TOKEN_PREFIX: &str = "yoi-workspace-v1"; -const WORKSPACE_SIGNING_INPUT_PREFIX: &str = "yoi.workspace.capability.v1."; +const WORKSPACE_TOKEN_PREFIX: &str = "yoi-workspace-v2"; +const WORKSPACE_SIGNING_INPUT_PREFIX: &str = "yoi.workspace.capability.v2."; const WORKSPACE_SIGNING_ALGORITHM: &str = "ed25519"; const MAX_TOKEN_BYTES: usize = 16 * 1024; const MAX_ID_BYTES: usize = 256; @@ -42,8 +42,7 @@ pub struct WorkspaceIssuerTrustRecord { pub algorithm: String, pub public_key: String, pub public_key_fingerprint: String, - pub identity_revision: u64, - pub trust_generation: u64, + pub trust_id: String, pub state: WorkspaceIssuerTrustState, pub registered_at_unix: i64, pub updated_at_unix: i64, @@ -52,13 +51,11 @@ pub struct WorkspaceIssuerTrustRecord { impl WorkspaceIssuerTrustRecord { pub fn from_bundle( bundle: WorkspacePublicIdentityBundle, - trust_generation: u64, + trust_id: String, now_unix: i64, ) -> Result { validate_bundle(&bundle)?; - if trust_generation == 0 { - return Err(WorkspaceIssuerTrustError::InvalidTrustGeneration); - } + validate_id(&trust_id)?; Ok(Self { workspace_id: bundle.workspace_id, backend_url: bundle.backend_url, @@ -66,8 +63,7 @@ impl WorkspaceIssuerTrustRecord { algorithm: bundle.algorithm, public_key: bundle.public_key, public_key_fingerprint: bundle.public_key_fingerprint, - identity_revision: bundle.revision, - trust_generation, + trust_id, state: WorkspaceIssuerTrustState::Active, registered_at_unix: now_unix, updated_at_unix: now_unix, @@ -82,11 +78,56 @@ impl WorkspaceIssuerTrustRecord { algorithm: self.algorithm.clone(), public_key: self.public_key.clone(), public_key_fingerprint: self.public_key_fingerprint.clone(), - revision: self.identity_revision, } } } +// Frozen pre-ID enrollment format. It is never accepted as live authority. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct LegacyWorkspaceIssuerTrustRecord { + workspace_id: String, + backend_url: String, + key_id: String, + algorithm: String, + public_key: String, + public_key_fingerprint: String, + identity_revision: u64, + trust_generation: u64, + state: WorkspaceIssuerTrustState, + registered_at_unix: i64, + updated_at_unix: i64, +} + +/// Migrate a pre-ID enrollment as revoked. Only an explicit operator replacement +/// can accept new trust; no legacy capability can authorize its own migration. +pub fn migrate_legacy_workspace_issuer_trust_record( + value: serde_json::Value, +) -> Result { + let legacy: LegacyWorkspaceIssuerTrustRecord = + serde_json::from_value(value).map_err(|_| WorkspaceIssuerTrustError::InvalidIdentifier)?; + if legacy.identity_revision == 0 || legacy.trust_generation == 0 { + return Err(WorkspaceIssuerTrustError::InvalidIdentifier); + } + let mut record = WorkspaceIssuerTrustRecord::from_bundle( + WorkspacePublicIdentityBundle { + workspace_id: legacy.workspace_id, + backend_url: legacy.backend_url, + key_id: legacy.key_id, + algorithm: legacy.algorithm, + public_key: legacy.public_key, + public_key_fingerprint: legacy.public_key_fingerprint, + }, + uuid::Uuid::now_v7().to_string(), + legacy.registered_at_unix, + )?; + // Both old Active and Revoked records require new enrollment acceptance. + let _old_state = legacy.state; + record.state = WorkspaceIssuerTrustState::Revoked; + record.updated_at_unix = legacy.updated_at_unix; + Ok(record) +} + #[derive(Debug, thiserror::Error, PartialEq, Eq)] pub enum WorkspaceIssuerTrustError { #[error("Workspace issuer trust already exists for `{0}`")] @@ -103,16 +144,10 @@ pub enum WorkspaceIssuerTrustError { InvalidPublicKey, #[error("Workspace signing public key fingerprint does not match the public key")] PublicKeyFingerprintMismatch, - #[error("Workspace signing identity revision must be greater than zero")] - InvalidIdentityRevision, - #[error("Workspace issuer trust generation must be greater than zero")] - InvalidTrustGeneration, - #[error("Workspace signing identity replacement revision is stale")] - StaleIdentityRevision, + #[error("Workspace signing key identity cannot name different key material")] + KeyIdentityMismatch, #[error("Workspace issuer trust record limit was reached")] TrustRecordLimitExceeded, - #[error("Workspace issuer trust generation overflow")] - TrustGenerationOverflow, } pub fn validate_workspace_issuer_trust_records( @@ -160,7 +195,11 @@ pub fn add_workspace_issuer_trust( if records.len() >= MAX_WORKSPACE_ISSUER_TRUST_RECORDS { return Err(WorkspaceIssuerTrustError::TrustRecordLimitExceeded); } - let record = WorkspaceIssuerTrustRecord::from_bundle(bundle, 1, now_unix)?; + let record = WorkspaceIssuerTrustRecord::from_bundle( + bundle, + uuid::Uuid::now_v7().to_string(), + now_unix, + )?; records.push(record.clone()); records.sort_by(|left, right| left.workspace_id.cmp(&right.workspace_id)); Ok((WorkspaceIssuerTrustMutation::Added, record)) @@ -179,22 +218,14 @@ pub fn replace_workspace_issuer_trust( if record.state == WorkspaceIssuerTrustState::Active && record.public_bundle() == bundle { return Ok((WorkspaceIssuerTrustMutation::Unchanged, record.clone())); } - if bundle.revision < record.identity_revision { - return Err(WorkspaceIssuerTrustError::StaleIdentityRevision); - } - if bundle.revision == record.identity_revision - && (bundle.key_id != record.key_id - || bundle.public_key != record.public_key - || bundle.public_key_fingerprint != record.public_key_fingerprint) + if bundle.key_id == record.key_id + && bundle.public_key_fingerprint != record.public_key_fingerprint { - return Err(WorkspaceIssuerTrustError::StaleIdentityRevision); + return Err(WorkspaceIssuerTrustError::KeyIdentityMismatch); } - let generation = record - .trust_generation - .checked_add(1) - .ok_or(WorkspaceIssuerTrustError::TrustGenerationOverflow)?; + let trust_id = uuid::Uuid::now_v7().to_string(); let registered_at_unix = record.registered_at_unix; - *record = WorkspaceIssuerTrustRecord::from_bundle(bundle, generation, now_unix)?; + *record = WorkspaceIssuerTrustRecord::from_bundle(bundle, trust_id, now_unix)?; record.registered_at_unix = registered_at_unix; Ok((WorkspaceIssuerTrustMutation::Replaced, record.clone())) } @@ -212,10 +243,7 @@ pub fn revoke_workspace_issuer_trust( if record.state == WorkspaceIssuerTrustState::Revoked { return Ok((WorkspaceIssuerTrustMutation::Unchanged, record.clone())); } - record.trust_generation = record - .trust_generation - .checked_add(1) - .ok_or(WorkspaceIssuerTrustError::TrustGenerationOverflow)?; + record.trust_id = uuid::Uuid::now_v7().to_string(); record.state = WorkspaceIssuerTrustState::Revoked; record.updated_at_unix = now_unix; Ok((WorkspaceIssuerTrustMutation::Revoked, record.clone())) @@ -246,9 +274,6 @@ fn validate_bundle( if bundle.algorithm != WORKSPACE_SIGNING_ALGORITHM { return Err(WorkspaceIssuerTrustError::UnsupportedAlgorithm); } - if bundle.revision == 0 { - return Err(WorkspaceIssuerTrustError::InvalidIdentityRevision); - } let public_key = crate::auth::decode_public_key(&bundle.public_key) .map_err(|_| WorkspaceIssuerTrustError::InvalidPublicKey)?; let fingerprint = format!("sha256:{}", hex_lower(&Sha256::digest(&public_key))); @@ -276,8 +301,8 @@ pub const WORKSPACE_VERIFICATION_CHALLENGE_PATH: &str = pub const WORKSPACE_VERIFICATION_ACK_PATH: &str = "/v1/workspace-runtime-verification/acknowledgement"; pub const WORKSPACE_VERIFICATION_OPERATION: &str = "workspace.runtime.verify"; -const RUNTIME_VERIFICATION_TOKEN_PREFIX: &str = "yoi-runtime-verification-v1"; -const RUNTIME_VERIFICATION_SIGNING_INPUT_PREFIX: &str = "yoi.runtime.verification.v1."; +const RUNTIME_VERIFICATION_TOKEN_PREFIX: &str = "yoi-runtime-verification-v2"; +const RUNTIME_VERIFICATION_SIGNING_INPUT_PREFIX: &str = "yoi.runtime.verification.v2."; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -285,12 +310,11 @@ pub struct WorkspaceRuntimeVerificationChallenge { pub challenge_id: String, pub workspace_id: String, pub runtime_id: String, - pub binding_revision: u64, + pub binding_id: String, pub workspace_key_id: String, - pub workspace_identity_revision: u64, - pub workspace_trust_generation: u64, + pub workspace_public_key_fingerprint: String, + pub workspace_trust_id: String, pub runtime_public_key_fingerprint: String, - pub runtime_identity_revision: u64, pub workspace_nonce: String, pub expires_at: i64, } @@ -301,12 +325,11 @@ pub struct WorkspaceRuntimeVerificationResponse { pub challenge_id: String, pub workspace_id: String, pub runtime_id: String, - pub binding_revision: u64, + pub binding_id: String, pub workspace_key_id: String, - pub workspace_identity_revision: u64, - pub workspace_trust_generation: u64, + pub workspace_public_key_fingerprint: String, + pub workspace_trust_id: String, pub runtime_public_key_fingerprint: String, - pub runtime_identity_revision: u64, pub workspace_nonce: String, pub runtime_nonce: String, pub expires_at: i64, @@ -319,12 +342,11 @@ pub struct WorkspaceRuntimeVerificationAcknowledgement { pub challenge_id: String, pub workspace_id: String, pub runtime_id: String, - pub binding_revision: u64, + pub binding_id: String, pub workspace_key_id: String, - pub workspace_identity_revision: u64, - pub workspace_trust_generation: u64, + pub workspace_public_key_fingerprint: String, + pub workspace_trust_id: String, pub runtime_public_key_fingerprint: String, - pub runtime_identity_revision: u64, pub workspace_nonce: String, pub runtime_nonce: String, pub response_digest: String, @@ -338,7 +360,7 @@ pub struct WorkspaceRuntimeVerificationReceipt { pub challenge_id: String, pub workspace_id: String, pub runtime_id: String, - pub binding_revision: u64, + pub binding_id: String, pub accepted_at: i64, } @@ -347,16 +369,24 @@ pub struct WorkspaceRuntimeVerificationReceipt { pub struct WorkspaceRuntimeVerificationRecord { pub workspace_id: String, pub runtime_id: String, - pub binding_revision: u64, + pub binding_id: String, pub workspace_key_id: String, - pub workspace_identity_revision: u64, - pub workspace_trust_generation: u64, + pub workspace_public_key_fingerprint: String, + pub workspace_trust_id: String, pub runtime_public_key_fingerprint: String, - pub runtime_identity_revision: u64, pub verified_at: i64, } pub trait WorkspaceRuntimeVerificationAuthority: fmt::Debug + Send + Sync { + fn begin( + &self, + challenge: WorkspaceRuntimeVerificationChallenge, + ) -> Result<(), WorkspaceCapabilityVerificationError>; + fn acknowledge( + &self, + challenge_id: &str, + record: WorkspaceRuntimeVerificationRecord, + ) -> Result<(), WorkspaceCapabilityVerificationError>; fn get( &self, workspace_id: &str, @@ -370,36 +400,63 @@ pub trait WorkspaceRuntimeVerificationAuthority: fmt::Debug + Send + Sync { #[derive(Debug, Default)] pub struct InMemoryWorkspaceRuntimeVerificationAuthority { - records: Mutex>, + state: Mutex, } impl WorkspaceRuntimeVerificationAuthority for InMemoryWorkspaceRuntimeVerificationAuthority { + fn begin( + &self, + challenge: WorkspaceRuntimeVerificationChallenge, + ) -> Result<(), WorkspaceCapabilityVerificationError> { + let mut state = self + .state + .lock() + .map_err(|_| WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable)?; + begin_verification(&mut state, challenge) + } + fn acknowledge( + &self, + challenge_id: &str, + record: WorkspaceRuntimeVerificationRecord, + ) -> Result<(), WorkspaceCapabilityVerificationError> { + let mut state = self + .state + .lock() + .map_err(|_| WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable)?; + acknowledge_verification(&mut state, challenge_id, record) + } fn get( &self, workspace_id: &str, runtime_id: &str, ) -> Result, WorkspaceCapabilityVerificationError> { - Ok(self - .records + let state = self + .state .lock() - .map_err(|_| WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable)? - .get(&(workspace_id.to_string(), runtime_id.to_string())) + .map_err(|_| WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable)?; + Ok(state + .records + .iter() + .find(|record| record.workspace_id == workspace_id && record.runtime_id == runtime_id) .cloned()) } - fn record( &self, record: WorkspaceRuntimeVerificationRecord, ) -> Result<(), WorkspaceCapabilityVerificationError> { validate_verification_record(&record)?; - self.records + let mut state = self + .state .lock() - .map_err(|_| WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable)? - .insert( - (record.workspace_id.clone(), record.runtime_id.clone()), - record, - ); + .map_err(|_| WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable)?; + state.records.retain(|current| { + current.workspace_id != record.workspace_id || current.runtime_id != record.runtime_id + }); + if state.records.len() >= MAX_WORKSPACE_ISSUER_TRUST_RECORDS { + return Err(WorkspaceCapabilityVerificationError::TrustRecordLimitExceeded); + } + state.records.push(record); Ok(()) } } @@ -410,11 +467,36 @@ pub struct FileWorkspaceRuntimeVerificationAuthority { lock: Arc>, } -#[derive(Default, Serialize, Deserialize)] +#[derive(Debug, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct WorkspaceRuntimeVerificationDocument { version: u32, records: Vec, + #[serde(default)] + pending: Vec, +} + +// Frozen verification evidence from before trust/binding IDs. Never promote +// counters into IDs or keep a pre-ID acknowledgement usable after migration. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct LegacyVerificationDocument { + version: u32, + records: Vec, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +#[allow(dead_code)] +struct LegacyVerificationRecord { + workspace_id: String, + runtime_id: String, + binding_revision: u64, + workspace_key_id: String, + workspace_identity_revision: u64, + workspace_trust_generation: u64, + runtime_public_key_fingerprint: String, + runtime_identity_revision: u64, + verified_at: i64, } impl FileWorkspaceRuntimeVerificationAuthority { @@ -430,12 +512,36 @@ impl FileWorkspaceRuntimeVerificationAuthority { ) -> Result { match fs::read(&self.path) { Ok(bytes) => { - let document: WorkspaceRuntimeVerificationDocument = serde_json::from_slice(&bytes) + let raw: serde_json::Value = serde_json::from_slice(&bytes).map_err(|_| { + WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable + })?; + if raw.get("version").and_then(serde_json::Value::as_u64) == Some(1) { + let legacy: LegacyVerificationDocument = + serde_json::from_value(raw).map_err(|_| { + WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable + })?; + if legacy.version != 1 + || legacy.records.len() > MAX_WORKSPACE_ISSUER_TRUST_RECORDS + { + return Err( + WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable, + ); + } + let migrated = WorkspaceRuntimeVerificationDocument { + version: 2, + records: Vec::new(), + pending: Vec::new(), + }; + self.write(&migrated)?; + return Ok(migrated); + } + let document: WorkspaceRuntimeVerificationDocument = serde_json::from_value(raw) .map_err(|_| { WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable })?; - if document.version != 1 + if document.version != 2 || document.records.len() > MAX_WORKSPACE_ISSUER_TRUST_RECORDS + || document.pending.len() > MAX_WORKSPACE_ISSUER_TRUST_RECORDS { return Err( WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable, @@ -452,12 +558,23 @@ impl FileWorkspaceRuntimeVerificationAuthority { ); } } + let mut pending_identities = HashSet::new(); + for challenge in &document.pending { + validate_verification_challenge(challenge)?; + if !pending_identities.insert((&challenge.workspace_id, &challenge.runtime_id)) + { + return Err( + WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable, + ); + } + } Ok(document) } Err(error) if error.kind() == std::io::ErrorKind::NotFound => { Ok(WorkspaceRuntimeVerificationDocument { - version: 1, + version: 2, records: Vec::new(), + pending: Vec::new(), }) } Err(_) => Err(WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable), @@ -495,6 +612,32 @@ impl FileWorkspaceRuntimeVerificationAuthority { } impl WorkspaceRuntimeVerificationAuthority for FileWorkspaceRuntimeVerificationAuthority { + fn begin( + &self, + challenge: WorkspaceRuntimeVerificationChallenge, + ) -> Result<(), WorkspaceCapabilityVerificationError> { + let _guard = self + .lock + .lock() + .map_err(|_| WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable)?; + let mut document = self.read()?; + begin_verification(&mut document, challenge)?; + self.write(&document) + } + fn acknowledge( + &self, + challenge_id: &str, + record: WorkspaceRuntimeVerificationRecord, + ) -> Result<(), WorkspaceCapabilityVerificationError> { + let _guard = self + .lock + .lock() + .map_err(|_| WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable)?; + let mut document = self.read()?; + acknowledge_verification(&mut document, challenge_id, record)?; + self.write(&document) + } + fn get( &self, workspace_id: &str, @@ -530,6 +673,64 @@ impl WorkspaceRuntimeVerificationAuthority for FileWorkspaceRuntimeVerificationA } } +fn begin_verification( + document: &mut WorkspaceRuntimeVerificationDocument, + challenge: WorkspaceRuntimeVerificationChallenge, +) -> Result<(), WorkspaceCapabilityVerificationError> { + validate_verification_challenge(&challenge)?; + if let Some(pending) = document.pending.iter().find(|pending| { + pending.workspace_id == challenge.workspace_id && pending.runtime_id == challenge.runtime_id + }) { + if pending.challenge_id == challenge.challenge_id && pending != &challenge { + return Err(WorkspaceCapabilityVerificationError::VerificationChallengeMismatch); + } + } + if document.pending.len() >= MAX_WORKSPACE_ISSUER_TRUST_RECORDS { + return Err(WorkspaceCapabilityVerificationError::TrustRecordLimitExceeded); + } + document.pending.retain(|pending| { + pending.workspace_id != challenge.workspace_id || pending.runtime_id != challenge.runtime_id + }); + document.records.retain(|record| { + record.workspace_id != challenge.workspace_id || record.runtime_id != challenge.runtime_id + }); + document.pending.push(challenge); + Ok(()) +} + +fn acknowledge_verification( + document: &mut WorkspaceRuntimeVerificationDocument, + challenge_id: &str, + record: WorkspaceRuntimeVerificationRecord, +) -> Result<(), WorkspaceCapabilityVerificationError> { + validate_verification_record(&record)?; + let index = document + .pending + .iter() + .position(|pending| { + pending.workspace_id == record.workspace_id + && pending.runtime_id == record.runtime_id + && pending.challenge_id == challenge_id + && pending.binding_id == record.binding_id + && pending.workspace_key_id == record.workspace_key_id + && pending.workspace_public_key_fingerprint + == record.workspace_public_key_fingerprint + && pending.workspace_trust_id == record.workspace_trust_id + && pending.runtime_public_key_fingerprint == record.runtime_public_key_fingerprint + && record.verified_at < pending.expires_at + }) + .ok_or(WorkspaceCapabilityVerificationError::VerificationChallengeMismatch)?; + if document.records.len() >= MAX_WORKSPACE_ISSUER_TRUST_RECORDS { + return Err(WorkspaceCapabilityVerificationError::TrustRecordLimitExceeded); + } + document.pending.remove(index); + document.records.retain(|current| { + current.workspace_id != record.workspace_id || current.runtime_id != record.runtime_id + }); + document.records.push(record); + Ok(()) +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct RuntimeVerificationResponseClaims { @@ -548,12 +749,11 @@ struct WorkspaceRuntimeVerificationResponseUnsigned { challenge_id: String, workspace_id: String, runtime_id: String, - binding_revision: u64, + binding_id: String, workspace_key_id: String, - workspace_identity_revision: u64, - workspace_trust_generation: u64, + workspace_public_key_fingerprint: String, + workspace_trust_id: String, runtime_public_key_fingerprint: String, - runtime_identity_revision: u64, workspace_nonce: String, runtime_nonce: String, expires_at: i64, @@ -565,12 +765,11 @@ impl WorkspaceRuntimeVerificationResponse { challenge_id: self.challenge_id.clone(), workspace_id: self.workspace_id.clone(), runtime_id: self.runtime_id.clone(), - binding_revision: self.binding_revision, + binding_id: self.binding_id.clone(), workspace_key_id: self.workspace_key_id.clone(), - workspace_identity_revision: self.workspace_identity_revision, - workspace_trust_generation: self.workspace_trust_generation, + workspace_public_key_fingerprint: self.workspace_public_key_fingerprint.clone(), + workspace_trust_id: self.workspace_trust_id.clone(), runtime_public_key_fingerprint: self.runtime_public_key_fingerprint.clone(), - runtime_identity_revision: self.runtime_identity_revision, workspace_nonce: self.workspace_nonce.clone(), runtime_nonce: self.runtime_nonce.clone(), expires_at: self.expires_at, @@ -636,7 +835,6 @@ impl RuntimeVerificationSigner { validate_verification_challenge(challenge)?; if challenge.runtime_id != self.runtime_id || challenge.runtime_public_key_fingerprint != self.public_key_fingerprint - || challenge.runtime_identity_revision == 0 || challenge.expires_at <= now_unix { return Err(WorkspaceCapabilityVerificationError::VerificationChallengeMismatch); @@ -645,12 +843,11 @@ impl RuntimeVerificationSigner { challenge_id: challenge.challenge_id.clone(), workspace_id: challenge.workspace_id.clone(), runtime_id: challenge.runtime_id.clone(), - binding_revision: challenge.binding_revision, + binding_id: challenge.binding_id.clone(), workspace_key_id: challenge.workspace_key_id.clone(), - workspace_identity_revision: challenge.workspace_identity_revision, - workspace_trust_generation: challenge.workspace_trust_generation, + workspace_public_key_fingerprint: challenge.workspace_public_key_fingerprint.clone(), + workspace_trust_id: challenge.workspace_trust_id.clone(), runtime_public_key_fingerprint: challenge.runtime_public_key_fingerprint.clone(), - runtime_identity_revision: challenge.runtime_identity_revision, workspace_nonce: challenge.workspace_nonce.clone(), runtime_nonce, expires_at: challenge.expires_at, @@ -678,12 +875,11 @@ impl RuntimeVerificationSigner { challenge_id: unsigned.challenge_id, workspace_id: unsigned.workspace_id, runtime_id: unsigned.runtime_id, - binding_revision: unsigned.binding_revision, + binding_id: unsigned.binding_id, workspace_key_id: unsigned.workspace_key_id, - workspace_identity_revision: unsigned.workspace_identity_revision, - workspace_trust_generation: unsigned.workspace_trust_generation, + workspace_public_key_fingerprint: unsigned.workspace_public_key_fingerprint, + workspace_trust_id: unsigned.workspace_trust_id, runtime_public_key_fingerprint: unsigned.runtime_public_key_fingerprint, - runtime_identity_revision: unsigned.runtime_identity_revision, workspace_nonce: unsigned.workspace_nonce, runtime_nonce: unsigned.runtime_nonce, expires_at: unsigned.expires_at, @@ -699,6 +895,12 @@ pub fn verify_runtime_verification_response( now_unix: i64, ) -> Result<(), WorkspaceCapabilityVerificationError> { validate_verification_challenge(challenge)?; + let public_key = crate::auth::decode_public_key(runtime_public_key) + .map_err(|_| WorkspaceCapabilityVerificationError::InvalidSignature)?; + let fingerprint = format!("sha256:{}", hex_lower(&Sha256::digest(&public_key))); + if fingerprint != challenge.runtime_public_key_fingerprint { + return Err(WorkspaceCapabilityVerificationError::VerificationChallengeMismatch); + } let signed = crate::auth::decode_signed_json_token::( &response.response_proof, RUNTIME_VERIFICATION_TOKEN_PREFIX, @@ -725,12 +927,11 @@ pub fn verify_runtime_verification_response( || response.challenge_id != challenge.challenge_id || response.workspace_id != challenge.workspace_id || response.runtime_id != challenge.runtime_id - || response.binding_revision != challenge.binding_revision + || response.binding_id != challenge.binding_id || response.workspace_key_id != challenge.workspace_key_id - || response.workspace_identity_revision != challenge.workspace_identity_revision - || response.workspace_trust_generation != challenge.workspace_trust_generation + || response.workspace_public_key_fingerprint != challenge.workspace_public_key_fingerprint + || response.workspace_trust_id != challenge.workspace_trust_id || response.runtime_public_key_fingerprint != challenge.runtime_public_key_fingerprint - || response.runtime_identity_revision != challenge.runtime_identity_revision || response.workspace_nonce != challenge.workspace_nonce || response.runtime_nonce.is_empty() { @@ -745,9 +946,9 @@ pub struct WorkspaceCapabilityClaims { pub issuer: String, pub issuer_workspace_id: String, pub issuer_key_id: String, - pub issuer_identity_revision: u64, - pub trust_generation: u64, - pub binding_revision: u64, + pub issuer_public_key_fingerprint: String, + pub trust_id: String, + pub binding_id: String, pub runtime_id: String, #[serde(skip_serializing_if = "Option::is_none")] pub worker_id: Option, @@ -763,7 +964,7 @@ pub struct WorkspaceCapabilityClaims { #[derive(Clone, Debug, PartialEq, Eq)] pub struct WorkspaceCapabilityExpectation<'a> { pub workspace_id: &'a str, - pub binding_revision: u64, + pub binding_id: &'a str, pub runtime_id: &'a str, pub worker_id: Option<&'a str>, pub operation: &'a str, @@ -778,9 +979,9 @@ pub struct VerifiedWorkspaceCapability { pub issuer: String, pub workspace_id: String, pub issuer_key_id: String, - pub issuer_identity_revision: u64, - pub trust_generation: u64, - pub binding_revision: u64, + pub issuer_public_key_fingerprint: String, + pub trust_id: String, + pub binding_id: String, pub runtime_id: String, pub worker_id: Option, pub operation: String, @@ -794,7 +995,7 @@ pub trait WorkspaceClaimReplayProtection: Send + Sync { fn consume_once( &self, workspace_id: &str, - trust_generation: u64, + trust_id: &str, token_id: &str, expires_at: i64, now_unix: i64, @@ -803,14 +1004,14 @@ pub trait WorkspaceClaimReplayProtection: Send + Sync { #[derive(Default)] pub struct InMemoryWorkspaceClaimReplayProtection { - consumed: Mutex>, + consumed: Mutex>, } impl WorkspaceClaimReplayProtection for InMemoryWorkspaceClaimReplayProtection { fn consume_once( &self, workspace_id: &str, - trust_generation: u64, + trust_id: &str, token_id: &str, expires_at: i64, now_unix: i64, @@ -822,7 +1023,7 @@ impl WorkspaceClaimReplayProtection for InMemoryWorkspaceClaimReplayProtection { consumed.retain(|_, expiry| *expiry > now_unix); let key = ( workspace_id.to_string(), - trust_generation, + trust_id.to_string(), token_id.to_string(), ); if consumed.contains_key(&key) { @@ -852,6 +1053,22 @@ struct WorkspaceClaimReplayDocument { #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct WorkspaceClaimReplayEntry { + workspace_id: String, + trust_id: String, + token_id: String, + expires_at: i64, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct LegacyReplayDocument { + version: u32, + entries: Vec, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +#[allow(dead_code)] +struct LegacyReplayEntry { workspace_id: String, trust_generation: u64, token_id: String, @@ -869,18 +1086,38 @@ impl FileWorkspaceClaimReplayProtection { fn read(&self) -> Result { match fs::read(&self.path) { Ok(bytes) => { - let document: WorkspaceClaimReplayDocument = serde_json::from_slice(&bytes) - .map_err(|_| { + let raw: serde_json::Value = serde_json::from_slice(&bytes).map_err(|_| { + WorkspaceCapabilityVerificationError::ReplayAuthorityUnavailable + })?; + if raw.get("version").and_then(serde_json::Value::as_u64) == Some(1) { + let legacy: LegacyReplayDocument = + serde_json::from_value(raw).map_err(|_| { + WorkspaceCapabilityVerificationError::ReplayAuthorityUnavailable + })?; + if legacy.version != 1 || legacy.entries.len() > MAX_REPLAY_ENTRIES { + return Err( + WorkspaceCapabilityVerificationError::ReplayAuthorityUnavailable, + ); + } + let migrated = WorkspaceClaimReplayDocument { + version: 2, + entries: Vec::new(), + }; + self.write(&migrated)?; + return Ok(migrated); + } + let document: WorkspaceClaimReplayDocument = + serde_json::from_value(raw).map_err(|_| { WorkspaceCapabilityVerificationError::ReplayAuthorityUnavailable })?; - if document.version != 1 || document.entries.len() > MAX_REPLAY_ENTRIES { + if document.version != 2 || document.entries.len() > MAX_REPLAY_ENTRIES { return Err(WorkspaceCapabilityVerificationError::ReplayAuthorityUnavailable); } Ok(document) } Err(error) if error.kind() == std::io::ErrorKind::NotFound => { Ok(WorkspaceClaimReplayDocument { - version: 1, + version: 2, entries: Vec::new(), }) } @@ -921,7 +1158,7 @@ impl WorkspaceClaimReplayProtection for FileWorkspaceClaimReplayProtection { fn consume_once( &self, workspace_id: &str, - trust_generation: u64, + trust_id: &str, token_id: &str, expires_at: i64, now_unix: i64, @@ -934,7 +1171,7 @@ impl WorkspaceClaimReplayProtection for FileWorkspaceClaimReplayProtection { document.entries.retain(|entry| entry.expires_at > now_unix); if document.entries.iter().any(|entry| { entry.workspace_id == workspace_id - && entry.trust_generation == trust_generation + && entry.trust_id == trust_id && entry.token_id == token_id }) { return Ok(false); @@ -944,7 +1181,7 @@ impl WorkspaceClaimReplayProtection for FileWorkspaceClaimReplayProtection { } document.entries.push(WorkspaceClaimReplayEntry { workspace_id: workspace_id.to_string(), - trust_generation, + trust_id: trust_id.to_string(), token_id: token_id.to_string(), expires_at, }); @@ -1019,14 +1256,14 @@ impl WorkspaceCapabilityVerifier { if record.backend_url != claims.issuer { return Err(WorkspaceCapabilityVerificationError::WrongIssuer); } - if record.trust_generation != claims.trust_generation { - return Err(WorkspaceCapabilityVerificationError::StaleTrustGeneration); + if record.trust_id != claims.trust_id { + return Err(WorkspaceCapabilityVerificationError::WrongTrustIdentity); } if record.key_id != claims.issuer_key_id { return Err(WorkspaceCapabilityVerificationError::WrongIssuerKey); } - if record.identity_revision != claims.issuer_identity_revision { - return Err(WorkspaceCapabilityVerificationError::StaleIdentityRevision); + if record.public_key_fingerprint != claims.issuer_public_key_fingerprint { + return Err(WorkspaceCapabilityVerificationError::WrongIssuerKey); } crate::auth::verify_signed_json_token( @@ -1045,8 +1282,8 @@ impl WorkspaceCapabilityVerifier { if claims.runtime_id != expected.runtime_id { return Err(WorkspaceCapabilityVerificationError::WrongRuntime); } - if claims.binding_revision != expected.binding_revision { - return Err(WorkspaceCapabilityVerificationError::StaleBindingRevision); + if claims.binding_id != expected.binding_id { + return Err(WorkspaceCapabilityVerificationError::WrongBindingIdentity); } if claims.worker_id.as_deref() != expected.worker_id { return Err(WorkspaceCapabilityVerificationError::WrongWorker); @@ -1076,7 +1313,7 @@ impl WorkspaceCapabilityVerifier { } if !self.replay.consume_once( &claims.issuer_workspace_id, - claims.trust_generation, + &claims.trust_id, &claims.jti, claims.exp, expected.now_unix, @@ -1088,9 +1325,9 @@ impl WorkspaceCapabilityVerifier { issuer: claims.issuer, workspace_id: claims.issuer_workspace_id, issuer_key_id: claims.issuer_key_id, - issuer_identity_revision: claims.issuer_identity_revision, - trust_generation: claims.trust_generation, - binding_revision: claims.binding_revision, + issuer_public_key_fingerprint: claims.issuer_public_key_fingerprint, + trust_id: claims.trust_id, + binding_id: claims.binding_id, runtime_id: claims.runtime_id, worker_id: claims.worker_id, operation: claims.operation, @@ -1126,20 +1363,18 @@ pub enum WorkspaceCapabilityVerificationError { WrongIssuer, #[error("Workspace capability issuer trust is revoked")] IssuerRevoked, - #[error("Workspace capability trust generation is stale")] - StaleTrustGeneration, + #[error("Workspace capability trust identity does not match current trust")] + WrongTrustIdentity, #[error("Workspace capability issuer key does not match trust")] WrongIssuerKey, - #[error("Workspace capability identity revision is stale")] - StaleIdentityRevision, #[error("Workspace capability signature is invalid")] InvalidSignature, #[error("Workspace capability targets another Workspace")] WrongWorkspace, #[error("Workspace capability targets another Runtime")] WrongRuntime, - #[error("Workspace capability binding revision is stale")] - StaleBindingRevision, + #[error("Workspace capability binding identity does not match the verified binding")] + WrongBindingIdentity, #[error("Workspace capability targets another Worker")] WrongWorker, #[error("Workspace capability does not authorize this operation")] @@ -1239,10 +1474,9 @@ fn validate_trust_record( algorithm: record.algorithm.clone(), public_key: record.public_key.clone(), public_key_fingerprint: record.public_key_fingerprint.clone(), - revision: record.identity_revision, }) .map_err(|_| WorkspaceCapabilityVerificationError::TrustRecordCorrupt)?; - if record.trust_generation == 0 { + if validate_id(&record.trust_id).is_err() { return Err(WorkspaceCapabilityVerificationError::TrustRecordCorrupt); } Ok(()) @@ -1255,6 +1489,8 @@ fn validate_verification_record( record.workspace_id.as_str(), record.runtime_id.as_str(), record.workspace_key_id.as_str(), + record.binding_id.as_str(), + record.workspace_trust_id.as_str(), ] { if value.is_empty() || value.len() > MAX_ID_BYTES @@ -1266,10 +1502,11 @@ fn validate_verification_record( return Err(WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable); } } - if record.binding_revision == 0 - || record.workspace_identity_revision == 0 - || record.workspace_trust_generation == 0 - || record.runtime_identity_revision == 0 + if record.binding_id.is_empty() + || record.workspace_public_key_fingerprint.is_empty() + || record.workspace_trust_id.is_empty() + || !is_key_fingerprint(&record.workspace_public_key_fingerprint) + || !is_key_fingerprint(&record.runtime_public_key_fingerprint) || record.verified_at <= 0 || record.runtime_public_key_fingerprint.len() > 128 || !record.runtime_public_key_fingerprint.starts_with("sha256:") @@ -1287,6 +1524,8 @@ fn validate_verification_challenge( challenge.workspace_id.as_str(), challenge.runtime_id.as_str(), challenge.workspace_key_id.as_str(), + challenge.binding_id.as_str(), + challenge.workspace_trust_id.as_str(), challenge.workspace_nonce.as_str(), ] { if value.is_empty() @@ -1299,10 +1538,11 @@ fn validate_verification_challenge( return Err(WorkspaceCapabilityVerificationError::VerificationChallengeMismatch); } } - if challenge.binding_revision == 0 - || challenge.workspace_identity_revision == 0 - || challenge.workspace_trust_generation == 0 - || challenge.runtime_identity_revision == 0 + if challenge.binding_id.is_empty() + || challenge.workspace_public_key_fingerprint.is_empty() + || challenge.workspace_trust_id.is_empty() + || !is_key_fingerprint(&challenge.workspace_public_key_fingerprint) + || !is_key_fingerprint(&challenge.runtime_public_key_fingerprint) || challenge.runtime_public_key_fingerprint.len() > 128 || !challenge .runtime_public_key_fingerprint @@ -1336,6 +1576,8 @@ fn validate_claim_shape( for value in [ claims.issuer_workspace_id.as_str(), claims.issuer_key_id.as_str(), + claims.binding_id.as_str(), + claims.trust_id.as_str(), claims.runtime_id.as_str(), claims.jti.as_str(), ] { @@ -1360,9 +1602,9 @@ fn validate_claim_shape( return Err(WorkspaceCapabilityVerificationError::InvalidIdentifier); } } - if claims.issuer_identity_revision == 0 - || claims.trust_generation == 0 - || claims.binding_revision == 0 + if !is_key_fingerprint(&claims.issuer_public_key_fingerprint) + || claims.trust_id.is_empty() + || claims.binding_id.is_empty() { return Err(WorkspaceCapabilityVerificationError::MalformedClaims); } @@ -1390,6 +1632,15 @@ fn validate_claim_shape( Ok(()) } +fn is_key_fingerprint(value: &str) -> bool { + value.strip_prefix("sha256:").is_some_and(|hex| { + hex.len() == 64 + && hex + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) +} + fn is_sha256_digest(value: &str) -> bool { crate::auth::is_request_body_digest(value) } @@ -1416,7 +1667,6 @@ mod tests { fn identity( workspace_id: &str, key_id: &str, - revision: u64, ) -> (Ed25519KeyPair, WorkspacePublicIdentityBundle) { let material = RuntimeIdentityMaterial::generate(key_id).unwrap(); let signing_key = material.signing_key().unwrap(); @@ -1435,7 +1685,6 @@ mod tests { algorithm: WORKSPACE_SIGNING_ALGORITHM.to_string(), public_key: material.public_key, public_key_fingerprint, - revision, }, ) } @@ -1445,9 +1694,9 @@ mod tests { issuer: record.backend_url.clone(), issuer_workspace_id: record.workspace_id.clone(), issuer_key_id: record.key_id.clone(), - issuer_identity_revision: record.identity_revision, - trust_generation: record.trust_generation, - binding_revision: 7, + issuer_public_key_fingerprint: record.public_key_fingerprint.clone(), + trust_id: record.trust_id.clone(), + binding_id: "binding-test".into(), runtime_id: "runtime-1".to_string(), worker_id: Some("worker-1".to_string()), operation: "worker.submit".to_string(), @@ -1463,7 +1712,7 @@ mod tests { fn expectation<'a>(body_digest: &'a str) -> WorkspaceCapabilityExpectation<'a> { WorkspaceCapabilityExpectation { workspace_id: "workspace-1", - binding_revision: 7, + binding_id: "binding-test", runtime_id: "runtime-1", worker_id: Some("worker-1"), operation: "worker.submit", @@ -1475,13 +1724,13 @@ mod tests { } #[test] - fn trust_mutations_are_idempotent_generation_fenced_and_reject_stale_keys() { - let (_, bundle_v1) = identity("workspace-1", "WK-1", 1); + fn trust_mutations_are_idempotent_identity_fenced_and_reject_key_id_reuse() { + let (_, bundle_v1) = identity("workspace-1", "WK-1"); let mut records = Vec::new(); let (mutation, first) = add_workspace_issuer_trust(&mut records, bundle_v1.clone(), 10).unwrap(); assert_eq!(mutation, WorkspaceIssuerTrustMutation::Added); - assert_eq!(first.trust_generation, 1); + assert!(!first.trust_id.is_empty()); let (mutation, replay) = add_workspace_issuer_trust(&mut records, bundle_v1.clone(), 11).unwrap(); assert_eq!(mutation, WorkspaceIssuerTrustMutation::Unchanged); @@ -1494,19 +1743,19 @@ mod tests { WorkspaceCapabilityVerificationError::TrustRecordLimitExceeded ); - let (_, stale_other_key) = identity("workspace-1", "WK-stale", 1); + let (_, stale_other_key) = identity("workspace-1", "WK-1"); assert_eq!( replace_workspace_issuer_trust(&mut records, stale_other_key, 12).unwrap_err(), - WorkspaceIssuerTrustError::StaleIdentityRevision + WorkspaceIssuerTrustError::KeyIdentityMismatch ); - let (_, bundle_v2) = identity("workspace-1", "WK-2", 2); + let (_, bundle_v2) = identity("workspace-1", "WK-2"); let (mutation, replaced) = replace_workspace_issuer_trust(&mut records, bundle_v2, 13).unwrap(); assert_eq!(mutation, WorkspaceIssuerTrustMutation::Replaced); - assert_eq!(replaced.trust_generation, 2); + assert_ne!(replaced.trust_id, first.trust_id); assert_eq!(replaced.registered_at_unix, 10); let (_, revoked) = revoke_workspace_issuer_trust(&mut records, "workspace-1", 14).unwrap(); - assert_eq!(revoked.trust_generation, 3); + assert_ne!(revoked.trust_id, replaced.trust_id); assert_eq!(revoked.state, WorkspaceIssuerTrustState::Revoked); let (mutation, same) = revoke_workspace_issuer_trust(&mut records, "workspace-1", 15).unwrap(); @@ -1515,11 +1764,13 @@ mod tests { } #[test] - fn verifier_binds_workspace_key_generation_runtime_worker_operation_and_body() { - let (key_1, bundle_1) = identity("workspace-1", "WK-1", 1); - let (key_2, bundle_2) = identity("workspace-2", "WK-2", 1); - let record_1 = WorkspaceIssuerTrustRecord::from_bundle(bundle_1, 1, 1).unwrap(); - let record_2 = WorkspaceIssuerTrustRecord::from_bundle(bundle_2, 1, 1).unwrap(); + fn verifier_binds_workspace_key_trust_identity_runtime_worker_operation_and_body() { + let (key_1, bundle_1) = identity("workspace-1", "WK-1"); + let (key_2, bundle_2) = identity("workspace-2", "WK-2"); + let record_1 = + WorkspaceIssuerTrustRecord::from_bundle(bundle_1, "trust-test".into(), 1).unwrap(); + let record_2 = + WorkspaceIssuerTrustRecord::from_bundle(bundle_2, "trust-test".into(), 1).unwrap(); let verifier = WorkspaceCapabilityVerifier::new( vec![record_1.clone(), record_2.clone()], Arc::new(InMemoryWorkspaceClaimReplayProtection::default()), @@ -1546,7 +1797,7 @@ mod tests { ); let workspace_2_expectation = WorkspaceCapabilityExpectation { workspace_id: "workspace-2", - binding_revision: 7, + binding_id: "binding-test".into(), runtime_id: "runtime-1", worker_id: Some("worker-1"), operation: "worker.submit", @@ -1590,9 +1841,10 @@ mod tests { ), ( "binding", - (|claims: &mut WorkspaceCapabilityClaims| claims.binding_revision = 8) - as ClaimsMutation, - WorkspaceCapabilityVerificationError::StaleBindingRevision, + (|claims: &mut WorkspaceCapabilityClaims| { + claims.binding_id = "other-binding".into() + }) as ClaimsMutation, + WorkspaceCapabilityVerificationError::WrongBindingIdentity, ), ( "issuer", @@ -1644,9 +1896,10 @@ mod tests { } #[test] - fn verifier_rejects_replay_revocation_stale_generation_expiry_and_future_claims() { - let (key, bundle) = identity("workspace-1", "WK-1", 1); - let record = WorkspaceIssuerTrustRecord::from_bundle(bundle, 2, 1).unwrap(); + fn verifier_rejects_replay_revocation_old_trust_expiry_and_future_claims() { + let (key, bundle) = identity("workspace-1", "WK-1"); + let record = + WorkspaceIssuerTrustRecord::from_bundle(bundle, "trust-test".into(), 1).unwrap(); let replay = Arc::new(InMemoryWorkspaceClaimReplayProtection::default()); let verifier = WorkspaceCapabilityVerifier::new(vec![record.clone()], replay).unwrap(); let body = workspace_request_body_digest(br#"{"content":"hello"}"#); @@ -1658,11 +1911,11 @@ mod tests { ); let mut stale = claims(&record, "stale"); - stale.trust_generation = 1; + stale.trust_id = "retired-trust".into(); let token = issue_workspace_capability_token(&key, &stale).unwrap(); assert_eq!( verifier.verify(&token, &expectation(&body)).unwrap_err(), - WorkspaceCapabilityVerificationError::StaleTrustGeneration + WorkspaceCapabilityVerificationError::WrongTrustIdentity ); let mut expired = claims(&record, "expired"); @@ -1685,7 +1938,7 @@ mod tests { let mut revoked = record; revoked.state = WorkspaceIssuerTrustState::Revoked; - revoked.trust_generation += 1; + revoked.trust_id = "revoked-trust".into(); let revoked_verifier = WorkspaceCapabilityVerifier::new( vec![revoked.clone()], Arc::new(InMemoryWorkspaceClaimReplayProtection::default()), @@ -1700,6 +1953,68 @@ mod tests { ); } + #[test] + fn reaccepting_the_same_key_does_not_reactivate_old_capabilities() { + let (key, bundle) = identity("workspace-1", "WK-1"); + let mut records = Vec::new(); + let (_, first) = add_workspace_issuer_trust(&mut records, bundle.clone(), 10).unwrap(); + let token = issue_workspace_capability_token(&key, &claims(&first, "old-token")).unwrap(); + revoke_workspace_issuer_trust(&mut records, "workspace-1", 11).unwrap(); + let (_, reaccepted) = replace_workspace_issuer_trust(&mut records, bundle, 12).unwrap(); + assert_ne!(first.trust_id, reaccepted.trust_id); + let verifier = WorkspaceCapabilityVerifier::new( + records, + Arc::new(InMemoryWorkspaceClaimReplayProtection::default()), + ) + .unwrap(); + let body = workspace_request_body_digest(br#"{"content":"hello"}"#); + assert_eq!( + verifier.verify(&token, &expectation(&body)).unwrap_err(), + WorkspaceCapabilityVerificationError::WrongTrustIdentity + ); + } + + #[test] + fn frozen_enrollment_migration_requires_explicit_reacceptance() { + let (_, bundle) = identity("workspace-1", "WK-1"); + let record = + WorkspaceIssuerTrustRecord::from_bundle(bundle, "trust-test".into(), 10).unwrap(); + for state in ["active", "revoked"] { + let mut legacy = serde_json::to_value(&record).unwrap(); + legacy.as_object_mut().unwrap().remove("trust_id"); + legacy["identity_revision"] = serde_json::json!(1); + legacy["trust_generation"] = serde_json::json!(2); + legacy["state"] = serde_json::json!(state); + assert!(serde_json::from_value::(legacy.clone()).is_err()); + let migrated = migrate_legacy_workspace_issuer_trust_record(legacy).unwrap(); + assert_eq!(migrated.state, WorkspaceIssuerTrustState::Revoked); + assert_eq!(migrated.public_bundle(), record.public_bundle()); + } + } + + #[test] + fn frozen_verification_file_is_invalidated_durably() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("verification.json"); + fs::write(&path, serde_json::to_vec(&serde_json::json!({"version":1,"records":[{ + "workspace_id":"workspace-1","runtime_id":"runtime-1","binding_revision":1, + "workspace_key_id":"WK-1","workspace_identity_revision":1,"workspace_trust_generation":1, + "runtime_public_key_fingerprint":"sha256:old","runtime_identity_revision":1,"verified_at":10 + }]})).unwrap()).unwrap(); + let authority = FileWorkspaceRuntimeVerificationAuthority::new(&path); + assert!(authority.get("workspace-1", "runtime-1").unwrap().is_none()); + let migrated: serde_json::Value = + serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + assert_eq!(migrated["version"], 2); + assert_eq!(migrated["records"], serde_json::json!([])); + assert!( + FileWorkspaceRuntimeVerificationAuthority::new(&path) + .get("workspace-1", "runtime-1") + .unwrap() + .is_none() + ); + } + #[test] fn file_replay_protection_survives_reconstruction() { let temporary = tempfile::tempdir().unwrap(); @@ -1707,18 +2022,18 @@ mod tests { let first = FileWorkspaceClaimReplayProtection::new(&path); assert!( first - .consume_once("workspace-a", 3, "token-a", 1_000, 100) + .consume_once("workspace-a", "trust-a", "token-a", 1_000, 100) .unwrap() ); let restored = FileWorkspaceClaimReplayProtection::new(&path); assert!( !restored - .consume_once("workspace-a", 3, "token-a", 1_000, 101) + .consume_once("workspace-a", "trust-a", "token-a", 1_000, 101) .unwrap() ); assert!( restored - .consume_once("workspace-a", 4, "token-a", 1_000, 101) + .consume_once("workspace-a", "trust-b", "token-a", 1_000, 101) .unwrap() ); } @@ -1731,12 +2046,11 @@ mod tests { let record = WorkspaceRuntimeVerificationRecord { workspace_id: "workspace-a".to_string(), runtime_id: "runtime-a".to_string(), - binding_revision: 7, + binding_id: "binding-test".into(), workspace_key_id: "WK-a".to_string(), - workspace_identity_revision: 2, - workspace_trust_generation: 3, - runtime_public_key_fingerprint: "sha256:runtime".to_string(), - runtime_identity_revision: 1, + workspace_public_key_fingerprint: format!("sha256:{}", "a".repeat(64)), + workspace_trust_id: "trust-test".into(), + runtime_public_key_fingerprint: format!("sha256:{}", "b".repeat(64)), verified_at: 100, }; authority.record(record.clone()).unwrap(); @@ -1761,12 +2075,11 @@ mod tests { challenge_id: "challenge-1".to_string(), workspace_id: "workspace-a".to_string(), runtime_id: "runtime-1".to_string(), - binding_revision: 7, + binding_id: "binding-test".into(), workspace_key_id: "WK-1".to_string(), - workspace_identity_revision: 2, - workspace_trust_generation: 3, + workspace_public_key_fingerprint: format!("sha256:{}", "a".repeat(64)), + workspace_trust_id: "trust-test".into(), runtime_public_key_fingerprint: public_key_fingerprint, - runtime_identity_revision: 1, workspace_nonce: "workspace-nonce".to_string(), expires_at: 1_100, }; @@ -1781,8 +2094,39 @@ mod tests { ) .unwrap(); + // A valid signature from a different key must not authenticate the + // fingerprint named by this challenge, even when the response repeats it. + let other_identity = RuntimeIdentityMaterial::generate("runtime-1").unwrap(); + let mut wrong_key = response.clone(); + wrong_key.response_proof = crate::auth::sign_json_token( + RUNTIME_VERIFICATION_TOKEN_PREFIX, + RUNTIME_VERIFICATION_SIGNING_INPUT_PREFIX, + &other_identity.signing_key().unwrap(), + &RuntimeVerificationResponseClaims { + response: response.unsigned(), + method: "POST".into(), + path_and_query: WORKSPACE_VERIFICATION_CHALLENGE_PATH.into(), + body_digest: workspace_request_body_digest( + &serde_json::to_vec(&challenge).unwrap(), + ), + iat: 1_000, + exp: challenge.expires_at, + jti: "different-key-proof".into(), + }, + ) + .unwrap(); + assert_eq!( + verify_runtime_verification_response( + &wrong_key, + &challenge, + &other_identity.public_key, + 1_001, + ), + Err(WorkspaceCapabilityVerificationError::VerificationChallengeMismatch) + ); + let mut tampered = response.clone(); - tampered.binding_revision += 1; + tampered.binding_id = "other-binding".into(); assert_eq!( verify_runtime_verification_response( &tampered, diff --git a/crates/worker/README.md b/crates/worker/README.md index 7168a1fd0..46f8fa2a3 100644 --- a/crates/worker/README.md +++ b/crates/worker/README.md @@ -68,13 +68,14 @@ commands and generic POSIX operations are not exposed. The adapter uses the type an owner config API as a bypass. Client-managed PATH validators are compared against freshly resolved Host -metadata. Each mutation captures the ROOT validator, whole-tree revision/digest, +metadata. Each mutation captures the ROOT validator, whole-tree content digest, canonical entrypoints and node state from that same observation. Backend resolves the live grant/link and validates canonical changes/CAS under its execution lock. No operation accepts LLM-supplied validators or `expected_digest` fields. Returned -post-call path validators are refreshed from the exact committed revision, not -substituted with root validators or later unrelated state. UI/Worker edits and -same-bytes replacement revisions reject stale calls. Old descriptors cannot +post-call path validators are refreshed from the exact committed content, not +substituted with root validators or later unrelated state. Content changes and +reattachment to a new connection lifetime reject stale calls, even when the new +attachment exposes the same bytes. Old descriptors cannot bypass read-only restrictions, detach, revocation or Workspace boundaries. Bounds: depth 0..8, 256 observed nodes/changes, 512-byte relative paths, 256 KiB diff --git a/crates/worker/src/backend_job.rs b/crates/worker/src/backend_job.rs index e5b01b431..51774314d 100644 --- a/crates/worker/src/backend_job.rs +++ b/crates/worker/src/backend_job.rs @@ -10,7 +10,7 @@ pub struct BackendJobExecutionBinding { pub job_id: String, pub attempt_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub input_revision: Option, + pub input_digest: Option, #[serde(default)] pub subjektiv_consolidation: bool, } @@ -19,10 +19,10 @@ impl BackendJobExecutionBinding { pub fn validate(&self) -> Result<(), &'static str> { if [&self.job_id, &self.attempt_id] .into_iter() - .chain(self.input_revision.iter()) + .chain(self.input_digest.iter()) .any(|value| value.trim().is_empty() || value.chars().any(char::is_control)) { - return Err("Backend Job binding contains an invalid identity or input revision"); + return Err("Backend Job binding contains an invalid identity or input digest"); } Ok(()) } diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index ef8cd7a2f..a187b0dfc 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -164,6 +164,12 @@ impl WorkerHandle { )) } + /// Hold through protocol subscription and initial snapshot capture so child + /// events cannot overtake the parent's snapshot publication boundary. + pub fn protocol_snapshot_publish_guard(&self) -> std::sync::MutexGuard<'_, ()> { + self.spawned_registry.protocol_snapshot_publish_guard() + } + pub fn snapshot_event(&self) -> Event { self.snapshot_event_with_entry_subscription().0 } @@ -546,7 +552,7 @@ fn notification_coalesce_remaining( fn prepare_pending_run( pending_submissions: &crate::worker::PendingSubmissionHandle, notify_buffer: &NotifyBuffer, - fence: Option<(u64, &str)>, + fence: Option<&str>, allow_notification_run: bool, ) -> Result, crate::worker::PendingSubmissionError> { Ok(match pending_submissions.prepare_next_activation(fence)? { @@ -2619,24 +2625,8 @@ async fn controller_loop( pending: pending_submissions.snapshot(), }); } - Method::CancelPendingSubmission { - submission_id, - expected_revision, - } => match pending_submissions.cancel(&submission_id, expected_revision) { - Ok(pending_snapshot) => { - let _ = working_event_tx.send(Event::PendingSubmissionsChanged { - pending: pending_snapshot, - }); - } - Err(error) => { - let _ = working_event_tx.send(Event::Error { - code: ErrorCode::InvalidRequest, - message: error.to_string(), - }); - } - }, - Method::ClearPendingSubmissions { expected_revision } => { - match pending_submissions.clear(expected_revision) { + Method::CancelPendingSubmission { submission_id } => { + match pending_submissions.cancel(&submission_id) { Ok(pending_snapshot) => { let _ = working_event_tx.send(Event::PendingSubmissionsChanged { pending: pending_snapshot, @@ -2650,10 +2640,22 @@ async fn controller_loop( } } } - Method::ContinuePending { - expected_revision, - expected_head_id, - } => { + Method::ClearPendingSubmissions { + expected_submission_ids, + } => match pending_submissions.clear(&expected_submission_ids) { + Ok(pending_snapshot) => { + let _ = working_event_tx.send(Event::PendingSubmissionsChanged { + pending: pending_snapshot, + }); + } + Err(error) => { + let _ = working_event_tx.send(Event::Error { + code: ErrorCode::InvalidRequest, + message: error.to_string(), + }); + } + }, + Method::ContinuePending { expected_head_id } => { if shared_state.catalog_status() != WorkerStatus::Idle { let _ = working_event_tx.send(Event::Error { code: ErrorCode::InvalidRequest, @@ -2664,7 +2666,7 @@ async fn controller_loop( match prepare_pending_run( &pending_submissions, ¬ify_buffer, - Some((expected_revision, &expected_head_id)), + Some(&expected_head_id), true, ) { Ok(Some(next)) => pending = Some(next), @@ -3023,8 +3025,9 @@ async fn controller_loop( }, Method::Shutdown { command } => { - // Shutdown ignores the state-revision fence but remains bound to the - // current execution generation and command payload identity. + // Shutdown applies to this controller at admission time, not + // a caller-observed Run. Exact command-id/kind retries remain + // valid so every shutdown path can reach the cleanup barrier. if let Err(disposition) = validate_shutdown_command(command, &shared_state) { acknowledge_command( &working_event_tx, @@ -3619,9 +3622,8 @@ where } Some(Method::CancelPendingSubmission { submission_id, - expected_revision, }) => { - match pending_submissions.cancel(&submission_id, expected_revision) { + match pending_submissions.cancel(&submission_id) { Ok(pending) => { let _ = working_event_tx.send(Event::PendingSubmissionsChanged { pending }); } @@ -3633,8 +3635,8 @@ where } } } - Some(Method::ClearPendingSubmissions { expected_revision }) => { - match pending_submissions.clear(expected_revision) { + Some(Method::ClearPendingSubmissions { expected_submission_ids }) => { + match pending_submissions.clear(&expected_submission_ids) { Ok(pending) => { let _ = working_event_tx.send(Event::PendingSubmissionsChanged { pending }); } @@ -4009,7 +4011,7 @@ mod tests { assert_eq!(request.path, "/api/w/workspace-1/drive/root"); Ok(crate::worker::WorkspaceResponse { status: 200, body: serde_json::json!({ "entry": {"workspace_id": "workspace-1", "node_id": "1"}, - "parent": null, "name": "", "kind": "folder", "revision": "1", + "parent": null, "name": "", "kind": "folder", "last_mutation_id": "", "size": null, "content_type": null, "updated_by": "test", "updated_at": "2026-10-08T00:00:00Z", "latest_url": "/api/w/workspace-1/drive/download?entry_workspace_id=workspace-1&id=1" diff --git a/crates/worker/src/feature/background.rs b/crates/worker/src/feature/background.rs index e83c8f744..8ef198ed1 100644 --- a/crates/worker/src/feature/background.rs +++ b/crates/worker/src/feature/background.rs @@ -756,7 +756,7 @@ mod tests { workspace_id: Some("workspace".into()), worker_id: "worker".into(), session_id: "session".into(), - session_revision: 3, + run_id: Some("run".into()), turn_index: Some(2), call_id: None, diff --git a/crates/worker/src/feature/builtin/backend_job_result.rs b/crates/worker/src/feature/builtin/backend_job_result.rs index bf398dcc5..b5cd4e681 100644 --- a/crates/worker/src/feature/builtin/backend_job_result.rs +++ b/crates/worker/src/feature/builtin/backend_job_result.rs @@ -2,7 +2,7 @@ //! //! The tool intentionally accepts only the result value. Workspace and Worker //! identity come from the authenticated Workspace client, while Job, attempt, -//! and input revision are resolved from the Backend-owned binding. +//! and input digest are resolved from the Backend-owned binding. use crate::feature::background::BackgroundTaskCancellation; use std::collections::HashMap; @@ -22,7 +22,7 @@ use crate::feature::{ use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod}; const TOOL_NAME: &str = "SubmitBackendJobResult"; -const TOOL_DESCRIPTION: &str = "Submit the bounded structured result for this Backend-owned Job attempt. Workspace, Job, attempt, input revision, and source Worker identity are resolved and fenced by Backend authority; normal final prose is not success."; +const TOOL_DESCRIPTION: &str = "Submit the bounded structured result for this Backend-owned Job attempt. Workspace, Job, attempt, input digest, and source Worker identity are resolved and fenced by Backend authority; normal final prose is not success."; #[derive(Debug, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] @@ -194,7 +194,7 @@ impl Tool for SubmitResultTool { let sealed = submission.sealed.as_mut().expect("sealed before dispatch"); if definitive_rejection && !was_previously_sealed { // Only a definitively unaccepted request can be corrected. Reuse - // a ready snapshot after a new preparation confirms its revision; + // a ready snapshot after a new preparation confirms its Memory fingerprint; // a conflict invalidates even that snapshot. Failed outcomes are // rebuilt, since another generation can replace their marker. submission.reusable_surface = if response.status != 409 { diff --git a/crates/worker/src/feature/builtin/drive.rs b/crates/worker/src/feature/builtin/drive.rs index 8557c44cd..d1f2e9477 100644 --- a/crates/worker/src/feature/builtin/drive.rs +++ b/crates/worker/src/feature/builtin/drive.rs @@ -188,7 +188,8 @@ impl DriveFeature { return Err(DriveError::Limit); } let read = self.backend.read(&args.entry, args.max_bytes).await?; - if pinned.is_some_and(|entry| entry.revision != read.entry.revision) { + if pinned.is_some_and(|entry| entry.last_mutation_id != read.entry.last_mutation_id) + { return Err(DriveError::Conflict); } self.observe(read.entry.clone())?; @@ -260,7 +261,7 @@ impl DriveFeature { request_id, mutation: DriveMutation::UpdateText { id: entry.entry, - expected_revision: entry.revision, + expected_mutation_id: entry.last_mutation_id, text, content_type: entry.content_type.unwrap_or_else(default_media), }, @@ -281,7 +282,7 @@ impl DriveFeature { .backend .read(&entry.entry, DRIVE_TEXT_MAX_BYTES as u32) .await?; - if read.entry.revision != entry.revision { + if read.entry.last_mutation_id != entry.last_mutation_id { return Err(DriveError::Conflict); } if read.truncated { @@ -297,7 +298,7 @@ impl DriveFeature { request_id, mutation: DriveMutation::UpdateText { id: entry.entry, - expected_revision: entry.revision, + expected_mutation_id: entry.last_mutation_id, text, content_type: entry.content_type.unwrap_or_else(default_media), }, @@ -316,7 +317,7 @@ impl DriveFeature { request_id, mutation: DriveMutation::Relocate { id: entry.entry, - expected_revision: entry.revision, + expected_mutation_id: entry.last_mutation_id, parent: args.parent, name: args.name, }, @@ -334,7 +335,7 @@ impl DriveFeature { request_id, mutation: DriveMutation::Delete { id: entry.entry, - expected_revision: entry.revision, + expected_mutation_id: entry.last_mutation_id, }, }) .await?, @@ -385,7 +386,7 @@ impl DriveFeature { parent_id: Some(args.parent.node_id), name: Some(args.name), id: None, - expected_revision: None, + expected_mutation_id: None, content_type: args.content_type, size: bytes.len() as u32, sha256: digest(&bytes), @@ -399,7 +400,7 @@ impl DriveFeature { } _ => return Err(DriveError::Invalid("unknown operation".into())), }; - // A successful mutation does not silently substitute its new revision + // A successful mutation does not silently substitute its new observation // into a prior observation. The caller observes explicitly next time. if let Some(id) = mutated_id { self.observations @@ -640,13 +641,13 @@ pub(super) const SPECS: &[Spec] = &[ Spec { name: "DriveMetadata", operation: "metadata", - description: "Inspect one Workspace-bound stable entry ID and observe its revision for mutation.", + description: "Inspect one Workspace-bound stable entry ID and retain its last committed request for mutation checks.", schema: schema::, }, Spec { name: "DriveList", operation: "list", - description: "List a bounded folder page (default root), up to 200 entries. Results do not expand the WIP tree or observe revisions for writes.", + description: "List a bounded folder page (default root), up to 200 entries. Results do not expand the WIP tree or establish mutation preconditions.", schema: schema::, }, Spec { @@ -658,7 +659,7 @@ pub(super) const SPECS: &[Spec] = &[ Spec { name: "DriveRead", operation: "read", - description: "Read up to 64 KiB UTF-8 text and observe entry revision. Past Session text remains an immutable observation.", + description: "Read up to 64 KiB UTF-8 text and retain the entry’s last committed request. Past Session text remains an immutable observation.", schema: schema::, }, Spec { @@ -676,31 +677,31 @@ pub(super) const SPECS: &[Spec] = &[ Spec { name: "DriveWrite", operation: "write", - description: "Replace <=64 KiB text using a previously read/metadata-observed revision. Conflicts are not retried with latest revision.", + description: "Replace <=64 KiB text using a previously observed committed request. Conflicts are not retried with a newer observation.", schema: schema::, }, Spec { name: "DriveEdit", operation: "edit", - description: "Partial text edit using previously observed revision and shared old_string rules. Require nonempty unique match unless replace_all. Truncated preimages cannot be edited.", + description: "Partial text edit using previously observed committed request and shared old_string rules. Require nonempty unique match unless replace_all. Truncated preimages cannot be edited.", schema: schema::, }, Spec { name: "DriveRelocate", operation: "relocate", - description: "Rename and/or move an observed entry with revision precondition; stable ID and URL remain unchanged.", + description: "Rename and/or move an observed entry only if its last committed request still matches the observation; stable ID and URL remain unchanged.", schema: schema::, }, Spec { name: "DriveDelete", operation: "delete", - description: "Delete one observed entry with revision precondition. A recreated name has a different ID; stale observations cannot target it.", + description: "Delete one observed entry only if its last committed request still matches the observation. A recreated name has a different ID; stale observations cannot target it.", schema: schema::, }, Spec { name: "DriveViewImage", operation: "view_image", - description: "Attach revision-fixed PNG/JPEG/GIF/WebP bytes (<=10 MiB) through durable ToolOutput history/capture; a URL alone is not image viewing.", + description: "Attach committed-request-bound PNG/JPEG/GIF/WebP bytes (<=10 MiB) through durable ToolOutput history/capture; a URL alone is not image viewing.", schema: schema::, }, Spec { diff --git a/crates/worker/src/feature/builtin/drive/backend.rs b/crates/worker/src/feature/builtin/drive/backend.rs index 24b4ec93e..eea66bc12 100644 --- a/crates/worker/src/feature/builtin/drive/backend.rs +++ b/crates/worker/src/feature/builtin/drive/backend.rs @@ -22,7 +22,9 @@ pub enum DriveError { Denied, #[error("Drive resource not found")] NotFound, - #[error("Drive revision or name conflict; observe explicitly before another operation")] + #[error( + "Drive content, location, or name conflict; observe explicitly before another operation" + )] Conflict, #[error("Invalid Drive arguments: {0}")] Invalid(String), @@ -54,7 +56,11 @@ impl DriveBackend { if let Some(parent) = &entry.parent { self.validate_ref(parent)?; } - if !decimal(&entry.revision) || entry.size.is_some_and(|n| n > DRIVE_FILE_MAX_BYTES) { + if (entry.last_mutation_id.len() > 128 + || entry.last_mutation_id.chars().any(char::is_control) + || (entry.parent.is_some() && entry.last_mutation_id.is_empty())) + || entry.size.is_some_and(|n| n > DRIVE_FILE_MAX_BYTES) + { return Err(DriveError::Unavailable); } Ok(()) @@ -238,14 +244,14 @@ impl DriveBackend { return Err(DriveError::Limit); } let mut bytes = Vec::with_capacity(total as usize); - // Even an empty file gets a revision-fixed read and current authorization. + // Even an empty file gets a read bound to the observed committed mutation and current authorization. loop { let offset = bytes.len() as u32; let length = total.saturating_sub(offset).clamp(1, DRIVE_CHUNK_MAX_BYTES); let query = query_string(&DriveReadChunkQuery { entry_workspace_id: entry.entry.workspace_id.clone(), id: entry.entry.node_id.clone(), - expected_revision: entry.revision.clone(), + expected_mutation_id: entry.last_mutation_id.clone(), offset, length, })?; diff --git a/crates/worker/src/feature/builtin/drive/delegation.rs b/crates/worker/src/feature/builtin/drive/delegation.rs index 3c3fbaffa..2c32aa625 100644 --- a/crates/worker/src/feature/builtin/drive/delegation.rs +++ b/crates/worker/src/feature/builtin/drive/delegation.rs @@ -144,9 +144,8 @@ impl WorkspaceClient for InternalChildWorkspaceClient { fn current_prompt_projection( &self, - minimum_revision: Option, ) -> Result, WorkspaceClientError> { - self.inner.current_prompt_projection(minimum_revision) + self.inner.current_prompt_projection() } fn list_workspace_workers( @@ -181,7 +180,7 @@ mod tests { enum Call { Json(WorkspaceRequest, Option), Binary(WorkspaceBinaryRequest, Option), - Projection(Option), + Projection, Discovery(WorkspaceWorkerDiscoveryRequest), Remove(String, String, String), Server, @@ -250,9 +249,8 @@ mod tests { } fn current_prompt_projection( &self, - revision: Option, ) -> Result, WorkspaceClientError> { - self.calls.lock().unwrap().push(Call::Projection(revision)); + self.calls.lock().unwrap().push(Call::Projection); Err(WorkspaceClientError::Request("projection marker".into())) } fn list_workspace_workers( @@ -390,7 +388,7 @@ mod tests { expected_bytes ); assert!( - matches!(client.current_prompt_projection(Some(42)), Err(WorkspaceClientError::Request(message)) if message == "projection marker") + matches!(client.current_prompt_projection(), Err(WorkspaceClientError::Request(message)) if message == "projection marker") ); let discovery = WorkspaceWorkerDiscoveryRequest { cursor: Some("next".into()), @@ -422,7 +420,7 @@ mod tests { Call::Json(request, Some(timeout)), Call::Binary(bytes.clone(), None), Call::Binary(bytes, Some(timeout)), - Call::Projection(Some(42)), + Call::Projection, Call::Discovery(discovery), Call::Remove("runtime".into(), "worker".into(), "reason".into()), Call::Server, diff --git a/crates/worker/src/feature/builtin/drive/tests.rs b/crates/worker/src/feature/builtin/drive/tests.rs index 8107139c6..dffefc3ce 100644 --- a/crates/worker/src/feature/builtin/drive/tests.rs +++ b/crates/worker/src/feature/builtin/drive/tests.rs @@ -44,7 +44,7 @@ fn response(value: impl Serialize) -> WorkspaceResponse { body: serde_json::to_string(&value).unwrap(), } } -fn entry(id: &str, revision: &str) -> DriveEntry { +fn entry(id: &str, last_mutation_id: &str) -> DriveEntry { DriveEntry { entry: DriveEntryRef { workspace_id: "workspace".into(), @@ -56,7 +56,7 @@ fn entry(id: &str, revision: &str) -> DriveEntry { }), name: "note.md".into(), kind: DriveEntryKind::File, - revision: revision.into(), + last_mutation_id: last_mutation_id.into(), size: Some(5), content_type: Some("text/markdown".into()), updated_by: "worker".into(), @@ -83,7 +83,7 @@ async fn execute( } #[test] -fn drive_feature_activation_never_creates_authority_and_schemas_hide_revisions() { +fn drive_feature_activation_never_creates_authority_and_schemas_hide_last_mutation_ids() { let client = Client::new(Vec::new()); let router = Arc::new(WorkdirSessionRouter::new()); assert!(DriveFeature::configured(client.clone(), false, router.clone()).is_none()); @@ -100,7 +100,7 @@ fn drive_feature_activation_never_creates_authority_and_schemas_hide_revisions() let (meta, _) = definition(); assert_eq!(name, meta.name); let schema = meta.input_schema.to_string(); - assert!(!schema.contains("expected_revision"), "{name}"); + assert!(!schema.contains("expected_mutation_id"), "{name}"); assert!(!schema.contains("grant_id"), "{name}"); } assert_eq!(feature.descriptor().instructions.len(), 1); @@ -139,7 +139,7 @@ async fn foreign_or_unobserved_entry_cannot_dispatch_mutation() { } #[tokio::test] -async fn stale_edit_returns_conflict_without_upgrading_revision_or_dispatching_write() { +async fn stale_edit_returns_conflict_without_upgrading_last_mutation_id_or_dispatching_write() { let old = entry("2", "1"); let current = entry("2", "2"); let client = Client::new(vec![response(DriveReadTextResponse { @@ -157,7 +157,10 @@ async fn stale_edit_returns_conflict_without_upgrading_revision_or_dispatching_w .await; assert!(matches!(result, Err(DriveError::Conflict))); assert_eq!(client.requests.lock().unwrap().len(), 1); - assert_eq!(feature.observed(&old.entry, None).unwrap().revision, "1"); + assert_eq!( + feature.observed(&old.entry, None).unwrap().last_mutation_id, + "1" + ); } #[tokio::test] @@ -192,8 +195,8 @@ async fn shared_edit_rules_reject_ambiguous_and_truncated_preimages_without_muta } #[tokio::test] -async fn successful_mutation_uses_observed_decimal_revision_and_requires_next_explicit_observation() -{ +async fn successful_mutation_uses_observed_committed_request_id_and_requires_next_explicit_observation() + { let old = entry("9007199254740993", "9007199254740993"); let updated = entry("9007199254740993", "9007199254740994"); let client = Client::new(Vec::new()); @@ -220,7 +223,7 @@ async fn successful_mutation_uses_observed_decimal_revision_and_requires_next_ex .unwrap(); let requests = client.requests.lock().unwrap(); let body: Value = serde_json::from_str(requests[0].body.as_ref().unwrap()).unwrap(); - assert_eq!(body["mutation"]["expected_revision"], "9007199254740993"); + assert_eq!(body["mutation"]["expected_mutation_id"], "9007199254740993"); assert_eq!(body["mutation"]["id"]["node_id"], "9007199254740993"); drop(requests); assert!(feature.observed(&old.entry, None).is_err()); @@ -296,7 +299,7 @@ async fn uncommitted_receipt_snapshot_does_not_turn_unknown_into_failure_or_retr fn invalid_completions(id: &str) -> Vec { let mut invalid_entry = entry("2", "2"); - invalid_entry.revision = "not-a-revision".into(); + invalid_entry.last_mutation_id = "\ninvalid-request-id".into(); vec![ DriveMutationResponse { request_id: "another-request".into(), @@ -340,7 +343,7 @@ async fn invalid_mutation_completion_recovers_from_original_receipt_without_rese .await .unwrap(); assert_eq!(output.value["request_id"], id); - assert_eq!(output.value["entry"]["metadata"]["revision"], "2"); + assert_eq!(output.value["entry"]["metadata"]["last_mutation_id"], "2"); let requests = client.requests.lock().unwrap(); assert_eq!(requests.len(), 2); assert_eq!( @@ -354,7 +357,7 @@ async fn invalid_mutation_completion_recovers_from_original_receipt_without_rese assert!(requests[1].path.ends_with(&format!("/requests/{id}"))); let body: Value = serde_json::from_str(requests[0].body.as_ref().unwrap()).unwrap(); assert_eq!(body["request_id"], id); - assert_eq!(body["mutation"]["expected_revision"], "1"); + assert_eq!(body["mutation"]["expected_mutation_id"], "1"); } } @@ -499,7 +502,7 @@ async fn drive_native_entry_is_directly_resolvable_but_never_indexable_and_binds !operations .iter() .flat_map(|op| &op.parameters) - .any(|parameter| parameter.name == "expected_revision") + .any(|parameter| parameter.name == "expected_mutation_id") ); assert_eq!(client.requests.lock().unwrap().len(), 1); } @@ -556,7 +559,7 @@ fn chunk(bytes: Vec) -> crate::worker::WorkspaceBinaryResponse { } } #[tokio::test] -async fn multi_chunk_image_uses_one_revision_and_never_exposes_binary_in_text_content() { +async fn multi_chunk_image_uses_one_last_mutation_id_and_never_exposes_binary_in_text_content() { let mut metadata = entry("2", "9007199254740993"); let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec(); bytes.resize(DRIVE_CHUNK_MAX_BYTES as usize + 13, 42); @@ -578,7 +581,11 @@ async fn multi_chunk_image_uses_one_revision_and_never_exposes_binary_in_text_co let requests = client.requests.lock().unwrap(); assert_eq!(requests.len(), 2); for request in &*requests { - assert!(request.path.contains("expected_revision=9007199254740993")); + assert!( + request + .path + .contains("expected_mutation_id=9007199254740993") + ); assert_eq!(request.max_response_bytes, DRIVE_CHUNK_MAX_BYTES as usize); } assert!(requests[0].path.split('&').any(|pair| pair == "offset=0")); @@ -590,7 +597,7 @@ async fn multi_chunk_image_uses_one_revision_and_never_exposes_binary_in_text_co ); } #[tokio::test] -async fn expired_revision_or_partial_chunk_aborts_image_without_rebinding_or_retrying() { +async fn expired_last_mutation_id_or_partial_chunk_aborts_image_without_rebinding_or_retrying() { for next in [ crate::worker::WorkspaceBinaryResponse { status: 409, @@ -618,12 +625,12 @@ async fn expired_revision_or_partial_chunk_aborts_image_without_rebinding_or_ret assert!( requests .iter() - .all(|request| request.path.contains("expected_revision=3&")) + .all(|request| request.path.contains("expected_mutation_id=3&")) ); assert_eq!( client.json.requests.lock().unwrap().len(), 1, - "must not fetch a new revision" + "must not fetch a new last_mutation_id" ); } } @@ -642,7 +649,7 @@ async fn oversized_images_and_unknown_arguments_fail_before_binary_transfer() { execute( &feature, "DriveRead", - json!({"entry":metadata.entry,"expected_revision":"1"}) + json!({"entry":metadata.entry,"expected_mutation_id":"1"}) ) .await .is_err() diff --git a/crates/worker/src/feature/builtin/drive/wip.rs b/crates/worker/src/feature/builtin/drive/wip.rs index c422ea415..086f95e79 100644 --- a/crates/worker/src/feature/builtin/drive/wip.rs +++ b/crates/worker/src/feature/builtin/drive/wip.rs @@ -1,5 +1,5 @@ //! Native non-indexable Drive entries. Publication resolves just one ID through -//! current Backend authorization; the Client owns identity/revision validators. +//! current Backend authorization; the Client owns entry/committed-request validators. use super::{ DriveFeature, SPECS, Spec, backend::{DriveError, decimal}, @@ -101,7 +101,7 @@ impl WipSubtreeProvider for DriveProvider { }; let descriptor = descriptor(&specs, root); let reference = contextual_reference(INTERFACE, path); - let validator = entry.revision.as_bytes().to_vec(); + let validator = entry.last_mutation_id.as_bytes().to_vec(); let object = Object { name: if root { "drive".into() @@ -183,7 +183,7 @@ fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclarati } } fn descriptor(specs: &[Spec], _root: bool) -> InterfaceDescriptor { - InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation {summary:"Backend-authorized Workspace Drive operations".into(),details:Some("Entry IDs are Workspace-bound. The Host/Client manage revisions; never supply validators or retry conflicts/outcome-unknown with a new revision. Read-only grants reject mutations at Backend. List/search return paths for direct Inspect, never tree inventories. Image bytes attach to durable ToolOutput; URLs are not image viewing. Workdir source read and Drive destination write are independent.".into())}),types:Vec::new(),operations:specs.iter().map(|spec|OperationDeclaration {name:spec.operation.into(),documentation:Some(Documentation {summary:spec.description.into(),details:None}),parameters:match spec.operation { + InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation {summary:"Backend-authorized Workspace Drive operations".into(),details:Some("Entry IDs are Workspace-bound. The Host/Client retain the last committed request observed for an entry. Never supply validators or silently retry a conflict against a newer observation. On outcome-unknown, query the original request receipt. Read-only grants reject mutations at Backend. List/search return paths for direct Inspect, never tree inventories. Image bytes attach to durable ToolOutput; URLs are not image viewing. Workdir source read and Drive destination write are independent.".into())}),types:Vec::new(),operations:specs.iter().map(|spec|OperationDeclaration {name:spec.operation.into(),documentation:Some(Documentation {summary:spec.description.into(),details:None}),parameters:match spec.operation { "list"=>vec![parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)], "search"=>vec![parameter("query",true,TypeExpr::String),parameter("include_text",false,TypeExpr::Boolean),parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)], "read"=>vec![parameter("max_bytes",false,TypeExpr::Integer)], @@ -230,7 +230,7 @@ impl WipOperationHandler for DriveHandler { .any(|key| !declaration.parameters.iter().any(|p| p.name == *key)) { return Err(map_error(DriveError::Invalid( - "unrecognized arguments; revision metadata is Client-managed".into(), + "unrecognized arguments; committed-request observations are Client-managed".into(), ))); } let mut input = wip_to_json(&Value::Record(arguments.clone())) @@ -296,12 +296,14 @@ impl WipOperationHandler for DriveHandler { // Attachments use the existing ToolOutput side channel and durable Engine // capture, not transient request injection or URL-only success. let result = if matches!(operation, "write" | "edit" | "relocate") { - let revision = output.value["entry"]["metadata"]["revision"] + let last_mutation_id = output.value["entry"]["metadata"]["last_mutation_id"] .as_str() .ok_or_else(|| { - WipOperationError::OutcomeUnknown("Committed Drive revision unavailable".into()) + WipOperationError::OutcomeUnknown( + "Committed Drive request identity unavailable".into(), + ) })?; - WipOperationOutput::native_with_validator(value, revision.as_bytes().to_vec()) + WipOperationOutput::native_with_validator(value, last_mutation_id.as_bytes().to_vec()) } else { WipOperationOutput::native(value) }; diff --git a/crates/worker/src/feature/builtin/flow_transition.rs b/crates/worker/src/feature/builtin/flow_transition.rs index b96d1919d..2a716d3a0 100644 --- a/crates/worker/src/feature/builtin/flow_transition.rs +++ b/crates/worker/src/feature/builtin/flow_transition.rs @@ -557,7 +557,7 @@ where input, cache_key: Some(format!( "flow:{}:{}", - attempt.instance_id, attempt.checked_state_revision + attempt.instance_id, attempt.attempt_id )), max_turns: Some(12), engine_configurator: None, @@ -653,9 +653,7 @@ mod tests { FlowTransitionAttempt { attempt_id: id.to_string(), instance_id: "instance-1".to_string(), - definition_revision: 1, definition_digest: "sha256:test".to_string(), - checked_state_revision: 0, from_state: StateId::new("work").unwrap(), reason: "ready".to_string(), transitions: vec![ @@ -711,7 +709,6 @@ mod tests { selector: "workspace:test-flow".parse().unwrap(), workspace_id: "workspace-1".to_string(), flow_id: "flow-1".to_string(), - revision: 1, content_digest: definition.content_digest.clone(), definition, }, diff --git a/crates/worker/src/feature/builtin/manage_workdir/wip.rs b/crates/worker/src/feature/builtin/manage_workdir/wip.rs index 2f0bcb642..30cf06dc2 100644 --- a/crates/worker/src/feature/builtin/manage_workdir/wip.rs +++ b/crates/worker/src/feature/builtin/manage_workdir/wip.rs @@ -419,7 +419,7 @@ impl WipOperationHandler for CollectionHandler { let mut digest = Sha256::new(); digest.update(self.domain.route()); if matches!(self.domain, Domain::Workdir | Domain::Attachment) { - // The complete-set revision is computed by the existing Backend + // The complete-set digest is computed by the existing Backend // registry/ledger in the same snapshot as a bounded page. Never infer // collection freshness from just the first page's items. let path = if matches!(self.domain, Domain::Workdir) { @@ -431,13 +431,13 @@ impl WipOperationHandler for CollectionHandler { .provider .request(path) .ok() - .and_then(|p| p.get("revision").and_then(Json::as_str).map(str::to_owned)) + .and_then(|p| p.get("digest").and_then(Json::as_str).map(str::to_owned)) { - Some(revision) => { + Some(inventory_digest) => { digest.update([0]); - digest.update(revision); + digest.update(inventory_digest); } - None => digest.update(b"inventory-revision-unavailable"), + None => digest.update(b"inventory-digest-unavailable"), } return Some(digest.finalize().to_vec()); } @@ -533,9 +533,9 @@ impl WipOperationHandler for CollectionHandler { "invalid bounded inventory page".into(), ) })?; - if page.get("revision").and_then(Json::as_str).is_none() { + if page.get("digest").and_then(Json::as_str).is_none() { return Err(WipOperationError::OutcomeUnknown( - "inventory page revision missing".into(), + "inventory page digest missing".into(), )); } let mut items = Vec::new(); @@ -1162,7 +1162,7 @@ pub(crate) mod tests { let cursor = query.get("cursor").copied(); let mut all: Vec<_> = state.workdirs.iter().collect(); all.sort_by_key(|v| v["working_directory_id"].as_str().unwrap()); - let revision = Sha256::digest(serde_json::to_vec(&all).unwrap()) + let digest = Sha256::digest(serde_json::to_vec(&all).unwrap()) .iter() .map(|b| format!("{b:02x}")) .collect::(); @@ -1179,7 +1179,7 @@ pub(crate) mod tests { } else { None }; - json!({"items":items, "next_cursor":next, "revision":revision}) + json!({"items":items, "next_cursor":next, "digest":digest}) } (WorkspaceRequestMethod::Get, "workers/self/workdir-attachments") => { let query: BTreeMap<_, _> = path @@ -1191,12 +1191,12 @@ pub(crate) mod tests { .collect(); let mut all: Vec<_> = state.attachments.iter().collect(); all.sort_by_key(|v| v["alias"].as_str().unwrap()); - let revision = Sha256::digest(serde_json::to_vec(&all).unwrap()) + let digest = Sha256::digest(serde_json::to_vec(&all).unwrap()) .iter() .map(|b| format!("{b:02x}")) .collect::(); if let Some(expected) = query.get("connection_id") { - json!({"items":all.into_iter().filter(|a| a["connection_id"] == *expected).collect::>(), "next_offset":null, "revision":revision}) + json!({"items":all.into_iter().filter(|a| a["connection_id"] == *expected).collect::>(), "next_offset":null, "digest":digest}) } else { let limit = query .get("limit") @@ -1209,7 +1209,7 @@ pub(crate) mod tests { let count = all.len(); let items: Vec<_> = all.into_iter().skip(offset).take(limit).collect(); let next = (offset + limit < count).then_some(offset + limit); - json!({"items":items, "next_offset":next, "revision":revision}) + json!({"items":items, "next_offset":next, "digest":digest}) } } (WorkspaceRequestMethod::Get, p) if p.starts_with("repositories/") => { diff --git a/crates/worker/src/feature/builtin/memory.rs b/crates/worker/src/feature/builtin/memory.rs index 96fe698cd..febbe047b 100644 --- a/crates/worker/src/feature/builtin/memory.rs +++ b/crates/worker/src/feature/builtin/memory.rs @@ -5,7 +5,6 @@ //! workspace backend instead of resolving `.yoi/memory` from a Worker workdir. use crate::subjektiv::{SubjektivHostConnection, SubjektivHostError}; -use std::collections::HashMap; use std::sync::{Arc, Mutex}; #[cfg(test)] use std::time::Duration; @@ -335,21 +334,21 @@ fn subjektiv_consolidation_tools(host: SubjektivHostConnection) -> Vec(), Operation::Query, ), ( "SubjektivMemoryRead", - "Read one current or historical Memory revision for the delegated subject.", + "Read one current or historical Memory change for the delegated subject.", schema_for::(), Operation::Read, ), ( - "SubjektivMemoryListRevisions", - "List immutable revisions of one Memory for the delegated subject.", - schema_for::(), - Operation::ListRevisions, + "SubjektivMemoryListChanges", + "List immutable changes of one Memory for the delegated subject.", + schema_for::(), + Operation::ListChanges, ), ( "MemoryStagingList", @@ -359,13 +358,13 @@ fn subjektiv_consolidation_tools(host: SubjektivHostConnection) -> Vec(), Operation::ReadCandidate, ), ( "MemoryApplyCandidate", - "Atomically apply or reject one subject candidate. A request_id is idempotent; application revalidates exact revision proposal metadata and returns concrete affected revision refs.", + "Atomically apply or reject one subject candidate. A request_id is idempotent; application revalidates exact change proposal metadata and returns concrete affected change refs.", schema_for::(), Operation::DecideCandidate, ), @@ -392,7 +391,7 @@ fn subjektiv_consolidation_tools(host: SubjektivHostConnection) -> Vec { server_api::SubjektivMemoryBackendOperation::Read(parse_input(input_json)?) } - Operation::ListRevisions => { - server_api::SubjektivMemoryBackendOperation::ListRevisions(parse_input(input_json)?) + Operation::ListChanges => { + server_api::SubjektivMemoryBackendOperation::ListChanges(parse_input(input_json)?) } Operation::ListCandidates => { server_api::SubjektivMemoryBackendOperation::ListCandidates(parse_input( @@ -452,12 +451,12 @@ impl Tool for SubjectConsolidationTool { } server_api::SubjektivMemoryBackendResponse::Read(value) => { format!( - "Read Memory {} revision {}.", - value.memory_id, value.revision + "Read Memory {} change {}.", + value.memory_id, value.change_id ) } - server_api::SubjektivMemoryBackendResponse::ListRevisions(value) => { - format!("Listed {} revision(s).", value.items.len()) + server_api::SubjektivMemoryBackendResponse::ListChanges(value) => { + format!("Listed {} change(s).", value.items.len()) } server_api::SubjektivMemoryBackendResponse::Candidates(value) => { format!("Listed {} pending candidate(s).", value.items.len()) @@ -640,8 +639,7 @@ struct WorkspaceResidentSummarySource { #[derive(Default)] struct SubjectBehaviorRefreshState { - applied: Option<(u64, String)>, - pending: HashMap, + applied: Option, } struct WorkspaceSubjektivResidentSummarySource { @@ -727,8 +725,7 @@ impl WorkspaceSubjektivResidentSummarySource { .behavior_state .lock() .expect("subject behavior state poisoned"); - state.applied = Some((output.behavior_revision, output.behavior_md.clone())); - state.pending.remove(&output.behavior_revision); + state.applied = Some(output.behavior_md.clone()); } } @@ -769,33 +766,22 @@ impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource .expect("subject behavior state poisoned") .applied .as_ref() - .is_none_or(|(revision, body)| { - *revision != output.behavior_revision || body != &output.behavior_md - }); + .is_none_or(|body| body != &output.behavior_md); if !changed { return Ok(None); } let rendered = self.render(&output)?; - let revision = output.behavior_revision; - self.behavior_state - .lock() - .expect("subject behavior state poisoned") - .pending - .insert(revision, output.behavior_md); Ok(Some(ResidentContextRefresh { body: rendered, - revision, + behavior_md: output.behavior_md, })) } - fn confirm_resident_context_revision(&self, revision: u64) { - let mut state = self - .behavior_state + fn confirm_resident_context_behavior(&self, behavior_md: &str) { + self.behavior_state .lock() - .expect("subject behavior state poisoned"); - if let Some(body) = state.pending.remove(&revision) { - state.applied = Some((revision, body)); - } + .expect("subject behavior state poisoned") + .applied = Some(behavior_md.to_owned()); } fn invalidate_resident_context_representation(&self) { @@ -942,7 +928,7 @@ impl Hook for SubjektivBehaviorRefreshHook { "Subject behavior refresh requires durable session append authority", ) })?; - system_items.append_subject_behavior_refresh(refresh.body, refresh.revision); + system_items.append_subject_behavior_refresh(refresh.body, refresh.behavior_md); } Ok(HookPreRequestAction::Continue) } @@ -971,7 +957,7 @@ impl Hook for SubjektivResidentRestoreRefreshHook { body, session_store::PromptRenderProvenance { workspace_id: self.workspace_id.clone(), - config_revision: projection.config_revision, + source_digest: projection.source_digest.clone(), projection_digest: projection.catalog_digest.clone(), logical_name: WorkerPrompt::ResidentMemoryRestoreSection.key().to_string(), @@ -1270,7 +1256,7 @@ mod tests { availability: memory::backend::MemoryResidentSummaryAvailability, content: Option, scope_allowed: bool, - behavior: Mutex<(u64, String)>, + behavior: Mutex, paths: Mutex>, timeouts: Mutex>, } @@ -1284,7 +1270,7 @@ mod tests { availability, content: content.map(str::to_string), scope_allowed: true, - behavior: Mutex::new((3, "Be deliberate.".to_string())), + behavior: Mutex::new("Be deliberate.".to_string()), paths: Mutex::new(Vec::new()), timeouts: Mutex::new(Vec::new()), } @@ -1295,7 +1281,7 @@ mod tests { availability: memory::backend::MemoryResidentSummaryAvailability::Ready, content: Some("must not be injected".into()), scope_allowed: false, - behavior: Mutex::new((3, "must not be injected".to_string())), + behavior: Mutex::new("must not be injected".to_string()), paths: Mutex::new(Vec::new()), timeouts: Mutex::new(Vec::new()), } @@ -1333,14 +1319,13 @@ mod tests { body: "subject scope unavailable".into(), }); } - let (behavior_revision, behavior_md) = self.behavior.lock().unwrap().clone(); + let behavior_md = self.behavior.lock().unwrap().clone(); Ok(WorkspaceResponse { status: 200, body: serde_json::to_string( &server_api::SubjektivMemoryBackendResponse::ResidentContext( server_api::SubjektivResidentContextOutput { behavior_md, - behavior_revision, memory_surface: memory::backend::MemoryResidentSummaryOutput { availability: self.availability, content: self.content.clone(), @@ -1483,7 +1468,7 @@ mod tests { enabled .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "workspace".to_string(), - settings_revision: 1, + language: "English".to_string(), }) .unwrap(); @@ -1543,7 +1528,7 @@ mod tests { .subjektiv .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "workspace".into(), - settings_revision: 1, + language: "English".into(), }) .unwrap(); @@ -1586,7 +1571,7 @@ mod tests { config .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "workspace".to_string(), - settings_revision: 1, + language: "English".to_string(), }) .unwrap(); @@ -1643,7 +1628,7 @@ permission = "write" .subjektiv .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: workspace_id.to_string(), - settings_revision: 1, + language: "English".to_string(), }) .unwrap(); @@ -1712,7 +1697,7 @@ permission = "write" } #[tokio::test] - async fn subject_resident_source_emits_only_changed_behavior_revisions() { + async fn subject_resident_source_emits_only_changed_behavior() { let manifest = subject_manifest("workspace"); let client = Arc::new(SubjectResidentClient::new( memory::backend::MemoryResidentSummaryAvailability::Ungenerated, @@ -1732,15 +1717,15 @@ permission = "write" .is_none() ); - *client.behavior.lock().unwrap() = (4, "Ask when uncertain.".to_string()); + *client.behavior.lock().unwrap() = "Ask when uncertain.".to_string(); let refresh = source .load_changed_resident_context() .await .unwrap() .expect("changed behavior must produce one refresh"); - assert_eq!(refresh.revision, 4); + assert_eq!(refresh.behavior_md, "Ask when uncertain."); assert!(refresh.body.contains("Ask when uncertain.")); - source.confirm_resident_context_revision(refresh.revision); + source.confirm_resident_context_behavior(&refresh.behavior_md); assert!( source .load_changed_resident_context() @@ -1749,15 +1734,15 @@ permission = "write" .is_none() ); - *client.behavior.lock().unwrap() = (5, String::new()); + *client.behavior.lock().unwrap() = String::new(); let cleared = source .load_changed_resident_context() .await .unwrap() .expect("cleared behavior must produce one refresh"); - assert_eq!(cleared.revision, 5); + assert_eq!(cleared.behavior_md, ""); assert!(cleared.body.contains("No user-managed behavior is set")); - source.confirm_resident_context_revision(cleared.revision); + source.confirm_resident_context_behavior(&cleared.behavior_md); assert!( source .load_changed_resident_context() @@ -1772,7 +1757,7 @@ permission = "write" .await .unwrap() .expect("history rewrite must require a durable current representation"); - assert_eq!(replay_after_rewrite.revision, 5); + assert_eq!(replay_after_rewrite.behavior_md, ""); assert!( replay_after_rewrite .body @@ -1786,6 +1771,63 @@ permission = "write" ); } + #[tokio::test] + async fn resident_behavior_ack_tracks_exact_committed_content_not_latest_fetch() { + let manifest = subject_manifest("workspace"); + let client = Arc::new(SubjectResidentClient::new( + memory::backend::MemoryResidentSummaryAvailability::Ungenerated, + None, + )); + let source = backend_resident_source(&manifest, client.clone(), test_prompts()) + .unwrap() + .unwrap(); + source.load().await; + *client.behavior.lock().unwrap() = "first behavior".into(); + let first = source + .load_changed_resident_context() + .await + .unwrap() + .unwrap(); + let retry = source + .load_changed_resident_context() + .await + .unwrap() + .unwrap(); + assert_eq!(retry, first); // Fetch alone never acknowledges model representation. + *client.behavior.lock().unwrap() = "second behavior".into(); + let second = source + .load_changed_resident_context() + .await + .unwrap() + .unwrap(); + source.confirm_resident_context_behavior(&first.behavior_md); + assert_eq!( + source + .load_changed_resident_context() + .await + .unwrap() + .unwrap(), + second + ); + source.confirm_resident_context_behavior(&second.behavior_md); + assert!( + source + .load_changed_resident_context() + .await + .unwrap() + .is_none() + ); + source.invalidate_resident_context_representation(); + assert_eq!( + source + .load_changed_resident_context() + .await + .unwrap() + .unwrap(), + second + ); + } + #[tokio::test] async fn subject_resident_source_fails_closed_without_trusted_subject_scope() { let manifest = subject_manifest("workspace"); @@ -2001,14 +2043,14 @@ permission = "write" "MemoryApplyCandidate", "MemoryStagingList", "MemoryStagingRead", - "SubjektivMemoryListRevisions", + "SubjektivMemoryListChanges", "SubjektivMemoryQuery", "SubjektivMemoryRead", ] ); let decision = tool_meta(tools, "MemoryApplyCandidate").to_string(); assert!(decision.contains("request_id")); - assert!(decision.contains("expected_revision")); + assert!(decision.contains("expected_change_id")); assert!(decision.contains("already_covered")); assert!(!decision.contains("subject_id")); assert!(!decision.contains("workspace_id")); diff --git a/crates/worker/src/feature/builtin/memory_lifecycle.rs b/crates/worker/src/feature/builtin/memory_lifecycle.rs index 13d68eba9..8ef2e9e26 100644 --- a/crates/worker/src/feature/builtin/memory_lifecycle.rs +++ b/crates/worker/src/feature/builtin/memory_lifecycle.rs @@ -972,7 +972,7 @@ impl WorkerAuditBase { ) -> Self { self.memory_settings = settings.map(|snapshot| memory::audit::MemorySettingsAudit { workspace_id: snapshot.workspace_id.clone(), - settings_revision: snapshot.settings_revision, + language: snapshot.language.clone(), }); self @@ -1292,7 +1292,7 @@ permission = "write" config .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "workspace-1".to_string(), - settings_revision: 1, + language: "English".to_string(), }) .unwrap(); @@ -1365,7 +1365,7 @@ permission = "write" .start_run_committed(HookInvocationContext { worker_id: "worker-1".to_string(), session_id: "session-1".to_string(), - session_revision: 1, + run_id: Some("run-1".to_string()), ..Default::default() }) @@ -1456,7 +1456,7 @@ permission = "write" config .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "workspace-1".to_string(), - settings_revision: 1, + language: "English".to_string(), }) .unwrap(); @@ -2002,7 +2002,7 @@ permission = "write" CommittedSessionCapture { session_id: "session-1".to_string(), segment_id: "segment-1".to_string(), - session_revision: history_len.try_into().unwrap(), + entry_count: history_len, run_exit: CommittedRunExit::Finished, history: (0..history_len) diff --git a/crates/worker/src/feature/builtin/memory_staging_output.rs b/crates/worker/src/feature/builtin/memory_staging_output.rs index 3896063b0..ad5de09df 100644 --- a/crates/worker/src/feature/builtin/memory_staging_output.rs +++ b/crates/worker/src/feature/builtin/memory_staging_output.rs @@ -386,7 +386,7 @@ fn evidence_origin(origin: &protocol::SessionEntryProvenance) -> EvidenceOrigin worker_id: None, flow_selector: None, flow_definition_id: None, - flow_definition_revision: None, + flow_definition_fingerprint: None, } } diff --git a/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs b/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs index 6f012aedc..375caa9c3 100644 --- a/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs +++ b/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs @@ -161,18 +161,23 @@ impl SubjektivSurfaceLifecycleFeature { impl FeatureModule for SubjektivSurfaceLifecycleFeature { fn descriptor(&self) -> FeatureDescriptor { - FeatureDescriptor::builtin("subjektiv-memory-surface-lifecycle", "subjektiv Memory Surface Lifecycle") - .with_description("Rebuilds one bounded subject Memory surface after each committed consolidation run.") - .with_background_task(BackgroundTaskDeclaration::worker_managed( - TASK_NAME, - "Generate and publish a current subject Memory surface from bounded confirmed revisions.", - )) + FeatureDescriptor::builtin( + "subjektiv-memory-surface-lifecycle", + "subjektiv Memory Surface Lifecycle", + ) + .with_description( + "Rebuilds one bounded subject Memory surface after each committed consolidation run.", + ) + .with_background_task(BackgroundTaskDeclaration::worker_managed( + TASK_NAME, + "Generate and publish a current subject Memory surface from bounded confirmed changes.", + )) } fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { let declaration = BackgroundTaskDeclaration::worker_managed( TASK_NAME, - "Generate and publish a current subject Memory surface from bounded confirmed revisions.", + "Generate and publish a current subject Memory surface from bounded confirmed changes.", ); let mut spec = BackgroundTaskSpec::single_flight(declaration, TASK_TIMEOUT); spec.trigger = BackgroundTaskTrigger::RunCommitted; @@ -240,16 +245,17 @@ impl SubjektivSurfaceLifecycleTask { if let Some(snapshot_id) = &generation.current_snapshot_id { return Ok(serde_json::json!({ "availability": "ready", "generation_id": generation.generation_id, - "store_revision": generation.store_revision, "snapshot_id": snapshot_id, + "memory_fingerprint": generation.memory_fingerprint, "snapshot_id": snapshot_id, })); } } // Preparation is Backend-fenced to the live attempt and immutable batch. - // Only a ready snapshot is immutable within its store revision. Never + // Only a ready snapshot is immutable within its Memory fingerprint. Never // reuse a failed marker, which a later generation can replace. if let Some(surface) = reusable_surface.filter(|surface| { surface["availability"] == "ready" - && surface["store_revision"].as_u64() == Some(generation.store_revision) + && surface["memory_fingerprint"].as_str() + == Some(generation.memory_fingerprint.as_str()) }) { return Ok(surface.clone()); } @@ -307,7 +313,7 @@ impl SubjektivSurfaceLifecycleTask { Ok(output) => { return Ok(serde_json::json!({ "availability": "ready", "generation_id": generation.generation_id, - "store_revision": output.built_from_store_revision, "snapshot_id": output.snapshot_id, + "memory_fingerprint": output.built_from_memory_fingerprint, "snapshot_id": output.snapshot_id, })); } Err(SubjektivHostError::Conflict { .. }) @@ -418,23 +424,24 @@ impl SubjektivSurfaceLifecycleTask { .await .map_err(surface_hook_error)?; // Success must attest the actual persisted marker, not merely receipt of - // a failure notification. A protected ready surface or moved revision + // a failure notification. A protected ready surface or moved fingerprint // must yield a Backend rejection/non-failed status, never a failed claim. let confirmed_status = if job_confirmation_required { "failed_confirmed" } else { "failed" }; - if failure.status != confirmed_status || failure.store_revision != generation.store_revision + if failure.status != confirmed_status + || failure.memory_fingerprint != generation.memory_fingerprint { return Err(HookError::new( HookErrorCategory::Internal, - "Backend did not confirm a failed surface for this generation's revision; no Job result was submitted", + "Backend did not confirm a failed surface for this generation's fingerprint; no Job result was submitted", )); } Ok(serde_json::json!({ "availability": "failed", "generation_id": generation.generation_id, - "store_revision": failure.store_revision, "reason_code": reason_code, + "memory_fingerprint": failure.memory_fingerprint, "reason_code": reason_code, })) } } @@ -604,7 +611,7 @@ mod tests { ) -> server_api::SubjektivSurfaceMaterial { server_api::SubjektivSurfaceMaterial { memory_id: id.into(), - revision: 1, + change_id: "change-1".into(), kind, body_md, why_useful: "budget fixture".into(), @@ -619,7 +626,7 @@ mod tests { server_api::SubjektivSurfacePrepareResponse { generation_id: "generation-1".into(), current_snapshot_id: None, - store_revision: 1, + memory_fingerprint: "memory-set-1".into(), active_memory_count: materials.len(), materials, body_token_budget: 1_024, @@ -646,9 +653,9 @@ mod tests { job_results: Mutex>, result_responses: Mutex>>, - current_revision: AtomicUsize, + current_fingerprint: Mutex>, failure_status: Mutex, - failure_revision: AtomicUsize, + failure_fingerprint: Mutex>, ready_snapshot: Mutex>, } @@ -662,9 +669,9 @@ mod tests { failure_reasons: Mutex::new(Vec::new()), job_results: Mutex::new(Vec::new()), result_responses: Mutex::new(Default::default()), - current_revision: AtomicUsize::new(0), + current_fingerprint: Mutex::new(None), failure_status: Mutex::new("failed".into()), - failure_revision: AtomicUsize::new(0), + failure_fingerprint: Mutex::new(None), ready_snapshot: Mutex::new(None), } } @@ -676,13 +683,12 @@ mod tests { } } - fn revision(&self, fallback: usize) -> u64 { - let current = self.current_revision.load(Ordering::SeqCst); - if current == 0 { - fallback as u64 - } else { - current as u64 - } + fn fingerprint(&self, fallback: usize) -> String { + self.current_fingerprint + .lock() + .unwrap() + .clone() + .unwrap_or_else(|| format!("memory-set-{fallback}")) } fn response(value: server_api::SubjektivMemoryBackendResponse) -> WorkspaceResponse { @@ -741,7 +747,7 @@ mod tests { let mut prepared = generation(self.input_token_budget, vec![material("memory-1", 32)]); prepared.generation_id = format!("generation-{call}"); - prepared.store_revision = self.revision(call); + prepared.memory_fingerprint = self.fingerprint(call); prepared.current_snapshot_id = self.ready_snapshot.lock().unwrap().clone(); Ok(Self::response( server_api::SubjektivMemoryBackendResponse::SurfacePrepared(prepared), @@ -760,7 +766,7 @@ mod tests { server_api::SubjektivMemoryBackendResponse::SurfacePublished( server_api::SubjektivSurfacePublishResponse { snapshot_id: format!("surface-{call}"), - built_from_store_revision: self.revision(call), + built_from_memory_fingerprint: self.fingerprint(call), empty: false, }, ), @@ -771,12 +777,14 @@ mod tests { Ok(Self::response( server_api::SubjektivMemoryBackendResponse::SurfaceFailed( server_api::SubjektivSurfaceFailureResponse { - store_revision: if self.failure_revision.load(Ordering::SeqCst) > 0 - { - self.failure_revision.load(Ordering::SeqCst) as u64 - } else { - self.revision(self.prepare_calls.load(Ordering::SeqCst)) - }, + memory_fingerprint: self + .failure_fingerprint + .lock() + .unwrap() + .clone() + .unwrap_or_else(|| { + self.fingerprint(self.prepare_calls.load(Ordering::SeqCst)) + }), status: self.failure_status.lock().unwrap().clone(), }, ), @@ -814,7 +822,7 @@ mod tests { serde_json::json!({ "points": [{ "body_md": "- Keep the confirmed constraint.", - "memory_refs": [{"memory_id": "memory-1", "revision": 1}] + "memory_refs": [{"memory_id": "memory-1", "change_id": "change-1"}] }] }) .to_string(), @@ -863,7 +871,7 @@ permission = "write" .subjektiv .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "workspace-1".into(), - settings_revision: 1, + language: "English".into(), }) .unwrap(); @@ -899,7 +907,7 @@ permission = "write" .start_run_committed(HookInvocationContext { worker_id: "worker-1".into(), session_id: "session-1".into(), - session_revision: 1, + run_id: Some("run-1".into()), ..Default::default() }) @@ -1146,7 +1154,10 @@ permission = "write" results[0]["result"]["surface"]["generation_id"], "generation-1" ); - assert_eq!(results[0]["result"]["surface"]["store_revision"], 1); + assert_eq!( + results[0]["result"]["surface"]["memory_fingerprint"], + "memory-set-1" + ); assert_eq!(workspace.prepare_calls.load(Ordering::SeqCst), 1); assert!( tool.execute( @@ -1289,7 +1300,7 @@ permission = "write" async fn job_result_definitive_rejection_allows_correction_and_reuses_current_ready_surface() { for status in [400, 403, 422] { let workspace = Arc::new(SurfaceWorkspaceClient::new(0)); - workspace.current_revision.store(1, Ordering::SeqCst); + *workspace.current_fingerprint.lock().unwrap() = Some("memory-set-1".into()); workspace .result_responses .lock() @@ -1317,7 +1328,7 @@ permission = "write" assert_eq!( workspace.prepare_calls.load(Ordering::SeqCst), 2, - "reuse must verify current revision" + "reuse must verify current Memory fingerprint" ); assert_eq!( workspace.publish_calls.load(Ordering::SeqCst), @@ -1338,7 +1349,7 @@ permission = "write" async fn job_result_rejection_rebuilds_conflicting_or_stale_surfaces() { for status in [400, 409] { let workspace = Arc::new(SurfaceWorkspaceClient::new(0)); - workspace.current_revision.store(1, Ordering::SeqCst); + *workspace.current_fingerprint.lock().unwrap() = Some("memory-set-1".into()); workspace .result_responses .lock() @@ -1350,14 +1361,20 @@ permission = "write" .await .is_err() ); - workspace.current_revision.store(2, Ordering::SeqCst); + *workspace.current_fingerprint.lock().unwrap() = Some("memory-set-2".into()); tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct()) .await .unwrap(); let results = workspace.job_results.lock().unwrap(); assert_eq!(results.len(), 2); - assert_eq!(results[0]["result"]["surface"]["store_revision"], 1); - assert_eq!(results[1]["result"]["surface"]["store_revision"], 2); + assert_eq!( + results[0]["result"]["surface"]["memory_fingerprint"], + "memory-set-1" + ); + assert_eq!( + results[1]["result"]["surface"]["memory_fingerprint"], + "memory-set-2" + ); assert_ne!( results[0]["result"]["surface"]["generation_id"], results[1]["result"]["surface"]["generation_id"] @@ -1415,18 +1432,18 @@ permission = "write" } #[tokio::test] - async fn job_result_never_submits_unconfirmed_failure_or_wrong_revision() { - for (status, revision) in [ - ("failed", 0), - ("ready", 0), - ("stale", 0), - ("failed_confirmed", 2), + async fn job_result_never_submits_unconfirmed_failure_or_wrong_fingerprint() { + for (status, fingerprint) in [ + ("failed", "memory-set-1"), + ("ready", "memory-set-1"), + ("stale", "memory-set-1"), + ("failed_confirmed", "memory-set-2"), ] { let workspace = Arc::new(SurfaceWorkspaceClient::with_input_token_budget(1)); - workspace.current_revision.store(1, Ordering::SeqCst); + *workspace.current_fingerprint.lock().unwrap() = Some("memory-set-1".into()); let tool = job_result_tool(workspace.clone()); *workspace.failure_status.lock().unwrap() = status.into(); - workspace.failure_revision.store(revision, Ordering::SeqCst); + *workspace.failure_fingerprint.lock().unwrap() = Some(fingerprint.into()); assert!( tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct()) .await @@ -1435,7 +1452,7 @@ permission = "write" assert!(workspace.job_results.lock().unwrap().is_empty()); // Nothing was dispatched/sealed: a real confirmed outcome remains retryable. *workspace.failure_status.lock().unwrap() = "failed_confirmed".into(); - workspace.failure_revision.store(0, Ordering::SeqCst); + *workspace.failure_fingerprint.lock().unwrap() = None; tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct()) .await .unwrap(); diff --git a/crates/worker/src/feature/builtin/memory_surface_output.rs b/crates/worker/src/feature/builtin/memory_surface_output.rs index 2527d0cd5..02068810d 100644 --- a/crates/worker/src/feature/builtin/memory_surface_output.rs +++ b/crates/worker/src/feature/builtin/memory_surface_output.rs @@ -64,7 +64,7 @@ impl MemorySurfaceOutputState { let allowed = self .materials .iter() - .map(|material| (material.memory_id.as_str(), material.revision)) + .map(|material| (material.memory_id.as_str(), material.change_id.as_str())) .collect::>(); let mut bodies = Vec::with_capacity(points.len()); for point in points { @@ -81,16 +81,20 @@ impl MemorySurfaceOutputState { } let mut point_refs = HashSet::new(); for reference in &point.memory_refs { - if !point_refs.insert((reference.memory_id.as_str(), reference.revision)) { + if !point_refs.insert((reference.memory_id.as_str(), reference.change_id.as_str())) + { return Err(ToolError::InvalidArgument(format!( "duplicate surface point ref {}@{}", - reference.memory_id, reference.revision + reference.memory_id, + reference.change_id.as_str() ))); } - if !allowed.contains(&(reference.memory_id.as_str(), reference.revision)) { + if !allowed.contains(&(reference.memory_id.as_str(), reference.change_id.as_str())) + { return Err(ToolError::InvalidArgument(format!( "surface ref {}@{} is not in the generation materials", - reference.memory_id, reference.revision + reference.memory_id, + reference.change_id.as_str() ))); } } @@ -218,7 +222,7 @@ mod tests { fn material(id: &str) -> server_api::SubjektivSurfaceMaterial { server_api::SubjektivSurfaceMaterial { memory_id: id.into(), - revision: 1, + change_id: "change-1".into(), kind: CandidateKind::Constraint, body_md: "Keep the boundary.".into(), why_useful: "Avoid regression.".into(), @@ -229,9 +233,9 @@ mod tests { fn point(id: &str) -> server_api::SubjektivSurfacePoint { server_api::SubjektivSurfacePoint { body_md: "- Keep the boundary unless it changes.".into(), - memory_refs: vec![server_api::SubjektivMemoryRevisionRef { + memory_refs: vec![server_api::SubjektivMemoryChangeRef { memory_id: id.into(), - revision: 1, + change_id: "change-1".into(), }], } } diff --git a/crates/worker/src/feature/builtin/merge_request.rs b/crates/worker/src/feature/builtin/merge_request.rs index 79c3ca6ff..156d86c16 100644 --- a/crates/worker/src/feature/builtin/merge_request.rs +++ b/crates/worker/src/feature/builtin/merge_request.rs @@ -509,12 +509,11 @@ fn native_merge_request_tools( } #[derive(Default)] -struct MergeRequestRevisionState { - revisions: HashMap, - mutation_sequence: u64, +struct MergeRequestObservationState { + content_digests: HashMap, } -type MergeRequestRevisions = Arc>; +type MergeRequestObservations = Arc>; /// Mount only the Merge Request operations enabled for this Worker as a native /// collection plus route-bound item objects. The ordinary Tool definitions remain @@ -542,7 +541,7 @@ pub fn mount_workspace_http_merge_request_wip( .filter(|tool| tool.projection.surface == NativeMergeRequestSurface::Item) .cloned() .collect::>(); - let revisions = Arc::new(Mutex::new(MergeRequestRevisionState::default())); + let observations = Arc::new(Mutex::new(MergeRequestObservationState::default())); let collection_descriptor = merge_request_descriptor( &collection_tools, @@ -574,7 +573,7 @@ pub fn mount_workspace_http_merge_request_wip( tools: merge_request_operation_map(collection_tools), permissions: permissions.clone(), collection_route: collection_route.clone(), - revisions: Arc::clone(&revisions), + observations: Arc::clone(&observations), }), })?; @@ -593,7 +592,7 @@ pub fn mount_workspace_http_merge_request_wip( tools: merge_request_operation_map(item_tools), permissions, collection_route: collection_route.clone(), - revisions, + observations, }), })?; registry.replace_compatibility_tools(&collection_route, claimed_tools)?; @@ -613,7 +612,7 @@ struct MergeRequestCollectionWipHandler { tools: HashMap, permissions: Option, collection_route: String, - revisions: MergeRequestRevisions, + observations: MergeRequestObservations, } #[async_trait] @@ -643,7 +642,7 @@ impl WipOperationHandler for MergeRequestCollectionWipHandler { ) })? .to_string(); - record_merge_request_observation(&self.revisions, &reference, &response, true); + record_merge_request_observation(&self.observations, &reference, &response, true); if let Some(object) = response.as_object_mut() { object.insert( "path".into(), @@ -663,7 +662,7 @@ struct MergeRequestItemResolver { tools: HashMap, permissions: Option, collection_route: String, - revisions: MergeRequestRevisions, + observations: MergeRequestObservations, } impl WipDynamicItemResolver for MergeRequestItemResolver { @@ -671,14 +670,13 @@ impl WipDynamicItemResolver for MergeRequestItemResolver { if !is_merge_request_route_reference(item_reference) { return None; } - let revision = self - .revisions + let content_digest = self + .observations .lock() .unwrap_or_else(|error| error.into_inner()) - .revisions + .content_digests .get(item_reference) - .cloned() - .unwrap_or_else(|| "unobserved".into()); + .cloned(); let route = format!("{}/{}", self.collection_route, item_reference); Some(WipDynamicItem { object: Object { @@ -686,13 +684,15 @@ impl WipDynamicItemResolver for MergeRequestItemResolver { description: Some("Authoritative Merge Request bound to this object route".into()), interfaces: vec![crate::wip::root_reference(MERGE_REQUEST_ITEM_INTERFACE)], r#ref: Some(format!("merge-request:{item_reference}")), - validator: Some(merge_request_route_validator(&route, &revision)), + validator: content_digest + .as_deref() + .map(|digest| merge_request_route_validator(&route, digest)), }, handler: Arc::new(MergeRequestItemWipHandler { tools: self.tools.clone(), permissions: self.permissions.clone(), merge_request_id: item_reference.into(), - revisions: Arc::clone(&self.revisions), + observations: Arc::clone(&self.observations), }), }) } @@ -702,7 +702,7 @@ struct MergeRequestItemWipHandler { tools: HashMap, permissions: Option, merge_request_id: String, - revisions: MergeRequestRevisions, + observations: MergeRequestObservations, } #[async_trait] @@ -737,7 +737,7 @@ impl WipOperationHandler for MergeRequestItemWipHandler { tool.kind.mutating(), )?; record_merge_request_observation( - &self.revisions, + &self.observations, &self.merge_request_id, &response, tool.kind.mutating(), @@ -911,23 +911,22 @@ fn merge_request_reference(response: &Value) -> Option<&str> { } fn record_merge_request_observation( - revisions: &MergeRequestRevisions, + observations: &MergeRequestObservations, reference: &str, response: &Value, mutation: bool, ) { - let mut state = revisions.lock().unwrap_or_else(|error| error.into_inner()); - let revision = if mutation { - state.mutation_sequence = state.mutation_sequence.saturating_add(1); - format!( - "{}#mutation-{}", - merge_request_response_fingerprint(response), - state.mutation_sequence - ) + let mut state = observations + .lock() + .unwrap_or_else(|error| error.into_inner()); + if mutation { + state.content_digests.remove(reference); } else { - merge_request_response_fingerprint(response) - }; - state.revisions.insert(reference.to_string(), revision); + state.content_digests.insert( + reference.to_string(), + merge_request_response_fingerprint(response), + ); + } } fn merge_request_response_fingerprint(response: &Value) -> String { @@ -947,11 +946,11 @@ fn is_merge_request_route_reference(reference: &str) -> bool { .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~')) } -fn merge_request_route_validator(route: &str, revision: &str) -> Vec { +fn merge_request_route_validator(route: &str, content_digest: &str) -> Vec { let mut digest = Sha256::new(); digest.update(route.as_bytes()); digest.update([0]); - digest.update(revision.as_bytes()); + digest.update(content_digest.as_bytes()); digest.finalize().to_vec() } @@ -1428,7 +1427,7 @@ mod tests { tools: HashMap::new(), permissions: None, collection_route: "/merge-requests".into(), - revisions: Arc::new(Mutex::new(MergeRequestRevisionState::default())), + observations: Arc::new(Mutex::new(MergeRequestObservationState::default())), }; assert!( resolver @@ -1574,16 +1573,16 @@ mod tests { #[test] fn successful_observations_and_mutations_stale_only_affected_merge_requests() { - let revisions = Arc::new(Mutex::new(MergeRequestRevisionState::default())); + let observations = Arc::new(Mutex::new(MergeRequestObservationState::default())); let resolver = MergeRequestItemResolver { tools: HashMap::new(), permissions: None, collection_route: "/merge-requests".into(), - revisions: Arc::clone(&revisions), + observations: Arc::clone(&observations), }; let first = resolver.resolve("MR-1").unwrap().object.validator; record_merge_request_observation( - &revisions, + &observations, "MR-1", &json!({"merge_request_id": "MR-1", "updated_at": "rev-1"}), false, @@ -1592,7 +1591,7 @@ mod tests { assert_ne!(first, observed); let other = resolver.resolve("MR-2").unwrap().object.validator; record_merge_request_observation( - &revisions, + &observations, "MR-1", &json!({"event_id": "review-1"}), true, @@ -1601,6 +1600,35 @@ mod tests { assert_eq!(other, resolver.resolve("MR-2").unwrap().object.validator); } + #[test] + fn merge_request_observation_digest_survives_identical_reads_without_mutation_counters() { + let observations = Arc::new(Mutex::new(MergeRequestObservationState::default())); + let resolver = MergeRequestItemResolver { + tools: HashMap::new(), + permissions: None, + collection_route: "/merge-requests".into(), + observations: Arc::clone(&observations), + }; + let response = json!({"merge_request_id": "MR-1", "state": "open", "thread": []}); + assert!(resolver.resolve("MR-1").unwrap().object.validator.is_none()); + record_merge_request_observation(&observations, "MR-1", &response, false); + let observed = resolver.resolve("MR-1").unwrap().object.validator; + assert!(observed.is_some()); + record_merge_request_observation(&observations, "MR-1", &response, false); + assert_eq!(resolver.resolve("MR-1").unwrap().object.validator, observed); + for _ in 0..2 { + record_merge_request_observation( + &observations, + "MR-1", + &json!({"event_id": "same-review"}), + true, + ); + assert!(resolver.resolve("MR-1").unwrap().object.validator.is_none()); + } + record_merge_request_observation(&observations, "MR-1", &response, false); + assert_eq!(resolver.resolve("MR-1").unwrap().object.validator, observed); + } + #[test] fn native_errors_preserve_backend_rejections_and_unknown_post_dispatch_outcomes() { for (status, code) in [ @@ -1681,7 +1709,7 @@ mod tests { } #[test] - fn schemas_hide_revision_and_commit_authority() { + fn schemas_hide_content_digest_and_commit_authority() { let schemas = [ schemars::schema_for!(OpenMergeRequestInput), schemars::schema_for!(CompleteMergeRequestInput), @@ -1689,7 +1717,7 @@ mod tests { for s in schemas { let j = serde_json::to_string(&s).unwrap(); for banned in [ - "revision_id", + "content_digest_id", "attempt_id", "base_commit", "head_commit", diff --git a/crates/worker/src/feature/builtin/objective.rs b/crates/worker/src/feature/builtin/objective.rs index f416bb6a1..d1340558f 100644 --- a/crates/worker/src/feature/builtin/objective.rs +++ b/crates/worker/src/feature/builtin/objective.rs @@ -5,8 +5,10 @@ //! local `.yoi/objectives` paths, so model-visible Objective tools go through //! the scoped Workspace API. -use std::collections::{BTreeMap, BTreeSet, HashMap}; -use std::sync::{Arc, Mutex}; +use std::collections::BTreeMap; +use std::sync::Arc; +#[cfg(test)] +use std::sync::Mutex; use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; use async_trait::async_trait; @@ -536,14 +538,6 @@ const OBJECTIVE_TOOL_NAMES: [&str; 7] = [ "ObjectiveUnlinkTicket", ]; -#[derive(Default)] -struct ObjectiveRevisionState { - revisions: HashMap, - aliases_by_canonical: HashMap>, -} - -type ObjectiveRevisions = Arc>; - /// Contribute the Objective Feature's native collection and item projections to /// the Host-owned WIP registry. Ordinary Objective tools remain registered for /// Tool mode and are claimed only from the WIP compatibility projection. @@ -555,14 +549,13 @@ pub fn mount_workspace_http_objective_wip( ) -> Result<(), WipMountError> { let collection_route = namespace_route.root().to_string(); let backend = WorkspaceHttpObjectiveBackend::new(client); - let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default())); + let collection_descriptor = objective_collection_descriptor(); let collection_validator = descriptor_validator(&collection_descriptor); let collection_handler = Arc::new(ObjectiveCollectionWipHandler { backend: backend.clone(), permissions: permissions.clone(), collection_route: collection_route.clone(), - revisions: Arc::clone(&revisions), }); registry.mount(WipProjection { route: collection_route.clone(), @@ -600,7 +593,6 @@ pub fn mount_workspace_http_objective_wip( backend, permissions, collection_route: collection_route.clone(), - revisions, }), })?; registry.replace_compatibility_tools(&collection_route, OBJECTIVE_TOOL_NAMES)?; @@ -611,7 +603,6 @@ struct ObjectiveCollectionWipHandler { backend: WorkspaceHttpObjectiveBackend, permissions: Option, collection_route: String, - revisions: ObjectiveRevisions, } #[async_trait] @@ -695,7 +686,7 @@ impl WipOperationHandler for ObjectiveCollectionWipHandler { .create_value(&input) .await .map_err(map_backend_error)?; - record_revision(&self.revisions, None, &response); + if let Some(reference) = response .get("objective") .and_then(Json::as_str) @@ -723,7 +714,6 @@ struct ObjectiveItemResolver { backend: WorkspaceHttpObjectiveBackend, permissions: Option, collection_route: String, - revisions: ObjectiveRevisions, } impl WipDynamicItemResolver for ObjectiveItemResolver { @@ -731,14 +721,6 @@ impl WipDynamicItemResolver for ObjectiveItemResolver { if !is_objective_route_reference(item_reference) { return None; } - let revision = self - .revisions - .lock() - .unwrap_or_else(|error| error.into_inner()) - .revisions - .get(item_reference) - .cloned() - .unwrap_or_else(|| "unobserved".into()); let route = format!("{}/{}", self.collection_route, item_reference); Some(WipDynamicItem { object: Object { @@ -746,13 +728,14 @@ impl WipDynamicItemResolver for ObjectiveItemResolver { description: Some("Authoritative Objective bound to this object route".into()), interfaces: vec![crate::wip::root_reference(OBJECTIVE_ITEM_INTERFACE)], r#ref: Some(format!("objective:{item_reference}")), - validator: Some(route_validator(&route, &revision)), + // The Object publishes fixed operation signatures, not Objective content. + // Each operation reads/writes current Backend state under its authority. + validator: Some(route_validator(&route, "objective-item")), }, handler: Arc::new(ObjectiveItemWipHandler { backend: self.backend.clone(), permissions: self.permissions.clone(), objective_reference: item_reference.to_string(), - revisions: Arc::clone(&self.revisions), }), }) } @@ -762,7 +745,6 @@ struct ObjectiveItemWipHandler { backend: WorkspaceHttpObjectiveBackend, permissions: Option, objective_reference: String, - revisions: ObjectiveRevisions, } #[async_trait] @@ -832,7 +814,7 @@ impl WipOperationHandler for ObjectiveItemWipHandler { } _ => return Err(operation_not_found()), }; - record_revision(&self.revisions, Some(&self.objective_reference), &response); + Ok(WipOperationOutput::native( json_to_wip(&response).map_err(WipOperationError::OutcomeUnknown)?, )) @@ -1022,36 +1004,6 @@ fn string_list_argument( } } -fn record_revision(revisions: &ObjectiveRevisions, bound_reference: Option<&str>, response: &Json) { - let (Some(revision), Some(canonical)) = ( - response.get("revision").and_then(Json::as_str), - response.get("objective").and_then(Json::as_str), - ) else { - return; - }; - let mut state = revisions.lock().unwrap_or_else(|error| error.into_inner()); - state - .aliases_by_canonical - .entry(canonical.to_string()) - .or_default() - .insert(canonical.to_string()); - if let Some(reference) = bound_reference { - state - .aliases_by_canonical - .entry(canonical.to_string()) - .or_default() - .insert(reference.to_string()); - } - let aliases = state - .aliases_by_canonical - .get(canonical) - .cloned() - .unwrap_or_default(); - for alias in aliases { - state.revisions.insert(alias, revision.to_string()); - } -} - fn is_objective_route_reference(reference: &str) -> bool { is_canonical_resource_key(reference, "O-") || (reference.len() >= 8 @@ -1059,11 +1011,11 @@ fn is_objective_route_reference(reference: &str) -> bool { && reference.bytes().all(|byte| byte.is_ascii_alphanumeric())) } -fn route_validator(route: &str, revision: &str) -> Vec { +fn route_validator(route: &str, signature: &str) -> Vec { let mut digest = Sha256::new(); digest.update(route.as_bytes()); digest.update([0]); - digest.update(revision.as_bytes()); + digest.update(signature.as_bytes()); digest.finalize().to_vec() } @@ -1115,7 +1067,7 @@ fn objective_collection_descriptor() -> InterfaceDescriptor { parameter("state", false, TypeExpr::String), parameter("linked_tickets", false, list_of(TypeExpr::String)), ], - returns: json_return("Created Objective with revision and bounded context"), + returns: json_return("Created Objective with bounded context"), }, ], } @@ -1182,7 +1134,7 @@ fn operation( details: None, }), parameters, - returns: json_return("Authoritative Objective detail with revision and bounded context"), + returns: json_return("Authoritative Objective detail with bounded context"), } } @@ -1293,7 +1245,7 @@ fn parse_input Deserialize<'de>>(input: &str) -> Result crate::worker::WorkspaceResponse { + fn objective_response(title: &str) -> crate::worker::WorkspaceResponse { crate::worker::WorkspaceResponse { status: 200, body: json!({ "id": "00001OBJECTIVE", "resource_key": "O-3", - "title": "Objective", + "title": title, "body": "Body", "body_truncated": false, "state": "active", - "revision": revision, "created_at": null, "updated_at": null, "linked_ticket_summaries": [], @@ -1635,7 +1586,7 @@ mod tests { "body": "Body", "body_truncated": false, "state": "active", - "revision": "rev-1", + "created_at": null, "updated_at": null, "linked_ticket_summaries": [], @@ -1704,7 +1655,6 @@ mod tests { "body": "Body", "body_truncated": false, "state": "active", - "revision": format!("rev-{mutation}"), "created_at": null, "updated_at": null, "linked_ticket_summaries": [], @@ -1798,15 +1748,13 @@ mod tests { } #[test] - fn dynamic_objective_routes_accept_only_objective_references_and_refresh_revision() { - let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default())); + fn dynamic_objective_routes_accept_only_objective_references() { let resolver = ObjectiveItemResolver { backend: WorkspaceHttpObjectiveBackend::new(Arc::new( crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"), )), permissions: None, collection_route: "/objectives".into(), - revisions: Arc::clone(&revisions), }; let initial = resolver.resolve("O-3").unwrap(); assert_eq!(initial.object.name, "O-3"); @@ -1814,22 +1762,10 @@ mod tests { assert!(resolver.resolve("T-3").is_none()); assert!(resolver.resolve("O-3/other").is_none()); - record_revision( - &revisions, - Some("00001OBJECTIVE"), - &json!({"objective": "O-3", "revision": "rev-1"}), + assert_ne!( + initial.object.validator, + resolver.resolve("O-4").unwrap().object.validator ); - let canonical_rev_1 = resolver.resolve("O-3").unwrap().object.validator; - let internal_rev_1 = resolver.resolve("00001OBJECTIVE").unwrap().object.validator; - record_revision( - &revisions, - Some("O-3"), - &json!({"objective": "O-3", "revision": "rev-2"}), - ); - let canonical_rev_2 = resolver.resolve("O-3").unwrap().object.validator; - let internal_rev_2 = resolver.resolve("00001OBJECTIVE").unwrap().object.validator; - assert_ne!(canonical_rev_1, canonical_rev_2); - assert_ne!(internal_rev_1, internal_rev_2); } #[test] @@ -1948,7 +1884,6 @@ mod tests { rules: Vec::new(), }), objective_reference: "O-3".into(), - revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())), }; let result = denied .call( @@ -1972,7 +1907,6 @@ mod tests { backend, permissions: None, collection_route: "/objectives".into(), - revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())), }; let result = collection .call( @@ -2009,12 +1943,11 @@ mod tests { objective_response("rev-4"), ])); let backend = WorkspaceHttpObjectiveBackend::new(client.clone()); - let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default())); + let collection = ObjectiveCollectionWipHandler { backend: backend.clone(), permissions: None, collection_route: "/objectives".into(), - revisions: Arc::clone(&revisions), }; let context = || WipCallContext { execution: ToolExecutionContext::direct(), @@ -2035,7 +1968,6 @@ mod tests { backend, permissions: None, objective_reference: "O-3".into(), - revisions, }; assert!(item.call("read", &BTreeMap::new(), context()).await.is_ok()); assert!( @@ -2100,7 +2032,6 @@ mod tests { backend: WorkspaceHttpObjectiveBackend::new(client), permissions: None, objective_reference: "O-404".into(), - revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())), }; for (operation, arguments) in [ ("read", BTreeMap::new()), @@ -2171,7 +2102,6 @@ mod tests { )), permissions: None, collection_route: "/objectives".into(), - revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())), }; let output = match handler .call( @@ -2198,7 +2128,7 @@ mod tests { } #[tokio::test(flavor = "multi_thread")] - async fn native_edit_binds_target_from_route_and_publishes_new_revision() { + async fn native_edit_returns_updated_content_without_changing_object_signatures() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap()); let server = thread::spawn(move || { @@ -2217,7 +2147,7 @@ mod tests { "body": "Body", "body_truncated": false, "state": "active", - "revision": "rev-2", + "created_at": null, "updated_at": null, "linked_ticket_summaries": [], @@ -2233,7 +2163,7 @@ mod tests { ) .unwrap(); }); - let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default())); + let backend = WorkspaceHttpObjectiveBackend::new(Arc::new( crate::worker::TestWorkspaceHttpClient::new("workspace", base_url), )); @@ -2241,14 +2171,12 @@ mod tests { backend: backend.clone(), permissions: None, collection_route: "/objectives".into(), - revisions: Arc::clone(&revisions), }; let before = resolver.resolve("O-3").unwrap().object.validator; let handler = ObjectiveItemWipHandler { backend, permissions: None, objective_reference: "O-3".into(), - revisions, }; let output = match handler .call( @@ -2267,9 +2195,9 @@ mod tests { server.join().unwrap(); let output = crate::wip::wip_to_json(&output.value).unwrap(); assert_eq!(output["objective"], "O-3"); - assert_eq!(output["revision"], "rev-2"); + assert_eq!(output["title"], "Changed"); let after = resolver.resolve("O-3").unwrap().object.validator; - assert_ne!(before, after); + assert_eq!(before, after); } #[test] diff --git a/crates/worker/src/feature/builtin/resource_projection.rs b/crates/worker/src/feature/builtin/resource_projection.rs index c3733f4af..d7feb5043 100644 --- a/crates/worker/src/feature/builtin/resource_projection.rs +++ b/crates/worker/src/feature/builtin/resource_projection.rs @@ -35,7 +35,7 @@ struct ModelTicketQueryItem { #[derive(Debug, Serialize)] pub(super) struct ModelTicketDetail { ticket: String, - item_revision: String, + content_digest: String, title: String, body: String, state: String, @@ -82,7 +82,6 @@ pub(super) struct ModelObjectiveDetail { body: String, body_truncated: bool, state: String, - revision: String, created_at: Option, updated_at: Option, linked_tickets: Vec, @@ -284,7 +283,7 @@ pub(super) fn project_ticket_detail(value: Value) -> Result Result, - proposal: Option, + proposal: Option, } impl SubjektivMemoryFeature { @@ -102,12 +101,12 @@ impl FeatureModule for SubjektivMemoryFeature { .with_tool(ToolDeclaration::new(QUERY_TOOL, QUERY_DESCRIPTION)) .with_tool(ToolDeclaration::new(READ_TOOL, READ_DESCRIPTION)) .with_tool(ToolDeclaration::new( - LIST_REVISIONS_TOOL, - LIST_REVISIONS_DESCRIPTION, + LIST_CHANGES_TOOL, + LIST_CHANGES_DESCRIPTION, )) .with_tool(ToolDeclaration::new(REMEMBER_TOOL, REMEMBER_DESCRIPTION)) .with_tool(ToolDeclaration::new( - PROPOSE_REVISION_TOOL, + PROPOSE_CHANGE_TOOL, PROPOSE_DESCRIPTION, )) .with_hook(HookDeclaration::new( @@ -129,9 +128,9 @@ impl FeatureModule for SubjektivMemoryFeature { (QUERY_TOOL, QUERY_DESCRIPTION, ReadOperation::Query), (READ_TOOL, READ_DESCRIPTION, ReadOperation::Read), ( - LIST_REVISIONS_TOOL, - LIST_REVISIONS_DESCRIPTION, - ReadOperation::ListRevisions, + LIST_CHANGES_TOOL, + LIST_CHANGES_DESCRIPTION, + ReadOperation::ListChanges, ), ] { context.tools().register(ToolContribution::new( @@ -149,9 +148,9 @@ impl FeatureModule for SubjektivMemoryFeature { ), ))?; context.tools().register(ToolContribution::new( - PROPOSE_REVISION_TOOL, + PROPOSE_CHANGE_TOOL, explicit_tool_definition( - PROPOSE_REVISION_TOOL, + PROPOSE_CHANGE_TOOL, PROPOSE_DESCRIPTION, self.state.clone(), ExplicitOperation::Propose, @@ -184,7 +183,7 @@ impl FeatureModule for SubjektivMemoryFeature { enum ReadOperation { Query, Read, - ListRevisions, + ListChanges, } fn read_tool_definition( @@ -197,7 +196,7 @@ fn read_tool_definition( let schema = match operation { ReadOperation::Query => schema_for::(), ReadOperation::Read => schema_for::(), - ReadOperation::ListRevisions => schema_for::(), + ReadOperation::ListChanges => schema_for::(), }; let tool: Arc = Arc::new(SubjektivReadTool { state: state.clone(), @@ -231,10 +230,9 @@ impl Tool for SubjektivReadTool { ReadOperation::Read => { SubjektivMemoryBackendOperation::Read(parse(input_json, READ_TOOL)?) } - ReadOperation::ListRevisions => SubjektivMemoryBackendOperation::ListRevisions(parse( - input_json, - LIST_REVISIONS_TOOL, - )?), + ReadOperation::ListChanges => { + SubjektivMemoryBackendOperation::ListChanges(parse(input_json, LIST_CHANGES_TOOL)?) + } }; let response = match self.state.execute(operation) { Ok(response) => response, @@ -247,14 +245,14 @@ impl Tool for SubjektivReadTool { ), SubjektivMemoryBackendResponse::Read(value) => ( format!( - "Read Memory {} revision {}.", - value.memory_id, value.revision + "Read Memory {} change {}.", + value.memory_id, value.change_id ), serde_json::to_value(value), ), - SubjektivMemoryBackendResponse::ListRevisions(value) => ( + SubjektivMemoryBackendResponse::ListChanges(value) => ( format!( - "Listed {} revision(s) for Memory {}.", + "Listed {} change(s) for Memory {}.", value.items.len(), value.memory_id ), @@ -296,7 +294,7 @@ fn explicit_tool_definition( Arc::new(move || { let schema = match operation { ExplicitOperation::Remember => schema_for::(), - ExplicitOperation::Propose => schema_for::(), + ExplicitOperation::Propose => schema_for::(), }; let tool: Arc = Arc::new(SubjektivExplicitTool { state: state.clone(), @@ -325,11 +323,11 @@ struct RememberParams { #[derive(Debug, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] -struct ProposeRevisionParams { +struct ProposeChangeParams { memory_id: String, - #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] - expected_revision: u64, - intent: SubjektivMemoryRevisionIntent, + #[schemars(length(min = 1))] + expected_change_id: String, + intent: SubjektivMemoryChangeIntent, claim: String, why_useful: String, change_reason: String, @@ -371,10 +369,10 @@ impl Tool for SubjektivExplicitTool { ) } ExplicitOperation::Propose => { - let params: ProposeRevisionParams = parse(input_json, PROPOSE_REVISION_TOOL)?; - if params.expected_revision == 0 { + let params: ProposeChangeParams = parse(input_json, PROPOSE_CHANGE_TOOL)?; + if params.expected_change_id.trim().is_empty() { return Err(ToolError::InvalidArgument( - "expected_revision must be a positive integer".into(), + "expected_change_id must not be empty".into(), )); } let validation = @@ -383,7 +381,7 @@ impl Tool for SubjektivExplicitTool { .execute(SubjektivMemoryBackendOperation::ValidateProposal( SubjektivMemoryValidateProposalRequest { memory_id: params.memory_id.clone(), - expected_revision: params.expected_revision, + expected_change_id: params.expected_change_id.clone(), intent: params.intent, }, )) { @@ -398,9 +396,9 @@ impl Tool for SubjektivExplicitTool { ))); } }; - let proposal = SubjektivMemoryRevisionProposal { + let proposal = SubjektivMemoryChangeProposal { memory_id: params.memory_id, - expected_revision: params.expected_revision, + expected_change_id: params.expected_change_id.clone(), intent: params.intent, change_reason: params.change_reason, }; @@ -447,7 +445,7 @@ impl Tool for SubjektivExplicitTool { "status": "pending_commit", "target": proposal.as_ref().map(|value| serde_json::json!({ "memory_id": value.memory_id, - "expected_revision": value.expected_revision, + "expected_change_id": value.expected_change_id, })), "intent": proposal.as_ref().map(|value| value.intent), "message": "Candidate is not confirmed Memory. It will be staged only after this tool call is durably committed; an uncommitted/cancelled operation is discarded.", @@ -514,7 +512,7 @@ impl SubjektivMemoryState { let session_id = capture.session_id.clone(); let view = SessionCapture::from_history_entries(capture.segment_id, capture.history); let committed = view - .committed_pending_tool_calls(&[REMEMBER_TOOL, PROPOSE_REVISION_TOOL]) + .committed_pending_tool_calls(&[REMEMBER_TOOL, PROPOSE_CHANGE_TOOL]) .into_iter() .any(|call| { receipt_id(&session_id, &call.call_id) @@ -537,7 +535,7 @@ impl SubjektivMemoryState { why_useful: String, staleness: Option, entries: Vec, - proposal: Option, + proposal: Option, ) -> Result { if entries.is_empty() { return Err(ToolError::ExecutionFailed( @@ -620,7 +618,7 @@ fn replay_pending_committed_memory_with_policy( let view = SessionCapture::from_history_entries(capture.segment_id.clone(), capture.history.clone()); let mut failures = Vec::new(); - for call in view.committed_pending_tool_calls(&[REMEMBER_TOOL, PROPOSE_REVISION_TOOL]) { + for call in view.committed_pending_tool_calls(&[REMEMBER_TOOL, PROPOSE_CHANGE_TOOL]) { let receipt_id = match receipt_id(&capture.session_id, &call.call_id) { Ok(value) => value, Err(error) => { @@ -693,12 +691,12 @@ fn pending_from_committed_call( None, ) } - PROPOSE_REVISION_TOOL => { - let params: ProposeRevisionParams = parse(arguments, PROPOSE_REVISION_TOOL)?; + PROPOSE_CHANGE_TOOL => { + let params: ProposeChangeParams = parse(arguments, PROPOSE_CHANGE_TOOL)?; let response = state.execute(SubjektivMemoryBackendOperation::ValidateProposal( SubjektivMemoryValidateProposalRequest { memory_id: params.memory_id.clone(), - expected_revision: params.expected_revision, + expected_change_id: params.expected_change_id.clone(), intent: params.intent, }, ))?; @@ -716,9 +714,9 @@ fn pending_from_committed_call( params.why_useful, params.staleness, params.entry_refs, - Some(SubjektivMemoryRevisionProposal { + Some(SubjektivMemoryChangeProposal { memory_id: params.memory_id, - expected_revision: params.expected_revision, + expected_change_id: params.expected_change_id.clone(), intent: params.intent, change_reason: params.change_reason, }), @@ -1067,7 +1065,7 @@ mod tests { Ok(CommittedSessionCapture { session_id: "session-1".into(), segment_id: "segment-1".into(), - session_revision: 1, + entry_count: 0, run_exit: CommittedRunExit::Finished, history: Vec::new(), @@ -1096,9 +1094,9 @@ mod tests { vec![ QUERY_TOOL, READ_TOOL, - LIST_REVISIONS_TOOL, + LIST_CHANGES_TOOL, REMEMBER_TOOL, - PROPOSE_REVISION_TOOL, + PROPOSE_CHANGE_TOOL, ] ); assert_eq!(descriptor.hooks.len(), 3); @@ -1128,11 +1126,11 @@ mod tests { .any(|field| field == "entry_refs") ); - let propose = schema_for::(); + let propose = schema_for::(); let propose_text = serde_json::to_string(&propose).unwrap(); assert!(!propose_text.contains("subject_id")); assert!(!propose_text.contains("\"kind\"")); - assert!(propose_text.contains("expected_revision")); + assert!(propose_text.contains("expected_change_id")); assert!(propose_text.contains("change_reason")); let parsed: RememberParams = serde_json::from_str( @@ -1152,7 +1150,7 @@ mod tests { worker_id: Some("w".repeat(64)), flow_selector: Some("f".repeat(64)), flow_definition_id: Some("d".repeat(64)), - flow_definition_revision: Some(1), + flow_definition_fingerprint: Some("fingerprint".into()), }; let evidence = (0..MAX_EVIDENCE_REFS) .map(|index| memory::extract::StagingEvidence { @@ -1178,8 +1176,8 @@ mod tests { .collect::>(); let response = server_api::SubjektivMemoryReadResponse { memory_id: "memory-1".into(), - revision: 1, - current_revision: 1, + change_id: "change-1".into(), + current_change_id: "change-1".into(), kind: CandidateKind::Lesson, state: server_api::SubjektivMemoryState::Active, claim: "Escape-aware read".into(), @@ -1252,7 +1250,7 @@ mod tests { #[tokio::test] async fn backend_conflict_discriminants_survive_as_structured_tool_output() { - for code in ["revision_conflict", "stale_cursor"] { + for code in ["change_conflict", "stale_cursor"] { let tool = SubjektivReadTool { state: SubjektivMemoryState { host: crate::subjektiv::test_connection(Arc::new(ConflictWorkspaceClient( @@ -1291,7 +1289,7 @@ mod tests { operation: ExplicitOperation::Remember, }; let input = - r#"{"kind":"lesson","claim":"Use fixed revisions","why_useful":"Avoid mixed reads"}"#; + r#"{"kind":"lesson","claim":"Use fixed changes","why_useful":"Avoid mixed reads"}"#; let output = tool .execute(input, ToolExecutionContext::new("call-1", "batch-1", 0)) .await @@ -1321,7 +1319,7 @@ mod tests { Ok(CommittedSessionCapture { session_id: "session-1".into(), segment_id: "segment-1".into(), - session_revision: 1, + entry_count: 3, run_exit: CommittedRunExit::Interrupted, history: vec![ @@ -1380,7 +1378,7 @@ mod tests { Ok(CommittedSessionCapture { session_id: "session-1".into(), segment_id: "segment-1".into(), - session_revision: 2, + entry_count: 2, run_exit: CommittedRunExit::Finished, history: vec![ @@ -1457,7 +1455,7 @@ mod tests { Ok(CommittedSessionCapture { session_id: "session-1".into(), segment_id: "segment-1".into(), - session_revision: 2, + entry_count: 2, run_exit: CommittedRunExit::Finished, history: vec![ diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index 281628f70..706580a51 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -63,7 +63,7 @@ impl WorkspaceTicketReadKind { "Query authoritative Workspace Tickets with bounded typed filters, stable snippets, evidence summaries, and cursor metadata." } Self::Show => { - "Show one authoritative Workspace Ticket with its item revision, paged thread, links, historical implementation reports, and current Merge Request readiness evidence." + "Show one authoritative Workspace Ticket with its content digest, paged thread, links, historical implementation reports, and current Merge Request readiness evidence." } } } @@ -292,8 +292,8 @@ struct WorkspaceCompleteTicketInput { ticket: String, /// Stable key for exact replay of this completion decision. operation_key: String, - /// Current item revision from ShowTicket. - expected_item_revision: String, + /// SHA-256 of the current editable Ticket content from ShowTicket. + expected_content_digest: String, expected_state: TicketWorkflowState, /// Required explanation of the completion decision; MR approval is not a prerequisite. reason: String, @@ -308,8 +308,8 @@ struct WorkspaceTicketStateUpdateInput { /// Ticket reference. Prefer `T-*`. ticket: String, operation_key: String, - /// Current item revision from ShowTicket. - expected_item_revision: String, + /// SHA-256 of the current editable Ticket content from ShowTicket. + expected_content_digest: String, expected_state: TicketWorkflowState, /// Progress-display state only: this does not start Workers or integrate Merge Requests. state: TicketWorkflowState, @@ -326,8 +326,8 @@ struct WorkspaceTicketCloseInput { ticket: String, /// Stable key for exact replay of this close decision. operation_key: String, - /// Current item revision from ShowTicket. - expected_item_revision: String, + /// SHA-256 of the current editable Ticket content from ShowTicket. + expected_content_digest: String, expected_state: TicketWorkflowState, /// Required explanation for closing this Ticket. resolution: String, @@ -381,13 +381,13 @@ struct WorkspaceTicketDecisionTool { fn validate_ticket_decision( ticket: &str, operation_key: &str, - revision: &str, + content_digest: &str, reason: &str, ) -> Result<(), ToolError> { for (name, value) in [ ("ticket", ticket), ("operation_key", operation_key), - ("expected_item_revision", revision), + ("expected_content_digest", content_digest), ("reason", reason), ] { if value.trim().is_empty() { @@ -412,7 +412,7 @@ impl Tool for WorkspaceTicketDecisionTool { validate_ticket_decision( &value.ticket, &value.operation_key, - &value.expected_item_revision, + &value.expected_content_digest, &value.reason, )?; let mut body = serde_json::to_value(&value) @@ -428,12 +428,12 @@ impl Tool for WorkspaceTicketDecisionTool { validate_ticket_decision( &value.ticket, &value.operation_key, - &value.expected_item_revision, + &value.expected_content_digest, &value.resolution, )?; let body = json!({ "operation_key": value.operation_key, - "expected_item_revision": value.expected_item_revision, + "expected_content_digest": value.expected_content_digest, "expected_state": value.expected_state, "state": TicketWorkflowState::Closed, "reason": value.resolution, @@ -447,7 +447,7 @@ impl Tool for WorkspaceTicketDecisionTool { validate_ticket_decision( &value.ticket, &value.operation_key, - &value.expected_item_revision, + &value.expected_content_digest, &value.reason, )?; let mut body = serde_json::to_value(&value) @@ -1005,7 +1005,7 @@ impl WorkspaceHttpTicketBackend { )?; serde_json::to_value(ticket::TicketStateUpdate { operation_key: uuid::Uuid::now_v7().to_string(), - expected_item_revision: ticket::ticket_item_revision(&snapshot), + expected_content_digest: ticket::ticket_content_digest(&snapshot), expected_state, state, reason: reason.into(), @@ -1205,7 +1205,7 @@ impl WorkspaceHttpTicketBackend { )?; let body = serde_json::to_value(ticket::TicketCompletion { operation_key: uuid::Uuid::now_v7().to_string(), - expected_item_revision: ticket::ticket_item_revision(&snapshot), + expected_content_digest: ticket::ticket_content_digest(&snapshot), expected_state: snapshot.meta.workflow_state, reason: resolution.as_str().to_owned(), references: Vec::new(), @@ -1763,13 +1763,12 @@ fn native_ticket_tools( } #[derive(Default)] -struct TicketRevisionState { - revisions: HashMap, +struct TicketObservationState { + content_digests: HashMap, aliases_by_canonical: HashMap>, - mutation_sequence: u64, } -type TicketRevisions = Arc>; +type TicketObservations = Arc>; /// Mount the enabled Ticket Feature surface as native collection and route-bound /// item objects. Only tools already enabled for this Worker are projected and @@ -1797,7 +1796,7 @@ pub fn mount_workspace_http_ticket_wip( .filter(|tool| tool.projection.surface == NativeTicketSurface::Item) .cloned() .collect::>(); - let revisions = Arc::new(Mutex::new(TicketRevisionState::default())); + let observations = Arc::new(Mutex::new(TicketObservationState::default())); let collection_descriptor = ticket_descriptor( &collection_tools, @@ -1824,7 +1823,7 @@ pub fn mount_workspace_http_ticket_wip( tools: operation_map(collection_tools), permissions: permissions.clone(), collection_route: collection_route.clone(), - revisions: Arc::clone(&revisions), + observations: Arc::clone(&observations), }), })?; @@ -1843,7 +1842,7 @@ pub fn mount_workspace_http_ticket_wip( tools: operation_map(item_tools), permissions, collection_route: collection_route.clone(), - revisions, + observations, }), })?; registry.replace_compatibility_tools(&collection_route, claimed_tools)?; @@ -1861,7 +1860,7 @@ struct TicketCollectionWipHandler { tools: HashMap, permissions: Option, collection_route: String, - revisions: TicketRevisions, + observations: TicketObservations, } #[async_trait] @@ -1879,7 +1878,7 @@ impl WipOperationHandler for TicketCollectionWipHandler { match operation { "query" => add_ticket_paths(&mut response, &self.collection_route), "create" => { - record_ticket_observation(&self.revisions, None, &response, true); + record_ticket_observation(&self.observations, None, &response, true); if let Some(reference) = ticket_reference(&response).map(ToOwned::to_owned) { response.as_object_mut().map(|object| { object.insert( @@ -1901,7 +1900,7 @@ struct TicketItemResolver { tools: HashMap, permissions: Option, collection_route: String, - revisions: TicketRevisions, + observations: TicketObservations, } impl WipDynamicItemResolver for TicketItemResolver { @@ -1909,14 +1908,13 @@ impl WipDynamicItemResolver for TicketItemResolver { if !is_ticket_route_reference(item_reference) { return None; } - let revision = self - .revisions + let content_digest = self + .observations .lock() .unwrap_or_else(|error| error.into_inner()) - .revisions + .content_digests .get(item_reference) - .cloned() - .unwrap_or_else(|| "unobserved".into()); + .cloned(); let route = format!("{}/{}", self.collection_route, item_reference); Some(WipDynamicItem { object: Object { @@ -1924,13 +1922,15 @@ impl WipDynamicItemResolver for TicketItemResolver { description: Some("Authoritative Ticket bound to this object route".into()), interfaces: vec![crate::wip::root_reference(TICKET_ITEM_INTERFACE)], r#ref: Some(format!("ticket:{item_reference}")), - validator: Some(route_validator(&route, &revision)), + validator: content_digest + .as_deref() + .map(|digest| route_validator(&route, digest)), }, handler: Arc::new(TicketItemWipHandler { tools: self.tools.clone(), permissions: self.permissions.clone(), ticket_reference: item_reference.into(), - revisions: Arc::clone(&self.revisions), + observations: Arc::clone(&self.observations), }), }) } @@ -1940,7 +1940,7 @@ struct TicketItemWipHandler { tools: HashMap, permissions: Option, ticket_reference: String, - revisions: TicketRevisions, + observations: TicketObservations, } #[async_trait] @@ -1960,13 +1960,13 @@ impl WipOperationHandler for TicketItemWipHandler { let output = execute_native_ticket_tool(tool, &self.permissions, input, context).await?; let response = ticket_tool_output_json(output)?; record_ticket_observation( - &self.revisions, + &self.observations, Some(&self.ticket_reference), &response, tool.projection.mutating, ); if tool.projection.mutating { - record_affected_ticket_mutations(&self.revisions, &self.ticket_reference, &response); + record_affected_ticket_mutations(&self.observations, &self.ticket_reference, &response); } Ok(WipOperationOutput::native( json_to_wip(&response).map_err(WipOperationError::OutcomeUnknown)?, @@ -2122,23 +2122,16 @@ fn ticket_reference(response: &Value) -> Option<&str> { .filter(|reference| is_canonical_ticket_resource_key(reference)) } -fn response_revision(response: &Value) -> Option<&str> { - response - .get("item_revision") - .or_else(|| response.get("revision")) - .or_else(|| response.get("updated_at")) - .or_else(|| response.get("meta").and_then(|meta| meta.get("updated_at"))) - .and_then(Value::as_str) -} - fn record_ticket_observation( - revisions: &TicketRevisions, + observations: &TicketObservations, bound_reference: Option<&str>, response: &Value, mutation: bool, ) { let observed_canonical = ticket_reference(response).map(ToOwned::to_owned); - let mut state = revisions.lock().unwrap_or_else(|error| error.into_inner()); + let mut state = observations + .lock() + .unwrap_or_else(|error| error.into_inner()); let existing_bound_group = bound_reference.and_then(|reference| { state .aliases_by_canonical @@ -2180,29 +2173,22 @@ fn record_ticket_observation( aliases.insert(reference.to_string()); } - let revision = if mutation { - state.mutation_sequence = state.mutation_sequence.saturating_add(1); - format!( - "{}#mutation-{}", - response_revision(response).unwrap_or("observed"), - state.mutation_sequence - ) - } else if let Some(revision) = response_revision(response) { - revision.to_string() - } else { - aliases - .iter() - .find_map(|alias| state.revisions.get(alias).cloned()) - .unwrap_or_else(|| "observed".into()) - }; + // Mutation receipts are not full representations. Forget the observed + // content rather than manufacturing a local change counter. for alias in &aliases { - state.revisions.insert(alias.clone(), revision.clone()); + if mutation { + state.content_digests.remove(alias); + } else { + let bytes = Sha256::digest(response.to_string().as_bytes()); + let digest = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + state.content_digests.insert(alias.clone(), digest); + } } state.aliases_by_canonical.insert(group_key, aliases); } fn record_affected_ticket_mutations( - revisions: &TicketRevisions, + observations: &TicketObservations, bound_reference: &str, response: &Value, ) { @@ -2228,7 +2214,7 @@ fn record_affected_ticket_mutations( ); } for reference in affected.difference(&subject_references) { - record_ticket_observation(revisions, Some(reference), &Value::Null, true); + record_ticket_observation(observations, Some(reference), &Value::Null, true); } } @@ -2239,11 +2225,11 @@ fn is_ticket_route_reference(reference: &str) -> bool { && reference.bytes().all(|byte| byte.is_ascii_alphanumeric())) } -fn route_validator(route: &str, revision: &str) -> Vec { +fn route_validator(route: &str, content_digest: &str) -> Vec { let mut digest = Sha256::new(); digest.update(route.as_bytes()); digest.update([0]); - digest.update(revision.as_bytes()); + digest.update(content_digest.as_bytes()); digest.finalize().to_vec() } @@ -2427,7 +2413,7 @@ mod tests { fn decision_input() -> Value { json!({ "ticket": "T-718", "operation_key": "decision-1", - "expected_item_revision": "revision-1", "expected_state": "planning", + "expected_content_digest": "content_digest-1", "expected_state": "planning", "reason": "No repository changes are needed" }) } @@ -2482,8 +2468,8 @@ mod tests { let request: ticket::TicketStateUpdate = serde_json::from_value(body.clone()).unwrap(); assert!(uuid::Uuid::parse_str(&request.operation_key).is_ok()); assert_eq!( - request.expected_item_revision, - ticket::ticket_item_revision(&snapshot) + request.expected_content_digest, + ticket::ticket_content_digest(&snapshot) ); assert_eq!(request.expected_state, TicketWorkflowState::Planning); assert_eq!(request.state, TicketWorkflowState::Closed); @@ -2592,8 +2578,8 @@ mod tests { assert!(uuid::Uuid::parse_str(&request.operation_key).is_ok()); assert!(keys.insert(request.operation_key)); assert_eq!( - request.expected_item_revision, - ticket::ticket_item_revision(&snapshot) + request.expected_content_digest, + ticket::ticket_content_digest(&snapshot) ); assert_eq!(request.expected_state, TicketWorkflowState::Ready); assert_eq!(request.reason, "Resolved without repository changes"); @@ -2643,7 +2629,7 @@ mod tests { ); for field in [ "operation_key", - "expected_item_revision", + "expected_content_digest", "expected_state", "resolution", ] { @@ -2660,13 +2646,19 @@ mod tests { .iter() .any(|p| p.name == "references" && !p.required) ); - let revisions = Arc::new(Mutex::new(TicketRevisionState::default())); + let observations = Arc::new(Mutex::new(TicketObservationState::default())); let resolver = TicketItemResolver { tools: operation_map(tools), permissions: None, collection_route: "/tickets".into(), - revisions, + observations, }; + record_ticket_observation( + &resolver.observations, + Some("T-718"), + &json!({"ticket": "T-718", "body": "observed requirements"}), + false, + ); let bound = resolver.resolve("T-718").unwrap(); let before = bound.object.validator; let other = resolver.resolve("T-719").unwrap().object.validator; @@ -2702,7 +2694,7 @@ mod tests { assert_eq!( body, json!({ - "operation_key": "decision-1", "expected_item_revision": "revision-1", + "operation_key": "decision-1", "expected_content_digest": "content_digest-1", "expected_state": "planning", "state": "closed", "reason": "No repository changes are needed", "references": [] }) @@ -2794,7 +2786,7 @@ mod tests { assert_eq!( body, json!({ - "operation_key": "decision-1", "expected_item_revision": "revision-1", + "operation_key": "decision-1", "expected_content_digest": "content_digest-1", "expected_state": "planning", "reason": "No repository changes are needed", "references": [] }) ); @@ -2859,7 +2851,7 @@ mod tests { assert!(validator.is_valid(&valid)); for field in [ "operation_key", - "expected_item_revision", + "expected_content_digest", "expected_state", reason_field, ] { @@ -2875,7 +2867,7 @@ mod tests { for field in [ reason_field, "operation_key", - "expected_item_revision", + "expected_content_digest", "ticket", ] { let mut invalid = valid.clone(); @@ -2968,7 +2960,7 @@ mod tests { #[tokio::test] async fn native_ticket_completion_binds_subject_and_stales_only_its_ticket_not_mrs() { let client = decision_client(200, TicketWorkflowState::Done); - let revisions = Arc::new(Mutex::new(TicketRevisionState::default())); + let observations = Arc::new(Mutex::new(TicketObservationState::default())); let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::work_report()).unwrap(); let descriptor = ticket_descriptor(&tools, "Ticket", "bound").unwrap(); @@ -2980,7 +2972,7 @@ mod tests { assert!(!complete.parameters.iter().any(|p| p.name == "ticket")); for field in [ "operation_key", - "expected_item_revision", + "expected_content_digest", "expected_state", "reason", ] { @@ -2995,8 +2987,14 @@ mod tests { tools: operation_map(tools), permissions: None, collection_route: "/tickets".into(), - revisions: revisions.clone(), + observations: observations.clone(), }; + record_ticket_observation( + &resolver.observations, + Some("T-718"), + &json!({"ticket": "T-718", "body": "observed requirements"}), + false, + ); let bound = resolver.resolve("T-718").unwrap(); let before = bound.object.validator; let other = resolver.resolve("T-719").unwrap().object.validator; @@ -3138,12 +3136,12 @@ mod tests { .is_err() ); - let revisions = Arc::new(Mutex::new(TicketRevisionState::default())); + let observations = Arc::new(Mutex::new(TicketObservationState::default())); let resolver = TicketItemResolver { tools: HashMap::new(), permissions: None, collection_route: "/tickets".into(), - revisions, + observations, }; assert!(resolver.resolve("T-42").is_some()); assert!(resolver.resolve("00001TICKET").is_some()); @@ -3202,36 +3200,36 @@ mod tests { #[test] fn ticket_alias_validators_refresh_together_after_mutation() { - let revisions = Arc::new(Mutex::new(TicketRevisionState::default())); + let observations = Arc::new(Mutex::new(TicketObservationState::default())); let resolver = TicketItemResolver { tools: HashMap::new(), permissions: None, collection_route: "/tickets".into(), - revisions: Arc::clone(&revisions), + observations: Arc::clone(&observations), }; let unobserved_internal = resolver.resolve("00001TICKET").unwrap().object.validator; record_ticket_observation( - &revisions, + &observations, Some("00001TICKET"), &json!({"ticket": "00001TICKET", "ok": true}), true, ); - assert_ne!( + assert_eq!( unobserved_internal, resolver.resolve("00001TICKET").unwrap().object.validator, - "a first mutation through an internal-id route must stale that route" + "an unobserved route stays unvalidated after mutation" ); record_ticket_observation( - &revisions, + &observations, Some("00001TICKET"), - &json!({"ticket": "T-42", "item_revision": "rev-1"}), + &json!({"ticket": "T-42", "content_digest": "rev-1"}), false, ); let canonical_v1 = resolver.resolve("T-42").unwrap().object.validator; let internal_v1 = resolver.resolve("00001TICKET").unwrap().object.validator; record_ticket_observation( - &revisions, + &observations, Some("T-42"), &json!({"ticket": "T-42", "updated_at": "rev-2"}), true, @@ -3246,14 +3244,43 @@ mod tests { ); } + #[test] + fn ticket_observation_digest_is_content_based_and_repeated_mutations_only_invalidate() { + let observations = Arc::new(Mutex::new(TicketObservationState::default())); + let resolver = TicketItemResolver { + tools: HashMap::new(), + permissions: None, + collection_route: "/tickets".into(), + observations: Arc::clone(&observations), + }; + let response = json!({"ticket": "T-42", "body": "actual content", "state": "planning"}); + assert!(resolver.resolve("T-42").unwrap().object.validator.is_none()); + record_ticket_observation(&observations, Some("T-42"), &response, false); + let observed = resolver.resolve("T-42").unwrap().object.validator; + assert!(observed.is_some()); + record_ticket_observation(&observations, Some("T-42"), &response, false); + assert_eq!(resolver.resolve("T-42").unwrap().object.validator, observed); + for _ in 0..2 { + record_ticket_observation( + &observations, + Some("T-42"), + &json!({"operation_key": "same-replay"}), + true, + ); + assert!(resolver.resolve("T-42").unwrap().object.validator.is_none()); + } + record_ticket_observation(&observations, Some("T-42"), &response, false); + assert_eq!(resolver.resolve("T-42").unwrap().object.validator, observed); + } + #[test] fn related_ticket_mutations_invalidate_target_and_queue_observations() { - let revisions = Arc::new(Mutex::new(TicketRevisionState::default())); + let observations = Arc::new(Mutex::new(TicketObservationState::default())); for ticket in ["T-42", "T-43", "T-44"] { record_ticket_observation( - &revisions, + &observations, Some(ticket), - &json!({"ticket": ticket, "item_revision": "rev-1"}), + &json!({"ticket": ticket, "content_digest": "rev-1"}), false, ); } @@ -3261,12 +3288,12 @@ mod tests { tools: HashMap::new(), permissions: None, collection_route: "/tickets".into(), - revisions: Arc::clone(&revisions), + observations: Arc::clone(&observations), }; let target_v1 = resolver.resolve("T-43").unwrap().object.validator; let queued_v1 = resolver.resolve("T-44").unwrap().object.validator; record_affected_ticket_mutations( - &revisions, + &observations, "T-42", &json!({ "ticket": "T-42", diff --git a/crates/worker/src/feature/builtin/workspace_config/content.rs b/crates/worker/src/feature/builtin/workspace_config/content.rs index 5dc564c04..ff1f0781e 100644 --- a/crates/worker/src/feature/builtin/workspace_config/content.rs +++ b/crates/worker/src/feature/builtin/workspace_config/content.rs @@ -99,7 +99,6 @@ fn same_snapshot( ) -> Result<(), WipOperationError> { if snapshot.connection_id != other.connection_id || snapshot.validator != other.validator - || snapshot.revision != other.revision || snapshot.digest != other.digest || snapshot.entrypoints != other.entrypoints { @@ -504,7 +503,7 @@ impl ConfigHandler { "Workspace config text exceeds limit", )); } - // Resolve canonical per-file preconditions at the SAME captured tree revision. + // Resolve canonical per-file preconditions at the SAME captured tree content. let metadata = observe( &self.backend, &self.attachment.connection_id, @@ -716,7 +715,6 @@ impl ConfigHandler { connection_id: self.attachment.connection_id.clone(), validator: self.snapshot.validator.clone(), request: ConfigCommitRequest { - base_revision: self.snapshot.revision, base_digest: self.snapshot.digest.clone(), changes, entrypoints: self.snapshot.entrypoints.clone(), @@ -735,10 +733,7 @@ impl ConfigHandler { ) .await .map_err(|_| unknown())?; - if updated.revision != result.revision - || updated.digest != result.digest - || updated.validator != result.validator - { + if updated.digest != result.digest || updated.validator != result.validator { return Err(unknown()); } let node = updated @@ -746,7 +741,7 @@ impl ConfigHandler { .iter() .find(|n| n.path == self.node.path) .ok_or_else(unknown)?; - let value = json_to_wip(&json!({"revision": result.revision, "digest": result.digest, + let value = json_to_wip(&json!({"digest": result.digest, "activation": "Saved to canonical Workspace configuration; existing Worker manifests are not rewritten"})).map_err(|_| unknown())?; Ok(WipOperationOutput::native_with_validator( value, diff --git a/crates/worker/src/feature/builtin/workspace_config/production_tests.rs b/crates/worker/src/feature/builtin/workspace_config/production_tests.rs index 3473d176d..9c9fd9d63 100644 --- a/crates/worker/src/feature/builtin/workspace_config/production_tests.rs +++ b/crates/worker/src/feature/builtin/workspace_config/production_tests.rs @@ -14,7 +14,6 @@ use protocol::{FeatureInvocation, FeatureInvocationIdentity}; use serde::Serialize; use serde_json::{Value as Json, json}; use server_api::*; -use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; use std::sync::{Arc, Mutex}; @@ -24,7 +23,6 @@ struct State { read_only: bool, active: Option, next_connection: u64, - revision: u64, entries: BTreeMap, commits: Vec, requests: Vec, @@ -32,6 +30,7 @@ struct State { fail_current: bool, lose_commit_reply: bool, race_commit: bool, + mismatched_commit_reply: Option<&'static str>, } #[derive(Debug)] struct Router { @@ -47,7 +46,6 @@ impl Router { read_only: false, active: None, next_connection: 0, - revision: 1, entries: BTreeMap::from([ ( "main.dcdl".into(), @@ -64,6 +62,7 @@ impl Router { fail_current: false, lose_commit_reply: false, race_commit: false, + mismatched_commit_reply: None, })), }) } @@ -88,20 +87,30 @@ fn denied(status: u16, classification: WorkspaceConfigFailureClassification) -> } } fn digest(content: &str) -> String { - Sha256::digest(content.as_bytes()) - .iter() - .map(|byte| format!("{byte:02x}")) - .collect() + config_source::digest_bytes(content.as_bytes()) } impl State { fn tree_digest(&self) -> String { - digest(&serde_json::to_string(&self.entries).unwrap()) + config_source::ConfigTreeSnapshot::from_entries(self.entries.iter().map( + |(path, (content_type, content))| { + config_source::ConfigEntry::new( + config_source::VirtualPath::parse(path).unwrap(), + match content_type { + ConfigContentType::Decodal => config_source::ConfigContentType::Decodal, + ConfigContentType::Text => config_source::ConfigContentType::Text, + }, + content.clone(), + ) + .unwrap() + }, + )) + .unwrap() + .digest } fn validator(&self, path: &str) -> String { format!( - "wc:{}:{}:{}:{}", + "wc:{}:{}:{}", self.active.as_ref().unwrap().connection_id, - self.revision, self.tree_digest(), path ) @@ -262,7 +271,6 @@ impl WorkspaceClient for Router { Ok(response(&WorkspaceConfigObserveResponse { connection_id: state.active.as_ref().unwrap().connection_id.clone(), validator: state.validator(""), - revision: state.revision, digest: state.tree_digest(), entrypoints: vec!["main.dcdl".into()], nodes, @@ -294,7 +302,7 @@ impl WorkspaceClient for Router { let request: WorkspaceConfigCommitRequest = serde_json::from_value(input).unwrap(); state.commits.push(request.clone()); if state.race_commit { - state.revision += 1; + state.entries.get_mut("main.dcdl").unwrap().1 = "model = concurrent".into(); state.race_commit = false; } if state.read_only { @@ -305,7 +313,6 @@ impl WorkspaceClient for Router { } assert_eq!(request.request.entrypoints, ["main.dcdl"]); if request.validator != state.validator("") - || request.request.base_revision != state.revision || request.request.base_digest != state.tree_digest() { return Ok(denied( @@ -396,18 +403,23 @@ impl WorkspaceClient for Router { )); } state.entries = entries; - state.revision += 1; if state.lose_commit_reply { state.lose_commit_reply = false; return Err(WorkspaceClientError::Request( "secret-token /private/host/config".into(), )); } - Ok(response(&WorkspaceConfigCommitResponse { + let mut result = WorkspaceConfigCommitResponse { validator: state.validator(""), - revision: state.revision, digest: state.tree_digest(), - })) + }; + match state.mismatched_commit_reply.take() { + Some("digest") => result.digest = "different-content".into(), + Some("validator") => result.validator = "different-authority".into(), + None => {} + Some(_) => unreachable!("unknown fixture mismatch"), + } + Ok(response(&result)) } _ => panic!("unregistered endpoint: {suffix}"), } @@ -668,13 +680,20 @@ fn runtime_for_cross(client: Arc) -> (WorkspaceConfigFeatur } #[tokio::test] -async fn workspace_config_production_stale_same_bytes_and_commit_race_never_overwrite() { +async fn workspace_config_production_stale_content_and_commit_race_never_overwrite() { let client = Router::new(); let (feature, runtime) = runtime(client.clone()); feature.attach("initial", None).await.unwrap(); let path = "/workspace-config/main.dcdl"; observe_interface(&runtime, path, true).await; - client.state.lock().unwrap().revision += 1; // Same bytes, different identity revision. + client + .state + .lock() + .unwrap() + .entries + .get_mut("main.dcdl") + .unwrap() + .1 = "model = external".into(); assert!( call(&runtime, path, "write", json!({"content":"overwritten"})) .await @@ -690,7 +709,7 @@ async fn workspace_config_production_stale_same_bytes_and_commit_race_never_over ); assert_eq!( client.state.lock().unwrap().entries["main.dcdl"].1, - "model = old" + "model = concurrent" ); assert_eq!(client.state.lock().unwrap().commits.len(), 1); } @@ -758,6 +777,26 @@ async fn workspace_config_production_invalid_size_save_failure_unknown_are_safe_ ); } +#[tokio::test] +async fn post_commit_observation_mismatch_reports_unknown_without_replaying_write() { + for field in ["digest", "validator"] { + let client = Router::new(); + let (feature, runtime) = runtime(client.clone()); + feature.attach("initial", None).await.unwrap(); + let path = "/workspace-config/main.dcdl"; + observe_interface(&runtime, path, true).await; + client.state.lock().unwrap().mismatched_commit_reply = Some(field); + let error = call(&runtime, path, "write", json!({"content":"saved"})) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("unknown"), "{field}: {error}"); + let state = client.state.lock().unwrap(); + assert_eq!(state.commits.len(), 1); + assert_eq!(state.entries["main.dcdl"].1, "saved"); + } +} + #[tokio::test] async fn workspace_config_production_bounds_and_traversal_reject_before_content_calls() { let client = Router::new(); @@ -876,7 +915,6 @@ async fn workspace_config_edit_shared_argument_rejection_never_commits() { assert!(error.to_string().contains(expected), "{error:?}"); let state = client.state.lock().unwrap(); assert!(state.commits.is_empty()); - assert_eq!(state.revision, 1); assert_eq!(state.entries["main.dcdl"].1, "model = old"); // Argument-only rejection must not even fetch a preimage. assert!( diff --git a/crates/worker/src/feature/session.rs b/crates/worker/src/feature/session.rs index 0729f0af5..de62a48eb 100644 --- a/crates/worker/src/feature/session.rs +++ b/crates/worker/src/feature/session.rs @@ -22,8 +22,7 @@ pub(crate) enum CommittedRunExit { pub(crate) struct CommittedSessionCapture { pub(crate) session_id: String, pub(crate) segment_id: String, - /// Monotonic committed-log revision for the captured Segment. - pub(crate) session_revision: u64, + pub(crate) entry_count: usize, pub(crate) run_exit: CommittedRunExit, pub(crate) history: Vec>, @@ -35,8 +34,7 @@ pub(crate) struct CommittedSessionCapture { pub(crate) struct CommittedSessionLocation { pub(crate) session_id: String, pub(crate) segment_id: String, - /// Monotonic committed-log revision for the captured Segment. - pub(crate) session_revision: u64, + pub(crate) entry_count: usize, } @@ -45,7 +43,7 @@ impl CommittedSessionCapture { CommittedSessionLocation { session_id: self.session_id.clone(), segment_id: self.segment_id.clone(), - session_revision: self.session_revision, + entry_count: self.entry_count, } } diff --git a/crates/worker/src/hook.rs b/crates/worker/src/hook.rs index 82f93d469..36bc1a253 100644 --- a/crates/worker/src/hook.rs +++ b/crates/worker/src/hook.rs @@ -282,16 +282,20 @@ impl SystemItemAppendHandle { .push(item); } - /// Queue a revisioned Subject behavior refresh. The caller receives this + /// Queue a Subject behavior refresh with its exact represented behavior. The caller receives this /// narrow operation only at a pre-request boundary, and the host commits it /// before exposing it to the model. - pub fn append_subject_behavior_refresh(&self, body: impl Into, behavior_revision: u64) { + pub fn append_subject_behavior_refresh( + &self, + body: impl Into, + behavior_md: impl Into, + ) { self.pending .lock() .expect("system-item append queue poisoned") .push(SystemItem::SubjectBehaviorRefresh { body: body.into(), - behavior_revision, + behavior_md: behavior_md.into(), prompt_provenance: None, }); } @@ -511,7 +515,7 @@ pub struct HookInvocationContext { pub workspace_id: Option, pub worker_id: String, pub session_id: String, - pub session_revision: u64, + pub run_id: Option, pub turn_index: Option, pub call_id: Option, @@ -990,7 +994,7 @@ mod tests { let handle = RestoreSystemItemAppendHandle::new(Arc::clone(&pending)); let provenance = session_store::PromptRenderProvenance { workspace_id: Some("workspace".into()), - config_revision: 1, + source_digest: "source".into(), projection_digest: "projection".into(), logical_name: "internal.resident_memory_restore_section".into(), @@ -1081,7 +1085,7 @@ mod tests { workspace_id: Some("workspace".into()), worker_id: "worker".into(), session_id: "session".into(), - session_revision: 4, + run_id: None, turn_index: None, call_id: None, diff --git a/crates/worker/src/ipc/interceptor.rs b/crates/worker/src/ipc/interceptor.rs index 96dd186f8..bfd713a4c 100644 --- a/crates/worker/src/ipc/interceptor.rs +++ b/crates/worker/src/ipc/interceptor.rs @@ -210,11 +210,8 @@ impl WorkerInterceptor { let Some(source) = self.resident_context_source.as_ref() else { return; }; - if let SystemItem::SubjectBehaviorRefresh { - behavior_revision, .. - } = item - { - source.confirm_resident_context_revision(*behavior_revision); + if let SystemItem::SubjectBehaviorRefresh { behavior_md, .. } = item { + source.confirm_resident_context_behavior(behavior_md); } } @@ -373,7 +370,7 @@ impl WorkerInterceptor { let projection = prompts.projection(); let provenance = |logical_name: &str| session_store::PromptRenderProvenance { workspace_id: self.prompt_workspace_id.clone(), - config_revision: projection.config_revision, + source_digest: projection.source_digest.clone(), projection_digest: projection.catalog_digest.clone(), logical_name: logical_name.to_string(), @@ -487,7 +484,7 @@ impl Interceptor for WorkerInterceptor { let projection = prompts.projection(); let provenance = session_store::PromptRenderProvenance { workspace_id: self.prompt_workspace_id.clone(), - config_revision: projection.config_revision, + source_digest: projection.source_digest.clone(), projection_digest: projection.catalog_digest.clone(), logical_name: "internal.notify_wrapper".to_string(), @@ -954,13 +951,13 @@ mod tests { input .system_items() .expect("test interceptor has durable append authority") - .append_subject_behavior_refresh("current Subject context", 7); + .append_subject_behavior_refresh("current Subject context", "Ask when uncertain."); Ok(HookPreRequestAction::Continue) } } struct ConfirmRecordingResidentSource { - confirmed: Arc>>, + confirmed: Arc>>, } #[async_trait] @@ -969,8 +966,8 @@ mod tests { SystemPromptContribution::Unavailable } - fn confirm_resident_context_revision(&self, revision: u64) { - self.confirmed.lock().unwrap().push(revision); + fn confirm_resident_context_behavior(&self, behavior_md: &str) { + self.confirmed.lock().unwrap().push(behavior_md.to_owned()); } } @@ -1478,7 +1475,7 @@ mod tests { } #[tokio::test] - async fn subject_behavior_revision_is_confirmed_only_after_durable_commit() { + async fn subject_behavior_is_confirmed_only_after_durable_commit() { let mut builder = HookRegistryBuilder::new(); builder.add_pre_llm_request(AppendingSubjectBehaviorHook); let committed = Arc::new(Mutex::new(Vec::new())); @@ -1511,19 +1508,49 @@ mod tests { .unwrap(); assert!(matches!(action, PreRequestAction::ContinueWith(_))); - assert_eq!(*confirmed.lock().unwrap(), [7]); + assert_eq!(*confirmed.lock().unwrap(), ["Ask when uncertain."]); assert!(matches!( committed.lock().unwrap().as_slice(), [SystemItem::SubjectBehaviorRefresh { - behavior_revision: 7, + behavior_md, prompt_provenance: Some(_), .. - }] + }] if behavior_md == "Ask when uncertain." )); } + #[test] + fn behavior_ack_uses_raw_content_including_clear_not_rendered_context() { + let committed = Arc::new(Mutex::new(Vec::new())); + let confirmed = Arc::new(Mutex::new(Vec::new())); + let source: Arc = + Arc::new(ConfirmRecordingResidentSource { + confirmed: Arc::clone(&confirmed), + }); + let interceptor = WorkerInterceptor::new( + Arc::new(HookRegistryBuilder::new().build()), + None, + None, + NotifyBuffer::new(), + Arc::new(Mutex::new(Vec::new())), + test_prompts(), + Some(Arc::new(RecordingSystemItemCommitter { + committed: Arc::clone(&committed), + })), + ) + .with_resident_context_source(Some(source)); + let items = ["raw behavior", ""].map(|behavior_md| SystemItem::SubjectBehaviorRefresh { + body: "same rendered context".into(), + behavior_md: behavior_md.into(), + prompt_provenance: None, + }); + interceptor.commit_system_items(&items).unwrap(); + assert_eq!(*confirmed.lock().unwrap(), ["raw behavior", ""]); + assert_eq!(committed.lock().unwrap().len(), 2); + } + #[tokio::test] - async fn failed_subject_behavior_persistence_does_not_confirm_revision() { + async fn failed_subject_behavior_persistence_does_not_confirm_behavior() { let mut builder = HookRegistryBuilder::new(); builder.add_pre_llm_request(AppendingSubjectBehaviorHook); let confirmed = Arc::new(Mutex::new(Vec::new())); @@ -1897,7 +1924,6 @@ mod tests { ); let mut projection = crate::prompt::catalog::EffectivePromptCatalog::new( templates, - 2, projection.schema_fingerprint.clone(), projection.toolchain_fingerprint.clone(), ) @@ -1926,7 +1952,10 @@ mod tests { panic!("notification Prompt provenance was not committed"); }; assert_eq!(provenance.workspace_id.as_deref(), Some("workspace-a")); - assert_eq!(provenance.config_revision, 2); + assert_eq!( + provenance.projection_digest, + prompts.load().projection().catalog_digest + ); assert_eq!(provenance.source_digest, "source-2"); assert_eq!(provenance.logical_name, "internal.notify_wrapper"); } @@ -1953,7 +1982,6 @@ mod tests { ); let mut projection = crate::prompt::catalog::EffectivePromptCatalog::new( templates, - 3, projection.schema_fingerprint.clone(), projection.toolchain_fingerprint.clone(), ) diff --git a/crates/worker/src/job/result.rs b/crates/worker/src/job/result.rs index ee3fedb30..525bba9e0 100644 --- a/crates/worker/src/job/result.rs +++ b/crates/worker/src/job/result.rs @@ -14,7 +14,7 @@ use crate::feature::{ }; pub const SUBMIT_JOB_RESULT_TOOL: &str = "SubmitJobResult"; -const DESCRIPTION: &str = "Submit this Host Job's bounded structured result. Job, attempt and input revision are immutable Host bindings. Only result is accepted as input; final prose is not success."; +const DESCRIPTION: &str = "Submit this Host Job's bounded structured result. Job, attempt and input digest are immutable Host bindings. Only result is accepted as input; final prose is not success."; /// Host acceptance authority. Implementations must validate the immutable fences and commit /// acceptance durably before returning Ok. Err may mean an unknown outcome; identical retries @@ -64,7 +64,7 @@ struct SubmissionState { pub struct JobResultFeature { job_id: String, attempt_id: String, - input_revision: String, + input_digest: String, max_result_bytes: u32, sink: Arc, state: Arc>, @@ -86,7 +86,7 @@ impl JobResultFeature { Ok(Self { job_id: request.job_id.clone(), attempt_id, - input_revision: request.input_revision.clone(), + input_digest: request.input_digest()?, max_result_bytes: request.limits.max_result_bytes, sink, state: Arc::default(), @@ -216,7 +216,7 @@ impl Tool for JobResultFeature { let submission = ::job::JobResultSubmission { job_id: self.job_id.clone(), attempt_id: self.attempt_id.clone(), - input_revision: self.input_revision.clone(), + input_digest: self.input_digest.clone(), result: prepared, }; let mut accepted = self.accepted.lock().expect("Job result state poisoned"); diff --git a/crates/worker/src/job/tests.rs b/crates/worker/src/job/tests.rs index 3c0c66ec6..f3babf160 100644 --- a/crates/worker/src/job/tests.rs +++ b/crates/worker/src/job/tests.rs @@ -94,7 +94,6 @@ fn request(profile: &str) -> ::job::JobRequest { ::job::JobRequest { job_id: "job-a".into(), purpose: "scripted".into(), - input_revision: "rev-1".into(), input_ref: "snapshot:one".into(), input: json!({"text":"input"}), instruction: "Summarize the input".into(), @@ -187,7 +186,7 @@ async fn executes_selected_profiles_with_only_result_authority_and_bounded_polic ::job::JobResultSubmission { job_id: "job-a".into(), attempt_id: "attempt-7".into(), - input_revision: "rev-1".into(), + input_digest: request("a").input_digest().unwrap(), result: json!({"ok":true}) } ); @@ -266,7 +265,10 @@ async fn selected_builtin_job_executes_with_catalog_contract_and_only_result_too assert!(rx.await.unwrap().is_closed()); assert_eq!(result.submission.job_id, request.job_id); assert_eq!(result.submission.attempt_id, "builtin-attempt"); - assert_eq!(result.submission.input_revision, request.input_revision); + assert_eq!( + result.submission.input_digest, + request.input_digest().unwrap() + ); assert_eq!(result.submission.result, json!({"ok":true})); assert_eq!(sink.0.lock().unwrap().len(), 1); let requests = client.requests.lock().unwrap(); diff --git a/crates/worker/src/prompt/catalog.rs b/crates/worker/src/prompt/catalog.rs index 999293d10..73beccb1c 100644 --- a/crates/worker/src/prompt/catalog.rs +++ b/crates/worker/src/prompt/catalog.rs @@ -30,7 +30,7 @@ const BUILTIN_TOOLCHAIN_FINGERPRINT: &str = "builtin:prompts:decodal-0.4"; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct EffectivePromptCatalog { pub templates: BTreeMap, - pub config_revision: u64, + #[serde(default, skip_serializing_if = "String::is_empty")] pub source_digest: String, pub schema_fingerprint: String, @@ -41,7 +41,7 @@ pub struct EffectivePromptCatalog { impl EffectivePromptCatalog { pub fn new( templates: BTreeMap, - config_revision: u64, + schema_fingerprint: impl Into, toolchain_fingerprint: impl Into, ) -> Result { @@ -49,7 +49,7 @@ impl EffectivePromptCatalog { let catalog_digest = catalog_digest(&templates)?; Ok(Self { templates, - config_revision, + source_digest: String::new(), schema_fingerprint: schema_fingerprint.into(), toolchain_fingerprint: toolchain_fingerprint.into(), @@ -59,7 +59,7 @@ impl EffectivePromptCatalog { pub fn from_projection( prompts: &serde_json::Value, - config_revision: u64, + schema_fingerprint: impl Into, toolchain_fingerprint: impl Into, ) -> Result { @@ -68,12 +68,7 @@ impl EffectivePromptCatalog { if let Some(default_prompt) = templates.remove("default_prompt") { templates.insert("default".to_string(), default_prompt); } - Self::new( - templates, - config_revision, - schema_fingerprint, - toolchain_fingerprint, - ) + Self::new(templates, schema_fingerprint, toolchain_fingerprint) } pub fn verify_digest(&self) -> Result<(), CatalogError> { @@ -279,7 +274,7 @@ pub enum CatalogError { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WorkspacePromptProjection { pub workspace_id: String, - pub config_revision: u64, + pub source_digest: String, pub projection_digest: String, pub schema_fingerprint: String, @@ -325,7 +320,7 @@ impl WorkspacePromptProjection { } Ok(Self { workspace_id, - config_revision: catalog.config_revision, + source_digest, projection_digest, schema_fingerprint: catalog.schema_fingerprint.clone(), @@ -345,8 +340,7 @@ impl WorkspacePromptProjection { self.projection_digest.clone(), self.catalog.clone(), )?; - if rebuilt.config_revision != self.config_revision - || rebuilt.schema_fingerprint != self.schema_fingerprint + if rebuilt.schema_fingerprint != self.schema_fingerprint || rebuilt.toolchain_fingerprint != self.toolchain_fingerprint { return Err(CatalogError::InvalidTemplateCatalog( @@ -365,7 +359,6 @@ pub struct PromptCatalog { impl std::fmt::Debug for PromptCatalog { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("PromptCatalog") - .field("config_revision", &self.projection.config_revision) .field("catalog_digest", &self.projection.catalog_digest) .finish_non_exhaustive() } @@ -376,7 +369,6 @@ impl PromptCatalog { let templates = builtin_prompt_templates()?; let projection = EffectivePromptCatalog::new( templates, - 0, BUILTIN_TOOLCHAIN_FINGERPRINT, BUILTIN_TOOLCHAIN_FINGERPRINT, )?; @@ -580,7 +572,7 @@ pub fn prompt_schema_source() -> Result { pub fn builtin_prompt_templates() -> Result, CatalogError> { let mut entries = Vec::new(); collect_builtin_entries(&BUILTIN_PROMPT_SOURCES, "", &mut entries)?; - let snapshot = ConfigTreeSnapshot::from_entries(0, entries) + let snapshot = ConfigTreeSnapshot::from_entries(entries) .map_err(|error| CatalogError::BuiltinTree(error.to_string()))?; let entry = VirtualPath::parse(BUILTIN_CATALOG_ENTRY) .map_err(|error| CatalogError::BuiltinTree(error.to_string()))?; @@ -825,7 +817,7 @@ mod tests { assert!(common.contains("normal non-force push")); assert!(common.contains("Moving only the target ref does not invalidate approval")); assert!(common.contains("take precedence over stale Memory")); - assert!(coder.contains("Never invent an add-revision operation")); + assert!(coder.contains("Never invent an operation to append another source candidate")); assert!(orchestrator.contains("Target-only movement preserves source approval")); assert!(reviewer.contains("target-only movement does not invalidate approval")); } @@ -913,7 +905,7 @@ mod tests { ("first".to_string(), "FIRST".to_string()), ("second".to_string(), "SECOND".to_string()), ]); - let projection = EffectivePromptCatalog::new(templates, 42, "schema", "toolchain").unwrap(); + let projection = EffectivePromptCatalog::new(templates, "schema", "toolchain").unwrap(); projection.verify_digest().unwrap(); let mut tampered = projection.clone(); tampered @@ -929,7 +921,6 @@ mod tests { fn workspace_prompt_projection_round_trips_and_rejects_tampered_metadata() { let catalog = EffectivePromptCatalog::new( BTreeMap::from([("default".to_string(), "PROMPT".to_string())]), - 8, "schema", "toolchain", ) @@ -947,7 +938,7 @@ mod tests { restored.validate().unwrap(); let mut tampered = restored; - tampered.config_revision += 1; + tampered.schema_fingerprint = "tampered-schema".into(); assert!(tampered.validate().is_err()); } @@ -955,11 +946,14 @@ mod tests { fn catalog_source_preserves_workspace_projection_for_subworkers() { let templates = BTreeMap::from([("template".to_string(), "OVERRIDE".to_string())]); let catalog = PromptCatalog::from_projection( - EffectivePromptCatalog::new(templates, 9, "schema", "toolchain").unwrap(), + EffectivePromptCatalog::new(templates, "schema", "toolchain").unwrap(), ) .unwrap(); let child = PromptCatalog::load(&catalog.source()).unwrap(); - assert_eq!(child.projection.config_revision, 9); + assert_eq!( + child.projection.catalog_digest, + catalog.projection.catalog_digest + ); assert_eq!(child.projection.templates["template"], "OVERRIDE"); } diff --git a/crates/worker/src/session_capture.rs b/crates/worker/src/session_capture.rs index 578045432..f67be5936 100644 --- a/crates/worker/src/session_capture.rs +++ b/crates/worker/src/session_capture.rs @@ -832,7 +832,7 @@ mod tests { WorkerHistoryProvenance::FlowInstruction { selector: "builtin:coder-review".into(), definition_id: "coder-review".into(), - definition_revision: 3, + definition_digest: Some("sha256:flow-source".into()), instance_id: "instance".into(), state_id: "implement".into(), }, diff --git a/crates/worker/src/session_history.rs b/crates/worker/src/session_history.rs index 5a5f60d8d..f7ed7df8d 100644 --- a/crates/worker/src/session_history.rs +++ b/crates/worker/src/session_history.rs @@ -153,7 +153,7 @@ mod tests { WorkerHistoryProvenance::FlowInstruction { selector: "builtin:coder-review".to_string(), definition_id: "coder-review".to_string(), - definition_revision: 7, + definition_digest: Some("sha256:flow-source".into()), instance_id: "flow-instance".to_string(), state_id: "implement".to_string(), }, diff --git a/crates/worker/src/skill.rs b/crates/worker/src/skill.rs index bdc22c59b..f9b15bf88 100644 --- a/crates/worker/src/skill.rs +++ b/crates/worker/src/skill.rs @@ -111,7 +111,6 @@ mod tests { let body = serde_json::json!({ "authority": "workspace-config-skills-v1", "projection": { - "config_revision": 7, "tree_digest": "tree-digest" }, "entries": [{ @@ -123,7 +122,6 @@ mod tests { "kind": "workspace", "id": "workspace:triage-errors", "virtual_path": "skills/triage-errors/SKILL.md", - "revision": 7, "source_digest": "source-digest", "tree_digest": "tree-digest" }, diff --git a/crates/worker/src/spawn/mod.rs b/crates/worker/src/spawn/mod.rs index a60b0ea58..23cb51ad3 100644 --- a/crates/worker/src/spawn/mod.rs +++ b/crates/worker/src/spawn/mod.rs @@ -1,3 +1,4 @@ pub mod comm_tools; +mod published; pub mod registry; pub mod tool; diff --git a/crates/worker/src/spawn/published.rs b/crates/worker/src/spawn/published.rs new file mode 100644 index 000000000..f6c322819 --- /dev/null +++ b/crates/worker/src/spawn/published.rs @@ -0,0 +1,408 @@ +//! Reconnect projection derived only from the parent's published child stream. +//! +//! Records are prepared before the first child Run. Their initial projection and +//! receiver are captured at that boundary, not when forwarding eventually starts. +//! Later source reads may resolve one immutable committed entry by ID, but never +//! replace live state with the child's (possibly unpublished) current state. + +use crate::internal_worker::InternalWorkerSessionHandle; +use protocol::{Event, InFlightBlock, InternalWorkerSnapshot, SessionSnapshotEntryData as Data}; +use std::sync::Mutex; +use tokio::sync::broadcast; + +pub(super) struct PublishedProtocol { + pub snapshot: Mutex, + pub events: Mutex>>, +} + +impl PublishedProtocol { + /// The host registers a prepared Idle session before its first Run. + pub fn new(source: &InternalWorkerSessionHandle, worker: protocol::InternalWorkerRef) -> Self { + let events = source.subscribe_events(); + let initial = source.protocol_snapshot(); + Self { + snapshot: Mutex::new(InternalWorkerSnapshot { + worker, + session: initial.session, + greeting: initial.greeting, + status: initial.status, + error: initial.error, + in_flight: initial.in_flight, + internal_workers: initial.internal_workers, + }), + events: Mutex::new(Some(events)), + } + } +} + +pub(super) fn apply( + snapshot: &mut InternalWorkerSnapshot, + event: &Event, + source: &InternalWorkerSessionHandle, +) { + match event { + Event::Snapshot { + session, + greeting, + state, + in_flight, + internal_workers, + } => { + snapshot.session = session.clone(); + snapshot.greeting = Some(greeting.clone()); + snapshot.status = state.catalog_status(); + snapshot.in_flight = in_flight.clone(); + snapshot.internal_workers = internal_workers.clone(); + } + Event::WorkerState { snapshot: state } => { + snapshot.status = state.catalog_status(); + if snapshot.status != protocol::WorkerStatus::Running { + snapshot.in_flight = Default::default(); + } else { + snapshot.error = None; + } + } + Event::Shutdown => { + snapshot.status = protocol::WorkerStatus::Stopped; + snapshot.in_flight = Default::default(); + } + Event::Error { message, .. } => snapshot.error = Some(message.clone()), + Event::ContextUsage { usage } => { + if let Some(greeting) = &mut snapshot.greeting { + greeting.context_usage = *usage; + greeting.context_tokens = usage.map_or(0, |usage| usage.tokens); + } + } + Event::PendingSubmissionsChanged { pending } => { + snapshot.session.pending_submissions = pending.clone() + } + Event::SegmentRotated { session } => { + snapshot.session = session.clone(); + snapshot.in_flight = Default::default(); + } + Event::SessionEntryCommitted { entry } => { + match &entry.data { + Data::ToolCall { call_id, .. } => snapshot.in_flight.blocks.retain( + |block| !matches!(block, InFlightBlock::ToolCall { id, .. } if id == call_id), + ), + Data::Message { + role: protocol::SessionMessageRole::Assistant, + content, + } => { + let body = content + .iter() + .map(|part| match part { + protocol::SessionContentPart::Text { text } => text.as_str(), + protocol::SessionContentPart::Refusal { refusal } => refusal.as_str(), + }) + .collect::(); + if let Some(index) = snapshot.in_flight.blocks.iter().position( + |block| matches!(block, InFlightBlock::Text { text, .. } if text == &body), + ) { + snapshot.in_flight.blocks.remove(index); + } + } + _ => {} + } + if let Some(existing) = snapshot + .session + .entries + .iter_mut() + .find(|old| old.entry_id == entry.entry_id) + { + *existing = entry.clone(); + } else { + snapshot.session.entries.push(entry.clone()); + } + } + Event::UserMessage { + entry_id: Some(id), .. + } + | Event::SystemItem { + entry_id: Some(id), .. + } => { + // Resolve only this committed identity. Reading the entire current + // source projection here would admit future/buffered history. + if !snapshot + .session + .entries + .iter() + .any(|entry| &entry.entry_id == id) + { + if let Some(entry) = committed_entries(source, &snapshot.worker.session_id) + .into_iter() + .find(|entry| &entry.entry_id == id) + { + snapshot.session.entries.push(entry); + } + } + } + Event::InvokeStart { + timestamp_ms: Some(ts), + .. + } + | Event::Usage { + timestamp_ms: Some(ts), + .. + } => { + if let Some(entry) = committed_entries(source, &snapshot.worker.session_id) + .into_iter() + .find(|entry| { + entry.timestamp == *ts + && !snapshot + .session + .entries + .iter() + .any(|old| old.entry_id == entry.entry_id) + && match (&entry.data, event) { + (Data::Invoke { trigger }, Event::InvokeStart { kind, .. }) => { + trigger == kind + } + ( + Data::Usage { + input_tokens, + cache_read_input_tokens, + output_tokens, + }, + Event::Usage { + input_tokens: input, + cache_read_input_tokens: cache, + output_tokens: output, + .. + }, + ) => { + *input_tokens == input.unwrap_or_default() + && *cache_read_input_tokens == cache.unwrap_or_default() + && *output_tokens == output.unwrap_or_default() + } + _ => false, + } + }) + { + snapshot.session.entries.push(entry); + } + } + Event::TextDelta { text } => match snapshot.in_flight.blocks.last_mut() { + Some(InFlightBlock::Text { + text: body, + finished: false, + }) => body.push_str(text), + _ => snapshot.in_flight.blocks.push(InFlightBlock::Text { + text: text.clone(), + finished: false, + }), + }, + Event::TextDone { text } => { + if let Some(InFlightBlock::Text { + text: body, + finished, + }) = snapshot.in_flight.blocks.last_mut() + { + *body = text.clone(); + *finished = true; + } else { + snapshot.in_flight.blocks.push(InFlightBlock::Text { + text: text.clone(), + finished: true, + }); + } + } + Event::ThinkingStart => snapshot.in_flight.blocks.push(InFlightBlock::Thinking { + text: String::new(), + finished: false, + }), + Event::ThinkingDelta { text } => { + if let Some(InFlightBlock::Thinking { text: body, .. }) = + snapshot.in_flight.blocks.last_mut() + { + body.push_str(text); + } + } + Event::ThinkingDone { text } => { + if let Some(InFlightBlock::Thinking { + text: body, + finished, + }) = snapshot.in_flight.blocks.last_mut() + { + *body = text.clone(); + *finished = true; + } + } + Event::ToolCallStart { id, name } => { + snapshot.in_flight.blocks.push(InFlightBlock::ToolCall { + id: id.clone(), + name: name.clone(), + args: String::new(), + state: protocol::InFlightToolCallState::Pending, + }) + } + Event::ToolCallArgsDelta { id, json } => if let Some(InFlightBlock::ToolCall { + args, + state, + .. + }) = snapshot.in_flight.blocks.iter_mut().find( + |block| matches!(block, InFlightBlock::ToolCall { id: current, .. } if current == id), + ) { + args.push_str(json); + *state = protocol::InFlightToolCallState::StreamingArgs; + }, + Event::ToolCallDone { + id, + name, + arguments, + } => if let Some(InFlightBlock::ToolCall { + args, + name: current_name, + state, + .. + }) = snapshot.in_flight.blocks.iter_mut().find( + |block| matches!(block, InFlightBlock::ToolCall { id: current, .. } if current == id), + ) { + *args = arguments.clone(); + *current_name = name.clone(); + *state = protocol::InFlightToolCallState::Done; + }, + Event::RunEnd { .. } => snapshot.in_flight = Default::default(), + Event::CompactionProgress { compaction } => { + snapshot.in_flight.compaction = compaction.clone() + } + Event::Command { event } => apply_command(&mut snapshot.in_flight.commands, event), + Event::InternalWorker { worker, event } => { + if let Some(child) = snapshot + .internal_workers + .iter_mut() + .find(|child| child.worker.session_id == worker.session_id) + { + apply(child, event, source); + } else if let Event::Snapshot { + session, + greeting, + state, + in_flight, + internal_workers, + } = event.as_ref() + { + snapshot.internal_workers.push(InternalWorkerSnapshot { + worker: worker.clone(), + session: session.clone(), + greeting: Some(greeting.clone()), + status: state.catalog_status(), + error: None, + in_flight: in_flight.clone(), + internal_workers: internal_workers.clone(), + }); + } + } + Event::InternalWorkerRemoved { worker } => snapshot + .internal_workers + .retain(|child| child.worker.session_id != worker.session_id), + _ => {} + } +} + +fn committed_entries( + source: &InternalWorkerSessionHandle, + session_id: &str, +) -> Vec { + fn find( + children: &[InternalWorkerSnapshot], + id: &str, + ) -> Option> { + for child in children { + if child.worker.session_id == id { + return Some(child.session.entries.clone()); + } + if let Some(entries) = find(&child.internal_workers, id) { + return Some(entries); + } + } + None + } + let projection = source.protocol_snapshot(); + if source.session_id_string() == session_id { + let mut entries = projection.session.entries; + // A buffered pre-rotation identity may no longer be in the current + // segment projection. Resolve it from the retained immutable log too. + for entry in + session_store::public_snapshot::project_current_session_snapshot(&source.entries()) + .entries + { + if !entries + .iter() + .any(|current| current.entry_id == entry.entry_id) + { + entries.push(entry); + } + } + entries + } else { + find(&projection.internal_workers, session_id).unwrap_or_default() + } +} + +fn apply_command(commands: &mut Vec, event: &protocol::CommandEvent) { + use protocol::{ + CommandEvent, CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, + }; + match event { + CommandEvent::Started { + command_id, + tool_call_id, + observed_at_ms, + } => { + commands.retain(|command| &command.command_id != command_id); + commands.push(CommandSnapshot { + command_id: command_id.clone(), + tool_call_id: tool_call_id.clone(), + status: CommandStatus::Running, + started_at_ms: *observed_at_ms, + observed_at_ms: *observed_at_ms, + last_output_at_ms: None, + stdout: CommandStreamSlice::default(), + stderr: CommandStreamSlice::default(), + exit_code: None, + }); + } + CommandEvent::Output { + command_id, + stream, + start_offset, + end_offset, + content, + observed_at_ms, + } => { + if let Some(command) = commands + .iter_mut() + .find(|command| &command.command_id == command_id) + { + command.observed_at_ms = *observed_at_ms; + command.last_output_at_ms = Some(*observed_at_ms); + let target = match stream { + CommandStream::Stdout => &mut command.stdout, + CommandStream::Stderr => &mut command.stderr, + }; + if target.end_offset != *start_offset { + target.content.clear(); + target.start_offset = *start_offset; + target.truncated = *start_offset > 0; + } + target.content.push_str(content); + target.end_offset = *end_offset; + if target.content.len() > 32 * 1024 { + let mut cut = target.content.len() - 32 * 1024; + while !target.content.is_char_boundary(cut) { + cut += 1; + } + target.content.drain(..cut); + target.start_offset = target + .end_offset + .saturating_sub(target.content.len() as u64); + target.truncated = true; + } + } + } + CommandEvent::Terminal { command_id, .. } => { + commands.retain(|command| &command.command_id != command_id) + } + } +} diff --git a/crates/worker/src/spawn/registry.rs b/crates/worker/src/spawn/registry.rs index 037cba6ee..05317f491 100644 --- a/crates/worker/src/spawn/registry.rs +++ b/crates/worker/src/spawn/registry.rs @@ -14,7 +14,7 @@ use std::collections::{BTreeMap, HashSet}; use std::io; use std::sync::{ Arc, Mutex, - atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, + atomic::{AtomicBool, AtomicUsize, Ordering}, }; use std::time::Instant; @@ -29,9 +29,7 @@ use tokio::sync::{Notify, broadcast}; use tracing::warn; use workdir::WorkdirScopeLeaseSet; -use crate::internal_worker::{ - InternalWorkerSessionHandle, InternalWorkerSessionSnapshot, InternalWorkerVisibility, -}; +use crate::internal_worker::{InternalWorkerSessionHandle, InternalWorkerVisibility}; use crate::runtime::dir::{RuntimeDir, SpawnedWorkerRecord}; use crate::runtime::worker_allocation; @@ -81,10 +79,9 @@ pub(crate) struct InternalSpawnedWorkerRecord { started_at: Instant, stop_lock: Arc>, scope_reclaimed: Arc, - protocol_revision: Arc, - protocol_emit_lock: Arc>, protocol_terminal: Arc, forwarding_started: Arc, + published: Arc, } impl InternalSpawnedWorkerRecord { @@ -98,19 +95,26 @@ impl InternalSpawnedWorkerRecord { change_tracker: Option, ) -> Self { Self { - worker_name, + worker_name: worker_name.clone(), scope_delegated, workdir_tool_scope: Arc::new(workdir_tool_scope), #[cfg(test)] installed_tools: installed_tools.into(), + published: Arc::new(super::published::PublishedProtocol::new( + &session, + InternalWorkerRef { + session_id: session.session_id_string(), + name: worker_name.clone(), + parent_session_id: None, + kind: InternalWorkerKind::SubWorker, + }, + )), session, child_registry, change_tracker, started_at: Instant::now(), stop_lock: Arc::new(tokio::sync::Mutex::new(())), scope_reclaimed: Arc::new(AtomicBool::new(false)), - protocol_revision: Arc::new(AtomicU64::new(0)), - protocol_emit_lock: Arc::new(Mutex::new(())), protocol_terminal: Arc::new(AtomicBool::new(false)), forwarding_started: Arc::new(AtomicBool::new(false)), } @@ -183,10 +187,6 @@ impl InternalSpawnedWorkerRecord { kind: InternalWorkerKind::SubWorker, } } - - fn protocol_revision(&self) -> u64 { - self.protocol_revision.load(Ordering::Acquire) - } } /// Parent-visible service Internal Worker. Unlike a SubWorker this record has no @@ -196,8 +196,7 @@ pub(crate) struct InternalServiceWorkerRecord { pub service_kind: String, pub display_name: String, pub session: InternalWorkerSessionHandle, - protocol_revision: Arc, - protocol_emit_lock: Arc>, + published: Arc, protocol_terminal: Arc, forwarding_started: Arc, } @@ -208,12 +207,23 @@ impl InternalServiceWorkerRecord { display_name: impl Into, session: InternalWorkerSessionHandle, ) -> Self { + let service_kind = service_kind.into(); + let display_name = display_name.into(); Self { - service_kind: service_kind.into(), - display_name: display_name.into(), + published: Arc::new(super::published::PublishedProtocol::new( + &session, + InternalWorkerRef { + session_id: session.session_id_string(), + name: display_name.clone(), + parent_session_id: None, + kind: InternalWorkerKind::Service { + kind: service_kind.clone(), + }, + }, + )), + service_kind, + display_name, session, - protocol_revision: Arc::new(AtomicU64::new(0)), - protocol_emit_lock: Arc::new(Mutex::new(())), protocol_terminal: Arc::new(AtomicBool::new(false)), forwarding_started: Arc::new(AtomicBool::new(false)), } @@ -229,10 +239,6 @@ impl InternalServiceWorkerRecord { }, } } - - fn protocol_revision(&self) -> u64 { - self.protocol_revision.load(Ordering::Acquire) - } } pub(crate) struct InternalSpawnReservation { @@ -324,6 +330,7 @@ pub struct SpawnedWorkerRegistry { internal_spawn_cleanup_failed: AtomicBool, parent_scope: Option, parent_protocol: Mutex, String)>>, + protocol_publish_lock: Arc>, } pub struct SpawnedWorkerRegistryLoad { @@ -346,6 +353,7 @@ impl SpawnedWorkerRegistry { internal_spawn_cleanup_failed: AtomicBool::new(false), parent_scope: None, parent_protocol: Mutex::new(None), + protocol_publish_lock: Arc::new(Mutex::new(())), }) } @@ -363,6 +371,7 @@ impl SpawnedWorkerRegistry { internal_spawn_cleanup_failed: AtomicBool::new(false), parent_scope: None, parent_protocol: Mutex::new(None), + protocol_publish_lock: Arc::new(Mutex::new(())), }) } @@ -379,6 +388,7 @@ impl SpawnedWorkerRegistry { internal_spawn_cleanup_failed: AtomicBool::new(false), parent_scope: Some(parent_scope), parent_protocol: Mutex::new(None), + protocol_publish_lock: Arc::new(Mutex::new(())), }) } @@ -464,6 +474,7 @@ impl SpawnedWorkerRegistry { internal_spawn_cleanup_failed: AtomicBool::new(false), parent_scope, parent_protocol: Mutex::new(None), + protocol_publish_lock: Arc::new(Mutex::new(())), }), reclaimed_unreachable: !persisted_children.is_empty(), }) @@ -503,6 +514,13 @@ impl SpawnedWorkerRegistry { }) } + /// Parent snapshot capture/subscription and child publication share this gate. + pub(crate) fn protocol_snapshot_publish_guard(&self) -> std::sync::MutexGuard<'_, ()> { + self.protocol_publish_lock + .lock() + .unwrap_or_else(|error| error.into_inner()) + } + pub(crate) fn attach_parent_protocol( &self, working_event_tx: broadcast::Sender, @@ -616,6 +634,7 @@ impl SpawnedWorkerRegistry { /// Remove one service Worker and emit the terminal projection fence. pub(crate) fn remove_service(&self, session_id: &str) -> io::Result { + let publication_guard = self.protocol_snapshot_publish_guard(); let removed = { let mut records = self .service_records @@ -627,7 +646,7 @@ impl SpawnedWorkerRegistry { .map(|index| records.remove(index)) }; if let Some(record) = removed { - self.publish_service_removal(&record); + self.publish_service_removal(&publication_guard, &record); Ok(true) } else { Ok(false) @@ -646,17 +665,23 @@ impl SpawnedWorkerRegistry { return; }; let worker = record.protocol_ref(Some(parent_session_id)); - let protocol_revision = record.protocol_revision.clone(); - let protocol_emit_lock = record.protocol_emit_lock.clone(); + let protocol_emit_lock = self.protocol_publish_lock.clone(); let protocol_terminal = record.protocol_terminal.clone(); - let mut child_rx = record.session.subscribe_events(); + let mut child_rx = record + .published + .events + .lock() + .unwrap() + .take() + .expect("forwarding starts once"); + let published = record.published.clone(); + let source = record.session.clone(); publish_initial_internal_snapshot( &parent_tx, &worker, - &protocol_revision, &protocol_emit_lock, &protocol_terminal, - &record.session, + &published, ); tokio::spawn(async move { loop { @@ -669,10 +694,13 @@ impl SpawnedWorkerRegistry { if protocol_terminal.load(Ordering::Acquire) { break; } - let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1; + super::published::apply( + &mut published.snapshot.lock().unwrap(), + &event, + &source, + ); let _ = parent_tx.send(Event::InternalWorker { worker: worker.clone(), - revision, event: Box::new(event), }); if shutdown { @@ -686,17 +714,20 @@ impl SpawnedWorkerRegistry { if protocol_terminal.load(Ordering::Acquire) { break; } - let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1; + let error = format!( + "internal Worker output lagged by {skipped} events; published projection is incomplete; stop and recreate the session" + ); + published.snapshot.lock().unwrap().error = Some(error); let _ = parent_tx.send(Event::InternalWorker { worker: worker.clone(), - revision, event: Box::new(Event::Error { code: protocol::ErrorCode::Internal, message: format!( - "internal Worker output lagged by {skipped} events; reconnect to resynchronize" + "internal Worker output lagged by {skipped} events; published projection is incomplete; stop and recreate the session" ), }), }); + break; } Err(broadcast::error::RecvError::Closed) => break, } @@ -716,17 +747,23 @@ impl SpawnedWorkerRegistry { return; }; let worker = record.protocol_ref(Some(parent_session_id)); - let protocol_revision = record.protocol_revision.clone(); - let protocol_emit_lock = record.protocol_emit_lock.clone(); + let protocol_emit_lock = self.protocol_publish_lock.clone(); let protocol_terminal = record.protocol_terminal.clone(); - let mut child_rx = record.session.subscribe_events(); + let mut child_rx = record + .published + .events + .lock() + .unwrap() + .take() + .expect("forwarding starts once"); + let published = record.published.clone(); + let source = record.session.clone(); publish_initial_internal_snapshot( &parent_tx, &worker, - &protocol_revision, &protocol_emit_lock, &protocol_terminal, - &record.session, + &published, ); tokio::spawn(async move { loop { @@ -739,10 +776,13 @@ impl SpawnedWorkerRegistry { if protocol_terminal.load(Ordering::Acquire) { break; } - let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1; + super::published::apply( + &mut published.snapshot.lock().unwrap(), + &event, + &source, + ); let _ = parent_tx.send(Event::InternalWorker { worker: worker.clone(), - revision, event: Box::new(event), }); if shutdown { @@ -756,17 +796,20 @@ impl SpawnedWorkerRegistry { if protocol_terminal.load(Ordering::Acquire) { break; } - let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1; + let error = format!( + "internal Worker output lagged by {skipped} events; published projection is incomplete; stop and recreate the session" + ); + published.snapshot.lock().unwrap().error = Some(error); let _ = parent_tx.send(Event::InternalWorker { worker: worker.clone(), - revision, event: Box::new(Event::Error { code: protocol::ErrorCode::Internal, message: format!( - "internal Worker output lagged by {skipped} events; reconnect to resynchronize" + "internal Worker output lagged by {skipped} events; published projection is incomplete; stop and recreate the session" ), }), }); + break; } Err(broadcast::error::RecvError::Closed) => break, } @@ -823,11 +866,9 @@ impl SpawnedWorkerRegistry { .iter() .filter(|record| record.session.visibility() == InternalWorkerVisibility::ParentClient) .map(|record| { - internal_worker_snapshot( - record.protocol_ref(parent_session_id.clone()), - record.protocol_revision(), - &record.session, - ) + let mut snapshot = record.published.snapshot.lock().unwrap().clone(); + snapshot.worker.parent_session_id = parent_session_id.clone(); + snapshot }) .collect::>(); snapshots.extend( @@ -839,11 +880,9 @@ impl SpawnedWorkerRegistry { record.session.visibility() == InternalWorkerVisibility::ParentClient }) .map(|record| { - internal_worker_snapshot( - record.protocol_ref(parent_session_id.clone()), - record.protocol_revision(), - &record.session, - ) + let mut snapshot = record.published.snapshot.lock().unwrap().clone(); + snapshot.worker.parent_session_id = parent_session_id.clone(); + snapshot }), ); snapshots @@ -970,6 +1009,8 @@ impl SpawnedWorkerRegistry { .map_err(|error| io::Error::other(error.to_string()))?; let summary = record.stop_summary(); self.reclaim_record_scope(&record)?; + // Membership removal and its terminal event are one publication boundary. + let publication_guard = self.protocol_snapshot_publish_guard(); let removed = { let mut records = self.internal_records.lock().map_err(|_| { @@ -992,12 +1033,16 @@ impl SpawnedWorkerRegistry { removed }; if removed.is_some() { - self.publish_internal_removal(&record); + self.publish_internal_removal(&publication_guard, &record); } Ok(removed.map(|_| summary)) } - fn publish_internal_removal(&self, record: &InternalSpawnedWorkerRecord) { + fn publish_internal_removal( + &self, + _guard: &std::sync::MutexGuard<'_, ()>, + record: &InternalSpawnedWorkerRecord, + ) { if record.session.visibility() != InternalWorkerVisibility::ParentClient { return; } @@ -1005,19 +1050,17 @@ impl SpawnedWorkerRegistry { else { return; }; - let _emit_guard = record - .protocol_emit_lock - .lock() - .unwrap_or_else(|error| error.into_inner()); record.protocol_terminal.store(true, Ordering::Release); - let revision = record.protocol_revision.fetch_add(1, Ordering::AcqRel) + 1; let _ = parent_tx.send(Event::InternalWorkerRemoved { worker: record.protocol_ref(Some(parent_session_id)), - revision, }); } - fn publish_service_removal(&self, record: &InternalServiceWorkerRecord) { + fn publish_service_removal( + &self, + _guard: &std::sync::MutexGuard<'_, ()>, + record: &InternalServiceWorkerRecord, + ) { if record.session.visibility() != InternalWorkerVisibility::ParentClient { return; } @@ -1025,15 +1068,9 @@ impl SpawnedWorkerRegistry { else { return; }; - let _emit_guard = record - .protocol_emit_lock - .lock() - .unwrap_or_else(|error| error.into_inner()); record.protocol_terminal.store(true, Ordering::Release); - let revision = record.protocol_revision.fetch_add(1, Ordering::AcqRel) + 1; let _ = parent_tx.send(Event::InternalWorkerRemoved { worker: record.protocol_ref(Some(parent_session_id)), - revision, }); } } @@ -1041,29 +1078,28 @@ impl SpawnedWorkerRegistry { fn publish_initial_internal_snapshot( parent_tx: &broadcast::Sender, worker: &InternalWorkerRef, - protocol_revision: &AtomicU64, protocol_emit_lock: &Mutex<()>, protocol_terminal: &AtomicBool, - session: &InternalWorkerSessionHandle, + published: &super::published::PublishedProtocol, ) { - let Some(event) = internal_worker_live_snapshot(session.protocol_snapshot()) else { - return; - }; let _emit_guard = protocol_emit_lock .lock() .unwrap_or_else(|error| error.into_inner()); if protocol_terminal.load(Ordering::Acquire) { return; } - let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1; + let mut snapshot = published.snapshot.lock().unwrap(); + snapshot.worker = worker.clone(); + let Some(event) = internal_worker_live_snapshot(snapshot.clone()) else { + return; + }; let _ = parent_tx.send(Event::InternalWorker { worker: worker.clone(), - revision, event: Box::new(event), }); } -fn internal_worker_live_snapshot(snapshot: InternalWorkerSessionSnapshot) -> Option { +fn internal_worker_live_snapshot(snapshot: InternalWorkerSnapshot) -> Option { Some(Event::Snapshot { session: snapshot.session, greeting: snapshot.greeting?, @@ -1073,24 +1109,6 @@ fn internal_worker_live_snapshot(snapshot: InternalWorkerSessionSnapshot) -> Opt }) } -fn internal_worker_snapshot( - worker: InternalWorkerRef, - revision: u64, - session: &InternalWorkerSessionHandle, -) -> InternalWorkerSnapshot { - let snapshot = session.protocol_snapshot(); - InternalWorkerSnapshot { - worker, - revision, - session: snapshot.session, - greeting: snapshot.greeting, - status: snapshot.status, - error: snapshot.error, - in_flight: snapshot.in_flight, - internal_workers: snapshot.internal_workers, - } -} - impl Drop for SpawnedWorkerRegistry { fn drop(&mut self) { let Some(parent_scope) = &self.parent_scope else { @@ -1389,7 +1407,6 @@ mod tests { assert!(matches!( initial, Event::InternalWorker { - revision: 1, event, .. } if matches!( @@ -1420,7 +1437,6 @@ mod tests { assert!(matches!( update, Event::InternalWorker { - revision: 2, event, .. } if matches!( @@ -1435,6 +1451,243 @@ mod tests { )); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn parent_publication_gate_serializes_snapshot_live_and_terminal_events() { + let registry = registry(); + let (parent_tx, mut parent_rx) = broadcast::channel(16); + registry.attach_parent_protocol(parent_tx.clone(), "parent-session".into()); + let (record, child_tx) = record_with_greeting( + "research", + InternalWorkerVisibility::ParentClient, + Some(protocol::Greeting { + worker_name: "research".into(), + cwd: "/child".into(), + provider: "provider".into(), + model: "child-model".into(), + reasoning: None, + scope_summary: "child scope".into(), + tools: Vec::new(), + context_window: 64_000, + context_tokens: 0, + context_usage: None, + }), + ) + .await; + registry + .internal_records + .lock() + .unwrap() + .push(record.clone()); + + // Capturing before acquiring the gate would publish the obsolete Idle state. + let guard = registry.protocol_snapshot_publish_guard(); + let worker_ref = record.protocol_ref(Some("parent-session".into())); + let gate = registry.protocol_publish_lock.clone(); + let terminal = record.protocol_terminal.clone(); + let published = record.published.clone(); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let publisher = std::thread::spawn(move || { + started_tx.send(()).unwrap(); + publish_initial_internal_snapshot( + &parent_tx, + &worker_ref, + &gate, + &terminal, + &published, + ); + }); + started_rx.recv().unwrap(); + record + .session + .force_status(InternalWorkerSessionStatus::Running); + assert!(matches!( + parent_rx.try_recv(), + Err(broadcast::error::TryRecvError::Empty) + )); + drop(guard); + publisher.join().unwrap(); + let Event::InternalWorker { event, .. } = parent_rx.recv().await.unwrap() else { + panic!("expected child snapshot"); + }; + let Event::Snapshot { state, .. } = *event else { + panic!("expected snapshot"); + }; + assert_eq!(state, protocol::WorkerStatus::Idle.into()); + + registry.start_protocol_forwarding(record.clone()); + parent_rx.recv().await.unwrap(); // initial snapshot from forwarding + let guard = registry.protocol_snapshot_publish_guard(); + child_tx + .send(Event::TextDone { + text: "live".into(), + }) + .unwrap(); + assert!( + tokio::time::timeout(Duration::from_millis(20), parent_rx.recv()) + .await + .is_err() + ); + drop(guard); + assert!( + matches!(parent_rx.recv().await.unwrap(), Event::InternalWorker { event, .. } + if matches!(*event, Event::TextDone { ref text } if text == "live")) + ); + + { + let guard = registry.protocol_snapshot_publish_guard(); + registry.publish_internal_removal(&guard, &record); + } + assert!(matches!( + parent_rx.recv().await.unwrap(), + Event::InternalWorkerRemoved { .. } + )); + child_tx + .send(Event::TextDone { + text: "must not publish".into(), + }) + .unwrap(); + assert!( + tokio::time::timeout(Duration::from_millis(20), parent_rx.recv()) + .await + .is_err() + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn reconnect_snapshot_excludes_buffered_child_state_history_and_deltas() { + let registry = registry(); + let (parent_tx, mut parent_rx) = broadcast::channel(32); + registry.attach_parent_protocol(parent_tx, "parent-session".into()); + let (record, child_tx) = record("buffered", InternalWorkerVisibility::ParentClient).await; + registry + .internal_records + .lock() + .unwrap() + .push(record.clone()); + registry.start_protocol_forwarding(record.clone()); + child_tx + .send(Event::WorkerState { + snapshot: protocol::WorkerStatus::Running.into(), + }) + .unwrap(); + parent_rx.recv().await.unwrap(); + let guard = registry.protocol_snapshot_publish_guard(); + record.session.emit_test_text_delta("not yet published"); + let entry = crate::session_history::test_logged_history_entry(agen::Item::user_message( + "future history", + )); + record + .session + .publish_test_entry(LogEntry::AnnotatedUserInput { + ts: 1, + segments: vec![protocol::Segment::text("future history")], + history: vec![entry], + extensions: Vec::new(), + }); + record + .session + .force_status(InternalWorkerSessionStatus::Idle); + child_tx + .send(Event::WorkerState { + snapshot: protocol::WorkerStatus::Idle.into(), + }) + .unwrap(); + let snapshot = registry.internal_worker_snapshots().pop().unwrap(); + assert_eq!(snapshot.status, protocol::WorkerStatus::Running); + assert!(snapshot.session.entries.is_empty()); + assert!(snapshot.in_flight.is_empty()); + drop(guard); + // The reconnect baseline is the exact published prefix, not the child's + // newer Idle/history state followed by buffered older output. + let mut replay = snapshot; + for _ in 0..3 { + let Event::InternalWorker { event, .. } = + tokio::time::timeout(Duration::from_secs(1), parent_rx.recv()) + .await + .unwrap() + .unwrap() + else { + panic!("expected child event"); + }; + super::super::published::apply(&mut replay, &event, &record.session); + } + let current = registry.internal_worker_snapshots().pop().unwrap(); + assert_eq!(replay.status, current.status); + assert_eq!(replay.session, current.session); + assert_eq!(replay.in_flight, current.in_flight); + assert_eq!(current.status, protocol::WorkerStatus::Idle); + assert_eq!(current.session.entries.len(), 1); + } + + #[tokio::test] + async fn prepared_projection_does_not_replay_pre_forwarding_deltas_twice() { + let registry = registry(); + let (parent_tx, mut parent_rx) = broadcast::channel(16); + registry.attach_parent_protocol(parent_tx, "parent-session".into()); + let (record, child_tx) = record("early", InternalWorkerVisibility::ParentClient).await; + registry + .internal_records + .lock() + .unwrap() + .push(record.clone()); + record + .session + .force_status(InternalWorkerSessionStatus::Running); + child_tx + .send(Event::WorkerState { + snapshot: protocol::WorkerStatus::Running.into(), + }) + .unwrap(); + record.session.emit_test_text_delta("once"); + let initial = registry.internal_worker_snapshots().pop().unwrap(); + assert_eq!(initial.status, protocol::WorkerStatus::Idle); + assert!(initial.in_flight.is_empty()); + registry.start_protocol_forwarding(record.clone()); + parent_rx.recv().await.unwrap(); + parent_rx.recv().await.unwrap(); + let published = registry.internal_worker_snapshots().pop().unwrap(); + assert_eq!(published.status, protocol::WorkerStatus::Running); + assert!(matches!(published.in_flight.blocks.as_slice(), + [protocol::InFlightBlock::Text { text, .. }] if text == "once")); + } + + #[tokio::test] + async fn lagged_child_stream_reports_incomplete_projection_without_replaying_tail() { + let registry = registry(); + let (parent_tx, mut parent_rx) = broadcast::channel(16); + registry.attach_parent_protocol(parent_tx, "parent-session".into()); + let (record, child_tx) = record("lagged", InternalWorkerVisibility::ParentClient).await; + registry + .internal_records + .lock() + .unwrap() + .push(record.clone()); + for _ in 0..300 { + child_tx + .send(Event::TextDelta { + text: "lost prefix".into(), + }) + .unwrap(); + } + registry.start_protocol_forwarding(record.clone()); + let event = parent_rx.recv().await.unwrap(); + assert!(matches!(event, Event::InternalWorker { event, .. } + if matches!(*event, Event::Error { ref message, .. } if message.contains("projection is incomplete")))); + let published = registry.internal_worker_snapshots().pop().unwrap(); + assert!( + published + .error + .as_deref() + .unwrap() + .contains("stop and recreate") + ); + assert!(published.in_flight.is_empty()); + assert!(matches!( + parent_rx.try_recv(), + Err(broadcast::error::TryRecvError::Empty) + )); + } + #[tokio::test] async fn visible_internal_output_is_wrapped_after_registry_insertion() { let registry = registry(); @@ -1459,7 +1712,7 @@ mod tests { .unwrap(); assert!(matches!( event, - Event::InternalWorker { worker, revision: 1, event } + Event::InternalWorker { worker, event } if worker.name == "research" && worker.parent_session_id.as_deref() == Some("parent-session") && matches!(*event, Event::TextDone { ref text } if text == "answer") @@ -1480,14 +1733,25 @@ mod tests { .unwrap(); assert!(matches!( committed, - Event::InternalWorker { revision: 2, event, .. } + Event::InternalWorker { event, .. } if matches!(*event, Event::UserMessage { .. }) )); let snapshots = registry.internal_worker_snapshots(); assert_eq!(snapshots.len(), 1); - assert_eq!(snapshots[0].revision, 2); assert_eq!(snapshots[0].session.entries.len(), 1); + record + .session + .publish_test_entry(LogEntry::AnnotatedAssistantItem { + ts: 2, + entry: crate::session_history::test_logged_history_entry( + agen::Item::assistant_message("answer"), + ), + }); + let committed = parent_rx.recv().await.unwrap(); + assert!(matches!(committed, Event::InternalWorker { event, .. } + if matches!(*event, Event::SessionEntryCommitted { .. }))); + record.session.emit_test_text_delta("partial"); let streamed = tokio::time::timeout(Duration::from_secs(1), parent_rx.recv()) .await @@ -1495,11 +1759,10 @@ mod tests { .unwrap(); assert!(matches!( streamed, - Event::InternalWorker { revision: 3, event, .. } + Event::InternalWorker { event, .. } if matches!(*event, Event::TextDelta { ref text } if text == "partial") )); let snapshots = registry.internal_worker_snapshots(); - assert_eq!(snapshots[0].revision, 3); assert_eq!(snapshots[0].in_flight.blocks.len(), 1); } @@ -1536,7 +1799,7 @@ mod tests { .unwrap(); assert!(matches!( event, - Event::InternalWorker { worker, revision: 1, event } + Event::InternalWorker { worker, event } if worker.session_id == session_id && matches!(worker.kind, InternalWorkerKind::Service { ref kind } if kind == "compaction") && matches!(*event, Event::TextDone { ref text } if text == "summary candidate") @@ -1554,7 +1817,7 @@ mod tests { .unwrap(); assert!(matches!( removed, - Event::InternalWorkerRemoved { worker, revision: 2 } + Event::InternalWorkerRemoved { worker } if worker.session_id == session_id )); assert!(registry.internal_worker_snapshots().is_empty()); @@ -1649,23 +1912,18 @@ mod tests { }) ); assert!(registry.get_internal("child").is_none()); - let terminal_revision = loop { - if let Event::InternalWorkerRemoved { worker, revision } = - parent_rx.recv().await.unwrap() - { + loop { + if let Event::InternalWorkerRemoved { worker } = parent_rx.recv().await.unwrap() { assert_eq!(worker.session_id, summary.session_id); - assert!(revision > 0); - break revision; + break; } - }; + } assert!(registry.remove_internal("child").await.unwrap().is_none()); while let Ok(Ok(event)) = tokio::time::timeout(Duration::from_millis(20), parent_rx.recv()).await { assert!(!matches!(event, Event::InternalWorkerRemoved { .. })); - if let Event::InternalWorker { revision, .. } = event { - assert!(revision > terminal_revision); - } + assert!(!matches!(event, Event::InternalWorker { .. })); } } diff --git a/crates/worker/src/spawn/tool.rs b/crates/worker/src/spawn/tool.rs index e4a26fb55..cf4d3d8f4 100644 --- a/crates/worker/src/spawn/tool.rs +++ b/crates/worker/src/spawn/tool.rs @@ -1339,7 +1339,7 @@ enabled = false .subjektiv .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "workspace-test".to_string(), - settings_revision: 1, + language: "English".to_string(), }) .unwrap(); @@ -2100,7 +2100,7 @@ enabled = false parent.feature.memory.profile.enabled = true; let settings = manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "workspace-1".to_string(), - settings_revision: 4, + language: "日本語".to_string(), }; parent diff --git a/crates/worker/src/subjektiv.rs b/crates/worker/src/subjektiv.rs index 3a3c31d5a..9e84d556e 100644 --- a/crates/worker/src/subjektiv.rs +++ b/crates/worker/src/subjektiv.rs @@ -161,7 +161,7 @@ fn decode( .find(|diagnostic| { matches!( diagnostic.code.as_str(), - "revision_conflict" + "change_conflict" | "stale_cursor" | "candidate_decision_conflict" | "subject_scope_mismatch" @@ -394,7 +394,7 @@ pub(crate) fn test_connection(client: Arc) -> SubjektivHost config .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: client.workspace_id().unwrap().to_string(), - settings_revision: 1, + language: "English".into(), }) .unwrap(); @@ -447,7 +447,6 @@ mod tests { Ok(SubjektivMemoryBackendResponse::ResidentContext( SubjektivResidentContextOutput { behavior_md: "Explicit local subject behavior.".into(), - behavior_revision: 1, memory_surface: memory::backend::MemoryResidentSummaryOutput { availability: memory::backend::MemoryResidentSummaryAvailability::Ungenerated, diff --git a/crates/worker/src/wip.rs b/crates/worker/src/wip.rs index 69f3eb453..0df009785 100644 --- a/crates/worker/src/wip.rs +++ b/crates/worker/src/wip.rs @@ -2987,7 +2987,7 @@ mod tests { } } - fn objective_detail_response(revision: &str) -> crate::worker::WorkspaceResponse { + fn objective_detail_response(content_digest: &str) -> crate::worker::WorkspaceResponse { crate::worker::WorkspaceResponse { status: 200, body: json!({ @@ -2997,7 +2997,7 @@ mod tests { "body": "Body", "body_truncated": false, "state": "active", - "revision": revision, + "content_digest": content_digest, "created_at": null, "updated_at": null, "linked_ticket_summaries": [], @@ -4009,7 +4009,7 @@ mod tests { struct ContextualItemResolver { owner: Arc, - revision: Arc, + content: Arc>, contextual_family: bool, } @@ -4032,7 +4032,7 @@ mod tests { description: None, interfaces: vec![root_reference("test.dynamic/v1")], r#ref: Some(format!("objective:{item_reference}")), - validator: Some(vec![self.revision.load(Ordering::SeqCst) as u8]), + validator: Some(self.content.lock().unwrap().as_bytes().to_vec()), }, handler: Arc::new(handler), }) @@ -4050,16 +4050,16 @@ mod tests { fn contextual_dynamic_registry( owner: Arc, contributor: Arc, - revision: Arc, + content: Arc>, contextual_collection: bool, ) -> WipMountRegistry { - let mut registry = dynamic_registry(Arc::clone(&revision), Arc::clone(&owner.calls)); + let mut registry = dynamic_registry(Arc::clone(&content), Arc::clone(&owner.calls)); let mut mounted = registry.dynamic_mounts.pop().unwrap(); mounted.descriptor.operations = vec![contributed_operation("read")]; mounted.interface_validator = Some(descriptor_validator(&mounted.descriptor)); mounted.resolver = Arc::new(ContextualItemResolver { owner, - revision, + content, contextual_family: !contextual_collection, }); if contextual_collection { @@ -4084,16 +4084,16 @@ mod tests { } #[tokio::test] - async fn contextual_dynamic_filters_by_path_and_rechecks_permission_and_object_revision() { + async fn contextual_dynamic_filters_by_path_and_rechecks_permission_and_object_content() { // Both ways to opt in a family must block unqualified descriptor fetches. for contextual_collection in [false, true] { let owner = Arc::new(ContextualHandler::new(true, true)); let contributor = Arc::new(ContextualHandler::new(true, true)); - let revision = Arc::new(AtomicUsize::new(1)); + let content = Arc::new(Mutex::new("initial fixture body".to_string())); let host = WipHost::new(contextual_dynamic_registry( Arc::clone(&owner), Arc::clone(&contributor), - Arc::clone(&revision), + Arc::clone(&content), contextual_collection, )); let initial = host.projection("/objectives/O-1").unwrap(); @@ -4161,7 +4161,7 @@ mod tests { .. })) )); - revision.store(2, Ordering::SeqCst); + *content.lock().unwrap() = "changed fixture body".to_string(); assert!(matches!( host.call(asset_request(¤t, "read"), direct_wip_context()) .await, @@ -4280,7 +4280,7 @@ mod tests { struct CurrentObjectValidatorHandler { inner: Arc, - revision: Arc, + content: Arc>, } #[async_trait] @@ -4290,7 +4290,7 @@ mod tests { } fn object_validator(&self) -> Option> { - Some(self.revision.load(Ordering::SeqCst).to_be_bytes().to_vec()) + Some(self.content.lock().unwrap().as_bytes().to_vec()) } async fn call( @@ -4306,14 +4306,14 @@ mod tests { #[tokio::test] async fn static_object_validator_hook_rejects_current_owner_state_changes() { let owner = Arc::new(ContextualHandler::new(true, true)); - let revision = Arc::new(AtomicUsize::new(1)); + let content = Arc::new(Mutex::new("initial fixture body".to_string())); let mut registry = WipMountRegistry::new(); registry.allocate_namespace("asset", "assets").unwrap(); registry .mount(contribution_projection(Arc::new( CurrentObjectValidatorHandler { inner: Arc::clone(&owner), - revision: Arc::clone(&revision), + content: Arc::clone(&content), }, ))) .unwrap(); @@ -4328,7 +4328,7 @@ mod tests { descriptor, handler: Arc::new(CurrentObjectValidatorHandler { inner: Arc::new(ContextualHandler::new(true, true)), - revision: Arc::new(AtomicUsize::new(99)), + content: Arc::new(Mutex::new("contributor body".to_string())), }), }) .unwrap(); @@ -4338,12 +4338,12 @@ mod tests { let initial = runtime.host.projection("/assets/A-1").unwrap(); assert_eq!( initial.object.validator, - Some(1usize.to_be_bytes().to_vec()) + Some(b"initial fixture body".to_vec()) ); - revision.store(2, Ordering::SeqCst); + *content.lock().unwrap() = "changed fixture body".to_string(); assert_eq!( runtime.host.object_at("/assets/A-1").unwrap().validator, - Some(2usize.to_be_bytes().to_vec()) + Some(b"changed fixture body".to_vec()) ); assert!(matches!( runtime @@ -4381,7 +4381,7 @@ mod tests { let mut registry = contextual_dynamic_registry( Arc::clone(&owner), Arc::new(ContextualHandler::new(false, true)), - Arc::new(AtomicUsize::new(1)), + Arc::new(Mutex::new("initial fixture body".to_string())), true, ); let denied_collection = Arc::new(ContextualHandler::new(false, true)); @@ -5682,7 +5682,7 @@ mod tests { } struct DynamicResolver { - revision: Arc, + content: Arc>, calls: Arc, } @@ -5715,7 +5715,7 @@ mod tests { description: None, interfaces: vec![root_reference("test.dynamic/v1")], r#ref: Some(format!("objective:{item_reference}")), - validator: Some(vec![self.revision.load(Ordering::SeqCst) as u8]), + validator: Some(self.content.lock().unwrap().as_bytes().to_vec()), }, handler: Arc::new(DynamicHandler { calls: Arc::clone(&self.calls), @@ -5738,7 +5738,7 @@ mod tests { } } - fn dynamic_registry(revision: Arc, calls: Arc) -> WipMountRegistry { + fn dynamic_registry(content: Arc>, calls: Arc) -> WipMountRegistry { let mut registry = WipMountRegistry::new(); let namespace = registry .allocate_namespace("objective", "objectives") @@ -5772,7 +5772,7 @@ mod tests { interface: root_reference("test.dynamic/v1"), descriptor, interface_validator, - resolver: Arc::new(DynamicResolver { revision, calls }), + resolver: Arc::new(DynamicResolver { content, calls }), }) .unwrap(); registry @@ -5780,15 +5780,15 @@ mod tests { #[tokio::test] async fn dynamic_routes_resolve_only_direct_children_and_reject_stale_calls() { - let revision = Arc::new(AtomicUsize::new(1)); + let content = Arc::new(Mutex::new("initial fixture body".to_string())); let calls = Arc::new(AtomicUsize::new(0)); - let host = WipHost::new(dynamic_registry(Arc::clone(&revision), Arc::clone(&calls))); + let host = WipHost::new(dynamic_registry(Arc::clone(&content), Arc::clone(&calls))); let initial = host.projection("/objectives/O-3").unwrap(); assert!(host.projection("/objectives/O-3/other").is_none()); assert!(host.projection("/objectives/T-3").is_none()); assert!(host.projection("/hidden/objectives/O-3").is_none()); - revision.store(2, Ordering::SeqCst); + *content.lock().unwrap() = "changed fixture body".to_string(); let result = host .call( CallOperationRequest { @@ -5825,9 +5825,9 @@ mod tests { #[test] fn dynamic_registration_rejects_foreign_owners_and_operation_collisions_atomically() { - let revision = Arc::new(AtomicUsize::new(1)); + let content = Arc::new(Mutex::new("initial fixture body".to_string())); let calls = Arc::new(AtomicUsize::new(0)); - let mut registry = dynamic_registry(Arc::clone(&revision), Arc::clone(&calls)); + let mut registry = dynamic_registry(Arc::clone(&content), Arc::clone(&calls)); let mounted = registry.dynamic_mounts.pop().unwrap(); let foreign = WipDynamicMount { collection_route: mounted.collection_route.clone(), @@ -5836,7 +5836,7 @@ mod tests { descriptor: mounted.descriptor.clone(), interface_validator: mounted.interface_validator.clone(), resolver: Arc::new(DynamicResolver { - revision, + content, calls: Arc::clone(&calls), }), }; @@ -5887,11 +5887,11 @@ mod tests { #[tokio::test] async fn dynamic_object_resolver_accepts_disjoint_feature_operations() { - let revision = Arc::new(AtomicUsize::new(1)); + let content = Arc::new(Mutex::new("initial fixture body".to_string())); let base_calls = Arc::new(AtomicUsize::new(0)); let contribution_calls = Arc::new(AtomicUsize::new(0)); let allowed = Arc::new(AtomicBool::new(true)); - let mut registry = dynamic_registry(revision, base_calls); + let mut registry = dynamic_registry(content, base_calls); let mounted = registry .dynamic_mounts .first() @@ -5952,8 +5952,8 @@ mod tests { engine.register_tool(echo_definition("Echo".into(), Arc::clone(&calls))); engine.register_tool(echo_definition("Other".into(), Arc::clone(&calls))); - let revision = Arc::new(AtomicUsize::new(1)); - let mut registry = dynamic_registry(revision, calls); + let content = Arc::new(Mutex::new("initial fixture body".to_string())); + let mut registry = dynamic_registry(content, calls); registry .replace_compatibility_tools("/objectives", ["Echo"]) .unwrap(); @@ -6302,7 +6302,7 @@ mod tests { "sequence": 2, "request_event_id": "request-1", "subject_ref": "source-1", - "ticket_item_revision": "ticket-rev-1", + "ticket_content_digest": "ticket-rev-1", "ticket_merge_request_subjects": [{"merge_request_id": "MR-1", "subject_ref": "source-1"}], "decision": "approve", "body": "approved", diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index ca5fde341..7218c3d76 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -225,7 +225,6 @@ struct NotificationReceipt { #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] pub(crate) struct PendingActivationState { - revision: u64, next_activation_sequence: u64, /// A prepared activation remains in checkpoints until the same atomic /// UserInput record commits the clearing checkpoint. Restore puts it back @@ -291,7 +290,6 @@ impl PendingActivationState { (None, None) => None, }; protocol::PendingSubmissionsSnapshot { - revision: self.revision, notification_count: u32::try_from(self.pending_notifications.len()).unwrap_or(u32::MAX), notification_previews: self .pending_notifications @@ -702,7 +700,6 @@ impl std::fmt::Debug for WorkspacePromptCatalogResolution { formatter .debug_struct("WorkspacePromptCatalogResolution") .field("workspace_id", &self.projection.workspace_id) - .field("config_revision", &self.projection.config_revision) .field("source_digest", &self.projection.source_digest) .field("projection_digest", &self.projection.projection_digest) .finish_non_exhaustive() @@ -927,7 +924,6 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync { /// launch/session state; this hook never reconstructs historical prompts. fn current_prompt_projection( &self, - _minimum_revision: Option, ) -> Result, WorkspaceClientError> { Ok(None) } @@ -995,9 +991,8 @@ impl WorkspaceClient for ReviewerChildWorkspaceClient { fn current_prompt_projection( &self, - minimum_revision: Option, ) -> Result, WorkspaceClientError> { - self.inner.current_prompt_projection(minimum_revision) + self.inner.current_prompt_projection() } fn execute_binary( @@ -1809,8 +1804,11 @@ pub(crate) enum PendingSubmissionError { ByteLimit, #[error("pending submission artifact references exceed {MAX_PENDING_ARTIFACT_REFS}")] ArtifactLimit, - #[error("pending queue revision conflict: expected {expected}, current {current}")] - RevisionConflict { expected: u64, current: u64 }, + #[error("pending queue content conflict: expected {expected:?}, current {current:?}")] + ContentConflict { + expected: Vec, + current: Vec, + }, #[error("pending queue head conflict: expected {expected}, current {current:?}")] HeadConflict { expected: String, @@ -1832,26 +1830,17 @@ impl PendingSubmissionHandle where St: Store + Clone, { - fn validate_fence( + fn validate_head( state: &PendingActivationState, - expected_revision: u64, - expected_head_id: Option<&str>, + expected: &str, ) -> Result<(), PendingSubmissionError> { - if state.revision != expected_revision { - return Err(PendingSubmissionError::RevisionConflict { - expected: expected_revision, - current: state.revision, + let current = state.snapshot().head_id; + if current.as_deref() != Some(expected) { + return Err(PendingSubmissionError::HeadConflict { + expected: expected.to_owned(), + current, }); } - if let Some(expected) = expected_head_id { - let current = state.snapshot().head_id; - if current.as_deref() != Some(expected) { - return Err(PendingSubmissionError::HeadConflict { - expected: expected.to_owned(), - current, - }); - } - } Ok(()) } @@ -2015,7 +2004,6 @@ where payload_digest, disposition, }); - current.revision = current.revision.saturating_add(1); if activate_now { current.activating = Some(pending.clone()); @@ -2166,7 +2154,6 @@ where source_namespace, payload_digest, }); - state.revision = state.revision.saturating_add(1); if let Err(error) = self.persist_locked(&state) { *state = original; return Err(error); @@ -2202,7 +2189,6 @@ where state .activating_notifications .extend(notifications.iter().cloned()); - state.revision = state.revision.saturating_add(1); notifications .into_iter() @@ -2218,7 +2204,7 @@ where pub(crate) fn prepare_next_activation( &self, - fence: Option<(u64, &str)>, + fence: Option<&str>, ) -> Result, PendingSubmissionError> { let _append_guard = self .writer @@ -2230,8 +2216,8 @@ where .state .lock() .expect("pending activation state poisoned"); - if let Some((expected_revision, expected_head_id)) = fence { - Self::validate_fence(&state, expected_revision, Some(expected_head_id))?; + if let Some(expected_head_id) = fence { + Self::validate_head(&state, expected_head_id)?; } if state.activating.is_some() { return Ok(None); @@ -2240,7 +2226,6 @@ where return Ok(None); }; state.activating = Some(pending.clone()); - state.revision = state.revision.saturating_add(1); Ok(Some(PendingActivation::Submission(pending))) } @@ -2267,7 +2252,6 @@ where .receipts .retain(|receipt| receipt.submission_id != pending.submission_id); } - state.revision = state.revision.saturating_add(1); if let Err(error) = self.persist_locked(&state) { tracing::error!(error = %error, "failed to persist aborted pending activation"); } @@ -2288,7 +2272,6 @@ where receipt.disposition = protocol::SubmissionDisposition::Started; } committed.activating = None; - committed.revision = committed.revision.saturating_add(1); pending_activation_extension(&committed) } @@ -2311,7 +2294,6 @@ where receipt.disposition = protocol::SubmissionDisposition::Started; } state.activating = None; - state.revision = state.revision.saturating_add(1); } } @@ -2341,7 +2323,6 @@ where pub(crate) fn cancel( &self, submission_id: &str, - expected_revision: u64, ) -> Result { let _append_guard = self .writer @@ -2353,7 +2334,6 @@ where .state .lock() .expect("pending activation state poisoned"); - Self::validate_fence(&state, expected_revision, None)?; let original = state.clone(); let Some(index) = state .pending @@ -2366,7 +2346,6 @@ where .pending .remove(index) .expect("located pending submission must exist"); - state.revision = state.revision.saturating_add(1); if let Err(error) = self.persist_locked(&state) { *state = original; return Err(error); @@ -2377,7 +2356,7 @@ where pub(crate) fn clear( &self, - expected_revision: u64, + expected_submission_ids: &[String], ) -> Result { let _append_guard = self .writer @@ -2389,11 +2368,19 @@ where .state .lock() .expect("pending activation state poisoned"); - Self::validate_fence(&state, expected_revision, None)?; let original = state.clone(); + let current = state + .pending + .iter() + .map(|pending| pending.submission_id.clone()) + .collect::>(); + if current != expected_submission_ids { + return Err(PendingSubmissionError::ContentConflict { + expected: expected_submission_ids.to_vec(), + current, + }); + } let removed = state.pending.drain(..).collect::>(); - state.pending_notifications.clear(); - state.revision = state.revision.saturating_add(1); if let Err(error) = self.persist_locked(&state) { *state = original; return Err(error); @@ -2502,7 +2489,6 @@ impl DurableNotificationCommitter for PendingSubmissionHandle } let mut committed = state.clone(); committed.activating_notifications.pop_front(); - committed.revision = committed.revision.saturating_add(1); let metadata = new_history_metadata( provenance.unwrap_or(WorkerHistoryProvenance::LegacyUnknown), None, @@ -2633,30 +2619,27 @@ struct PreparedFlowProjection { selector: String, instructions: String, definition_id: String, - definition_revision: u64, + definition_digest: String, instance_id: String, state_id: String, } /// Sole live owner of committed model-visible Worker history. /// -/// `Engine` borrows this history only while executing a run. The revision is -/// advanced together with every live rewrite so projections can fence stale -/// observations without maintaining a second transcript. +/// `Engine` borrows this history only while executing a run. Features capture +/// the committed log location and check it under the append lock before writing; +/// this live view does not maintain an independent freshness counter. #[derive(Clone)] pub struct WorkerSession { session_id: SessionId, - revision: u64, history: History, pending_activations: Arc>, } impl WorkerSession { fn new(session_id: SessionId, entries: Vec>) -> Self { - let revision = u64::try_from(entries.len()).unwrap_or(u64::MAX); Self { session_id, - revision, history: History::from_entries(entries), pending_activations: Arc::new(Mutex::new(PendingActivationState::default())), } @@ -2684,13 +2667,11 @@ impl WorkerSession { } if let Some(activating) = state.activating.take() { state.pending.push_front(activating); - state.revision = state.revision.saturating_add(1); } if !state.activating_notifications.is_empty() { let mut restored = std::mem::take(&mut state.activating_notifications); restored.append(&mut state.pending_notifications); state.pending_notifications = restored; - state.revision = state.revision.saturating_add(1); } *self .pending_activations @@ -2717,10 +2698,6 @@ impl WorkerSession { self.session_id } - pub fn revision(&self) -> u64 { - self.revision - } - pub fn history(&self) -> &History { &self.history } @@ -2729,13 +2706,8 @@ impl WorkerSession { &mut self.history } - fn note_mutation(&mut self) { - self.revision = self.revision.saturating_add(1); - } - fn replace_history(&mut self, entries: Vec>) { self.history.replace_entries(entries); - self.note_mutation(); } } @@ -2748,7 +2720,7 @@ pub(crate) enum SystemPromptContribution { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ResidentContextRefresh { pub body: String, - pub revision: u64, + pub behavior_md: String, } #[async_trait::async_trait] @@ -2763,8 +2735,8 @@ pub(crate) trait SystemPromptContributionSource: Send + Sync { Ok(None) } - /// Acknowledges that the revision's typed refresh was durably committed. - fn confirm_resident_context_revision(&self, _revision: u64) {} + /// Acknowledges the exact behavior represented by a durably committed refresh. + fn confirm_resident_context_behavior(&self, _behavior_md: &str) {} /// Invalidates the in-memory representation fence before history may be /// compacted or rewound. @@ -3266,7 +3238,7 @@ impl Worker { .workspace_context .workspace_id() .map(|workspace_id| workspace_id.as_str().to_string()), - config_revision: projection.config_revision, + source_digest: projection.source_digest.clone(), projection_digest: projection.catalog_digest.clone(), logical_name: logical_name.to_string(), @@ -3283,7 +3255,7 @@ impl Worker { let Some(resolution) = self .workspace_context .client() - .current_prompt_projection(None) + .current_prompt_projection() .map_err(|source| WorkerError::WorkspacePromptProjection { message: source.to_string(), })? @@ -3292,8 +3264,7 @@ impl Worker { }; let projection = &resolution.projection; let current = self.prompts.load(); - if current.projection().config_revision == projection.config_revision - && current.projection().source_digest == projection.source_digest + if current.projection().source_digest == projection.source_digest && current.projection().catalog_digest == projection.projection_digest && Arc::ptr_eq(¤t, &resolution.catalog) { @@ -3524,7 +3495,7 @@ impl Worker { std::iter::once(history_entry.item), &mut annotate, )?; - session.note_mutation(); + Ok(activation) } @@ -3613,7 +3584,7 @@ impl Worker { .map(|id| id.as_str().to_string()), worker_id: self.manifest.worker.name.clone(), session_id: self.session.session_id().to_string(), - session_revision: self.session.revision(), + run_id, turn_index: Some(self.engine().turn_count()), call_id: None, @@ -4036,7 +4007,7 @@ impl Worker { Ok(CommittedSessionCapture { session_id: location.session_id.to_string(), segment_id: location.segment_id.to_string(), - session_revision: entries.len().try_into().unwrap_or(u64::MAX), + entry_count: entries.len(), run_exit, history, @@ -4059,10 +4030,11 @@ impl Worker { .lock() .expect("segment append lock poisoned"); let location = writer.state.location(); + // Compare the actual append position while holding the same lock as + // the write. Any intervening log append or segment replacement makes + // this captured result stale; a separate change counter adds nothing. if location.session_id.to_string() != expected.session_id || location.segment_id.to_string() != expected.segment_id - || u64::try_from(writer.state.entries_written()).unwrap_or(u64::MAX) - != expected.session_revision || writer.state.entries_written() != expected.entry_count { return Ok(false); @@ -4420,7 +4392,6 @@ impl Worker { std::iter::once(history_entry.item), &mut annotate, )?; - session.note_mutation(); } self.restore_feature_lifecycle_pending = false; Ok(()) @@ -4641,7 +4612,7 @@ impl Worker { snapshot: EmptyTurnRollbackSnapshot, ) -> Result<(), StoreError> { self.session.history_mut().truncate(snapshot.history_len); - self.session.note_mutation(); + self.last_run_interrupted = snapshot.last_run_interrupted; self.engine_mut() .set_active_run_turn_count(snapshot.active_run_turn_count); @@ -4773,7 +4744,7 @@ impl Worker { selector: selector.to_string(), instructions: initial_instructions, definition_id: state.instance.definition_id.clone(), - definition_revision: state.instance.definition_revision, + definition_digest: state.instance.definition_digest.clone(), instance_id: state.instance.instance_id.clone(), state_id: state.instance.current_state.to_string(), }; @@ -4974,7 +4945,6 @@ impl Worker { .run_with_annotation(self.session.history_mut(), input_string, &mut annotate) .await; self.engine = Some(locked.unlock()); - self.session.note_mutation(); if let Some(input) = &committed_activation_input { // A prompt hook may cancel before Engine materializes input/results. @@ -5194,7 +5164,6 @@ impl Worker { self.session.history_mut().push_entry(entry); } } - self.session.note_mutation(); } pub(crate) fn can_schedule_notification_run(&self) -> bool { @@ -5336,7 +5305,6 @@ impl Worker { entry: to_logged_history_entry(&entry), })?; self.session.history_mut().push_entry(entry); - self.session.note_mutation(); } Ok(()) } @@ -5369,7 +5337,7 @@ impl Worker { let interrupt_entry = HistoryEntry::new(agen::Item::system_message(system_note), interrupt_metadata); self.session.history_mut().push_entry(interrupt_entry); - self.session.note_mutation(); + Ok(()) } @@ -5507,7 +5475,7 @@ impl Worker { WorkerHistoryProvenance::FlowInstruction { selector: flow.selector.clone(), definition_id: flow.definition_id.clone(), - definition_revision: flow.definition_revision, + definition_digest: Some(flow.definition_digest.clone()), instance_id: flow.instance_id.clone(), state_id: flow.state_id.clone(), }, @@ -5584,7 +5552,6 @@ impl Worker { .resume_with_annotation(self.session.history_mut(), &mut annotate) .await; self.engine = Some(locked.unlock()); - self.session.note_mutation(); self.handle_worker_result(result, history_before).await } @@ -5638,7 +5605,6 @@ impl Worker { .resume_with_annotation(self.session.history_mut(), &mut annotate) .await; self.engine = Some(locked.unlock()); - self.session.note_mutation(); self.handle_worker_result(result, history_before).await } @@ -6414,7 +6380,7 @@ impl Worker { } /// Runs one parent-owned observable compaction service and returns the new - /// Segment ID. Lifecycle revisions are committed before they are broadcast. + /// Segment ID. Lifecycle states are committed before they are broadcast. pub async fn compact(&mut self, retained_tokens: u64) -> Result { self.compact_with_cancel(retained_tokens, None, CompactionTrigger::RequestThreshold) .await @@ -6434,7 +6400,6 @@ impl Worker { let mut lifecycle = CompactionLifecycle { schema_version: 3, compaction_id: uuid::Uuid::now_v7().to_string(), - revision: 1, internal_worker: None, state: CompactionLifecycleState::Running, started_at_ms: segment_log::now_millis(), @@ -6516,7 +6481,6 @@ impl Worker { ) { self.try_record_metric(&metric); } - lifecycle.revision = lifecycle.revision.saturating_add(1); lifecycle.state = if matches!(&error, WorkerError::CompactCancelled) { CompactionLifecycleState::Interrupted } else { @@ -6873,7 +6837,6 @@ impl Worker { handle.clone(), )) .map_err(|error| WorkerError::InvalidState(error.to_string()))?; - lifecycle.revision = lifecycle.revision.saturating_add(1); lifecycle.internal_worker = Some(internal_ref); let cleanup = PendingCompactionCleanup { session_id: handle.session_id_string(), @@ -7293,7 +7256,6 @@ impl Worker { // their blocks from `SegmentStart.history`. No per-item // broadcast is required. let _ = &compact_introduced_system_messages; - lifecycle.revision = lifecycle.revision.saturating_add(1); lifecycle.state = CompactionLifecycleState::Done; lifecycle.ended_at_ms = Some(segment_log::now_millis()); Ok(( @@ -9948,7 +9910,7 @@ permission = "read" .memory .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "workspace-a".to_string(), - settings_revision: 7, + language: "Japanese".to_string(), }) .unwrap(); @@ -9964,7 +9926,7 @@ permission = "read" restored.feature.memory.workspace_settings(), Some(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "workspace-a".to_string(), - settings_revision: 7, + language: "Japanese".to_string(), }) ); @@ -10068,7 +10030,7 @@ mod build_summary_prompt_tests { availability: memory::backend::MemoryResidentSummaryAvailability, content: Option, load_count: Arc, - behavior_revision: Option>, + behavior_md: Option>>, } impl WorkspaceClient for RestoreSubjectResidentClient { @@ -10100,11 +10062,10 @@ mod build_summary_prompt_tests { body: serde_json::to_string( &server_api::SubjektivMemoryBackendResponse::ResidentContext( server_api::SubjektivResidentContextOutput { - behavior_md: "restore behavior".to_string(), - behavior_revision: self - .behavior_revision - .as_ref() - .map_or(1, |revision| revision.load(Ordering::SeqCst) as u64), + behavior_md: self.behavior_md.as_ref().map_or_else( + || "restore behavior".to_string(), + |body| body.lock().unwrap().clone(), + ), memory_surface: memory::backend::MemoryResidentSummaryOutput { availability: self.availability, content: self.content.clone(), @@ -10127,7 +10088,7 @@ mod build_summary_prompt_tests { availability, content: content.map(str::to_string), load_count: Arc::clone(&load_count), - behavior_revision: None, + behavior_md: None, }); let source = crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source( manifest, @@ -10417,7 +10378,6 @@ mod build_summary_prompt_tests { selector: "builtin:coder-review".parse().unwrap(), workspace_id: "workspace-test".to_string(), flow_id: "flow-source-1".to_string(), - revision: 3, content_digest: definition.content_digest.clone(), definition, }; @@ -10818,7 +10778,7 @@ mod build_summary_prompt_tests { .bind_backend_job(crate::BackendJobExecutionBinding { job_id: "job-1".into(), attempt_id: "attempt-1".into(), - input_revision: None, + input_digest: None, subjektiv_consolidation: false }) .is_err() @@ -10833,7 +10793,7 @@ mod build_summary_prompt_tests { let binding = crate::BackendJobExecutionBinding { job_id: "job-1".into(), attempt_id: "attempt-1".into(), - input_revision: Some("revision-1".into()), + input_digest: Some("revision-1".into()), subjektiv_consolidation: true, }; worker.bind_backend_job(binding.clone()).unwrap(); @@ -11983,7 +11943,10 @@ mod build_summary_prompt_tests { WorkerHistoryProvenance::FlowInstruction { .. } )); assert_eq!(projected[1].annotation.origin, input_provenance); - assert_eq!(state.instance.definition_revision, 3); + assert_eq!( + state.instance.definition_digest, + state.definition.content_digest + ); assert_eq!(state.instance.current_state.as_str(), "implement"); assert_eq!(workspace_client.requests.lock().unwrap().len(), 1); @@ -13022,7 +12985,7 @@ permission = "write" .memory .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "workspace-test".to_string(), - settings_revision: 1, + language: "English".to_string(), }) .unwrap(); @@ -13119,7 +13082,7 @@ permission = "write" .subjektiv .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "workspace-test".to_string(), - settings_revision: 1, + language: "English".to_string(), }) .unwrap(); @@ -13317,7 +13280,7 @@ permission = "write" memory .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "workspace-test".to_string(), - settings_revision: 3, + language: "Japanese".to_string(), }) .unwrap(); @@ -13633,7 +13596,7 @@ permission = "write" .append_if_current( &stale.location(), "test.feature", - serde_json::json!({"revision": "stale"}), + serde_json::json!({"result": "stale"}), ) .unwrap() ); @@ -13644,7 +13607,7 @@ permission = "write" .append_if_current( ¤t.location(), "test.feature", - serde_json::json!({"revision": "current"}), + serde_json::json!({"result": "current"}), ) .unwrap() ); @@ -13927,9 +13890,7 @@ permission = "write" .is_ok() ); - handle - .cancel(&accepted.submission_id, handle.snapshot().revision) - .unwrap(); + handle.cancel(&accepted.submission_id).unwrap(); assert_eq!( handle .writer @@ -14039,9 +14000,7 @@ permission = "write" format!("{}…", "界".repeat(240)) ] ); - let after = handle - .cancel(&submit.submission_id, before.revision) - .unwrap(); + let after = handle.cancel(&submit.submission_id).unwrap(); assert!(after.submissions.is_empty()); assert_eq!(after.notification_previews, before.notification_previews); let entries = handle.persisted_entries_for_test(); @@ -14192,21 +14151,19 @@ permission = "write" let fence = handle.snapshot(); assert!(matches!( - handle.cancel(&accepted.submission_id, fence.revision.saturating_sub(1)), - Err(PendingSubmissionError::RevisionConflict { .. }) + handle.clear(&["stale-submission".to_owned()]), + Err(PendingSubmissionError::ContentConflict { .. }) )); assert!(matches!( - handle.prepare_next_activation(Some((fence.revision, "wrong-head"))), + handle.prepare_next_activation(Some("wrong-head")), Err(PendingSubmissionError::HeadConflict { .. }) )); assert_eq!(handle.snapshot(), fence); - let snapshot = handle - .cancel(&accepted.submission_id, handle.snapshot().revision) - .unwrap(); + let snapshot = handle.cancel(&accepted.submission_id).unwrap(); assert!(snapshot.submissions.is_empty()); assert!(matches!( - handle.cancel(&accepted.submission_id, handle.snapshot().revision), + handle.cancel(&accepted.submission_id), Err(PendingSubmissionError::NotFound(_)) )); @@ -14224,11 +14181,74 @@ permission = "write" Err(PendingSubmissionError::CountLimit) )); assert_eq!(handle.snapshot().submissions.len(), MAX_PENDING_SUBMISSIONS); - let cleared = handle.clear(handle.snapshot().revision).unwrap(); + let cleared = handle + .clear( + &handle + .snapshot() + .submissions + .iter() + .map(|submission| submission.submission_id.clone()) + .collect::>(), + ) + .unwrap(); assert!(cleared.submissions.is_empty()); assert_eq!(cleared.notification_count, 0); } + #[test] + fn pending_queue_fences_use_target_state_and_exact_fifo_content() { + let temp = tempfile::tempdir().unwrap(); + let handle = PendingSubmissionHandle::for_test(temp.path()); + let first = handle + .accept("first".into(), vec![Segment::text("one")], false) + .unwrap(); + let observed = vec![first.submission_id.clone()]; + let second = handle + .accept("second".into(), vec![Segment::text("two")], false) + .unwrap(); + handle + .accept_notification("notice".into(), "keep this".into()) + .unwrap(); + assert!(matches!( + handle.clear(&observed), + Err(PendingSubmissionError::ContentConflict { .. }) + )); + assert!(matches!( + handle.clear(&[second.submission_id.clone(), first.submission_id.clone()]), + Err(PendingSubmissionError::ContentConflict { .. }) + )); + assert_eq!(handle.snapshot().submissions.len(), 2); + handle.cancel(&second.submission_id).unwrap(); + let activation = handle + .prepare_next_activation(Some(&first.submission_id)) + .unwrap() + .unwrap(); + assert!(matches!( + handle.cancel(&first.submission_id), + Err(PendingSubmissionError::NotFound(_)) + )); + let PendingActivation::Submission(submission) = activation; + handle.abort_activation(submission); + let cleared = handle.clear(&observed).unwrap(); + assert!(cleared.submissions.is_empty()); + assert_eq!(cleared.notification_count, 1); + assert_eq!(cleared.notification_previews, ["keep this"]); + let entries = handle.persisted_entries_for_test(); + let payload = entries + .iter() + .rev() + .find_map(|entry| match entry { + LogEntry::Extension { + domain, payload, .. + } if domain == SESSION_PENDING_ACTIVATIONS_EXTENSION_DOMAIN => Some(payload), + _ => None, + }) + .unwrap(); + assert!(payload.get("revision").is_none()); + let restored: PendingActivationState = serde_json::from_value(payload.clone()).unwrap(); + assert_eq!(restored.snapshot(), cleared); + } + #[test] fn durable_notification_commits_authenticated_history_provenance() { let temp = tempfile::tempdir().unwrap(); @@ -14477,7 +14497,7 @@ permission = "write" assert_eq!(checkpoint.notification_receipts.len(), 2); assert_eq!(checkpoint.pending.len(), 1); assert_eq!(checkpoint.receipts.len(), 2); - assert_eq!(checkpoint.revision, handle.state.lock().unwrap().revision); + assert_eq!(checkpoint.snapshot(), handle.snapshot()); let restored = PendingSubmissionHandle { state: Arc::new(Mutex::new(checkpoint)), writer: handle.writer.clone(), @@ -14979,7 +14999,7 @@ permission = "write" .subjektiv .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "workspace-test".into(), - settings_revision: 1, + language: "English".into(), }) .unwrap(); @@ -15006,7 +15026,7 @@ permission = "write" .await .unwrap(); let calls = Arc::new(AtomicUsize::new(0)); - let behavior_revision = Arc::new(AtomicUsize::new(1)); + let behavior_md = Arc::new(Mutex::new("restore behavior".to_string())); let resident_source = || { crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source( &manifest, @@ -15015,7 +15035,7 @@ permission = "write" availability: memory::backend::MemoryResidentSummaryAvailability::Ready, content: Some("literal /prepare()".into()), load_count: Arc::default(), - behavior_revision: Some(behavior_revision.clone()), + behavior_md: Some(behavior_md.clone()), }, ))), Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())), @@ -15049,7 +15069,7 @@ permission = "write" .unwrap(), ); original.materialize_durable_session_head().await.unwrap(); - behavior_revision.store(2, Ordering::SeqCst); + *behavior_md.lock().unwrap() = "updated behavior".to_string(); *client.sink.lock().unwrap() = Some(original.sink()); let pending = original.pending_submission_handle(); pending @@ -15738,7 +15758,6 @@ permission = "write" fn restoring_an_in_flight_activation_requeues_it_at_the_fifo_head() { let mut session = WorkerSession::new(session_store::new_session_id(), Vec::new()); let state = PendingActivationState { - revision: 4, next_activation_sequence: 3, invocation_receipts: Vec::new(), invocation_recovery_blocked: false, diff --git a/crates/worker/tests/controller_test.rs b/crates/worker/tests/controller_test.rs index 99a8d9947..3b2466f3e 100644 --- a/crates/worker/tests/controller_test.rs +++ b/crates/worker/tests/controller_test.rs @@ -867,7 +867,7 @@ async fn backend_job_capability_is_explicit_and_preserves_profile_features() { .bind_backend_job(worker::BackendJobExecutionBinding { job_id: "job-1".into(), attempt_id: "attempt-1".into(), - input_revision: Some("input-1".into()), + input_digest: Some("input-1".into()), subjektiv_consolidation: false, }) .unwrap(); @@ -913,7 +913,6 @@ async fn consolidation_job_preserves_custom_instruction_and_explicit_tool_policy .subjektiv .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "workspace-1".into(), - settings_revision: 1, language: "English".into(), }) .unwrap(); @@ -932,7 +931,7 @@ async fn consolidation_job_preserves_custom_instruction_and_explicit_tool_policy .bind_backend_job(worker::BackendJobExecutionBinding { job_id: "job-1".into(), attempt_id: "attempt-1".into(), - input_revision: None, + input_digest: None, subjektiv_consolidation: true, }) .unwrap(); @@ -1897,7 +1896,6 @@ async fn submit_while_running_or_paused_is_durably_queued() { assert_eq!(snapshot.head_id, pending.head_id); handle .send(Method::ContinuePending { - expected_revision: snapshot.revision, expected_head_id: snapshot.head_id.unwrap(), }) .await @@ -3506,7 +3504,6 @@ async fn paused_submit_waits_for_cancel_and_continue_pending() { assert_eq!(pending.submissions.len(), 1); handle .send(Method::ContinuePending { - expected_revision: pending.revision, expected_head_id: pending.head_id.unwrap(), }) .await diff --git a/crates/worker/tests/system_prompt_template_test.rs b/crates/worker/tests/system_prompt_template_test.rs index 9293917d1..727990c6e 100644 --- a/crates/worker/tests/system_prompt_template_test.rs +++ b/crates/worker/tests/system_prompt_template_test.rs @@ -127,7 +127,7 @@ async fn make_worker_with_body( .clone(); templates.insert("test".to_string(), body.to_string()); let projection = - EffectivePromptCatalog::new(templates, 1, "test-schema", "test-toolchain").unwrap(); + EffectivePromptCatalog::new(templates, "test-schema", "test-toolchain").unwrap(); let loader = PromptCatalogSource::builtins_only().with_effective_catalog(projection); let worker = @@ -162,7 +162,7 @@ async fn template_parse_rejects_invalid_syntax() { .clone(); templates.insert("broken".to_string(), "{{ unclosed".to_string()); let error = - EffectivePromptCatalog::new(templates, 1, "test-schema", "test-toolchain").unwrap_err(); + EffectivePromptCatalog::new(templates, "test-schema", "test-toolchain").unwrap_err(); assert!(error.to_string().contains("does not compile")); } diff --git a/crates/workspace-drive/src/lib.rs b/crates/workspace-drive/src/lib.rs index 861d2e4b9..4be8aa41e 100644 --- a/crates/workspace-drive/src/lib.rs +++ b/crates/workspace-drive/src/lib.rs @@ -2,9 +2,11 @@ //! blocking thread, never on an async executor. SQL IMMEDIATE transactions are //! the cross-process authority for uploads, tree changes, reads, and collection. mod blob; +mod migrations; +use migrations::MIGRATIONS; use feature_storage::{ - FeatureDatabase, FeatureMigration, FeatureRegistration, FeatureStorageError, RegisteredFeature, + FeatureDatabase, FeatureRegistration, FeatureStorageError, RegisteredFeature, ScopedFeatureStorage, }; use rusqlite::{Connection, OptionalExtension, Transaction, params}; @@ -25,9 +27,9 @@ pub const MAX_NAME_BYTES: usize = 255; pub enum Error { #[error("invalid Drive request: {0}")] Invalid(String), - #[error("Drive node or revision no longer exists")] + #[error("Drive node no longer exists")] NotFound, - #[error("Drive revision, name, or request identity conflict")] + #[error("Drive node changed, name is taken, or request identity conflicts")] Conflict, #[error("Drive access denied")] Denied, @@ -72,7 +74,6 @@ macro_rules! decimal_id { }; } decimal_id!(NodeId); -decimal_id!(Revision); #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -88,7 +89,8 @@ pub struct Node { pub parent_id: Option, pub name: String, pub kind: Kind, - pub revision: Revision, + /// Request ID of the last committed mutation; empty only for the immutable root. + pub last_mutation_id: String, pub size: u64, pub content_type: Option, pub updated_by: String, @@ -110,19 +112,19 @@ pub enum Mutation { }, Update { id: NodeId, - expected_revision: Revision, + expected_mutation_id: String, content_type: String, bytes: Vec, }, Relocate { id: NodeId, - expected_revision: Revision, + expected_mutation_id: String, parent: NodeId, name: String, }, Delete { id: NodeId, - expected_revision: Revision, + expected_mutation_id: String, }, } @@ -151,7 +153,7 @@ pub struct Page { #[derive(Debug, Serialize, Deserialize)] pub struct ReadChunk { pub id: NodeId, - pub revision: Revision, + pub last_mutation_id: String, pub offset: u64, pub bytes: Vec, pub eof: bool, @@ -159,7 +161,7 @@ pub struct ReadChunk { #[derive(Debug, Serialize, Deserialize)] pub struct TextChunk { pub id: NodeId, - pub revision: Revision, + pub last_mutation_id: String, pub offset: u64, pub text: String, pub eof: bool, @@ -187,35 +189,6 @@ pub struct Drive { _storage: ScopedFeatureStorage, } -static MIGRATIONS: &[FeatureMigration] = - &[FeatureMigration::new(1, "drive_tree_and_receipts", migrate)]; -fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> { - tx.execute_batch("\ - CREATE TABLE drive_state (singleton INTEGER PRIMARY KEY CHECK(singleton=1), - workspace_id TEXT NOT NULL UNIQUE, deleting INTEGER NOT NULL DEFAULT 0 CHECK(deleting IN (0,1))); - CREATE TABLE drive_nodes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - workspace_id TEXT NOT NULL REFERENCES drive_state(workspace_id), - parent_id INTEGER, - name TEXT NOT NULL CHECK(length(CAST(name AS BLOB)) <= 255), - kind TEXT NOT NULL CHECK(kind IN ('file','directory')), - revision INTEGER NOT NULL CHECK(revision > 0), - blob_key TEXT, size INTEGER NOT NULL CHECK(size >= 0 AND size <= 16777216), - content_type TEXT, updated_by TEXT NOT NULL, updated_at_ms INTEGER NOT NULL, - UNIQUE(workspace_id,id), - FOREIGN KEY(workspace_id,parent_id) REFERENCES drive_nodes(workspace_id,id), - CHECK((kind='directory' AND blob_key IS NULL AND size=0 AND content_type IS NULL) - OR (kind='file' AND blob_key IS NOT NULL AND content_type IS NOT NULL))); - CREATE UNIQUE INDEX drive_sibling_name ON drive_nodes(workspace_id,ifnull(parent_id,0),name); - CREATE INDEX drive_children ON drive_nodes(parent_id,id); - CREATE INDEX drive_blob_reference ON drive_nodes(blob_key) WHERE blob_key IS NOT NULL; - CREATE UNIQUE INDEX drive_single_root ON drive_nodes(workspace_id) WHERE parent_id IS NULL; - CREATE TABLE drive_receipts (request_id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, - result_json TEXT NOT NULL, committed_at_ms INTEGER NOT NULL); - ")?; - Ok(()) -} - impl Drive { pub fn register(storage: &ScopedFeatureStorage) -> feature_storage::Result { storage.register(FeatureRegistration::new("workspace-drive", MIGRATIONS)) @@ -232,8 +205,8 @@ impl Drive { tx.execute("INSERT OR IGNORE INTO drive_state(singleton,workspace_id) VALUES (1,?1)", [&workspace_id])?; let stored: String = tx.query_row("SELECT workspace_id FROM drive_state WHERE singleton=1", [], |r| r.get(0))?; if stored != workspace_id { return Err(Error::Invalid("metadata belongs to another Workspace".into())); } - tx.execute("INSERT OR IGNORE INTO drive_nodes(workspace_id,parent_id,name,kind,revision,size,updated_by,updated_at_ms) - VALUES (?1,NULL,'','directory',1,0,'server',?2)", params![workspace_id, now_ms()])?; + tx.execute("INSERT OR IGNORE INTO drive_nodes(workspace_id,parent_id,name,kind,last_mutation_id,size,updated_by,updated_at_ms) + VALUES (?1,NULL,'','directory','',0,'server',?2)", params![workspace_id, now_ms()])?; Ok(()) })?; Ok(Self { @@ -356,6 +329,9 @@ impl Drive { }) } + /// Recover the committed result by request identity, including pre-cutover + /// receipts whose payload cannot be verified for replay. Committed status is + /// not proof that a different actor/payload matches that request. pub fn request_status(&self, request_id: &str) -> Result { validate_identity(request_id, "request_id", 128)?; self.database.try_transaction(|c| { @@ -379,6 +355,9 @@ impl Drive { /// DB commit is publication. The blob and receipt are never independently /// treated as a successful mutation. Successful request IDs are retained for /// the Workspace lifetime; reuse with a different actor/payload is conflict. + /// Pre-cutover replay checks the original typed-payload digest using proven + /// receipt correspondence. If that evidence is unavailable, returns Conflict + /// without executing; recover the committed result through request_status. pub fn mutate( &self, request_id: &str, @@ -396,7 +375,9 @@ impl Drive { self.authorize(tx, true)?; if let Some((old_fingerprint,json)) = tx.query_row("SELECT fingerprint,result_json FROM drive_receipts WHERE request_id=?1", [request_id], |r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?))).optional()? { - if old_fingerprint != fingerprint { return Err(Error::Conflict); } + if old_fingerprint != fingerprint && !migrations::matches_legacy_replay(tx, request_id, actor, &mutation, &old_fingerprint)? { + return Err(Error::Conflict); + } return Ok(serde_json::from_str(&json)?); } accepting(tx)?; @@ -404,40 +385,40 @@ impl Drive { let result = match mutation { Mutation::CreateFolder { parent,name } => { directory(tx,parent)?; - insert(tx,&self.workspace_id,parent,&name,Kind::Directory,None,0,None,actor,at)? + insert(tx,&self.workspace_id,parent,&name,Kind::Directory,None,0,None,actor,at,request_id)? } Mutation::CreateFile { parent,name,content_type,bytes } => { directory(tx,parent)?; let key = Uuid::now_v7().to_string(); self.blobs.put(&key,&bytes)?; - insert(tx,&self.workspace_id,parent,&name,Kind::File,Some(&key),bytes.len(),Some(&content_type),actor,at)? + insert(tx,&self.workspace_id,parent,&name,Kind::File,Some(&key),bytes.len(),Some(&content_type),actor,at,request_id)? } - Mutation::Update { id,expected_revision,content_type,bytes } => { - let old = changeable(tx,id,expected_revision)?; + Mutation::Update { id,expected_mutation_id,content_type,bytes } => { + let old = changeable(tx,id,&expected_mutation_id)?; if old.kind != Kind::File { return Err(Error::Invalid("cannot write a directory".into())); } let key = Uuid::now_v7().to_string(); self.blobs.put(&key,&bytes)?; - cas(tx.execute("UPDATE drive_nodes SET blob_key=?1,size=?2,content_type=?3,revision=revision+1,updated_by=?4,updated_at_ms=?5 - WHERE id=?6 AND revision=?7", params![key,bytes.len(),content_type,actor,at,id.0,expected_revision.0])?)?; + cas(tx.execute("UPDATE drive_nodes SET blob_key=?1,size=?2,content_type=?3,last_mutation_id=?7,updated_by=?4,updated_at_ms=?5 + WHERE id=?6", params![key,bytes.len(),content_type,actor,at,id.0,request_id])?)?; MutationResult { node: node(tx,id)?, deleted:false } } - Mutation::Relocate { id,expected_revision,parent,name } => { - changeable(tx,id,expected_revision)?; + Mutation::Relocate { id,expected_mutation_id,parent,name } => { + changeable(tx,id,&expected_mutation_id)?; directory(tx,parent)?; let cyclic: bool = tx.query_row("WITH RECURSIVE ancestors(id,parent_id) AS ( SELECT id,parent_id FROM drive_nodes WHERE id=?1 UNION ALL SELECT n.id,n.parent_id FROM drive_nodes n JOIN ancestors a ON n.id=a.parent_id) SELECT EXISTS(SELECT 1 FROM ancestors WHERE id=?2)",params![parent.0,id.0],|r|r.get(0))?; if cyclic { return Err(Error::Invalid("move would create a cycle".into())); } - unique(tx.execute("UPDATE drive_nodes SET parent_id=?1,name=?2,revision=revision+1,updated_by=?3,updated_at_ms=?4 - WHERE id=?5 AND revision=?6",params![parent.0,name,actor,at,id.0,expected_revision.0]))?; + unique(tx.execute("UPDATE drive_nodes SET parent_id=?1,name=?2,last_mutation_id=?6,updated_by=?3,updated_at_ms=?4 + WHERE id=?5",params![parent.0,name,actor,at,id.0,request_id]))?; MutationResult { node:node(tx,id)?,deleted:false } } - Mutation::Delete { id,expected_revision } => { - let old = changeable(tx,id,expected_revision)?; + Mutation::Delete { id,expected_mutation_id } => { + let old = changeable(tx,id,&expected_mutation_id)?; let children: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM drive_nodes WHERE parent_id=?1)",[id.0],|r|r.get(0))?; if children { return Err(Error::Conflict); } - cas(tx.execute("DELETE FROM drive_nodes WHERE id=?1 AND revision=?2",params![id.0,expected_revision.0])?)?; + cas(tx.execute("DELETE FROM drive_nodes WHERE id=?1",params![id.0])?)?; MutationResult { node:old,deleted:true } } }; @@ -447,13 +428,13 @@ impl Drive { }) } - /// Each chunk requires the originally selected revision. Old revisions are + /// Each chunk requires the originally observed committed mutation. Old blobs are /// not retained: after update/delete return conflict/not-found, never switch /// silently to the latest blob. GC cannot run while this read holds SQL lock. pub fn read( &self, id: NodeId, - revision: Revision, + expected_mutation_id: &str, offset: u64, length: usize, ) -> Result { @@ -462,7 +443,7 @@ impl Drive { self.authorize(tx, false)?; accepting(tx)?; let selected = node(tx, id)?; - if selected.revision != revision { + if selected.last_mutation_id != expected_mutation_id { return Err(Error::Conflict); } if selected.kind != Kind::File { @@ -486,7 +467,7 @@ impl Drive { } Ok(ReadChunk { id, - revision, + last_mutation_id: selected.last_mutation_id, offset, bytes, eof: end == selected.size, @@ -499,17 +480,17 @@ impl Drive { pub fn read_text( &self, id: NodeId, - revision: Revision, + expected_mutation_id: &str, offset: u64, length: usize, ) -> Result { - let chunk = self.read(id, revision, offset, length)?; + let chunk = self.read(id, expected_mutation_id, offset, length)?; let text = String::from_utf8(chunk.bytes).map_err(|_| { Error::Invalid("range is not valid UTF-8; select character-aligned offsets".into()) })?; Ok(TextChunk { id, - revision, + last_mutation_id: chunk.last_mutation_id, offset, text, eof: chunk.eof, @@ -693,9 +674,9 @@ fn accepting(c: &Connection) -> Result<()> { Ok(()) } fn node(c: &Connection, id: NodeId) -> Result { - c.query_row("SELECT id,workspace_id,parent_id,name,kind,revision,size,content_type,updated_by,updated_at_ms FROM drive_nodes WHERE id=?1",[id.0],|r| { + c.query_row("SELECT id,workspace_id,parent_id,name,kind,last_mutation_id,size,content_type,updated_by,updated_at_ms FROM drive_nodes WHERE id=?1",[id.0],|r| { Ok(Node {id:NodeId(r.get(0)?),workspace_id:r.get(1)?,parent_id:r.get::<_,Option>(2)?.map(NodeId),name:r.get(3)?, - kind:if r.get::<_,String>(4)?=="file" {Kind::File} else {Kind::Directory},revision:Revision(r.get(5)?), + kind:if r.get::<_,String>(4)?=="file" {Kind::File} else {Kind::Directory},last_mutation_id:r.get(5)?, size:r.get::<_,i64>(6)? as u64,content_type:r.get(7)?,updated_by:r.get(8)?,updated_at_ms:r.get(9)?}) }).optional()?.ok_or(Error::NotFound) } @@ -706,17 +687,18 @@ fn directory(c: &Connection, id: NodeId) -> Result<()> { Ok(()) } } -fn changeable(c: &Connection, id: NodeId, revision: Revision) -> Result { +// Called only inside the same IMMEDIATE transaction that publishes the mutation. +// Compare the last successful request, not time: even identical bytes or a move +// away and back cannot revive an old request. Receipts prevent request ID reuse. +fn changeable(c: &Transaction<'_>, id: NodeId, expected_mutation_id: &str) -> Result { let n = node(c, id)?; if n.parent_id.is_none() { return Err(Error::Invalid("root is immutable".into())); } - if n.revision != revision { + validate_identity(expected_mutation_id, "expected_mutation_id", 128)?; + if n.last_mutation_id != expected_mutation_id { return Err(Error::Conflict); } - if revision.0 == i64::MAX { - return Err(Error::Invalid("revision exhausted".into())); - } Ok(n) } fn unique(result: rusqlite::Result) -> Result { @@ -748,9 +730,10 @@ fn insert( content_type: Option<&str>, actor: &str, at: i64, + request_id: &str, ) -> Result { - unique(tx.execute("INSERT INTO drive_nodes(workspace_id,parent_id,name,kind,revision,blob_key,size,content_type,updated_by,updated_at_ms) - VALUES (?1,?2,?3,?4,1,?5,?6,?7,?8,?9)",params![workspace,parent.0,name,if kind==Kind::File {"file"} else {"directory"},key,size,content_type,actor,at]))?; + unique(tx.execute("INSERT INTO drive_nodes(workspace_id,parent_id,name,kind,last_mutation_id,blob_key,size,content_type,updated_by,updated_at_ms) + VALUES (?1,?2,?3,?4,?10,?5,?6,?7,?8,?9)",params![workspace,parent.0,name,if kind==Kind::File {"file"} else {"directory"},key,size,content_type,actor,at,request_id]))?; Ok(MutationResult { node: node(tx, NodeId(tx.last_insert_rowid()))?, deleted: false, diff --git a/crates/workspace-drive/src/migrations.rs b/crates/workspace-drive/src/migrations.rs new file mode 100644 index 000000000..3d57d73f8 --- /dev/null +++ b/crates/workspace-drive/src/migrations.rs @@ -0,0 +1,665 @@ +//! Frozen schema upgrades. Old column/JSON names occur only to preserve existing data. +use crate::{Mutation, NodeId}; +use feature_storage::FeatureMigration; +use rusqlite::{OptionalExtension, Transaction}; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +pub(super) static MIGRATIONS: &[FeatureMigration] = &[ + FeatureMigration::new(1, "drive_tree_and_receipts", create_original_schema), + FeatureMigration::new( + 2, + "bind_nodes_to_committed_requests", + bind_committed_requests, + ), +]; +fn create_original_schema(tx: &Transaction<'_>) -> feature_storage::Result<()> { + tx.execute_batch("\ + CREATE TABLE drive_state (singleton INTEGER PRIMARY KEY CHECK(singleton=1), + workspace_id TEXT NOT NULL UNIQUE, deleting INTEGER NOT NULL DEFAULT 0 CHECK(deleting IN (0,1))); + CREATE TABLE drive_nodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id TEXT NOT NULL REFERENCES drive_state(workspace_id), + parent_id INTEGER, + name TEXT NOT NULL CHECK(length(CAST(name AS BLOB)) <= 255), + kind TEXT NOT NULL CHECK(kind IN ('file','directory')), + revision INTEGER NOT NULL CHECK(revision > 0), + blob_key TEXT, size INTEGER NOT NULL CHECK(size >= 0 AND size <= 16777216), + content_type TEXT, updated_by TEXT NOT NULL, updated_at_ms INTEGER NOT NULL, + UNIQUE(workspace_id,id), + FOREIGN KEY(workspace_id,parent_id) REFERENCES drive_nodes(workspace_id,id), + CHECK((kind='directory' AND blob_key IS NULL AND size=0 AND content_type IS NULL) + OR (kind='file' AND blob_key IS NOT NULL AND content_type IS NOT NULL))); + CREATE UNIQUE INDEX drive_sibling_name ON drive_nodes(workspace_id,ifnull(parent_id,0),name); + CREATE INDEX drive_children ON drive_nodes(parent_id,id); + CREATE INDEX drive_blob_reference ON drive_nodes(blob_key) WHERE blob_key IS NOT NULL; + CREATE UNIQUE INDEX drive_single_root ON drive_nodes(workspace_id) WHERE parent_id IS NULL; + CREATE TABLE drive_receipts (request_id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, + result_json TEXT NOT NULL, committed_at_ms INTEGER NOT NULL); + ")?; + Ok(()) +} + +fn bind_committed_requests(tx: &Transaction<'_>) -> feature_storage::Result<()> { + // Materialize the original receipt correspondence once. In particular, a + // deletion receipt contains the pre-delete node, so it refers to the earlier + // create/update request, not to the deletion request itself. Equal timestamps + // do not matter. Missing/ambiguous evidence fails constraints and rolls back. + // Keep only proven predecessor correspondence for legacy digest verification. + // No request payload is stored: fingerprints remain untouched. A missing + // predecessor leaves replay unavailable, but the migrated result recoverable + // through request_status. This evidence never authorizes a new mutation. + tx.execute_batch("\ + CREATE TEMP TABLE drive_observed_requests ( + node_id INTEGER NOT NULL, previous_counter INTEGER NOT NULL, + request_id TEXT NOT NULL, matches INTEGER NOT NULL CHECK(matches=1), + PRIMARY KEY(node_id,previous_counter)); + INSERT INTO drive_observed_requests + SELECT CAST(json_extract(result_json, '$.node.id') AS INTEGER), + CAST(json_extract(result_json, '$.node.revision') AS INTEGER), + min(request_id), count(*) FROM drive_receipts + WHERE json_extract(result_json, '$.deleted') = 0 GROUP BY 1,2; + CREATE TABLE drive_legacy_replays ( + request_id TEXT PRIMARY KEY REFERENCES drive_receipts(request_id) ON DELETE CASCADE, + expected_mutation_id TEXT NOT NULL, expected_revision TEXT NOT NULL); + INSERT INTO drive_legacy_replays + SELECT receipt.request_id, observed.request_id, CAST(observed.previous_counter AS TEXT) + FROM drive_receipts AS receipt JOIN drive_observed_requests AS observed + ON observed.node_id = CAST(json_extract(receipt.result_json, '$.node.id') AS INTEGER) + AND observed.previous_counter = CAST(json_extract(receipt.result_json, '$.node.revision') AS INTEGER) + - CASE WHEN json_extract(receipt.result_json, '$.deleted') = 1 THEN 0 ELSE 1 END; + ALTER TABLE drive_nodes ADD COLUMN last_mutation_id TEXT NOT NULL DEFAULT ''; + UPDATE drive_nodes SET last_mutation_id = ( + SELECT request_id FROM drive_observed_requests + WHERE node_id = drive_nodes.id AND previous_counter = drive_nodes.revision + ) WHERE parent_id IS NOT NULL; + UPDATE drive_receipts SET result_json = ( + SELECT json_remove(json_set(result_json, '$.node.last_mutation_id', observed.request_id), '$.node.revision') + FROM drive_observed_requests AS observed + WHERE observed.node_id = CAST(json_extract(result_json, '$.node.id') AS INTEGER) + AND observed.previous_counter = CAST(json_extract(result_json, '$.node.revision') AS INTEGER) + ); + ALTER TABLE drive_nodes DROP COLUMN revision; + DROP TABLE drive_observed_requests; + ")?; + Ok(()) +} + +// Frozen pre-cutover typed payload encoding, including field order and decimal +// strings. Never reconstruct a fingerprint from the result: compare the caller's +// complete actor/payload against the original digest. Historical counters below +// are replay-only evidence and must never participate in live node CAS. +#[derive(Serialize)] +#[serde(tag = "operation", rename_all = "snake_case")] +enum LegacyMutation<'a> { + Update { + id: NodeId, + expected_revision: &'a str, + content_type: &'a str, + bytes: &'a [u8], + }, + Relocate { + id: NodeId, + expected_revision: &'a str, + parent: NodeId, + name: &'a str, + }, + Delete { + id: NodeId, + expected_revision: &'a str, + }, +} + +pub(super) fn matches_legacy_replay( + tx: &Transaction<'_>, + request_id: &str, + actor: &str, + mutation: &Mutation, + fingerprint: &str, +) -> crate::Result { + let evidence = tx + .query_row( + "SELECT expected_mutation_id,expected_revision FROM drive_legacy_replays WHERE request_id=?1", + [request_id], + |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)), + ) + .optional()?; + let Some((expected_request, counter)) = evidence else { + // Missing prior receipt: do not guess. request_status still recovers the + // committed result, while mutate rejects this identity without execution. + return Ok(false); + }; + let (expected, legacy) = match mutation { + Mutation::Update { + id, + expected_mutation_id, + content_type, + bytes, + } => ( + expected_mutation_id, + LegacyMutation::Update { + id: *id, + expected_revision: &counter, + content_type, + bytes, + }, + ), + Mutation::Relocate { + id, + expected_mutation_id, + parent, + name, + } => ( + expected_mutation_id, + LegacyMutation::Relocate { + id: *id, + expected_revision: &counter, + parent: *parent, + name, + }, + ), + Mutation::Delete { + id, + expected_mutation_id, + } => ( + expected_mutation_id, + LegacyMutation::Delete { + id: *id, + expected_revision: &counter, + }, + ), + Mutation::CreateFolder { .. } | Mutation::CreateFile { .. } => return Ok(false), + }; + if expected != &expected_request { + return Ok(false); + } + let candidate: String = Sha256::digest(serde_json::to_vec(&(actor, legacy))?) + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + Ok(candidate == fingerprint) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Drive, Mutation, NodeId, RequestStatus}; + use feature_storage::{FeatureRegistration, FeatureStorage}; + use rusqlite::params; + use serde_json::json; + use sha2::{Digest, Sha256}; + use std::path::Path; + + fn seed_old(path: &Path, copies_of_current_receipt: usize) { + let storage = FeatureStorage::new(path).workspace("ws").unwrap(); + let registration = storage + .register(FeatureRegistration::new( + "workspace-drive", + &MIGRATIONS[..1], + )) + .unwrap(); + let database = storage.open(®istration).unwrap(); + let key = "00000000-0000-0000-0000-000000000004"; + crate::blob::BlobStore::open(&path.parent().unwrap().join("blobs")) + .unwrap() + .put(key, b"persisted-file") + .unwrap(); + database.try_transaction::<_, feature_storage::FeatureStorageError>(|tx| { + tx.execute_batch("INSERT INTO drive_state(singleton,workspace_id) VALUES(1,'ws'); + INSERT INTO drive_nodes(id,workspace_id,parent_id,name,kind,revision,size,updated_by,updated_at_ms) + VALUES(1,'ws',NULL,'','directory',1,0,'server',42), + (2,'ws',1,'after','directory',2,0,'actor',42), + (3,'ws',1,'deleted','directory',1,0,'actor',42); + DELETE FROM drive_nodes WHERE id=3;")?; + let receipt = |request: &str, id: &str, name: &str, number: i64, deleted: bool| -> feature_storage::Result<()> { + // Literal frozen typed JSON keeps this fixture independent of the + // production legacy serializer (field order is part of the digest). + let payload = if deleted { + format!(r#"["actor",{{"operation":"delete","id":"{id}","expected_revision":"{number}"}}]"#) + } else if number > 1 { + format!(r#"["actor",{{"operation":"relocate","id":"{id}","expected_revision":"{}","parent":"1","name":"{name}"}}]"#, number - 1) + } else { + format!(r#"["actor",{{"operation":"create_folder","parent":"1","name":"{name}"}}]"#) + }; + let fingerprint = digest(payload.as_bytes()); + let result = json!({"node":{"id":id,"workspace_id":"ws","parent_id":"1","name":name, + "kind":"directory","revision":number.to_string(),"size":0,"content_type":null, + "updated_by":"actor","updated_at_ms":42},"deleted":deleted}); + tx.execute("INSERT INTO drive_receipts VALUES(?1,?2,?3,42)", params![request,fingerprint,result.to_string()])?; + Ok(()) + }; + receipt("create", "2", "before", 1, false)?; + for index in 0..copies_of_current_receipt { + receipt(&format!("move-{index}"), "2", "after", 2, false)?; + } + receipt("create-deleted", "3", "deleted", 1, false)?; + receipt("delete", "3", "deleted", 1, true)?; + tx.execute("INSERT INTO drive_nodes(id,workspace_id,parent_id,name,kind,revision,blob_key,size,content_type,updated_by,updated_at_ms) + VALUES(4,'ws',1,'file','file',1,?1,14,'text/plain','actor',42)", [key])?; + let file_result = json!({"node":{"id":"4","workspace_id":"ws","parent_id":"1","name":"file", + "kind":"file","revision":"1","size":14,"content_type":"text/plain", + "updated_by":"actor","updated_at_ms":42},"deleted":false}); + let create_file = Mutation::CreateFile { parent: NodeId(1), name: "file".into(), content_type: "text/plain".into(), bytes: b"persisted-file".to_vec() }; + let fingerprint = digest(&serde_json::to_vec(&("actor", create_file)).unwrap()); + tx.execute("INSERT INTO drive_receipts VALUES('file-create',?1,?2,42)", params![fingerprint,file_result.to_string()])?; + Ok(()) + }).unwrap(); + } + + #[test] + fn upgrade_uses_receipts_not_timestamps_and_preserves_replay_and_deleted_results() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("metadata"); + seed_old(&path, 1); + let storage = FeatureStorage::new(&path).workspace("ws").unwrap(); + let registration = Drive::register(&storage).unwrap(); + let drive = Drive::open(&storage, ®istration, &temp.path().join("blobs")).unwrap(); + assert_eq!(drive.database.schema_version().unwrap(), 2); + assert_eq!(drive.root().unwrap().last_mutation_id, ""); + let file = drive.metadata(NodeId(4)).unwrap(); + assert_eq!(file.last_mutation_id, "file-create"); + assert_eq!( + drive + .read(file.id, &file.last_mutation_id, 0, 100) + .unwrap() + .bytes, + b"persisted-file" + ); + let current = drive.metadata(NodeId(2)).unwrap(); + assert_eq!(current.name, "after"); + assert_eq!(current.last_mutation_id, "move-0"); + // Original create fingerprint is unchanged, and replay never overwrites the live node. + let replay = drive + .mutate( + "create", + "actor", + Mutation::CreateFolder { + parent: NodeId(1), + name: "before".into(), + }, + ) + .unwrap(); + assert_eq!(replay.node.name, "before"); + assert_eq!(replay.node.last_mutation_id, "create"); + assert_eq!(drive.metadata(NodeId(2)).unwrap(), current); + let RequestStatus::Committed { result } = drive.request_status("delete").unwrap() else { + panic!("lost deletion receipt"); + }; + assert!(result.deleted); + assert_eq!(result.node.last_mutation_id, "create-deleted"); + assert_eq!(result.node.id, NodeId(3)); + assert!(matches!( + drive.metadata(NodeId(3)), + Err(crate::Error::NotFound) + )); + let next = drive + .mutate( + "new", + "actor", + Mutation::CreateFolder { + parent: NodeId(1), + name: "new".into(), + }, + ) + .unwrap(); + assert!( + next.node.id > NodeId(3), + "deleted IDs must not be reused after migration" + ); + drive + .database + .try_with_connection::<_, feature_storage::FeatureStorageError>(|c| { + let count: i64 = c.query_row( + "SELECT count(*) FROM pragma_table_info('drive_nodes') WHERE name='revision'", + [], + |r| r.get(0), + )?; + assert_eq!(count, 0, "old counter must not remain in the live schema"); + Ok(()) + }) + .unwrap(); + } + + fn digest(payload: &[u8]) -> String { + Sha256::digest(payload) + .iter() + .map(|b| format!("{b:02x}")) + .collect() + } + + fn seed_update(path: &Path, missing_predecessors: bool) { + seed_old(path, 1); + let storage = FeatureStorage::new(path).workspace("ws").unwrap(); + let old = storage + .register(FeatureRegistration::new( + "workspace-drive", + &MIGRATIONS[..1], + )) + .unwrap(); + let database = storage.open(&old).unwrap(); + database.try_transaction::<_, feature_storage::FeatureStorageError>(|tx| { + let result = json!({"node":{"id":"4","workspace_id":"ws","parent_id":"1","name":"file", + "kind":"file","revision":"2","size":14,"content_type":"text/plain", + "updated_by":"actor","updated_at_ms":42},"deleted":false}); + let payload = br#"["actor",{"operation":"update","id":"4","expected_revision":"1","content_type":"text/plain","bytes":[112,101,114,115,105,115,116,101,100,45,102,105,108,101]}]"#; + tx.execute("INSERT INTO drive_receipts VALUES('file-update',?1,?2,42)", params![digest(payload), result.to_string()])?; + tx.execute("UPDATE drive_nodes SET revision=2 WHERE id=4", [])?; + if missing_predecessors { + tx.execute("DELETE FROM drive_receipts WHERE request_id IN ('file-create','create')", [])?; + } + Ok(()) + }).unwrap(); + } + + fn changed_replays() -> Vec<(&'static str, Mutation)> { + vec![ + ( + "file-update", + Mutation::Update { + id: NodeId(4), + expected_mutation_id: "file-create".into(), + content_type: "text/plain".into(), + bytes: b"persisted-file".to_vec(), + }, + ), + ( + "move-0", + Mutation::Relocate { + id: NodeId(2), + expected_mutation_id: "create".into(), + parent: NodeId(1), + name: "after".into(), + }, + ), + ( + "delete", + Mutation::Delete { + id: NodeId(3), + expected_mutation_id: "create-deleted".into(), + }, + ), + ] + } + + fn fingerprints(database: &feature_storage::FeatureDatabase) -> Vec<(String, String)> { + database + .try_with_connection::<_, feature_storage::FeatureStorageError>(|c| { + let mut stmt = c.prepare( + "SELECT request_id,fingerprint FROM drive_receipts ORDER BY request_id", + )?; + Ok(stmt + .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))? + .collect::>>()?) + }) + .unwrap() + } + + #[test] + fn upgrade_preserves_original_digests_and_never_uses_old_counters_for_new_mutations() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("metadata"); + seed_update(&path, false); + let original = { + let storage = FeatureStorage::new(&path).workspace("ws").unwrap(); + let old = storage + .register(FeatureRegistration::new( + "workspace-drive", + &MIGRATIONS[..1], + )) + .unwrap(); + fingerprints(&storage.open(&old).unwrap()) + }; + let storage = FeatureStorage::new(&path).workspace("ws").unwrap(); + let registration = Drive::register(&storage).unwrap(); + let drive = Drive::open(&storage, ®istration, &temp.path().join("blobs")).unwrap(); + assert_eq!(fingerprints(&drive.database), original); + assert!(matches!( + drive.mutate( + "new-update", + "actor", + Mutation::Update { + id: NodeId(4), + expected_mutation_id: "2".into(), + content_type: "text/plain".into(), + bytes: b"changed".to_vec(), + } + ), + Err(crate::Error::Conflict) + )); + assert_eq!( + drive.request_status("new-update").unwrap(), + RequestStatus::Uncommitted + ); + assert_eq!( + drive.metadata(NodeId(4)).unwrap().last_mutation_id, + "file-update" + ); + assert_eq!(fingerprints(&drive.database), original); + } + + #[test] + fn migrated_update_relocate_delete_replay_original_results_without_reexecution() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("metadata"); + seed_update(&path, false); + let storage = FeatureStorage::new(&path).workspace("ws").unwrap(); + let registration = Drive::register(&storage).unwrap(); + let drive = Drive::open(&storage, ®istration, &temp.path().join("blobs")).unwrap(); + assert_eq!(drive.database.schema_version().unwrap(), 2); + // Historical replay must not overwrite a newer committed mutation. + for (id, expected) in [(NodeId(2), "move-0"), (NodeId(4), "file-update")] { + drive + .mutate( + &format!("new-{}", id.get()), + "actor", + Mutation::Relocate { + id, + expected_mutation_id: expected.into(), + parent: NodeId(1), + name: format!("new-{}", id.get()), + }, + ) + .unwrap(); + } + let before = drive.list(NodeId(1), None, 100).unwrap().nodes; + for (request, mutation) in changed_replays() { + let RequestStatus::Committed { result } = drive.request_status(request).unwrap() else { + panic!("lost {request}") + }; + assert_eq!( + drive.mutate(request, "actor", mutation).unwrap(), + result, + "{request}" + ); + assert_eq!(drive.list(NodeId(1), None, 100).unwrap().nodes, before); + } + // The unchanged CreateFile encoding remains replayable as well. + let created = drive + .mutate( + "file-create", + "actor", + Mutation::CreateFile { + parent: NodeId(1), + name: "file".into(), + content_type: "text/plain".into(), + bytes: b"persisted-file".to_vec(), + }, + ) + .unwrap(); + assert_eq!(created.node.last_mutation_id, "file-create"); + assert_eq!( + drive.collect(None, 100).unwrap().removed, + 0, + "replay must not write blobs" + ); + drop(drive); + let drive = Drive::open(&storage, ®istration, &temp.path().join("blobs")).unwrap(); + assert_eq!(drive.list(NodeId(1), None, 100).unwrap().nodes, before); + assert!(matches!( + drive.metadata(NodeId(3)), + Err(crate::Error::NotFound) + )); + } + + #[test] + fn migrated_receipts_reject_changed_actor_payload_or_precondition_without_side_effects() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("metadata"); + seed_update(&path, false); + let storage = FeatureStorage::new(&path).workspace("ws").unwrap(); + let registration = Drive::register(&storage).unwrap(); + let drive = Drive::open(&storage, ®istration, &temp.path().join("blobs")).unwrap(); + let before = drive.list(NodeId(1), None, 100).unwrap().nodes; + for (request, mutation) in changed_replays() { + let status = drive.request_status(request).unwrap(); + assert!(matches!( + drive.mutate(request, "other-actor", mutation.clone()), + Err(crate::Error::Conflict) + )); + let mut changed = mutation.clone(); + match &mut changed { + Mutation::Update { bytes, .. } => *bytes = b"changed".to_vec(), + Mutation::Relocate { name, .. } => *name = "changed".into(), + Mutation::Delete { id, .. } => *id = NodeId(2), + _ => unreachable!(), + } + assert!(matches!( + drive.mutate(request, "actor", changed), + Err(crate::Error::Conflict) + )); + let mut changed = mutation; + match &mut changed { + Mutation::Update { + expected_mutation_id, + .. + } + | Mutation::Relocate { + expected_mutation_id, + .. + } + | Mutation::Delete { + expected_mutation_id, + .. + } => *expected_mutation_id = "wrong-request".into(), + _ => unreachable!(), + } + assert!(matches!( + drive.mutate(request, "actor", changed), + Err(crate::Error::Conflict) + )); + assert_eq!(drive.request_status(request).unwrap(), status); + } + assert_eq!(drive.list(NodeId(1), None, 100).unwrap().nodes, before); + assert_eq!(drive.collect(None, 100).unwrap().removed, 0); + } + + #[test] + fn missing_prior_receipt_requires_status_recovery_and_never_reexecutes_mutation() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("metadata"); + seed_update(&path, true); + let storage = FeatureStorage::new(&path).workspace("ws").unwrap(); + let registration = Drive::register(&storage).unwrap(); + let drive = Drive::open(&storage, ®istration, &temp.path().join("blobs")).unwrap(); + let before = drive.list(NodeId(1), None, 100).unwrap().nodes; + for (request, mutation) in changed_replays().into_iter().take(2) { + let status = drive.request_status(request).unwrap(); + assert!(matches!(status, RequestStatus::Committed { .. })); + assert!(matches!( + drive.mutate(request, "actor", mutation.clone()), + Err(crate::Error::Conflict) + )); + // Even substituting the *current* guard cannot reexecute an old identity. + let mut changed = mutation; + match &mut changed { + Mutation::Update { + expected_mutation_id, + bytes, + .. + } => { + *expected_mutation_id = request.into(); + *bytes = b"changed".to_vec(); + } + Mutation::Relocate { + expected_mutation_id, + name, + .. + } => { + *expected_mutation_id = request.into(); + *name = "changed".into(); + } + _ => unreachable!(), + } + assert!(matches!( + drive.mutate(request, "actor", changed), + Err(crate::Error::Conflict) + )); + assert_eq!(drive.request_status(request).unwrap(), status); + } + assert_eq!(drive.list(NodeId(1), None, 100).unwrap().nodes, before); + assert_eq!(drive.collect(None, 100).unwrap().removed, 0); + } + + #[test] + fn migrated_receipt_evidence_does_not_prevent_workspace_purge() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("metadata"); + seed_update(&path, false); + let storage = FeatureStorage::new(&path).workspace("ws").unwrap(); + let registration = Drive::register(&storage).unwrap(); + let drive = Drive::open(&storage, ®istration, &temp.path().join("blobs")).unwrap(); + drive.purge().unwrap(); + drop(drive); + let drive = Drive::open(&storage, ®istration, &temp.path().join("blobs")).unwrap(); + assert!(drive.list(NodeId(1), None, 100).unwrap().nodes.is_empty()); + for (request, _) in changed_replays() { + assert_eq!( + drive.request_status(request).unwrap(), + RequestStatus::Uncommitted + ); + } + drive + .database + .try_with_connection::<_, feature_storage::FeatureStorageError>(|c| { + let remaining: i64 = + c.query_row("SELECT count(*) FROM drive_legacy_replays", [], |r| { + r.get(0) + })?; + assert_eq!(remaining, 0); + Ok(()) + }) + .unwrap(); + } + + #[test] + fn missing_or_ambiguous_receipts_abort_upgrade_without_partial_schema_or_data_changes() { + for copies in [0, 2] { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("metadata"); + seed_old(&path, copies); + { + let storage = FeatureStorage::new(&path).workspace("ws").unwrap(); + let registration = Drive::register(&storage).unwrap(); + assert!(storage.open(®istration).is_err()); + } + let storage = FeatureStorage::new(&path).workspace("ws").unwrap(); + let old = storage + .register(FeatureRegistration::new( + "workspace-drive", + &MIGRATIONS[..1], + )) + .unwrap(); + let database = storage.open(&old).unwrap(); + assert_eq!(database.schema_version().unwrap(), 1); + database.try_with_connection::<_, feature_storage::FeatureStorageError>(|c| { + let row: (String, i64) = c.query_row("SELECT name,revision FROM drive_nodes WHERE id=2", [], |r| Ok((r.get(0)?,r.get(1)?)))?; + assert_eq!(row, ("after".into(), 2)); + let added: i64 = c.query_row("SELECT count(*) FROM pragma_table_info('drive_nodes') WHERE name='last_mutation_id'", [], |r| r.get(0))?; + assert_eq!(added, 0); + let receipt: String = c.query_row("SELECT result_json FROM drive_receipts WHERE request_id='create'", [], |r| r.get(0))?; + assert_eq!(serde_json::from_str::(&receipt).unwrap()["node"]["revision"], "1"); + Ok(()) + }).unwrap(); + } + } +} diff --git a/crates/workspace-drive/src/tests.rs b/crates/workspace-drive/src/tests.rs index 703e4c51b..e0a506f72 100644 --- a/crates/workspace-drive/src/tests.rs +++ b/crates/workspace-drive/src/tests.rs @@ -20,7 +20,7 @@ fn create(parent: NodeId, name: &str) -> Mutation { fn migration_enforces_workspace_parent_and_unique_names() { let t = tempfile::tempdir().unwrap(); let d = fixture(&t); - assert_eq!(d.database.schema_version().unwrap(), 1); + assert_eq!(d.database.schema_version().unwrap(), 2); let root = d.root().unwrap(); d.mutate("a", "actor", create(root.id, "a")).unwrap(); assert!(matches!( @@ -28,7 +28,7 @@ fn migration_enforces_workspace_parent_and_unique_names() { Err(Error::Conflict) )); let result=d.database.try_transaction::<_,Error>(|tx| { - tx.execute("INSERT INTO drive_nodes(workspace_id,parent_id,name,kind,revision,size,updated_by,updated_at_ms) + tx.execute("INSERT INTO drive_nodes(workspace_id,parent_id,name,kind,last_mutation_id,size,updated_by,updated_at_ms) VALUES ('other',?1,'bad','directory',1,0,'actor',0)",[root.id.0])?; Ok(()) }); @@ -66,7 +66,7 @@ fn saved_blob_before_metadata_failure_is_not_published_and_restart_collects_it() .mutate("failed", "actor", create(root.id, "failed")) .unwrap(); assert_eq!( - d.read(result.node.id, result.node.revision, 0, 100) + d.read(result.node.id, &result.node.last_mutation_id, 0, 100) .unwrap() .bytes, b"persisted" @@ -117,10 +117,11 @@ fn collection_cannot_remove_in_flight_upload_or_selected_read() { .node; let go = Arc::new(std::sync::Barrier::new(2)); let g = go.clone(); + let selected = first.clone(); let reader = std::thread::spawn(move || { g.wait(); for _ in 0..20 { - match other.read(first.id, first.revision, 0, 100) { + match other.read(selected.id, &selected.last_mutation_id, 0, 100) { Ok(chunk) => assert_eq!(chunk.bytes, b"persisted"), Err(Error::Conflict) => {} e => panic!("unexpected selected read outcome: {e:?}"), @@ -137,7 +138,7 @@ fn collection_cannot_remove_in_flight_upload_or_selected_read() { "actor", Mutation::Update { id: current.id, - expected_revision: current.revision, + expected_mutation_id: current.last_mutation_id.clone(), content_type: "text/plain".into(), bytes: vec![i; 1024], }, @@ -149,7 +150,9 @@ fn collection_cannot_remove_in_flight_upload_or_selected_read() { d.collect(None, 200).unwrap(); assert_eq!(d.blobs.list(None, 200).unwrap().len(), 1); assert_eq!( - d.read(current.id, current.revision, 0, 1024).unwrap().bytes, + d.read(current.id, ¤t.last_mutation_id, 0, 1024) + .unwrap() + .bytes, vec![19; 1024] ); } @@ -191,7 +194,7 @@ fn attached_server_authority_serializes_deletion_with_cached_drive_mutations() { Err(Error::Fenced) )); assert!(matches!( - d.read(child.id, child.revision, 0, 100), + d.read(child.id, &child.last_mutation_id, 0, 100), Err(Error::Fenced) )); assert!(matches!(d.collect(None, 10), Err(Error::Fenced))); @@ -228,7 +231,7 @@ fn restarted_collection_removes_crashed_staging_without_removing_referenced_hard let d = fixture(&t); assert_eq!(d.collect(None, 200).unwrap().removed, 2); assert_eq!( - d.read(n.id, n.revision, 0, 100).unwrap().bytes, + d.read(n.id, &n.last_mutation_id, 0, 100).unwrap().bytes, b"persisted" ); assert_eq!(std::fs::read_dir(blobs).unwrap().count(), 1); @@ -257,7 +260,7 @@ fn interrupted_full_permission_and_sync_failed_uploads_do_not_publish_metadata_o ); let mutation = Mutation::Update { id: original.id, - expected_revision: original.revision, + expected_mutation_id: original.last_mutation_id.clone(), content_type: "text/plain".into(), bytes: b"failed replacement".to_vec(), }; @@ -281,7 +284,7 @@ fn interrupted_full_permission_and_sync_failed_uploads_do_not_publish_metadata_o d.blobs = Arc::new(blob::BlobStore::open(&t.path().join("blobs")).unwrap()); d.collect(None, 200).unwrap(); assert_eq!( - d.read(original.id, original.revision, 0, 100) + d.read(original.id, &original.last_mutation_id, 0, 100) .unwrap() .bytes, b"persisted" @@ -292,7 +295,7 @@ fn interrupted_full_permission_and_sync_failed_uploads_do_not_publish_metadata_o ); let replaced = d.mutate("failed", "actor", mutation).unwrap().node; assert_eq!( - d.read(replaced.id, replaced.revision, 0, 100) + d.read(replaced.id, &replaced.last_mutation_id, 0, 100) .unwrap() .bytes, b"failed replacement" @@ -360,12 +363,12 @@ fn bound_authorizer_checks_every_read_write_and_receipt_replay_without_caching() assert!(bound.search("file", true, None, 10).is_ok()); assert!( bound - .read(result.node.id, result.node.revision, 0, 100) + .read(result.node.id, &result.node.last_mutation_id, 0, 100) .is_ok() ); assert!( bound - .read_text(result.node.id, result.node.revision, 0, 100) + .read_text(result.node.id, &result.node.last_mutation_id, 0, 100) .is_ok() ); assert!(bound.request_status("request").is_ok()); @@ -388,11 +391,11 @@ fn bound_authorizer_checks_every_read_write_and_receipt_replay_without_caching() Err(Error::Denied) )); assert!(matches!( - clone.read(result.node.id, result.node.revision, 0, 100), + clone.read(result.node.id, &result.node.last_mutation_id, 0, 100), Err(Error::Denied) )); assert!(matches!( - clone.read_text(result.node.id, result.node.revision, 0, 100), + clone.read_text(result.node.id, &result.node.last_mutation_id, 0, 100), Err(Error::Denied) )); assert!(matches!( diff --git a/crates/workspace-drive/tests/storage.rs b/crates/workspace-drive/tests/storage.rs index 911e659b8..2bf55d28b 100644 --- a/crates/workspace-drive/tests/storage.rs +++ b/crates/workspace-drive/tests/storage.rs @@ -46,12 +46,14 @@ fn file( .node } fn read(d: &Drive, n: &Node) -> Vec { - d.read(n.id, n.revision, 0, MAX_READ_BYTES).unwrap().bytes + d.read(n.id, &n.last_mutation_id, 0, MAX_READ_BYTES) + .unwrap() + .bytes } fn update(n: &Node, bytes: &[u8]) -> Mutation { Mutation::Update { id: n.id, - expected_revision: n.revision, + expected_mutation_id: n.last_mutation_id.clone(), content_type: "text/markdown".into(), bytes: bytes.to_vec(), } @@ -117,7 +119,7 @@ fn relocate_and_paged_list_only_use_metadata_even_when_blob_is_missing() { "account", Mutation::Relocate { id: f.id, - expected_revision: f.revision, + expected_mutation_id: f.last_mutation_id.clone(), parent: b.id, name: "改名".into(), }, @@ -133,7 +135,7 @@ fn relocate_and_paged_list_only_use_metadata_even_when_blob_is_missing() { std::fs::remove_file(blobs.join(&before[0])).unwrap(); assert_eq!(d.list(b.id, None, 1).unwrap().nodes, vec![moved.clone()]); assert!(matches!( - d.read(moved.id, moved.revision, 0, 1), + d.read(moved.id, &moved.last_mutation_id, 0, 1), Err(Error::Blob(_)) )); let first = d.list(root.id, None, 1).unwrap(); @@ -144,7 +146,7 @@ fn relocate_and_paged_list_only_use_metadata_even_when_blob_is_missing() { } #[test] -fn parallel_revision_and_name_competitors_use_database_authority() { +fn parallel_last_mutation_id_and_name_competitors_use_database_authority() { let t = tempfile::tempdir().unwrap(); let db = t.path().join("db"); let blobs = t.path().join("blobs"); @@ -215,7 +217,7 @@ fn concurrent_cycle_moves_and_parent_delete_child_create_preserve_tree() { "account", Mutation::Relocate { id: b2.id, - expected_revision: b2.revision, + expected_mutation_id: b2.last_mutation_id.clone(), parent: aa.id, name: "b".into(), }, @@ -227,7 +229,7 @@ fn concurrent_cycle_moves_and_parent_delete_child_create_preserve_tree() { "account", Mutation::Relocate { id: a.id, - expected_revision: a.revision, + expected_mutation_id: a.last_mutation_id.clone(), parent: b.id, name: "a".into(), }, @@ -255,7 +257,7 @@ fn concurrent_cycle_moves_and_parent_delete_child_create_preserve_tree() { "account", Mutation::Delete { id: p.id, - expected_revision: p.revision, + expected_mutation_id: p.last_mutation_id.clone(), }, ); let created = worker.join().unwrap(); @@ -308,7 +310,7 @@ fn receipts_survive_response_loss_and_restart_without_double_mutation() { } #[test] -fn deletion_recreation_does_not_reuse_id_or_silently_switch_read_revision() { +fn deletion_recreation_does_not_reuse_id_or_silently_switch_read_last_mutation_id() { let t = tempfile::tempdir().unwrap(); let d = open(&t.path().join("db"), &t.path().join("blobs"), "ws"); let root = d.root().unwrap(); @@ -318,7 +320,7 @@ fn deletion_recreation_does_not_reuse_id_or_silently_switch_read_revision() { .unwrap() .node; assert!(matches!( - d.read(old.id, old.revision, 0, 1), + d.read(old.id, &old.last_mutation_id, 0, 1), Err(Error::Conflict) )); d.mutate( @@ -326,7 +328,7 @@ fn deletion_recreation_does_not_reuse_id_or_silently_switch_read_revision() { "account", Mutation::Delete { id: next.id, - expected_revision: next.revision, + expected_mutation_id: next.last_mutation_id.clone(), }, ) .unwrap(); @@ -366,11 +368,11 @@ fn bounded_search_and_read_reject_paths_oversize_and_invalid_parents() { ); let g = file(&d, &root, "g", "needle.png", "image/png", b"needle"); assert!(matches!( - d.read(f.id, f.revision, 0, MAX_READ_BYTES + 1), + d.read(f.id, &f.last_mutation_id, 0, MAX_READ_BYTES + 1), Err(Error::Invalid(_)) )); assert!(matches!( - d.read(f.id, f.revision, f.size + 1, 1), + d.read(f.id, &f.last_mutation_id, f.size + 1, 1), Err(Error::Invalid(_)) )); assert!(matches!( @@ -434,7 +436,7 @@ fn fence_purge_restart_and_workspace_boundaries_are_persistent() { } #[test] -fn stopped_writer_backup_restores_ids_revisions_receipts_and_sequence_with_blobs() { +fn stopped_writer_backup_restores_ids_last_mutation_ids_receipts_and_sequence_with_blobs() { let t = tempfile::tempdir().unwrap(); let metadata = t.path().join("metadata"); let blobs = t.path().join("blobs"); @@ -454,7 +456,7 @@ fn stopped_writer_backup_restores_ids_revisions_receipts_and_sequence_with_blobs "account", Mutation::Delete { id: deleted.id, - expected_revision: deleted.revision, + expected_mutation_id: deleted.last_mutation_id.clone(), }, ) .unwrap(); @@ -486,15 +488,10 @@ fn stopped_writer_backup_restores_ids_revisions_receipts_and_sequence_with_blobs } #[test] -fn external_ids_and_revisions_keep_full_integer_precision() { +fn external_node_ids_keep_full_integer_precision() { let max = "9223372036854775807"; let id: workspace_drive::NodeId = serde_json::from_str(&format!("\"{max}\"")).unwrap(); - let revision: workspace_drive::Revision = serde_json::from_str(&format!("\"{max}\"")).unwrap(); assert_eq!(serde_json::to_string(&id).unwrap(), format!("\"{max}\"")); - assert_eq!( - serde_json::to_string(&revision).unwrap(), - format!("\"{max}\"") - ); for bad in ["0", "-1", "01", "9223372036854775808"] { assert!(serde_json::from_str::(&format!("\"{bad}\"")).is_err()); } @@ -508,9 +505,12 @@ fn text_read_is_bounded_utf8_and_referenced_size_corruption_is_diagnosed() { let d = open(&t.path().join("db"), &blobs, "ws"); let root = d.root().unwrap(); let f = file(&d, &root, "f", "f", "text/plain", "日本語".as_bytes()); - assert_eq!(d.read_text(f.id, f.revision, 0, 3).unwrap().text, "日"); + assert_eq!( + d.read_text(f.id, &f.last_mutation_id, 0, 3).unwrap().text, + "日" + ); assert!(matches!( - d.read_text(f.id, f.revision, 1, 2), + d.read_text(f.id, &f.last_mutation_id, 1, 2), Err(Error::Invalid(_)) )); let path = std::fs::read_dir(&blobs) @@ -521,7 +521,7 @@ fn text_read_is_bounded_utf8_and_referenced_size_corruption_is_diagnosed() { .path(); std::fs::write(&path, b"corrupt length").unwrap(); assert!(matches!( - d.read(f.id, f.revision, 0, 1), + d.read(f.id, &f.last_mutation_id, 0, 1), Err(Error::Invalid(_)) )); assert!(matches!( @@ -537,7 +537,7 @@ fn text_read_is_bounded_utf8_and_referenced_size_corruption_is_diagnosed() { } } assert!(matches!( - d.read(empty.id, empty.revision, 0, 1), + d.read(empty.id, &empty.last_mutation_id, 0, 1), Err(Error::Blob(_)) )); } @@ -573,7 +573,7 @@ fn simultaneous_same_request_replays_one_create_and_folder_deletion_is_empty_onl "account", Mutation::Delete { id: one.node.id, - expected_revision: one.node.revision + expected_mutation_id: one.node.last_mutation_id } ), Err(Error::Conflict) @@ -585,7 +585,7 @@ fn simultaneous_same_request_replays_one_create_and_folder_deletion_is_empty_onl "account", Mutation::Delete { id: root.id, - expected_revision: root.revision + expected_mutation_id: root.last_mutation_id } ), Err(Error::Invalid(_)) @@ -596,7 +596,7 @@ fn simultaneous_same_request_replays_one_create_and_folder_deletion_is_empty_onl "account", Mutation::Relocate { id: child.id, - expected_revision: child.revision, + expected_mutation_id: child.last_mutation_id.clone(), parent: root.id, name: "child".into(), }, @@ -609,10 +609,73 @@ fn simultaneous_same_request_replays_one_create_and_folder_deletion_is_empty_onl "account", Mutation::Delete { id: child.id, - expected_revision: child.revision + expected_mutation_id: child.last_mutation_id } ), Err(Error::Conflict) )); assert_eq!(d.metadata(changed.id).unwrap(), changed); } + +#[test] +fn identical_writes_and_move_away_back_do_not_revive_old_observations() { + let t = tempfile::tempdir().unwrap(); + let d = open(&t.path().join("db"), &t.path().join("blobs"), "ws"); + let root = d.root().unwrap(); + let first = file( + &d, + &root, + "create-資料", + "original", + "text/markdown", + b"same", + ); + assert_eq!(first.last_mutation_id, "create-資料"); + let identical = d + .mutate("same-content", "account", update(&first, b"same")) + .unwrap() + .node; + assert_eq!(identical.last_mutation_id, "same-content"); + assert!(matches!( + d.read(first.id, &first.last_mutation_id, 0, 4), + Err(Error::Conflict) + )); + let mut current = identical; + for (request, name) in [("away", "temporary"), ("back", "original")] { + current = d + .mutate( + request, + "account", + Mutation::Relocate { + id: current.id, + expected_mutation_id: current.last_mutation_id, + parent: root.id, + name: name.into(), + }, + ) + .unwrap() + .node; + } + assert_eq!(current.name, first.name); + assert_eq!(current.last_mutation_id, "back"); + assert!(matches!( + d.mutate( + "stale-delete", + "account", + Mutation::Delete { + id: first.id, + expected_mutation_id: first.last_mutation_id, + } + ), + Err(Error::Conflict) + )); + assert!( + matches!( + d.mutate("same-content", "account", update(¤t, b"same")), + Err(Error::Conflict) + ), + "reusing a receipt ID with another observation must not publish" + ); + assert_eq!(d.metadata(current.id).unwrap(), current); + assert_eq!(read(&d, ¤t), b"same"); +} diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index 89873cee2..d3241b528 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -310,21 +310,12 @@ impl SqliteWorkspaceAuthority { created_at: event.created_at.clone(), }) .collect::>(); - let revision = format!( - "{}:{}", - record.updated_at, - all_events - .last() - .map(|event| event.event_id.as_str()) - .unwrap_or("none") - ); Ok(ObjectiveDetail { resource_key: self .resource_key(WorkspaceResourceKind::Objective, &record.objective_id)?, id: record.objective_id, title: record.title, state: record.state, - revision, created_at: Some(record.created_at), updated_at: Some(record.updated_at), linked_tickets, @@ -878,16 +869,9 @@ impl SqliteWorkspaceAuthority { }) .collect::>>()?; let merge_request = (merge_requests.len() == 1).then(|| merge_requests[0].clone()); - let item_revision = ticket - .events - .iter() - .rev() - .find(|event| matches!(event.kind.as_str(), "create" | "item_edit")) - .and_then(|event| event.attributes.get("event_id").cloned()) - .or_else(|| ticket.meta.updated_at.clone()) - .unwrap_or_else(|| format!("{}:0", ticket.meta.id)); + let content_digest = ticket::ticket_content_digest(&ticket); let requirement_approved = - ticket_requirement_approved(&requests, &merge_requests, &item_revision); + ticket_requirement_approved(&requests, &merge_requests, &content_digest); let evidence = ticket_evidence_summary( &write_repository_keys, &merge_requests, @@ -934,7 +918,7 @@ impl SqliteWorkspaceAuthority { priority: ticket.meta.priority, created_at: ticket.meta.created_at, updated_at: ticket.meta.updated_at, - item_revision, + content_digest, queued_by: ticket.meta.queued_by, queued_at: ticket.meta.queued_at, targets, @@ -1846,7 +1830,7 @@ fn ticket_write_target_repository_keys(targets: &[ticket::TicketTarget]) -> Vec< fn ticket_requirement_approved( requests: &[MergeRequest], summaries: &[TicketMergeRequestSummary], - item_revision: &str, + content_digest: &str, ) -> bool { let subjects = summaries .iter() @@ -1860,7 +1844,7 @@ fn ticket_requirement_approved( }) .collect::>>(); subjects.is_some_and(|subjects| { - merge_request::requirement_approval(requests, item_revision, &subjects, None).is_ok() + merge_request::requirement_approval(requests, content_digest, &subjects, None).is_ok() }) } @@ -2333,7 +2317,7 @@ fn ticket_query_item( priority: summary.priority, created_at: detail.created_at.clone(), updated_at: summary.updated_at, - item_revision: detail.item_revision.clone(), + content_digest: detail.content_digest.clone(), workspace_action_priority: summary.workspace_action_priority, matched_fields, snippet: snippet.map(|value| truncate_body(&value, 512).0), @@ -2794,7 +2778,7 @@ mod tests { event_id: "request-1".to_string(), sequence: 1, subject_ref: "commit-1".to_string(), - ticket_item_revision: "revision-1".to_string(), + ticket_content_digest: "revision-1".to_string(), ticket_merge_request_subjects: vec![merge_request::MergeRequestReviewSubject { merge_request_id: "mr-1".to_string(), subject_ref: "commit-1".to_string(), @@ -2808,7 +2792,7 @@ mod tests { sequence: 2, request_event_id: "request-1".to_string(), subject_ref: "commit-1".to_string(), - ticket_item_revision: "revision-1".to_string(), + ticket_content_digest: "revision-1".to_string(), ticket_merge_request_subjects: vec![merge_request::MergeRequestReviewSubject { merge_request_id: "mr-1".to_string(), subject_ref: "commit-1".to_string(), @@ -3079,14 +3063,14 @@ mod tests { MergeRequestThreadEvent::ReviewRequested(review) => { review.event_id = format!("request-{sequence}"); review.sequence = sequence; - review.ticket_item_revision = revision.into(); + review.ticket_content_digest = revision.into(); review.ticket_merge_request_subjects = subjects.clone(); } MergeRequestThreadEvent::Review(review) => { review.event_id = format!("review-{sequence}"); review.request_event_id = format!("request-{sequence}"); review.sequence = sequence + 1; - review.ticket_item_revision = revision.into(); + review.ticket_content_digest = revision.into(); review.ticket_merge_request_subjects = subjects.clone(); } _ => unreachable!(), @@ -3521,7 +3505,7 @@ VALUES ('workspace-test', 'ticket', 4); assert!(ticket.body.contains("Ticket body")); assert!(ticket.body_truncated); assert!(!ticket.body.contains("Deep Ticket marker")); - assert!(!ticket.item_revision.is_empty()); + assert!(!ticket.content_digest.is_empty()); assert_eq!(ticket.linked_objectives[0].id, "00000000001J3"); assert_eq!(ticket.event_page.returned, ticket.events.len()); let ticket_query = authority @@ -3704,7 +3688,6 @@ VALUES ('workspace-test', 'ticket', 4); let objective = authority.objective("00000000001J3").unwrap(); assert!(objective.body.contains("Objective body")); - assert!(!objective.revision.is_empty()); assert_eq!(objective.linked_ticket_summaries[0].id, "00000000001J2"); assert_eq!(objective.linked_ticket_summaries[0].state, "ready"); let linked_ticket_key = objective.linked_ticket_summaries[0].resource_key.clone(); diff --git a/crates/workspace-server/src/backend_job.rs b/crates/workspace-server/src/backend_job.rs index f7c6fc827..e7d2189bc 100644 --- a/crates/workspace-server/src/backend_job.rs +++ b/crates/workspace-server/src/backend_job.rs @@ -261,8 +261,6 @@ pub struct BackendJobRequest { pub job_id: String, /// Stable machine-readable use-site name, for example `ticket_item_check`. pub purpose: String, - /// Revision fenced by the structured result. - pub input_revision: String, /// Durable domain reference used to reload or explain the input. pub input_ref: String, /// Bounded immutable input snapshot. This must not contain credentials. @@ -295,7 +293,6 @@ impl BackendJobRequest { Self { job_id: request.job_id, purpose: request.purpose, - input_revision: request.input_revision, input_ref: request.input_ref, input: request.input, instruction: request.instruction, @@ -347,7 +344,6 @@ impl BackendJobRequest { job::JobRequest { job_id: self.job_id.clone(), purpose: self.purpose.clone(), - input_revision: self.input_revision.clone(), input_ref: self.input_ref.clone(), input: self.input.clone(), instruction: self.instruction.clone(), @@ -371,6 +367,10 @@ impl BackendJobRequest { .or_else(|| self.serialization_key.clone()) } + pub fn input_digest(&self) -> Result { + self.to_job_request().input_digest().map_err(backend_error) + } + pub fn fingerprint(&self) -> Result { self.validate()?; job::fingerprint(self).map_err(backend_error) @@ -436,7 +436,7 @@ pub struct BackendJobAttemptRecord { pub job_id: String, pub attempt_id: String, pub attempt: u8, - pub input_revision: String, + pub input_digest: String, pub state: BackendJobAttemptState, pub worker: Option, pub runtime_run_id: Option, @@ -492,7 +492,7 @@ impl BackendJobResultAcceptance { fn job_outcome(job: &BackendJobRecord, attempt: &BackendJobAttemptRecord) -> job::JobOutcome { job::JobOutcome { job_id: job.request.job_id.clone(), - input_revision: job.request.input_revision.clone(), + input_digest: attempt.input_digest.clone(), attempt_id: attempt.attempt_id.clone(), attempt: attempt.attempt, state: job.state.into(), @@ -566,7 +566,6 @@ mod tests { BackendJobRequest { job_id: "ticket-check:T-1:r7".to_string(), purpose: "ticket_item_check".to_string(), - input_revision: "7".to_string(), input_ref: "ticket://T-1/revisions/7".to_string(), input: serde_json::json!({"title": "Check me"}), instruction: "Check this immutable Ticket snapshot.".to_string(), @@ -595,7 +594,7 @@ mod tests { "job_id": "ticket-check:T-1:r7", "attempt_id": "ticket-check:T-1:r7:attempt:2", "attempt": 2, - "input_revision": "7", + "input_digest": "7", "state": "reserved", "worker": {"runtime_id": "runtime-a", "worker_id": "worker-a"}, "runtime_run_id": "run-a", @@ -687,7 +686,7 @@ mod tests { reservation.outcome(), job::JobOutcome { job_id: reservation.job.request.job_id.clone(), - input_revision: "7".into(), + input_digest: "7".into(), attempt_id: "ticket-check:T-1:r7:attempt:2".into(), attempt: 2, state: job::JobState::Pending, @@ -767,11 +766,11 @@ mod tests { let encoded = serde_json::to_string(&request).unwrap(); assert_eq!( encoded, - r#"{"job_id":"ticket-check:T-1:r7","purpose":"ticket_item_check","input_revision":"7","input_ref":"ticket://T-1/revisions/7","input":{"title":"Check me"},"instruction":"Check this immutable Ticket snapshot.","profile":"builtin:backend-job","source_worker":{"runtime_id":"runtime-a","worker_id":"worker-a"},"notification_target":{"runtime_id":"runtime-a","worker_id":"worker-a"},"limits":{"max_concurrent_jobs":8,"timeout_seconds":120,"max_result_bytes":16384,"max_attempts":2}}"# + r#"{"job_id":"ticket-check:T-1:r7","purpose":"ticket_item_check","input_ref":"ticket://T-1/revisions/7","input":{"title":"Check me"},"instruction":"Check this immutable Ticket snapshot.","profile":"builtin:backend-job","source_worker":{"runtime_id":"runtime-a","worker_id":"worker-a"},"notification_target":{"runtime_id":"runtime-a","worker_id":"worker-a"},"limits":{"max_concurrent_jobs":8,"timeout_seconds":120,"max_result_bytes":16384,"max_attempts":2}}"# ); assert_eq!( request.fingerprint().unwrap(), - "sha256:920187fa9efa111a97c4d6869d134943e45b52edb2861cad19a0a173233c8f60" + "sha256:250b626572e954d2aa2228364274c946d9f6b176eb0f0a205157e55031581147" ); let neutral = request.to_job_request(); neutral.validate().unwrap(); @@ -785,7 +784,7 @@ mod tests { ); assert_eq!( request.worker_input("attempt-1").unwrap(), - "Check this immutable Ticket snapshot.\n\nBackend Job envelope (immutable):\njob_id: ticket-check:T-1:r7\nattempt_id: attempt-1\ninput_revision: 7\ninput_ref: ticket://T-1/revisions/7\ninput_json: {\"title\":\"Check me\"}\n\nReturn success only through the structured Backend Job result capability. Final prose and Worker Idle/Stopped state are not result authority." + "Check this immutable Ticket snapshot.\n\nBackend Job envelope (immutable):\njob_id: ticket-check:T-1:r7\nattempt_id: attempt-1\ninput_digest: sha256:7d0f26b7fc612e982fd7acf2b64f29d2e8f65c7e1292683e36d7dcac38905c50\ninput_ref: ticket://T-1/revisions/7\ninput_json: {\"title\":\"Check me\"}\n\nReturn success only through the structured Backend Job result capability. Final prose and Worker Idle/Stopped state are not result authority." ); } @@ -794,13 +793,13 @@ mod tests { let submission = BackendJobResultSubmission { job_id: "job-1".into(), attempt_id: "job-1:attempt:1".into(), - input_revision: "7".into(), + input_digest: "7".into(), result: serde_json::json!({"ok": true}), }; let encoded = serde_json::to_string(&submission).unwrap(); assert_eq!( encoded, - r#"{"job_id":"job-1","attempt_id":"job-1:attempt:1","input_revision":"7","result":{"ok":true}}"# + r#"{"job_id":"job-1","attempt_id":"job-1:attempt:1","input_digest":"7","result":{"ok":true}}"# ); let neutral: job::JobResultSubmission = serde_json::from_str(&encoded).unwrap(); assert_eq!(neutral, submission); @@ -832,12 +831,14 @@ mod tests { } #[test] - fn fingerprint_covers_revision_profile_limits_and_notification_provenance() { + fn fingerprint_covers_input_profile_limits_and_notification_provenance() { let request = request(); let first = request.fingerprint().unwrap(); assert_eq!(first, request.fingerprint().unwrap()); for mutate in [ - |request: &mut BackendJobRequest| request.input_revision = "8".to_string(), + |request: &mut BackendJobRequest| { + request.input = serde_json::json!({"title":"changed"}) + }, |request: &mut BackendJobRequest| request.purpose = "alternate_check".to_string(), |request: &mut BackendJobRequest| request.limits.max_attempts = 3, |request: &mut BackendJobRequest| request.notification_target = None, diff --git a/crates/workspace-server/src/config_source.rs b/crates/workspace-server/src/config_source.rs index 19d566416..58158f464 100644 --- a/crates/workspace-server/src/config_source.rs +++ b/crates/workspace-server/src/config_source.rs @@ -109,8 +109,9 @@ pub struct WorkspaceConfigState { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct EvaluatedConfigCandidate { - pub base_revision: u64, pub base_digest: String, + pub base_toolchain_fingerprint: String, + pub base_projection_digest: String, pub snapshot: ConfigTreeSnapshot, pub contract: ToolchainContract, pub evaluation: EvaluationResult, @@ -167,9 +168,8 @@ pub(crate) fn validate_workspace_config_candidate_projections( } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)] +#[serde(deny_unknown_fields)] pub struct ConfigCommitRequest { - #[ts(type = "number")] - pub base_revision: u64, pub base_digest: String, pub changes: Vec, pub entrypoints: Vec, @@ -183,7 +183,7 @@ impl SqliteWorkspaceStore { schema_bundle: WorkspaceConfigSchemaBundle, ) -> Result { let desired_schema = schema_bundle.clone(); - let (state, requires_toolchain_refresh) = self.with_conn_mut(|conn| { + let (state, requires_toolchain_refresh, base_identity) = self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; let workspace_exists: bool = tx.query_row( "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)", @@ -211,8 +211,9 @@ impl SqliteWorkspaceStore { state } }; + let base_identity = load_evaluation_identity(&tx, workspace_id)?; tx.commit()?; - Ok((state, requires_toolchain_refresh)) + Ok((state, requires_toolchain_refresh, base_identity)) })?; let main_needs_normalization = state .snapshot @@ -222,7 +223,7 @@ impl SqliteWorkspaceStore { || state.contract.schema_bundle.fingerprint != desired_schema.fingerprint || main_needs_normalization { - let candidate = evaluate_candidate(state, &[], desired_schema)?; + let candidate = evaluate_candidate(state, base_identity, &[], desired_schema)?; return self.commit_evaluated_workspace_config(workspace_id, &candidate); } Ok(state) @@ -235,16 +236,16 @@ impl SqliteWorkspaceStore { self.with_conn(|conn| load_state(conn, workspace_id)) } - pub fn load_workspace_config_revision( + pub fn load_workspace_config_history( &self, workspace_id: &str, - revision: u64, + content_digest: &str, ) -> Result> { self.with_conn(|conn| { let manifest = conn .query_row( - "SELECT tree_digest, manifest_json FROM workspace_config_tree_revisions WHERE workspace_id = ?1 AND revision = ?2", - params![workspace_id, revision as i64], + "SELECT content_digest, manifest_json FROM workspace_config_tree_history WHERE workspace_id = ?1 AND content_digest = ?2", + params![workspace_id, content_digest], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), ) .optional()?; @@ -254,11 +255,16 @@ impl SqliteWorkspaceStore { let entries: std::collections::BTreeMap = serde_json::from_str(&manifest_json) .map_err(|error| Error::RegistryInconsistency(error.to_string()))?; - let snapshot = ConfigTreeSnapshot::from_entries(revision, entries.into_values()) + if entries.iter().any(|(path, entry)| path != &entry.path) { + return Err(Error::RegistryInconsistency( + "virtual config history manifest path mismatch".into(), + )); + } + let snapshot = ConfigTreeSnapshot::from_entries(entries.into_values()) .map_err(config_error)?; if snapshot.digest != stored_digest { return Err(Error::RegistryInconsistency(format!( - "virtual config revision digest mismatch for Workspace {workspace_id} revision {revision}" + "virtual config history digest mismatch for Workspace {workspace_id} content {content_digest}" ))); } Ok(Some(snapshot)) @@ -271,19 +277,18 @@ impl SqliteWorkspaceStore { request: &ConfigCommitRequest, schema_bundle: WorkspaceConfigSchemaBundle, ) -> Result { - let current = self - .load_workspace_config(workspace_id)? - .ok_or_else(config_not_materialized)?; + let (current, base_identity) = self.with_conn(|conn| { + let tx = conn.unchecked_transaction()?; + let current = load_state(&tx, workspace_id)?.ok_or_else(config_not_materialized)?; + let identity = load_evaluation_identity(&tx, workspace_id)?; + tx.commit()?; + Ok((current, identity)) + })?; validate_entrypoint_request(&request.entrypoints)?; - if current.snapshot.revision != request.base_revision - || current.snapshot.digest != request.base_digest - { - return Err(config_conflict(format!( - "base revision/digest mismatch; current revision is {}", - current.snapshot.revision - ))); + if current.snapshot.digest != request.base_digest { + return Err(config_conflict("base content digest mismatch")); } - evaluate_candidate(current, &request.changes, schema_bundle) + evaluate_candidate(current, base_identity, &request.changes, schema_bundle) } pub fn evaluate_workspace_config_candidate( @@ -314,88 +319,28 @@ impl SqliteWorkspaceStore { return Err(Error::WorkspaceIdMismatch); } let current = load_state(&tx, workspace_id)?.ok_or_else(config_not_materialized)?; - if current.snapshot.revision != candidate.base_revision - || current.snapshot.digest != candidate.base_digest - { - return Err(config_conflict(format!( - "base revision/digest mismatch; current revision is {}", - current.snapshot.revision - ))); + if current.snapshot.digest != candidate.base_digest { + return Err(config_conflict("base content digest mismatch")); } - let next_revision = current.snapshot.revision + 1; - let mut snapshot = candidate.snapshot.clone(); - snapshot.revision = next_revision; + let (base_toolchain, base_projection) = load_evaluation_identity(&tx, workspace_id)?; + if base_toolchain != candidate.base_toolchain_fingerprint + || base_projection != candidate.base_projection_digest + { + return Err(config_conflict("base evaluation identity mismatch")); + } + let snapshot = candidate.snapshot.clone(); + snapshot.validate().map_err(config_error)?; let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); - tx.execute( - r#"INSERT INTO workspace_config_trees ( - workspace_id, revision, tree_digest, schema_version, entrypoints_json, - decodal_version, import_policy_version, schema_bundle_json, - toolchain_fingerprint, projection_digest, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) - ON CONFLICT(workspace_id) DO UPDATE SET - revision = excluded.revision, - tree_digest = excluded.tree_digest, - schema_version = excluded.schema_version, - entrypoints_json = excluded.entrypoints_json, - decodal_version = excluded.decodal_version, - import_policy_version = excluded.import_policy_version, - schema_bundle_json = excluded.schema_bundle_json, - toolchain_fingerprint = excluded.toolchain_fingerprint, - projection_digest = excluded.projection_digest, - updated_at = excluded.updated_at"#, - params![ - workspace_id, - next_revision as i64, - snapshot.digest, - candidate.contract.schema_version, - serde_json::to_string(&candidate.contract.entrypoints) - .map_err(|error| Error::Store(error.to_string()))?, - candidate.contract.decodal_version, - candidate.contract.import_policy_version, - serde_json::to_string(&candidate.contract.schema_bundle) - .map_err(|error| Error::Store(error.to_string()))?, - candidate.contract.fingerprint, - candidate.evaluation.projection_digest, - now, - ], - )?; tx.execute( "DELETE FROM workspace_config_entries WHERE workspace_id = ?1", [workspace_id], )?; - for entry in snapshot.entries.values() { - tx.execute( - r#"INSERT INTO workspace_config_entries ( - workspace_id, path, content_type, content, content_digest - ) VALUES (?1, ?2, ?3, ?4, ?5)"#, - params![ - workspace_id, - entry.path.as_str(), - content_type_label(entry.content_type), - entry.content, - entry.content_digest, - ], - )?; - } - let manifest_json = serde_json::to_string(&snapshot.entries) - .map_err(|error| Error::Store(error.to_string()))?; - tx.execute( - r#"INSERT INTO workspace_config_tree_revisions ( - workspace_id, revision, tree_digest, toolchain_fingerprint, - schema_bundle_json, projection_digest, manifest_json, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"#, - params![ - workspace_id, - next_revision as i64, - snapshot.digest, - candidate.contract.fingerprint, - serde_json::to_string(&candidate.contract.schema_bundle) - .map_err(|error| Error::Store(error.to_string()))?, - candidate.evaluation.projection_digest, - manifest_json, - now, - ], - )?; + let state = WorkspaceConfigState { + snapshot: snapshot.clone(), + contract: candidate.contract.clone(), + projection_digest: candidate.evaluation.projection_digest.clone(), + }; + insert_materialized_state(&tx, workspace_id, &state, &now)?; tx.commit()?; Ok(WorkspaceConfigState { snapshot, @@ -447,7 +392,6 @@ fn normalize_main_config_schema_assertion( "{MAIN_CONFIG_ENTRYPOINT} must be a top-level object so it can be asserted as {WORKSPACE_CONFIG_SCHEMA_ASSERTION}" ))); }; - let revision = snapshot.revision; let mut entries = Vec::with_capacity(snapshot.entries.len()); for entry in snapshot.entries.into_values() { if entry.path == main_path { @@ -459,7 +403,7 @@ fn normalize_main_config_schema_assertion( entries.push(entry); } } - ConfigTreeSnapshot::from_entries(revision, entries).map_err(config_error) + ConfigTreeSnapshot::from_entries(entries).map_err(config_error) } fn format_candidate_sources( @@ -480,7 +424,6 @@ fn format_candidate_sources( } let environment = SnapshotEnvironment::new(snapshot.clone()); - let revision = snapshot.revision; let mut entries = Vec::with_capacity(snapshot.entries.len()); for entry in snapshot.entries.into_values() { if paths.contains(&entry.path) && entry.content_type == ConfigContentType::Decodal { @@ -493,11 +436,25 @@ fn format_candidate_sources( entries.push(entry); } } - ConfigTreeSnapshot::from_entries(revision, entries).map_err(config_error) + ConfigTreeSnapshot::from_entries(entries).map_err(config_error) +} + +// Read persisted provenance, not load_state's upgraded in-memory contract. +fn load_evaluation_identity( + conn: &rusqlite::Connection, + workspace_id: &str, +) -> Result<(String, String)> { + conn.query_row( + "SELECT toolchain_fingerprint, projection_digest FROM workspace_config_trees WHERE workspace_id = ?1", + [workspace_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(Error::from) } fn evaluate_candidate( current: WorkspaceConfigState, + base_identity: (String, String), changes: &[ConfigTreeChange], schema_bundle: WorkspaceConfigSchemaBundle, ) -> Result { @@ -516,8 +473,9 @@ fn evaluate_candidate( ) })?; Ok(EvaluatedConfigCandidate { - base_revision: current.snapshot.revision, base_digest: current.snapshot.digest, + base_toolchain_fingerprint: base_identity.0, + base_projection_digest: base_identity.1, snapshot, contract, evaluation, @@ -528,61 +486,28 @@ pub(crate) fn load_state( conn: &rusqlite::Connection, workspace_id: &str, ) -> Result> { - let has_schema_bundle: bool = conn.query_row( - "SELECT EXISTS( - SELECT 1 FROM pragma_table_info('workspace_config_trees') - WHERE name = 'schema_bundle_json' - )", - [], - |row| row.get(0), - )?; - let header = if has_schema_bundle { - conn.query_row( - r#"SELECT revision, tree_digest, schema_version, entrypoints_json, - decodal_version, import_policy_version, schema_bundle_json, - toolchain_fingerprint, projection_digest - FROM workspace_config_trees WHERE workspace_id = ?1"#, + let header = conn + .query_row( + r#"SELECT content_digest, schema_version, entrypoints_json, + decodal_version, import_policy_version, schema_bundle_json, + toolchain_fingerprint, projection_digest + FROM workspace_config_trees WHERE workspace_id = ?1"#, [workspace_id], |row| { Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, u32>(2)?, + row.get::<_, String>(0)?, + row.get::<_, u32>(1)?, + row.get::<_, String>(2)?, row.get::<_, String>(3)?, - row.get::<_, String>(4)?, - row.get::<_, u32>(5)?, - Some(row.get::<_, String>(6)?), - row.get::<_, String>(7)?, - row.get::<_, String>(8)?, - )) - }, - ) - .optional()? - } else { - conn.query_row( - r#"SELECT revision, tree_digest, schema_version, entrypoints_json, - decodal_version, import_policy_version, - toolchain_fingerprint, projection_digest - FROM workspace_config_trees WHERE workspace_id = ?1"#, - [workspace_id], - |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, u32>(2)?, - row.get::<_, String>(3)?, - row.get::<_, String>(4)?, - row.get::<_, u32>(5)?, - None, + row.get::<_, u32>(4)?, + row.get::<_, String>(5)?, row.get::<_, String>(6)?, row.get::<_, String>(7)?, )) }, ) - .optional()? - }; + .optional()?; let Some(( - revision, stored_digest, schema_version, entrypoints_json, @@ -622,8 +547,7 @@ pub(crate) fn load_state( Ok(entry) }) .collect::>>()?; - let snapshot = - ConfigTreeSnapshot::from_entries(revision as u64, entries).map_err(config_error)?; + let snapshot = ConfigTreeSnapshot::from_entries(entries).map_err(config_error)?; if snapshot.digest != stored_digest { return Err(Error::RegistryInconsistency(format!( "virtual config tree digest mismatch for Workspace {workspace_id}" @@ -631,11 +555,9 @@ pub(crate) fn load_state( } let entrypoints: Vec = serde_json::from_str(&entrypoints_json) .map_err(|error| Error::RegistryInconsistency(error.to_string()))?; - let stored_schema_bundle: WorkspaceConfigSchemaBundle = match schema_bundle_json { - Some(schema_bundle_json) => serde_json::from_str(&schema_bundle_json) - .map_err(|error| Error::RegistryInconsistency(error.to_string()))?, - None => WorkspaceConfigSchemaBundle::empty(), - }; + let stored_schema_bundle: WorkspaceConfigSchemaBundle = + serde_json::from_str(&schema_bundle_json) + .map_err(|error| Error::RegistryInconsistency(error.to_string()))?; let requires_toolchain_refresh = decodal_version != DECODAL_VERSION; if requires_toolchain_refresh && !matches!(decodal_version.as_str(), "0.2.0" | "0.3.0") { return Err(Error::RegistryInconsistency(format!( @@ -716,79 +638,37 @@ pub(crate) fn insert_materialized_state( .map_err(|error| Error::Store(error.to_string()))?; let manifest_json = serde_json::to_string(&state.snapshot.entries) .map_err(|error| Error::Store(error.to_string()))?; - let has_schema_bundle: bool = tx.query_row( - "SELECT EXISTS( - SELECT 1 FROM pragma_table_info('workspace_config_trees') - WHERE name = 'schema_bundle_json' - )", - [], - |row| row.get(0), - )?; let schema_bundle_json = serde_json::to_string(&state.contract.schema_bundle) .map_err(|error| Error::Store(error.to_string()))?; - if has_schema_bundle { - tx.execute( - "INSERT INTO workspace_config_trees ( - workspace_id, revision, tree_digest, schema_version, entrypoints_json, - decodal_version, import_policy_version, schema_bundle_json, - toolchain_fingerprint, projection_digest, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) - ON CONFLICT(workspace_id) DO UPDATE SET - revision = excluded.revision, - tree_digest = excluded.tree_digest, - schema_version = excluded.schema_version, - entrypoints_json = excluded.entrypoints_json, - decodal_version = excluded.decodal_version, - import_policy_version = excluded.import_policy_version, - schema_bundle_json = excluded.schema_bundle_json, - toolchain_fingerprint = excluded.toolchain_fingerprint, - projection_digest = excluded.projection_digest, - updated_at = excluded.updated_at", - rusqlite::params![ - workspace_id, - state.snapshot.revision, - state.snapshot.digest, - state.contract.schema_version, - entrypoints_json, - DECODAL_VERSION, - state.contract.import_policy_version, - schema_bundle_json, - state.contract.fingerprint, - state.projection_digest, - materialized_at, - ], - )?; - } else { - tx.execute( - "INSERT INTO workspace_config_trees ( - workspace_id, revision, tree_digest, schema_version, entrypoints_json, - decodal_version, import_policy_version, toolchain_fingerprint, - projection_digest, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) - ON CONFLICT(workspace_id) DO UPDATE SET - revision = excluded.revision, - tree_digest = excluded.tree_digest, - schema_version = excluded.schema_version, - entrypoints_json = excluded.entrypoints_json, - decodal_version = excluded.decodal_version, - import_policy_version = excluded.import_policy_version, - toolchain_fingerprint = excluded.toolchain_fingerprint, - projection_digest = excluded.projection_digest, - updated_at = excluded.updated_at", - rusqlite::params![ - workspace_id, - state.snapshot.revision, - state.snapshot.digest, - state.contract.schema_version, - entrypoints_json, - DECODAL_VERSION, - state.contract.import_policy_version, - state.contract.fingerprint, - state.projection_digest, - materialized_at, - ], - )?; - } + tx.execute( + "INSERT INTO workspace_config_trees ( + workspace_id, content_digest, schema_version, entrypoints_json, + decodal_version, import_policy_version, schema_bundle_json, + toolchain_fingerprint, projection_digest, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + ON CONFLICT(workspace_id) DO UPDATE SET + content_digest = excluded.content_digest, + schema_version = excluded.schema_version, + entrypoints_json = excluded.entrypoints_json, + decodal_version = excluded.decodal_version, + import_policy_version = excluded.import_policy_version, + schema_bundle_json = excluded.schema_bundle_json, + toolchain_fingerprint = excluded.toolchain_fingerprint, + projection_digest = excluded.projection_digest, + updated_at = excluded.updated_at", + params![ + workspace_id, + state.snapshot.digest, + state.contract.schema_version, + entrypoints_json, + DECODAL_VERSION, + state.contract.import_policy_version, + schema_bundle_json, + state.contract.fingerprint, + state.projection_digest, + materialized_at, + ], + )?; for entry in state.snapshot.entries.values() { tx.execute( "INSERT INTO workspace_config_entries ( @@ -803,40 +683,24 @@ pub(crate) fn insert_materialized_state( ], )?; } - if has_schema_bundle { - tx.execute( - "INSERT INTO workspace_config_tree_revisions ( - workspace_id, revision, tree_digest, toolchain_fingerprint, - schema_bundle_json, projection_digest, manifest_json, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - rusqlite::params![ - workspace_id, - state.snapshot.revision, - state.snapshot.digest, - state.contract.fingerprint, - schema_bundle_json, - state.projection_digest, - manifest_json, - materialized_at, - ], - )?; - } else { - tx.execute( - "INSERT INTO workspace_config_tree_revisions ( - workspace_id, revision, tree_digest, toolchain_fingerprint, - projection_digest, manifest_json, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - rusqlite::params![ - workspace_id, - state.snapshot.revision, - state.snapshot.digest, - state.contract.fingerprint, - state.projection_digest, - manifest_json, - materialized_at, - ], - )?; - } + // Preserve every distinct evaluation's provenance; only an identical + // source/toolchain/projection tuple is deduplicated. + tx.execute( + "INSERT INTO workspace_config_tree_history ( + workspace_id, content_digest, toolchain_fingerprint, + schema_bundle_json, projection_digest, manifest_json, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(workspace_id, content_digest, toolchain_fingerprint, projection_digest) DO NOTHING", + params![ + workspace_id, + state.snapshot.digest, + state.contract.fingerprint, + schema_bundle_json, + state.projection_digest, + manifest_json, + materialized_at, + ], + )?; Ok(()) } @@ -945,7 +809,6 @@ mod tests { changes: Vec, ) -> ConfigCommitRequest { ConfigCommitRequest { - base_revision: current.snapshot.revision, base_digest: current.snapshot.digest.clone(), changes, entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)], @@ -974,7 +837,11 @@ mod tests { .unwrap(); let current = initial_state_with_schema(schema.clone()).unwrap(); let change = update_main(¤t, source); - evaluate_candidate(current, &[change], schema).unwrap() + let identity = ( + current.contract.fingerprint.clone(), + current.projection_digest.clone(), + ); + evaluate_candidate(current, identity, &[change], schema).unwrap() } #[test] @@ -1075,7 +942,6 @@ mod tests { .evaluate_workspace_config_candidate_with_schema( "w-config", &ConfigCommitRequest { - base_revision: current.snapshot.revision, base_digest: current.snapshot.digest.clone(), changes: vec![ConfigTreeChange::Update { path: path(MAIN_CONFIG_ENTRYPOINT), @@ -1111,15 +977,12 @@ mod tests { #[test] fn active_state_evaluation_rejects_provider_fingerprint_drift() { - let snapshot = ConfigTreeSnapshot::from_entries( - 1, - [ConfigEntry::new( - path(MAIN_CONFIG_ENTRYPOINT), - ConfigContentType::Decodal, - "{}", - ) - .unwrap()], + let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new( + path(MAIN_CONFIG_ENTRYPOINT), + ConfigContentType::Decodal, + "{}", ) + .unwrap()]) .unwrap(); let persisted_bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( @@ -1178,7 +1041,7 @@ mod tests { } #[tokio::test] - async fn toolchain_upgrade_re_evaluates_current_tree_and_preserves_prior_revision() { + async fn toolchain_upgrade_re_evaluates_current_tree_and_preserves_prior_content_history() { let store = open_store().await; let schema_bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( "builtin:test", @@ -1204,15 +1067,16 @@ mod tests { [], )?; conn.execute( - "UPDATE workspace_config_tree_revisions + "UPDATE workspace_config_tree_history SET toolchain_fingerprint = 'sha256:legacy' - WHERE workspace_id = 'w-config' AND revision = ?1", - [current.snapshot.revision], + WHERE workspace_id = 'w-config' AND content_digest = ?1", + [current.snapshot.digest.as_str()], )?; Ok(()) }) .unwrap(); + let before_history = history_count(&store); let refreshed = store .ensure_workspace_config_materialized_with_schema( "w-config", @@ -1220,12 +1084,12 @@ mod tests { schema_bundle, ) .unwrap(); - assert_eq!(refreshed.snapshot.revision, current.snapshot.revision + 1); + assert_eq!(refreshed.snapshot, current.snapshot); assert_eq!(refreshed.snapshot.digest, current.snapshot.digest); assert_eq!(refreshed.contract.decodal_version, DECODAL_VERSION); assert_ne!(refreshed.contract.fingerprint, "sha256:legacy"); let prior = store - .load_workspace_config_revision("w-config", current.snapshot.revision) + .load_workspace_config_history("w-config", ¤t.snapshot.digest) .unwrap() .unwrap(); assert_eq!(prior, current.snapshot); @@ -1233,19 +1097,21 @@ mod tests { .with_conn(|conn| { conn.query_row( "SELECT toolchain_fingerprint - FROM workspace_config_tree_revisions - WHERE workspace_id = 'w-config' AND revision = ?1", - [current.snapshot.revision], + FROM workspace_config_tree_history + WHERE workspace_id = 'w-config' AND content_digest = ?1 + AND toolchain_fingerprint = 'sha256:legacy'", + [current.snapshot.digest.as_str()], |row| row.get::<_, String>(0), ) .map_err(Error::from) }) .unwrap(); assert_eq!(prior_fingerprint, "sha256:legacy"); + assert_eq!(history_count(&store), before_history + 1); } #[tokio::test] - async fn schema_provider_addition_re_evaluates_and_pins_a_new_revision() { + async fn schema_refresh_preserves_evaluation_provenance_and_rejects_prior_candidate() { let store = open_store().await; let initial = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( "builtin:profile-test", @@ -1262,6 +1128,14 @@ mod tests { initial, ) .unwrap(); + let stale_candidate = store + .evaluate_workspace_config_candidate_with_schema( + "w-config", + &commit_request(¤t, vec![]), + current.contract.schema_bundle.clone(), + ) + .unwrap(); + let before_history = history_count(&store); let extended = WorkspaceConfigSchemaBundle::compose([ ConfigSchemaContribution::new( "builtin:profile-test", @@ -1287,16 +1161,43 @@ mod tests { extended.clone(), ) .unwrap(); - assert_eq!(refreshed.snapshot.revision, current.snapshot.revision + 1); + assert_eq!(refreshed.snapshot, current.snapshot); assert_eq!(refreshed.snapshot.digest, current.snapshot.digest); assert_eq!(refreshed.contract.schema_bundle, extended); + assert!(matches!( + store.commit_evaluated_workspace_config("w-config", &stale_candidate), + Err(Error::WorkspaceConfigConflict(_)) + )); + assert_eq!( + store.load_workspace_config("w-config").unwrap().unwrap(), + refreshed + ); + assert_eq!(history_count(&store), before_history + 1); + for state in [¤t, &refreshed] { + let saved: (String, String) = store.with_conn(|conn| { + conn.query_row( + "SELECT schema_bundle_json, manifest_json FROM workspace_config_tree_history + WHERE workspace_id=?1 AND content_digest=?2 AND toolchain_fingerprint=?3 AND projection_digest=?4", + params!["w-config", state.snapshot.digest, state.contract.fingerprint, state.projection_digest], + |row| Ok((row.get(0)?, row.get(1)?)), + ).map_err(Error::from) + }).unwrap(); + assert_eq!( + saved.0, + serde_json::to_string(&state.contract.schema_bundle).unwrap() + ); + assert_eq!( + saved.1, + serde_json::to_string(¤t.snapshot.entries).unwrap() + ); + } assert_ne!( refreshed.projection_digest, current.projection_digest, "the newly defaulted namespace changes the evaluated projection" ); assert!( store - .load_workspace_config_revision("w-config", current.snapshot.revision) + .load_workspace_config_history("w-config", ¤t.snapshot.digest) .unwrap() .is_some() ); @@ -1326,9 +1227,7 @@ mod tests { "{ test = {}; custom = 42; }\n", ) .unwrap(); - let legacy_snapshot = - ConfigTreeSnapshot::from_entries(current.snapshot.revision, [legacy_entry.clone()]) - .unwrap(); + let legacy_snapshot = ConfigTreeSnapshot::from_entries([legacy_entry.clone()]).unwrap(); let manifest_json = serde_json::to_string(&legacy_snapshot.entries).unwrap(); store .with_conn(|conn| { @@ -1340,21 +1239,24 @@ mod tests { )?; conn.execute( "UPDATE workspace_config_trees - SET tree_digest = ?1, decodal_version = '0.2.0', + SET content_digest = ?1, decodal_version = '0.2.0', toolchain_fingerprint = 'sha256:legacy', projection_digest = 'sha256:legacy-projection' WHERE workspace_id = 'w-config'", [legacy_snapshot.digest.as_str()], )?; conn.execute( - "UPDATE workspace_config_tree_revisions - SET tree_digest = ?1, toolchain_fingerprint = 'sha256:legacy', + "UPDATE workspace_config_tree_history + SET content_digest = ?1, toolchain_fingerprint = 'sha256:legacy', projection_digest = 'sha256:legacy-projection', manifest_json = ?2 - WHERE workspace_id = 'w-config' AND revision = ?3", + WHERE workspace_id = 'w-config' AND content_digest = ?3 + AND toolchain_fingerprint = ?4 AND projection_digest = ?5", rusqlite::params![ legacy_snapshot.digest, manifest_json, - current.snapshot.revision + current.snapshot.digest, + current.contract.fingerprint, + current.projection_digest ], )?; Ok(()) @@ -1375,12 +1277,12 @@ mod tests { let persisted = store .with_conn(|conn| { conn.query_row( - "SELECT revision, decodal_version, toolchain_fingerprint + "SELECT content_digest, decodal_version, toolchain_fingerprint FROM workspace_config_trees WHERE workspace_id = 'w-config'", [], |row| { Ok(( - row.get::<_, u64>(0)?, + row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, )) @@ -1392,7 +1294,7 @@ mod tests { assert_eq!( persisted, ( - current.snapshot.revision, + legacy_snapshot.digest, "0.2.0".into(), "sha256:legacy".into() ) @@ -1403,7 +1305,7 @@ mod tests { async fn workspace_materializes_main_entrypoint() { let store = open_store().await; let current = store.load_workspace_config("w-config").unwrap().unwrap(); - assert_eq!(current.snapshot.revision, 0); + assert!(!current.snapshot.digest.is_empty()); assert_eq!( current.contract.entrypoints, vec![path(MAIN_CONFIG_ENTRYPOINT)] @@ -1459,18 +1361,15 @@ mod tests { } #[tokio::test] - async fn materialization_upgrades_legacy_main_and_preserves_prior_revision() { + async fn materialization_upgrades_legacy_main_and_preserves_prior_content_history() { let store = open_store().await; let current = store.load_workspace_config("w-config").unwrap().unwrap(); - let legacy_snapshot = ConfigTreeSnapshot::from_entries( - current.snapshot.revision + 1, - [ConfigEntry::new( - path(MAIN_CONFIG_ENTRYPOINT), - ConfigContentType::Decodal, - "{}", - ) - .unwrap()], + let legacy_snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new( + path(MAIN_CONFIG_ENTRYPOINT), + ConfigContentType::Decodal, + "{}", ) + .unwrap()]) .unwrap(); let legacy = WorkspaceConfigState { projection_digest: current.projection_digest.clone(), @@ -1494,7 +1393,7 @@ mod tests { current.contract.schema_bundle.clone(), ) .unwrap(); - assert_eq!(upgraded.snapshot.revision, legacy.snapshot.revision + 1); + assert_ne!(upgraded.snapshot.digest, legacy.snapshot.digest); assert_eq!( upgraded .snapshot @@ -1505,7 +1404,7 @@ mod tests { ); assert_eq!( store - .load_workspace_config_revision("w-config", legacy.snapshot.revision) + .load_workspace_config_history("w-config", &legacy.snapshot.digest) .unwrap() .unwrap() .get(&path(MAIN_CONFIG_ENTRYPOINT)) @@ -1563,7 +1462,6 @@ mod tests { .evaluate_and_commit_workspace_config( "w-config", &ConfigCommitRequest { - base_revision: current.snapshot.revision, base_digest: current.snapshot.digest.clone(), changes: vec![ConfigTreeChange::Update { path: path(MAIN_CONFIG_ENTRYPOINT), @@ -1579,7 +1477,7 @@ mod tests { } #[tokio::test] - async fn valid_candidate_commits_snapshot_revision_and_provenance_atomically() { + async fn valid_candidate_commits_snapshot_content_and_provenance_atomically() { let store = SqliteWorkspaceStore::in_memory().unwrap(); store.upsert_workspace(&workspace()).await.unwrap(); let current = store.load_workspace_config("w-config").unwrap().unwrap(); @@ -1589,7 +1487,7 @@ mod tests { &commit_request(¤t, vec![update_main(¤t, "{ answer = 42; }")]), ) .unwrap(); - assert_eq!(committed.snapshot.revision, 1); + assert_ne!(committed.snapshot.digest, current.snapshot.digest); assert_eq!(committed.contract.decodal_version, DECODAL_VERSION); assert!(!committed.projection_digest.is_empty()); let reread = store.load_workspace_config("w-config").unwrap().unwrap(); @@ -1615,7 +1513,7 @@ mod tests { } #[tokio::test] - async fn committed_revision_remains_retrievable_after_later_commit() { + async fn committed_content_remains_retrievable_after_later_commit() { let store = SqliteWorkspaceStore::in_memory().unwrap(); store.upsert_workspace(&workspace()).await.unwrap(); let current = store.load_workspace_config("w-config").unwrap().unwrap(); @@ -1630,7 +1528,6 @@ mod tests { .evaluate_and_commit_workspace_config( "w-config", &ConfigCommitRequest { - base_revision: first.snapshot.revision, base_digest: first.snapshot.digest.clone(), changes: vec![ConfigTreeChange::Update { path: path(MAIN_CONFIG_ENTRYPOINT), @@ -1641,19 +1538,234 @@ mod tests { }, ) .unwrap(); - let revision = store - .load_workspace_config_revision("w-config", 1) + let history = store + .load_workspace_config_history("w-config", &first.snapshot.digest) .unwrap() .unwrap(); - assert_eq!(revision, first.snapshot); + assert_eq!(history, first.snapshot); + } + + fn history_count(store: &SqliteWorkspaceStore) -> i64 { + store + .with_conn(|conn| { + conn.query_row( + "SELECT COUNT(*) FROM workspace_config_tree_history WHERE workspace_id = ?1", + ["w-config"], + |row| row.get(0), + ) + .map_err(Error::from) + }) + .unwrap() + } + + #[tokio::test] + async fn identical_content_commit_reuses_immutable_history() { + let store = open_store().await; + let current = store.load_workspace_config("w-config").unwrap().unwrap(); + let before = history_count(&store); + let original_history: (String, String) = store + .with_conn(|conn| { + conn.query_row( + "SELECT manifest_json, created_at FROM workspace_config_tree_history + WHERE workspace_id = ?1 AND content_digest = ?2", + params!["w-config", current.snapshot.digest], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(Error::from) + }) + .unwrap(); + let request = commit_request( + ¤t, + vec![update_main(¤t, DEFAULT_MAIN_CONFIG_SOURCE)], + ); + let first = store + .evaluate_and_commit_workspace_config("w-config", &request) + .unwrap(); + let repeated = store + .evaluate_and_commit_workspace_config("w-config", &request) + .unwrap(); + assert_eq!(first.snapshot, current.snapshot); + assert_eq!(repeated.snapshot, current.snapshot); + assert_eq!(history_count(&store), before); + let saved_history: (String, String) = store + .with_conn(|conn| { + conn.query_row( + "SELECT manifest_json, created_at FROM workspace_config_tree_history + WHERE workspace_id = ?1 AND content_digest = ?2", + params!["w-config", current.snapshot.digest], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(Error::from) + }) + .unwrap(); + assert_eq!(saved_history, original_history); + } + + #[tokio::test] + async fn returning_to_prior_content_reuses_digest_history() { + let store = open_store().await; + let initial = store.load_workspace_config("w-config").unwrap().unwrap(); + let before = history_count(&store); + let changed = store + .evaluate_and_commit_workspace_config( + "w-config", + &commit_request( + &initial, + vec![ConfigTreeChange::Create { + path: path("notes.txt"), + content_type: ConfigContentType::Text, + content: "temporary".into(), + }], + ), + ) + .unwrap(); + assert_ne!(changed.snapshot.digest, initial.snapshot.digest); + let entry = changed.snapshot.get(&path("notes.txt")).unwrap(); + let returned = store + .evaluate_and_commit_workspace_config( + "w-config", + &commit_request( + &changed, + vec![ConfigTreeChange::Delete { + path: entry.path.clone(), + expected_digest: entry.content_digest.clone(), + }], + ), + ) + .unwrap(); + assert_eq!(returned.snapshot, initial.snapshot); + assert_eq!(history_count(&store), before + 1); + assert_eq!( + store + .load_workspace_config_history("w-config", &changed.snapshot.digest) + .unwrap() + .unwrap(), + changed.snapshot + ); + assert_eq!( + store + .load_workspace_config_history("w-config", &initial.snapshot.digest) + .unwrap() + .unwrap(), + initial.snapshot + ); + assert!( + store + .load_workspace_config_history("another-workspace", &initial.snapshot.digest) + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn candidate_from_prior_projection_is_rejected_without_replacing_current_state() { + let store = open_store().await; + let current = store.load_workspace_config("w-config").unwrap().unwrap(); + let candidate = store + .evaluate_workspace_config_candidate("w-config", &commit_request(¤t, vec![])) + .unwrap(); + store.with_conn(|conn| { + conn.execute("UPDATE workspace_config_trees SET projection_digest='new-projection' WHERE workspace_id='w-config'", [])?; + Ok(()) + }).unwrap(); + assert!(matches!( + store.commit_evaluated_workspace_config("w-config", &candidate), + Err(Error::WorkspaceConfigConflict(_)) + )); + assert_eq!( + store + .load_workspace_config("w-config") + .unwrap() + .unwrap() + .projection_digest, + "new-projection" + ); + } + + #[tokio::test] + async fn incorrect_base_digest_is_rejected_before_evaluation() { + let store = open_store().await; + let current = store.load_workspace_config("w-config").unwrap().unwrap(); + let mut request = + commit_request(¤t, vec![update_main(¤t, "not valid Decodal")]); + request.base_digest = "sha256:incorrect".into(); + let error = store + .evaluate_workspace_config_candidate("w-config", &request) + .unwrap_err(); + assert!(matches!(error, Error::WorkspaceConfigConflict(_))); + assert_eq!( + store.load_workspace_config("w-config").unwrap().unwrap(), + current + ); + } + + #[tokio::test] + async fn history_content_and_manifest_paths_are_verified() { + let store = open_store().await; + let current = store.load_workspace_config("w-config").unwrap().unwrap(); + assert!( + store + .load_workspace_config_history("w-config", "sha256:missing") + .unwrap() + .is_none() + ); + let original_manifest = serde_json::to_string(¤t.snapshot.entries).unwrap(); + // A syntactically valid manifest with different complete content cannot + // satisfy the requested digest. + store.with_conn(|conn| { + conn.execute("UPDATE workspace_config_tree_history SET manifest_json = '{}' WHERE workspace_id = ?1", + ["w-config"])?; + Ok(()) + }).unwrap(); + assert!(matches!( + store.load_workspace_config_history("w-config", ¤t.snapshot.digest), + Err(Error::RegistryInconsistency(_)) + )); + let mut manifest: serde_json::Value = serde_json::from_str(&original_manifest).unwrap(); + let entry = manifest + .as_object_mut() + .unwrap() + .remove(MAIN_CONFIG_ENTRYPOINT) + .unwrap(); + manifest + .as_object_mut() + .unwrap() + .insert("aliased.dcdl".into(), entry); + store.with_conn(|conn| { + conn.execute("UPDATE workspace_config_tree_history SET manifest_json = ?1 WHERE workspace_id = ?2", + params![manifest.to_string(), "w-config"])?; + Ok(()) + }).unwrap(); + assert!(matches!( + store.load_workspace_config_history("w-config", ¤t.snapshot.digest), + Err(Error::RegistryInconsistency(_)) + )); + assert_eq!( + store.load_workspace_config("w-config").unwrap().unwrap(), + current + ); + } + + #[test] + fn config_commit_request_requires_only_content_identity() { + let request: ConfigCommitRequest = serde_json::from_value(serde_json::json!({ + "base_digest": "sha256:source", "changes": [], "entrypoints": [MAIN_CONFIG_ENTRYPOINT], + })) + .unwrap(); + assert_eq!(request.base_digest, "sha256:source"); + let mut unexpected = serde_json::to_value(&request).unwrap(); + unexpected + .as_object_mut() + .unwrap() + .insert("unexpected_counter".into(), 7.into()); + assert!(serde_json::from_value::(unexpected).is_err()); } #[test] fn exports_typescript_transport_contract() { use ts_rs::TS; - let output = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../web/workspace/src/lib/workspace/config-source/generated/types"); - let config = ts_rs::Config::default().with_out_dir(&output); + let output = tempfile::tempdir().unwrap(); + let config = ts_rs::Config::default().with_out_dir(output.path()); WorkspaceConfigState::export_all(&config).unwrap(); ConfigCommitRequest::export_all(&config).unwrap(); } @@ -1666,7 +1778,7 @@ mod tests { for table in [ "workspace_config_trees", "workspace_config_entries", - "workspace_config_tree_revisions", + "workspace_config_tree_history", ] { let exists: bool = conn.query_row( "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)", diff --git a/crates/workspace-server/src/frozen_content_state_migration.rs b/crates/workspace-server/src/frozen_content_state_migration.rs new file mode 100644 index 000000000..947c270f1 --- /dev/null +++ b/crates/workspace-server/src/frozen_content_state_migration.rs @@ -0,0 +1,241 @@ +// Frozen schema-85 cutover. Legacy ordinal/wire names belong only here. +fn migrate_content_state_v85_to_v86(conn: &Connection) -> Result<()> { + if current_schema_version(conn)? != 85 { + return Err(Error::Store( + "content state migration requires schema 85".into(), + )); + } + if !conn.is_autocommit() { + return Err(Error::Store( + "content state migration requires an autocommit connection".into(), + )); + } + let foreign_keys: bool = conn.query_row("PRAGMA foreign_keys", [], |r| r.get(0))?; + let legacy_alter: bool = conn.query_row("PRAGMA legacy_alter_table", [], |r| r.get(0))?; + // Replacing a parent table must not cascade-delete its referencing rows or + // rewrite their FK targets. Restore both connection settings on every exit. + conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;")?; + let result = (|| -> Result<()> { + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Immediate)?; + if table_exists(&tx, "workspace_config_tree_revisions")? { + tx.execute_batch( + r#" + CREATE TABLE workspace_config_tree_history ( + workspace_id TEXT NOT NULL, + content_digest TEXT NOT NULL, + toolchain_fingerprint TEXT NOT NULL, + projection_digest TEXT NOT NULL, + manifest_json TEXT NOT NULL, + created_at TEXT NOT NULL, + schema_bundle_json TEXT NOT NULL DEFAULT '{"contributions":[],"source":"{}","fingerprint":""}', + PRIMARY KEY (workspace_id, content_digest, toolchain_fingerprint, projection_digest), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); + "#, + )?; + let history = { + let mut stmt = tx.prepare("SELECT workspace_id, tree_digest, toolchain_fingerprint, projection_digest, manifest_json, created_at, schema_bundle_json FROM workspace_config_tree_revisions ORDER BY revision")?; + stmt.query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, String>(3)?, + r.get::<_, String>(4)?, + r.get::<_, String>(5)?, + r.get::<_, String>(6)?, + )) + })? + .collect::>>()? + }; + for (workspace, digest, toolchain, projection, manifest, created, schema) in history { + let entries: std::collections::BTreeMap< + config_source::VirtualPath, + config_source::ConfigEntry, + > = serde_json::from_str(&manifest) + .map_err(|e| Error::Store(format!("invalid legacy config manifest: {e}")))?; + if entries.iter().any(|(path, entry)| path != &entry.path) { + return Err(Error::Store("legacy config manifest path mismatch".into())); + } + let snapshot = + config_source::ConfigTreeSnapshot::from_entries(entries.into_values()) + .map_err(|e| Error::Store(format!("invalid legacy config content: {e}")))?; + if snapshot.digest != digest { + return Err(Error::Store("legacy config content digest mismatch".into())); + } + tx.execute("INSERT OR IGNORE INTO workspace_config_tree_history(workspace_id,content_digest,toolchain_fingerprint,projection_digest,manifest_json,created_at,schema_bundle_json) VALUES(?1,?2,?3,?4,?5,?6,?7)", + params![workspace,digest,toolchain,projection,manifest,created,schema])?; + } + tx.execute_batch( + "DROP TABLE workspace_config_tree_revisions; + ALTER TABLE workspace_config_trees DROP COLUMN revision; + ALTER TABLE workspace_config_trees RENAME COLUMN tree_digest TO content_digest;", + )?; + } + let missing_current: i64 = tx.query_row("SELECT COUNT(*) FROM workspace_config_trees t WHERE NOT EXISTS ( + SELECT 1 FROM workspace_config_tree_history h WHERE h.workspace_id=t.workspace_id AND h.content_digest=t.content_digest)", [], |r| r.get(0))?; + if missing_current != 0 { + return Err(Error::Store( + "config cutover found missing current content".into(), + )); + } + if column_exists(&tx, "workspace_memory_settings", "settings_revision")? { + tx.execute_batch( + "ALTER TABLE workspace_memory_settings DROP COLUMN settings_revision", + )?; + } + if column_exists( + &tx, + "worker_create_reservations", + "memory_settings_revision", + )? { + remove_legacy_reservation_memory_revision(&tx)?; + } + // Older retained chains rebuilt this table without the lookup index. + // Converge on the canonical schema for both legacy creation paths. + tx.execute_batch("CREATE INDEX IF NOT EXISTS worker_create_reservations_worker ON worker_create_reservations(workspace_id, worker_id);")?; + if column_exists(&tx, "backend_jobs", "input_revision")? { + let jobs = { + let mut stmt = + tx.prepare("SELECT workspace_id,job_id,request_json FROM backend_jobs")?; + stmt.query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + )) + })? + .collect::>>()? + }; + tx.execute_batch( + "ALTER TABLE backend_jobs RENAME COLUMN input_revision TO input_digest; + ALTER TABLE backend_job_attempts RENAME COLUMN input_revision TO input_digest;", + )?; + for (workspace, id, encoded) in jobs { + let mut value: serde_json::Value = serde_json::from_str(&encoded) + .map_err(|e| Error::Store(format!("invalid legacy Job intent: {e}")))?; + value + .as_object_mut() + .ok_or_else(|| Error::Store("legacy Job intent is not an object".into()))? + .remove("input_revision"); + let request: BackendJobRequest = serde_json::from_value(value) + .map_err(|e| Error::Store(format!("invalid legacy Job intent: {e}")))?; + let digest = request.input_digest()?; + let encoded = + serde_json::to_string(&request).map_err(|e| Error::Store(e.to_string()))?; + // A Reserved-only ledger proves no dispatch was admitted. Fail it + // definitively and record vacuous cleanup so its resource is released. + let reserved_only: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM backend_job_attempts WHERE workspace_id=?1 AND job_id=?2) + AND NOT EXISTS(SELECT 1 FROM backend_job_attempts WHERE workspace_id=?1 AND job_id=?2 + AND (state != 'reserved' OR runtime_id IS NOT NULL OR worker_cleanup_state IS NOT NULL))", + params![workspace, id], |r| r.get(0), + )?; + // Outstanding old capabilities must not gain authority under a new input fence. + // Preserve completed results and cleanup receipts; uncertain execution is not retryable. + tx.execute("UPDATE backend_jobs SET input_digest=?3,request_json=?4,intent_fingerprint=?5, + state=CASE WHEN state='pending' THEN ?6 ELSE state END, + failure_category=CASE WHEN state='pending' THEN 'content_contract_cutover' ELSE failure_category END, + completed_at=CASE WHEN state='pending' THEN COALESCE(completed_at,updated_at) ELSE completed_at END + WHERE workspace_id=?1 AND job_id=?2", params![workspace,id,digest,encoded,request.fingerprint()?, if reserved_only { "failed" } else { "unknown" }])?; + tx.execute("UPDATE backend_job_attempts SET input_digest=?3, + state=CASE WHEN state='reserved' AND ?4 THEN 'failed' WHEN state IN ('reserved','dispatching','dispatched') THEN 'unknown' ELSE state END, + failure_category=CASE WHEN state IN ('reserved','dispatching','dispatched') THEN 'content_contract_cutover' ELSE failure_category END, + worker_cleanup_state=CASE WHEN state='reserved' AND ?4 THEN 'completed' WHEN runtime_id IS NOT NULL AND state IN ('dispatching','dispatched') AND worker_cleanup_state IS NULL THEN 'pending' ELSE worker_cleanup_state END, + completed_at=CASE WHEN state IN ('reserved','dispatching','dispatched') THEN COALESCE(completed_at,updated_at) ELSE completed_at END + WHERE workspace_id=?1 AND job_id=?2", params![workspace,id,digest,reserved_only])?; + tx.execute("UPDATE backend_job_deliveries SET state='unknown', failure_category='content_contract_cutover' + WHERE workspace_id=?1 AND job_id=?2 AND state IN ('pending','sending')", params![workspace,id])?; + } + } + // A legacy pending manual claim cannot be admitted against a newly inferred digest. + tx.execute("UPDATE ticket_assignment_operations SET claim_state='failed',failure_reason='content_contract_cutover' + WHERE claim_state='pending' AND binding_recovery_json IS NOT NULL AND assignment_id IS NULL", [])?; + let broken: i64 = + tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| { + r.get(0) + })?; + if broken != 0 { + return Err(Error::Store( + "content state migration foreign-key verification failed".into(), + )); + } + tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES(86,'content-fenced config, Memory settings and Jobs')", [])?; + tx.commit()?; + Ok(()) + })(); + conn.pragma_update(None, "legacy_alter_table", legacy_alter)?; + conn.pragma_update(None, "foreign_keys", foreign_keys)?; + result +} + +fn remove_legacy_reservation_memory_revision(conn: &Connection) -> Result<()> { + // This is the exact table CHECK introduced by the frozen v52 -> v53 + // migration. Fresh v85 databases do not have it. Do not rewrite arbitrary + // constraints: SQLite's DROP COLUMN must still reject unknown dependencies. + const LEGACY_CHECK: &str = ",\n CHECK (\n (request_fingerprint IS NULL AND memory_settings_revision IS NULL AND memory_language IS NULL)\n OR\n (request_fingerprint IS NOT NULL AND memory_settings_revision IS NOT NULL AND memory_settings_revision > 0 AND memory_language IS NOT NULL AND length(trim(memory_language)) > 0)\n )"; + let ddl: String = conn.query_row( + "SELECT sql FROM sqlite_schema WHERE type='table' AND name='worker_create_reservations'", + [], + |r| r.get(0), + )?; + if !ddl.contains(LEGACY_CHECK) { + conn.execute_batch( + "ALTER TABLE worker_create_reservations DROP COLUMN memory_settings_revision", + )?; + return Ok(()); + } + let ddl = ddl.replacen(LEGACY_CHECK, "", 1); + let definition = ddl + .find('(') + .ok_or_else(|| Error::Store("invalid legacy reservation DDL".into()))?; + // Retain the actual table definition, not a baseline copy: historical + // column order, additional constraints and outbound FKs must survive. + conn.execute_batch(&format!( + "CREATE TABLE worker_create_reservations_v86 {}", + &ddl[definition..] + ))?; + conn.execute_batch( + "ALTER TABLE worker_create_reservations_v86 DROP COLUMN memory_settings_revision", + )?; + let columns = { + let mut stmt = conn.prepare( + "SELECT name FROM pragma_table_info('worker_create_reservations_v86') ORDER BY cid", + )?; + stmt.query_map([], |r| r.get::<_, String>(0))? + .collect::>>()? + }; + let columns = columns + .into_iter() + .map(|name| format!("\"{}\"", name.replace('"', "\"\""))) + .collect::>() + .join(","); + let indexes = { + let mut stmt = conn.prepare("SELECT sql FROM sqlite_schema WHERE type='index' AND tbl_name='worker_create_reservations' AND sql IS NOT NULL")?; + stmt.query_map([], |r| r.get::<_, String>(0))? + .collect::>>()? + }; + // Cross-table triggers can also reference this table during replacement. + let triggers = { + let mut stmt = conn.prepare("SELECT name,sql FROM sqlite_schema WHERE type='trigger'")?; + stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))? + .collect::>>()? + }; + for (name, _) in &triggers { + conn.execute_batch(&format!("DROP TRIGGER \"{}\"", name.replace('"', "\"\"")))?; + } + // No state, fingerprint, language, lease, timestamp or uncertain admission + // is inferred from the removed ordinal. Copy every remaining column as-is. + conn.execute_batch(&format!( + "INSERT INTO worker_create_reservations_v86 ({columns}) SELECT {columns} FROM worker_create_reservations; + DROP TABLE worker_create_reservations; + ALTER TABLE worker_create_reservations_v86 RENAME TO worker_create_reservations;" + ))?; + for sql in indexes { + conn.execute_batch(&sql)?; + } + for (_, sql) in triggers { + conn.execute_batch(&sql)?; + } + Ok(()) +} diff --git a/crates/workspace-server/src/frozen_content_state_tests.rs b/crates/workspace-server/src/frozen_content_state_tests.rs new file mode 100644 index 000000000..5b77ad7ee --- /dev/null +++ b/crates/workspace-server/src/frozen_content_state_tests.rs @@ -0,0 +1,633 @@ +use super::*; + +fn legacy_content_database(path: &Path) -> (Connection, String) { + let conn = Connection::open(path).unwrap(); + conn.execute_batch(r#" + PRAGMA foreign_keys=ON; + CREATE TABLE __yoi_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL); + INSERT INTO __yoi_schema_migrations VALUES(85,'workspace schema baseline'); + CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY); + INSERT INTO workspaces VALUES('space'); + CREATE TABLE workspace_config_trees(workspace_id TEXT PRIMARY KEY,revision INTEGER,tree_digest TEXT); + CREATE TABLE workspace_config_tree_revisions(workspace_id TEXT,revision INTEGER,tree_digest TEXT, + toolchain_fingerprint TEXT,projection_digest TEXT,manifest_json TEXT,created_at TEXT,schema_bundle_json TEXT, + PRIMARY KEY(workspace_id,revision)); + CREATE TABLE workspace_memory_settings(workspace_id TEXT PRIMARY KEY,settings_revision INTEGER, + language TEXT,created_at TEXT,updated_at TEXT); + INSERT INTO workspace_memory_settings VALUES('space',9,'Japanese','created','updated'); + CREATE TABLE ticket_assignment_operations(claim_state TEXT,failure_reason TEXT,binding_recovery_json TEXT,assignment_id TEXT); + INSERT INTO ticket_assignment_operations VALUES('pending',NULL,'legacy recovery',NULL); + "#).unwrap(); + // Reproduce the real upgrade path: v53's table CHECK survives v73's + // singleton ALTERs, unlike the simplified fresh-v85 baseline definition. + let legacy = include_str!("frozen_legacy_migrations.rs"); + let start = legacy + .find("CREATE TABLE worker_create_reservations_v53 (") + .unwrap(); + let end = start + + legacy[start..] + .find("INSERT INTO worker_create_reservations_v53 (") + .unwrap(); + conn.execute_batch(&legacy[start..end].replace( + "worker_create_reservations_v53", + "worker_create_reservations", + )) + .unwrap(); + conn.execute_batch("ALTER TABLE worker_create_reservations ADD COLUMN singleton_key TEXT; + ALTER TABLE worker_create_reservations ADD COLUMN singleton_generation INTEGER + CHECK (singleton_generation IS NULL OR singleton_generation > 0); + INSERT INTO worker_create_reservations(workspace_id,allocation_key,worker_id,runtime_id,create_fingerprint,state, + request_fingerprint,memory_settings_revision,memory_language,created_at,updated_at) + VALUES('space','create','worker','runtime','legacy-create','reserved','legacy-request',8,'English','created','updated');").unwrap(); + let frozen = include_str!("frozen_schema_v85.sql"); + let start = frozen + .find("CREATE TRIGGER worker_create_reservation_singleton_lease_insert") + .unwrap(); + let end = frozen + .find("CREATE TABLE worker_singleton_owners (") + .unwrap(); + conn.execute_batch(&frozen[start..end]).unwrap(); + // The frozen Job DDL protects the persisted checks and public replay/resource contract. + let jobs = frozen.find("CREATE TABLE backend_jobs (").unwrap(); + let end = frozen + .find("CREATE TABLE worker_registry_observations (") + .unwrap(); + conn.execute_batch(&frozen[jobs..end]).unwrap(); + let snapshot = + config_source::ConfigTreeSnapshot::from_entries([config_source::ConfigEntry::new( + config_source::VirtualPath::parse("main.dcdl").unwrap(), + config_source::ConfigContentType::Decodal, + "{} as WorkspaceConfigSchema", + ) + .unwrap()]) + .unwrap(); + let manifest = serde_json::to_string(&snapshot.entries).unwrap(); + conn.execute( + "INSERT INTO workspace_config_trees VALUES('space',3,?1)", + [&snapshot.digest], + ) + .unwrap(); + for (ordinal, created) in [(1, "first"), (3, "third")] { + conn.execute("INSERT INTO workspace_config_tree_revisions VALUES('space',?1,?2,'toolchain','projection',?3,?4,'{}')", + params![ordinal,snapshot.digest,manifest,created]).unwrap(); + } + let request = BackendJobRequest { + job_id: "legacy-job".into(), + purpose: "migration_test".into(), + input_ref: "test://input".into(), + input: serde_json::json!({"content":"immutable"}), + instruction: "Inspect immutable input.".into(), + profile: "builtin:backend-job".into(), + grants: Default::default(), + serialization_key: Some("migration-resource".into()), + source_worker: None, + notification_target: None, + limits: Default::default(), + }; + let mut value = serde_json::to_value(&request).unwrap(); + value["input_revision"] = "old-number".into(); + conn.execute("INSERT INTO backend_jobs(workspace_id,job_id,purpose,input_ref,resource_key,request_json,input_revision,intent_fingerprint,state,current_attempt,created_at,updated_at) + VALUES('space','legacy-job','migration_test','test://input',?2,?1,'old-number','old-fingerprint','pending',1,'2026-08-13T00:00:00Z','2026-08-13T00:00:00Z')", + params![serde_json::to_string(&value).unwrap(),request.resource_key()]).unwrap(); + conn.execute_batch("INSERT INTO backend_job_attempts(workspace_id,job_id,attempt_id,attempt,input_revision,state,runtime_id,worker_id,deadline_at,created_at,updated_at) + VALUES('space','legacy-job','legacy-job:attempt:1',1,'old-number','dispatched','runtime','worker','2026-08-14T00:00:00Z','2026-08-13T00:00:00Z','2026-08-13T00:00:00Z'); + INSERT INTO backend_job_deliveries(workspace_id,delivery_id,job_id,attempt_id,target_runtime_id,target_worker_id,state,created_at,updated_at) + VALUES('space','delivery','legacy-job','legacy-job:attempt:1','runtime','target','pending','created','updated');") .unwrap(); + (conn, snapshot.digest) +} + +#[test] +fn legacy_reservation_check_cutover_preserves_rows_constraints_indexes_triggers_and_foreign_keys() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("content.db"); + let (conn, _) = legacy_content_database(&path); + let error = conn + .execute_batch( + "ALTER TABLE worker_create_reservations DROP COLUMN memory_settings_revision", + ) + .unwrap_err(); + assert!( + error.to_string().contains("memory_settings_revision"), + "{error}" + ); + conn.execute_batch(r#" + CREATE TABLE reservation_owners(owner_id TEXT PRIMARY KEY); + INSERT INTO reservation_owners VALUES('owner'); + ALTER TABLE worker_create_reservations ADD COLUMN extension_owner TEXT + REFERENCES reservation_owners(owner_id) ON DELETE RESTRICT; + UPDATE worker_create_reservations SET extension_owner='owner'; + INSERT INTO worker_create_reservations(workspace_id,allocation_key,worker_id,runtime_id,create_fingerprint,state, + request_fingerprint,memory_settings_revision,memory_language,created_at,updated_at,singleton_key,singleton_generation) + VALUES ('space','uncertain','uncertain-worker','runtime','old-uncertain','reserved',NULL,NULL,NULL,'old','old',NULL,NULL), + ('space','created','created-worker','runtime','old-created','created','request',2,'Japanese','old','new','singleton',3), + ('space','removed','removed-worker','runtime','old-removed','removed','request',7,'French','old','new',NULL,NULL); + CREATE INDEX reservation_pending_idx ON worker_create_reservations(workspace_id,updated_at) WHERE state='reserved'; + CREATE TABLE reservation_receipts(workspace_id TEXT,allocation_key TEXT, + FOREIGN KEY(workspace_id,allocation_key) REFERENCES worker_create_reservations(workspace_id,allocation_key) ON DELETE CASCADE); + INSERT INTO reservation_receipts VALUES('space','create'); + CREATE TABLE reservation_events(allocation_key TEXT,state TEXT); + CREATE TRIGGER reservation_state_audit AFTER UPDATE OF state ON worker_create_reservations + BEGIN INSERT INTO reservation_events VALUES(NEW.allocation_key,NEW.state); END; + CREATE TRIGGER reservation_receipt_touch AFTER INSERT ON reservation_receipts + BEGIN UPDATE worker_create_reservations SET updated_at=updated_at + WHERE workspace_id=NEW.workspace_id AND allocation_key=NEW.allocation_key; END; + "#).unwrap(); + let schema_objects = |conn: &Connection| -> Vec<(String, String)> { + conn.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL AND + (tbl_name='worker_create_reservations' AND type IN ('index','trigger') OR name IN ('reservation_receipts','reservation_receipt_touch')) ORDER BY name") + .unwrap().query_map([], |r| Ok((r.get(0)?,r.get(1)?))).unwrap() + .collect::>().unwrap() + }; + let original_objects = schema_objects(&conn); + let columns: Vec = conn.prepare("SELECT name FROM pragma_table_info('worker_create_reservations') WHERE name != 'memory_settings_revision' ORDER BY cid") + .unwrap().query_map([], |r| r.get(0)).unwrap().collect::>().unwrap(); + let select = format!( + "SELECT {} FROM worker_create_reservations ORDER BY allocation_key", + columns.join(",") + ); + let rows = |conn: &Connection| -> Vec> { + conn.prepare(&select) + .unwrap() + .query_map([], |r| { + (0..columns.len()) + .map(|i| r.get(i)) + .collect::>>() + }) + .unwrap() + .collect::>() + .unwrap() + }; + let original_rows = rows(&conn); + let foreign_keys = |conn: &Connection| -> Vec<(String, String, String, String)> { + conn.prepare("SELECT \"table\",\"from\",\"to\",on_delete FROM pragma_foreign_key_list('worker_create_reservations') ORDER BY id,seq") + .unwrap().query_map([], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?))).unwrap() + .collect::>().unwrap() + }; + let original_foreign_keys = foreign_keys(&conn); + migrate_content_state_v85_to_v86(&conn).unwrap(); + assert!( + conn.pragma_query_value(None, "foreign_keys", |r| r.get::<_, bool>(0)) + .unwrap() + ); + assert!( + !conn + .pragma_query_value(None, "legacy_alter_table", |r| r.get::<_, bool>(0)) + .unwrap() + ); + drop(conn); + let conn = Connection::open(path).unwrap(); + conn.execute_batch("PRAGMA foreign_keys=ON").unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 86); + assert!( + !column_exists( + &conn, + "worker_create_reservations", + "memory_settings_revision" + ) + .unwrap() + ); + assert_eq!(rows(&conn), original_rows); + let mut expected_objects = original_objects; + expected_objects.push(( + "worker_create_reservations_worker".into(), + "CREATE INDEX worker_create_reservations_worker ON worker_create_reservations(workspace_id, worker_id)".into(), + )); + expected_objects.sort(); + assert_eq!(schema_objects(&conn), expected_objects); + assert_eq!(foreign_keys(&conn), original_foreign_keys); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM reservation_receipts", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 1 + ); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM reservation_events", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 0 + ); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| r + .get::<_, i64>( + 0 + )) + .unwrap(), + 0 + ); + for sql in [ + "UPDATE worker_create_reservations SET singleton_generation=NULL WHERE allocation_key='created'", + "UPDATE worker_create_reservations SET singleton_generation=0 WHERE allocation_key='created'", + "UPDATE worker_create_reservations SET state='pending' WHERE allocation_key='create'", + "UPDATE worker_create_reservations SET worker_id='worker' WHERE allocation_key='uncertain'", + "UPDATE worker_create_reservations SET extension_owner='missing' WHERE allocation_key='create'", + "UPDATE worker_create_reservations SET workspace_id='missing' WHERE allocation_key='create'", + ] { + assert!(conn.execute(sql, []).is_err(), "{sql}"); + } + conn.execute( + "UPDATE worker_create_reservations SET state='removed' WHERE allocation_key='create'", + [], + ) + .unwrap(); + let audit: (String, String) = conn + .query_row( + "SELECT allocation_key,state FROM reservation_events", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(audit, ("create".into(), "removed".into())); +} + +#[test] +fn fresh_v85_reservations_cut_over_without_legacy_check_and_restore_connection_settings() { + for (foreign_keys, legacy_alter) in [(false, false), (false, true), (true, false), (true, true)] + { + let dir = tempfile::tempdir().unwrap(); + let (conn, _) = legacy_content_database(&dir.path().join("content.db")); + conn.execute_batch("DROP TABLE worker_create_reservations") + .unwrap(); + let frozen = include_str!("frozen_schema_v85.sql"); + let start = frozen + .find("CREATE TABLE worker_create_reservations (") + .unwrap(); + let end = frozen + .find("CREATE TABLE worker_singleton_owners (") + .unwrap(); + conn.execute_batch(&frozen[start..end]).unwrap(); + conn.execute_batch("INSERT INTO worker_create_reservations(workspace_id,allocation_key,worker_id,runtime_id, + create_fingerprint,state,created_at,updated_at,memory_settings_revision,memory_language) + VALUES('space','fresh','worker','runtime','fingerprint','reserved','created','updated',9,'Japanese')").unwrap(); + conn.pragma_update(None, "foreign_keys", foreign_keys) + .unwrap(); + conn.pragma_update(None, "legacy_alter_table", legacy_alter) + .unwrap(); + migrate_content_state_v85_to_v86(&conn).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 86); + assert!( + !column_exists( + &conn, + "worker_create_reservations", + "memory_settings_revision" + ) + .unwrap() + ); + let saved: (String, String) = conn + .query_row( + "SELECT state,memory_language FROM worker_create_reservations", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(saved, ("reserved".into(), "Japanese".into())); + assert_eq!( + conn.pragma_query_value(None, "foreign_keys", |r| r.get::<_, bool>(0)) + .unwrap(), + foreign_keys + ); + assert_eq!( + conn.pragma_query_value(None, "legacy_alter_table", |r| r.get::<_, bool>(0)) + .unwrap(), + legacy_alter + ); + } +} + +#[test] +fn content_cutover_preserves_content_and_languages_but_fences_old_job_capabilities() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("content.db"); + let (conn, digest) = legacy_content_database(&path); + migrate_content_state_v85_to_v86(&conn).unwrap(); + drop(conn); + let conn = Connection::open(path).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 86); + assert!(!table_exists(&conn, "workspace_config_tree_revisions").unwrap()); + let history: (i64, String, String) = conn + .query_row( + "SELECT COUNT(*),content_digest,created_at FROM workspace_config_tree_history", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .unwrap(); + assert_eq!(history, (1, digest, "first".into())); + assert!(!column_exists(&conn, "workspace_memory_settings", "settings_revision").unwrap()); + assert!( + !column_exists( + &conn, + "worker_create_reservations", + "memory_settings_revision" + ) + .unwrap() + ); + let language: String = conn + .query_row("SELECT language FROM workspace_memory_settings", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(language, "Japanese"); + let captured: String = conn + .query_row( + "SELECT memory_language FROM worker_create_reservations", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(captured, "English"); + let (encoded, state): (String, String) = conn + .query_row("SELECT request_json,state FROM backend_jobs", [], |r| { + Ok((r.get(0)?, r.get(1)?)) + }) + .unwrap(); + let request: BackendJobRequest = serde_json::from_str(&encoded).unwrap(); + assert_eq!(state, "unknown"); + let (input_digest, state, cleanup): (String, String, String) = conn + .query_row( + "SELECT input_digest,state,worker_cleanup_state FROM backend_job_attempts", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .unwrap(); + assert_eq!(input_digest, request.input_digest().unwrap()); + assert_eq!(state, "unknown"); + assert_eq!(cleanup, "pending"); + let delivery: String = conn + .query_row("SELECT state FROM backend_job_deliveries", [], |r| r.get(0)) + .unwrap(); + assert_eq!(delivery, "unknown"); + let claim: String = conn + .query_row( + "SELECT claim_state FROM ticket_assignment_operations", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(claim, "failed"); +} + +#[test] +fn content_cutover_keeps_each_evaluation_provenance_and_deduplicates_only_identical_evaluations() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("content.db"); + let (conn, _) = legacy_content_database(&path); + for (revision, toolchain, projection, schema) in [ + (4, "new-toolchain", "projection", "schema-2"), + (5, "toolchain", "new-projection", "schema-1"), + ] { + conn.execute( + "INSERT INTO workspace_config_tree_revisions + SELECT workspace_id,?1,tree_digest,?2,?3,manifest_json,'later',?4 + FROM workspace_config_tree_revisions WHERE revision=1", + params![revision, toolchain, projection, schema], + ) + .unwrap(); + } + let expected: Vec<(String,String,String,String,String,String)> = conn.prepare( + "SELECT tree_digest,toolchain_fingerprint,projection_digest,manifest_json,created_at,schema_bundle_json + FROM workspace_config_tree_revisions WHERE revision != 3 ORDER BY toolchain_fingerprint,projection_digest" + ).unwrap().query_map([], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?))) + .unwrap().collect::>().unwrap(); + migrate_content_state_v85_to_v86(&conn).unwrap(); + drop(conn); + let conn = Connection::open(path).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 86); + let saved: Vec<(String,String,String,String,String,String)> = conn.prepare( + "SELECT content_digest,toolchain_fingerprint,projection_digest,manifest_json,created_at,schema_bundle_json + FROM workspace_config_tree_history ORDER BY toolchain_fingerprint,projection_digest" + ).unwrap().query_map([], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?))) + .unwrap().collect::>().unwrap(); + assert_eq!(saved, expected); + let default: String = conn.query_row( + "SELECT dflt_value FROM pragma_table_info('workspace_config_tree_history') WHERE name='schema_bundle_json'", + [], |r| r.get(0), + ).unwrap(); + assert_eq!( + default, + "'{\"contributions\":[],\"source\":\"{}\",\"fingerprint\":\"\"}'" + ); +} + +#[test] +fn content_cutover_reserved_only_releases_resource_but_uncertain_dispatch_keeps_it() { + for (state, expected_job, expected_attempt, expected_cleanup) in [ + ( + "reserved", + BackendJobState::Failed, + BackendJobAttemptState::Failed, + Some(BackendJobWorkerCleanupState::Completed), + ), + ( + "dispatching", + BackendJobState::Unknown, + BackendJobAttemptState::Unknown, + Some(BackendJobWorkerCleanupState::Pending), + ), + ( + "dispatched", + BackendJobState::Unknown, + BackendJobAttemptState::Unknown, + Some(BackendJobWorkerCleanupState::Pending), + ), + ] { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("content.db"); + let (conn, _) = legacy_content_database(&path); + conn.execute( + "UPDATE backend_job_attempts SET state=?1, + runtime_id=CASE WHEN ?1='reserved' THEN NULL ELSE runtime_id END, + worker_id=CASE WHEN ?1='reserved' THEN NULL ELSE worker_id END", + [state], + ) + .unwrap(); + migrate_content_state_v85_to_v86(&conn).unwrap(); + drop(conn); + let store = SqliteWorkspaceStore { + conn: Arc::new(Mutex::new(Connection::open(path).unwrap())), + }; + let job = store + .get_backend_job("space", "legacy-job") + .unwrap() + .unwrap(); + assert_eq!(job.state, expected_job, "{state}"); + let replay = store + .reserve_backend_job("space", &job.request, "2026-08-15T00:00:00Z") + .unwrap(); + assert!(replay.replayed); + assert_eq!(replay.attempt.state, expected_attempt); + assert_eq!(replay.attempt.worker_cleanup_state, expected_cleanup); + let mut next = job.request.clone(); + next.job_id = "next-job".into(); + let next = store + .reserve_backend_job("space", &next, "2026-08-15T00:00:00Z") + .unwrap(); + assert_eq!(next.resource_reused, state != "reserved"); + assert_eq!( + next.job.request.job_id, + if state == "reserved" { + "next-job" + } else { + "legacy-job" + } + ); + } +} + +#[test] +fn content_cutover_completed_result_cleanup_and_replay_remain_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("content.db"); + let (conn, _) = legacy_content_database(&path); + let result = serde_json::json!({"finding":"preserved"}); + let (digest, encoded) = job::result_digest(&result, 4096).unwrap(); + conn.execute("UPDATE backend_jobs SET state='completed',result_json=?1,result_digest=?2,completed_at='finished'",params![encoded,digest]).unwrap(); + conn.execute("UPDATE backend_job_attempts SET state='completed',result_json=?1,result_digest=?2, + worker_cleanup_state='completed',worker_cleanup_completed_at='cleaned',completed_at='finished'",params![encoded,digest]).unwrap(); + conn.execute( + "UPDATE backend_job_deliveries SET state='completed',delivered_at='delivered'", + [], + ) + .unwrap(); + migrate_content_state_v85_to_v86(&conn).unwrap(); + drop(conn); + let store = SqliteWorkspaceStore { + conn: Arc::new(Mutex::new(Connection::open(path).unwrap())), + }; + let job = store + .get_backend_job("space", "legacy-job") + .unwrap() + .unwrap(); + let replay = store + .reserve_backend_job("space", &job.request, "2026-08-15T00:00:00Z") + .unwrap(); + assert!(replay.replayed); + assert_eq!(replay.job.state, BackendJobState::Completed); + assert_eq!(replay.job.result, Some(result.clone())); + assert_eq!(replay.job.result_digest, Some(digest.clone())); + assert_eq!(replay.job.completed_at.as_deref(), Some("finished")); + assert_eq!(replay.attempt.state, BackendJobAttemptState::Completed); + assert_eq!(replay.attempt.result, Some(result)); + assert_eq!(replay.attempt.result_digest, Some(digest)); + assert_eq!( + replay.attempt.worker_cleanup_state, + Some(BackendJobWorkerCleanupState::Completed) + ); + assert_eq!( + replay.attempt.worker_cleanup_completed_at.as_deref(), + Some("cleaned") + ); + let delivery: (String, String) = store + .with_conn(|conn| { + conn.query_row( + "SELECT state,delivered_at FROM backend_job_deliveries", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .map_err(Error::from) + }) + .unwrap(); + assert_eq!(delivery, ("completed".into(), "delivered".into())); +} + +#[test] +fn late_job_failure_rolls_back_config_memory_jobs_and_migration_version() { + let dir = tempfile::tempdir().unwrap(); + let (conn, _) = legacy_content_database(&dir.path().join("content.db")); + let original_reservation_schema: Vec<(String,String)> = conn.prepare( + "SELECT name,sql FROM sqlite_schema WHERE tbl_name='worker_create_reservations' AND sql IS NOT NULL ORDER BY name" + ).unwrap().query_map([],|r| Ok((r.get(0)?,r.get(1)?))).unwrap().collect::>().unwrap(); + conn.execute_batch( + "CREATE TRIGGER fail_delivery_update BEFORE UPDATE ON backend_job_deliveries + BEGIN SELECT RAISE(ABORT,'late cutover failure'); END;", + ) + .unwrap(); + let original: (String, String) = conn + .query_row( + "SELECT request_json,intent_fingerprint FROM backend_jobs", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + let error = migrate_content_state_v85_to_v86(&conn).unwrap_err(); + assert!( + error.to_string().contains("late cutover failure"), + "{error}" + ); + let restored_reservation_schema: Vec<(String,String)> = conn.prepare( + "SELECT name,sql FROM sqlite_schema WHERE tbl_name='worker_create_reservations' AND sql IS NOT NULL ORDER BY name" + ).unwrap().query_map([],|r| Ok((r.get(0)?,r.get(1)?))).unwrap().collect::>().unwrap(); + assert_eq!(restored_reservation_schema, original_reservation_schema); + assert!(!table_exists(&conn, "worker_create_reservations_v86").unwrap()); + assert!( + conn.pragma_query_value(None, "foreign_keys", |r| r.get::<_, bool>(0)) + .unwrap() + ); + assert!( + !conn + .pragma_query_value(None, "legacy_alter_table", |r| r.get::<_, bool>(0)) + .unwrap() + ); + assert_eq!(current_schema_version(&conn).unwrap(), 85); + assert!(table_exists(&conn, "workspace_config_tree_revisions").unwrap()); + assert!(!table_exists(&conn, "workspace_config_tree_history").unwrap()); + assert!(column_exists(&conn, "workspace_config_trees", "revision").unwrap()); + assert!(column_exists(&conn, "workspace_memory_settings", "settings_revision").unwrap()); + assert!( + column_exists( + &conn, + "worker_create_reservations", + "memory_settings_revision" + ) + .unwrap() + ); + assert!(column_exists(&conn, "backend_jobs", "input_revision").unwrap()); + assert!(column_exists(&conn, "backend_job_attempts", "input_revision").unwrap()); + let saved: (String, String, String) = conn + .query_row( + "SELECT request_json,intent_fingerprint,state FROM backend_jobs", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .unwrap(); + assert_eq!(saved, (original.0, original.1, "pending".into())); + let attempt: (String, Option) = conn + .query_row( + "SELECT state,worker_cleanup_state FROM backend_job_attempts", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(attempt, ("dispatched".into(), None)); + let delivery: String = conn + .query_row("SELECT state FROM backend_job_deliveries", [], |r| r.get(0)) + .unwrap(); + assert_eq!(delivery, "pending"); + let claim: String = conn + .query_row( + "SELECT claim_state FROM ticket_assignment_operations", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(claim, "pending"); +} + +#[test] +fn corrupt_config_history_rolls_back_entire_content_cutover() { + let dir = tempfile::tempdir().unwrap(); + let (conn, _) = legacy_content_database(&dir.path().join("content.db")); + conn.execute( + "UPDATE workspace_config_tree_revisions SET tree_digest='corrupt'", + [], + ) + .unwrap(); + assert!(migrate_content_state_v85_to_v86(&conn).is_err()); + assert_eq!(current_schema_version(&conn).unwrap(), 85); + assert!(table_exists(&conn, "workspace_config_tree_revisions").unwrap()); + assert!(!table_exists(&conn, "workspace_config_tree_history").unwrap()); + assert!(column_exists(&conn, "backend_jobs", "input_revision").unwrap()); + let state: String = conn + .query_row("SELECT state FROM backend_jobs", [], |r| r.get(0)) + .unwrap(); + assert_eq!(state, "pending"); +} diff --git a/crates/workspace-server/src/frozen_flow_content_migration.rs b/crates/workspace-server/src/frozen_flow_content_migration.rs new file mode 100644 index 000000000..42ec85ab8 --- /dev/null +++ b/crates/workspace-server/src/frozen_flow_content_migration.rs @@ -0,0 +1,98 @@ +// Frozen schema-84 conversion. Legacy ordinal names are confined to this migration. +fn migrate_flow_content_v84_to_v85(conn: &Connection) -> Result<()> { + if current_schema_version(conn)? != 84 { + return Err(Error::Store( + "Flow content migration requires schema 84".into(), + )); + } + let tx = conn.unchecked_transaction()?; + if table_exists(&tx, "flow_source_revisions")? { + // Validate every saved row before deduplicating or changing the schema. + // Do not recompile: compiler changes must not rewrite historical meaning. + { + let mut statement = tx.prepare( + "SELECT workspace_id, flow_id, revision, content, content_digest, definition_json + FROM flow_source_revisions", + )?; + let mut rows = statement.query([])?; + while let Some(row) = rows.next()? { + let workspace_id: String = row.get(0)?; + let flow_id: String = row.get(1)?; + let revision: i64 = row.get(2)?; + let content: String = row.get(3)?; + let stored_digest: String = row.get(4)?; + let definition_json: String = row.get(5)?; + let identity = format!("{workspace_id}/{flow_id} revision {revision}"); + let source_digest = format!( + "sha256:{}", + Sha256::digest(content.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); + if source_digest != stored_digest { + return Err(Error::Store(format!( + "Flow content migration found source digest mismatch for {identity}" + ))); + } + let definition: CompiledFlowDefinition = + serde_json::from_str(&definition_json).map_err(|error| { + Error::Store(format!( + "Flow content migration found invalid stored definition for {identity}: {error}" + )) + })?; + if definition.content_digest != stored_digest { + return Err(Error::Store(format!( + "Flow content migration found definition digest mismatch for {identity}" + ))); + } + } + } + let conflicts: i64 = tx.query_row( + "SELECT COUNT(*) FROM (SELECT 1 FROM flow_source_revisions GROUP BY workspace_id,flow_id,content_digest + HAVING COUNT(DISTINCT content)>1 OR COUNT(DISTINCT definition_json)>1)", [], |row| row.get(0))?; + if conflicts != 0 { + return Err(Error::Store( + "Flow content migration found inconsistent content identity".into(), + )); + } + tx.execute_batch( + r#" + CREATE TABLE flow_source_contents ( + workspace_id TEXT NOT NULL, + flow_id TEXT NOT NULL, + content TEXT NOT NULL, + content_digest TEXT NOT NULL, + definition_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, flow_id, content_digest), + FOREIGN KEY (workspace_id, flow_id) + REFERENCES flow_sources(workspace_id, flow_id) ON DELETE CASCADE + ); + INSERT INTO flow_source_contents + (workspace_id, flow_id, content, content_digest, definition_json, created_at) + SELECT workspace_id, flow_id, content, content_digest, definition_json, MIN(created_at) + FROM flow_source_revisions + GROUP BY workspace_id, flow_id, content_digest; + DROP TABLE flow_source_revisions; + ALTER TABLE flow_sources DROP COLUMN revision; + "#, + )?; + } + let missing: i64 = tx.query_row( + "SELECT COUNT(*) FROM flow_sources s WHERE NOT EXISTS ( + SELECT 1 FROM flow_source_contents c WHERE c.workspace_id=s.workspace_id + AND c.flow_id=s.flow_id AND c.content_digest=s.content_digest + AND c.content=s.content)", + [], + |row| row.get(0), + )?; + if missing != 0 { + return Err(Error::Store( + "Flow content migration found missing current content".into(), + )); + } + tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES(85,'content-addressed Flow sources')", [])?; + tx.commit()?; + Ok(()) +} diff --git a/crates/workspace-server/src/frozen_flow_content_tests.rs b/crates/workspace-server/src/frozen_flow_content_tests.rs new file mode 100644 index 000000000..372da09a0 --- /dev/null +++ b/crates/workspace-server/src/frozen_flow_content_tests.rs @@ -0,0 +1,231 @@ +use super::*; + +fn legacy_flow_source(instructions: &str) -> String { + format!( + r#"{{ + schema_version = 1; + name = "flow"; + initial = "work"; + states = {{ + work = {{ + instructions = "{instructions}"; + transitions = {{ done = {{ target = "done"; condition = "Finished."; }}; }}; + }}; + done = {{ instructions = ""; terminal = true; }}; + }}; + }}"# + ) +} + +fn legacy_flow_definition(content: &str) -> CompiledFlowDefinition { + let mut definition = compile_flow_source(content).unwrap(); + // Model a saved definition from a previous compiler. Migration must preserve + // its runtime-supplied meaning, not replace it with today's compiler output. + for state in definition.states.values_mut() { + for transition in &mut state.transitions { + if transition.synthetic { + transition.condition = "Frozen compiler cancellation condition.".into(); + } + } + } + definition +} + +// Frozen schema-84 fixture: old ordinals are migration input only. +fn legacy_flow_database(path: &Path) -> Connection { + let conn = Connection::open(path).unwrap(); + conn.execute_batch( + r#" + PRAGMA foreign_keys=ON; + CREATE TABLE __yoi_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL); + INSERT INTO __yoi_schema_migrations VALUES(84,'workspace schema baseline'); + CREATE TABLE flow_sources(workspace_id TEXT,flow_id TEXT,content TEXT,content_digest TEXT, + revision INTEGER,PRIMARY KEY(workspace_id,flow_id)); + CREATE TABLE flow_source_revisions(workspace_id TEXT,flow_id TEXT,revision INTEGER, + content TEXT,content_digest TEXT,definition_json TEXT,created_at TEXT, + PRIMARY KEY(workspace_id,flow_id,revision)); + "#, + ) + .unwrap(); + let alpha = legacy_flow_source("alpha"); + let beta = legacy_flow_source("beta"); + let alpha_definition = legacy_flow_definition(&alpha); + let beta_definition = legacy_flow_definition(&beta); + conn.execute( + "INSERT INTO flow_sources VALUES('space','flow',?1,?2,3)", + params![alpha, alpha_definition.content_digest], + ) + .unwrap(); + for (revision, content, definition, created_at) in [ + (1, &alpha, &alpha_definition, "first"), + (2, &beta, &beta_definition, "second"), + (3, &alpha, &alpha_definition, "third"), + ] { + conn.execute( + "INSERT INTO flow_source_revisions VALUES('space','flow',?1,?2,?3,?4,?5)", + params![ + revision, + content, + definition.content_digest, + serde_json::to_string_pretty(definition).unwrap(), + created_at + ], + ) + .unwrap(); + } + conn +} + +#[test] +fn flow_content_migration_preserves_current_and_historical_content_after_reopen() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("flows.db"); + let conn = legacy_flow_database(&path); + let expected: Vec<(String, String, String, String)> = conn + .prepare("SELECT content_digest,content,definition_json,created_at FROM flow_source_revisions WHERE revision IN (1,2) ORDER BY content_digest") + .unwrap() + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))) + .unwrap() + .collect::>() + .unwrap(); + for (_, content, definition_json, _) in &expected { + let saved: CompiledFlowDefinition = serde_json::from_str(definition_json).unwrap(); + assert_ne!(saved, compile_flow_source(content).unwrap()); + } + migrate_flow_content_v84_to_v85(&conn).unwrap(); + drop(conn); + let conn = Connection::open(path).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 85); + let rows: Vec<(String, String, String, String)> = conn + .prepare("SELECT content_digest,content,definition_json,created_at FROM flow_source_contents ORDER BY content_digest") + .unwrap() + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!(rows, expected); + let current: String = conn + .query_row("SELECT c.content FROM flow_sources s JOIN flow_source_contents c USING(workspace_id,flow_id,content_digest)", [], |r| r.get(0)) + .unwrap(); + assert_eq!(current, legacy_flow_source("alpha")); + assert!(!table_exists(&conn, "flow_source_revisions").unwrap()); + let failures: i64 = conn + .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(failures, 0); +} + +// Snapshot all fixture data and schema so rejection proves durable rollback, +// including the corrupted input (which migration must not silently repair). +fn legacy_flow_snapshot(conn: &Connection) -> Vec>> { + [ + "SELECT name,sql FROM sqlite_schema ORDER BY name", + "SELECT * FROM __yoi_schema_migrations ORDER BY version", + "SELECT * FROM flow_sources ORDER BY workspace_id,flow_id", + "SELECT * FROM flow_source_revisions ORDER BY workspace_id,flow_id,revision", + ] + .into_iter() + .map(|sql| { + let mut statement = conn.prepare(sql).unwrap(); + let count = statement.column_count(); + statement + .query_map([], |row| (0..count).map(|column| row.get(column)).collect()) + .unwrap() + .collect::>() + .unwrap() + }) + .collect() +} + +fn assert_flow_migration_rolls_back(conn: Connection, path: &Path, expected_error: &str) { + let before = legacy_flow_snapshot(&conn); + let error = migrate_flow_content_v84_to_v85(&conn).unwrap_err(); + assert!(error.to_string().contains(expected_error), "{error}"); + assert_eq!(legacy_flow_snapshot(&conn), before); + drop(conn); + let reopened = Connection::open(path).unwrap(); + assert_eq!(current_schema_version(&reopened).unwrap(), 84); + assert!(table_exists(&reopened, "flow_source_revisions").unwrap()); + assert!(!table_exists(&reopened, "flow_source_contents").unwrap()); + assert_eq!(legacy_flow_snapshot(&reopened), before); +} + +#[test] +fn flow_content_migration_missing_current_content_rolls_back_data_and_marker() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("flows.db"); + let conn = legacy_flow_database(&path); + conn.execute( + "DELETE FROM flow_source_revisions WHERE revision IN (1,3)", + [], + ) + .unwrap(); + assert_flow_migration_rolls_back(conn, &path, "missing current content"); +} + +#[test] +fn flow_content_migration_single_historical_wrong_source_digest_rolls_back_data_and_marker() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("flows.db"); + let conn = legacy_flow_database(&path); + let mut definition = legacy_flow_definition(&legacy_flow_source("beta")); + let wrong_digest = format!("sha256:{}", "0".repeat(64)); + definition.content_digest = wrong_digest.clone(); + conn.execute( + "UPDATE flow_source_revisions SET content_digest=?1,definition_json=?2 WHERE revision=2", + params![wrong_digest, serde_json::to_string(&definition).unwrap()], + ) + .unwrap(); + assert_flow_migration_rolls_back(conn, &path, "source digest mismatch"); +} + +#[test] +fn flow_content_migration_invalid_historical_definition_json_rolls_back_data_and_marker() { + for invalid_definition in ["{", "{}"] { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("flows.db"); + let conn = legacy_flow_database(&path); + conn.execute( + "UPDATE flow_source_revisions SET definition_json=?1 WHERE revision=2", + [invalid_definition], + ) + .unwrap(); + assert_flow_migration_rolls_back(conn, &path, "invalid stored definition"); + } +} + +#[test] +fn flow_content_migration_historical_definition_digest_mismatch_rolls_back_data_and_marker() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("flows.db"); + let conn = legacy_flow_database(&path); + let mut definition = legacy_flow_definition(&legacy_flow_source("beta")); + definition.content_digest = format!("sha256:{}", "0".repeat(64)); + conn.execute( + "UPDATE flow_source_revisions SET definition_json=?1 WHERE revision=2", + [serde_json::to_string(&definition).unwrap()], + ) + .unwrap(); + assert_flow_migration_rolls_back(conn, &path, "definition digest mismatch"); +} + +#[test] +fn flow_content_migration_inconsistent_saved_definitions_rolls_back_without_data_loss() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("flows.db"); + let conn = legacy_flow_database(&path); + let mut definition = legacy_flow_definition(&legacy_flow_source("alpha")); + definition + .states + .get_mut(&flow::StateId::new("work").unwrap()) + .unwrap() + .instructions = "Different saved meaning.".into(); + conn.execute( + "UPDATE flow_source_revisions SET definition_json=?1 WHERE revision=3", + [serde_json::to_string_pretty(&definition).unwrap()], + ) + .unwrap(); + assert_flow_migration_rolls_back(conn, &path, "inconsistent content identity"); +} diff --git a/crates/workspace-server/src/frozen_legacy_migrations.rs b/crates/workspace-server/src/frozen_legacy_migrations.rs new file mode 100644 index 000000000..ed11fc07a --- /dev/null +++ b/crates/workspace-server/src/frozen_legacy_migrations.rs @@ -0,0 +1,2575 @@ +// Frozen historical Workspace schema conversions. Keep legacy names and SQL immutable. + +const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#" +CREATE TABLE IF NOT EXISTS worker_registry_observations ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + availability TEXT NOT NULL CHECK (availability IN ('observed', 'unavailable')), + worker_json TEXT, + connection_generation INTEGER NOT NULL, + subject_revision INTEGER NOT NULL, + snapshot_revision INTEGER NOT NULL, + projection_revision INTEGER NOT NULL, + observed_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id, worker_id) + REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE +); +CREATE TABLE IF NOT EXISTS worker_registry_projection_cursors ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + connection_generation INTEGER NOT NULL, + snapshot_revision INTEGER NOT NULL, + PRIMARY KEY (workspace_id, runtime_id) +); +CREATE TABLE IF NOT EXISTS worker_registry_projection_revisions ( + workspace_id TEXT PRIMARY KEY, + revision INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS worker_registry_projection_removals ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + projection_revision INTEGER NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, worker_id) +); +CREATE TABLE IF NOT EXISTS worker_registry_projection_diagnostics ( + diagnostic_id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + reason TEXT NOT NULL, + observed_at TEXT NOT NULL +); +"#; + +fn migrate_workspace_drive_grants_v83_to_v84(conn: &Connection) -> Result<()> { + if current_schema_version(conn)? != 83 { + return Err(Error::Store( + "Drive grants migration requires schema 83".into(), + )); + } + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch(include_str!("workspace_drive_grants.sql"))?; + tx.execute( + "INSERT INTO __yoi_schema_migrations(version,name) VALUES(?1,?2)", + params![84_i64, WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + +fn migrate_worker_restore_intents_v79_to_v80(conn: &Connection) -> Result<()> { + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch("CREATE TABLE worker_restore_intents ( + workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, + domain_key TEXT NOT NULL, request_id TEXT NOT NULL, expected_token TEXT NOT NULL, + settled INTEGER NOT NULL CHECK(settled IN (0,1)), + PRIMARY KEY(workspace_id,runtime_id,worker_id,request_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); + CREATE UNIQUE INDEX worker_restore_intent_pending_domain ON worker_restore_intents(workspace_id,runtime_id,worker_id,domain_key) WHERE settled=0;")?; + tx.execute( + "INSERT INTO __yoi_schema_migrations(version,name) VALUES(?1,?2)", + params![80_i64, WORKER_RESTORE_INTENTS_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + +fn migrate_workspace_runtime_bindings_v50_to_v51(conn: &Connection) -> Result<()> { + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + let legacy_columns = table_columns(&tx, "trusted_runtime_records")? + .into_iter() + .collect::>(); + let expected_columns = [ + "runtime_id", + "display_name", + "base_url", + "public_key", + "created_at", + "updated_at", + "revoked_at", + "workspace_id", + ] + .into_iter() + .map(str::to_string) + .collect::>(); + if legacy_columns != expected_columns { + return Err(Error::Store(format!( + "schema-50 trusted_runtime_records columns are not canonical" + ))); + } + + let all_workspace_ids = { + let mut stmt = tx.prepare("SELECT workspace_id FROM workspaces ORDER BY workspace_id")?; + stmt.query_map([], |row| row.get::<_, String>(0))? + .collect::>>()? + }; + + let mut bindings = Vec::new(); + { + let mut stmt = tx.prepare( + r#"SELECT runtime_id, workspace_id, display_name, base_url, public_key, + created_at, updated_at, revoked_at + FROM trusted_runtime_records ORDER BY runtime_id"#, + )?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, Option>(7)?, + )) + })?; + for row in rows { + let ( + runtime_id, + workspace_id, + display_name, + base_url, + public_key, + created_at, + updated_at, + revoked_at, + ) = row?; + let workspace_ids = if runtime_id == crate::hosts::EMBEDDED_RUNTIME_ID { + if all_workspace_ids.is_empty() { + return Err(Error::Store( + "embedded Runtime migration requires at least one registered Workspace" + .to_string(), + )); + } + all_workspace_ids.clone() + } else if let Some(workspace_id) = workspace_id.filter(|value| !value.trim().is_empty()) + { + vec![workspace_id] + } else { + continue; + }; + let (public_key, fingerprint) = normalize_runtime_public_key(&public_key)?; + for workspace_id in workspace_ids { + let workspace_exists = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)", + params![workspace_id], + |row| row.get::<_, i64>(0), + )? != 0; + if !workspace_exists { + return Err(Error::Store(format!( + "Runtime `{runtime_id}` references unknown Workspace `{workspace_id}`" + ))); + } + bindings.push(WorkspaceRuntimeBindingV51 { + workspace_id, + runtime_id: runtime_id.clone(), + display_name: display_name.clone(), + base_url: base_url.clone(), + public_key: Some(public_key.clone()), + public_key_fingerprint: Some(fingerprint.clone()), + created_at: created_at.clone(), + updated_at: updated_at.clone(), + revoked_at: revoked_at.clone(), + }); + } + } + } + + let mut binding_keys = HashSet::new(); + let mut trust_keys = HashSet::new(); + for binding in &bindings { + if !binding_keys.insert((binding.workspace_id.clone(), binding.runtime_id.clone())) { + return Err(Error::Store(format!( + "duplicate Runtime binding `{}/{}` in schema-50", + binding.workspace_id, binding.runtime_id + ))); + } + let fingerprint = binding + .public_key_fingerprint + .clone() + .expect("normalized key"); + if !trust_keys.insert((binding.workspace_id.clone(), fingerprint.clone())) { + return Err(Error::Store(format!( + "duplicate Runtime trust fingerprint `{fingerprint}` in Workspace `{}`", + binding.workspace_id + ))); + } + } + + let mut consumed_jtis = Vec::new(); + { + let runtime_workspaces = bindings.iter().fold( + HashMap::<&str, Vec<&str>>::new(), + |mut workspaces, binding| { + workspaces + .entry(binding.runtime_id.as_str()) + .or_default() + .push(binding.workspace_id.as_str()); + workspaces + }, + ); + let mut stmt = tx.prepare( + "SELECT runtime_id, jti, expires_at, consumed_at FROM worker_mutation_source_proof_jtis", + )?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, String>(3)?, + )) + })?; + for row in rows { + let (runtime_id, jti, expires_at, consumed_at) = row?; + let Some(workspace_ids) = runtime_workspaces.get(runtime_id.as_str()) else { + continue; + }; + for workspace_id in workspace_ids { + consumed_jtis.push(( + (*workspace_id).to_string(), + runtime_id.clone(), + jti.clone(), + expires_at, + consumed_at.clone(), + )); + } + } + } + + tx.execute_batch( + r#" + ALTER TABLE worker_mutation_source_proof_jtis + RENAME TO worker_mutation_source_proof_jtis_v50; + ALTER TABLE trusted_runtime_records + RENAME TO trusted_runtime_records_v50; + + CREATE TABLE workspace_runtime_bindings ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + display_name TEXT NOT NULL, + base_url TEXT NOT NULL, + public_key TEXT, + public_key_fingerprint TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revoked_at TEXT, + PRIMARY KEY (workspace_id, runtime_id), + UNIQUE (workspace_id, public_key_fingerprint), + CHECK ((public_key IS NULL) = (public_key_fingerprint IS NULL)), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT + ); + CREATE INDEX idx_workspace_runtime_bindings_workspace + ON workspace_runtime_bindings(workspace_id, revoked_at, runtime_id); + CREATE TABLE worker_mutation_source_proof_jtis ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + jti TEXT NOT NULL, + expires_at INTEGER NOT NULL, + consumed_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, jti) + ); + "#, + )?; + for binding in bindings { + tx.execute( + r#"INSERT INTO workspace_runtime_bindings ( + workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, created_at, updated_at, revoked_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"#, + params![ + binding.workspace_id, + binding.runtime_id, + binding.display_name, + binding.base_url, + binding.public_key, + binding.public_key_fingerprint, + binding.created_at, + binding.updated_at, + binding.revoked_at, + ], + )?; + } + for (workspace_id, runtime_id, jti, expires_at, consumed_at) in consumed_jtis { + tx.execute( + "INSERT INTO worker_mutation_source_proof_jtis ( + workspace_id, runtime_id, jti, expires_at, consumed_at + ) VALUES (?1, ?2, ?3, ?4, ?5)", + params![workspace_id, runtime_id, jti, expires_at, consumed_at], + )?; + } + tx.execute_batch( + "DROP TABLE worker_mutation_source_proof_jtis_v50; + DROP TABLE trusted_runtime_records_v50;", + )?; + let foreign_key_failures = + tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get::<_, i64>(0) + })?; + if foreign_key_failures != 0 { + return Err(Error::Store(format!( + "schema-51 migration produced {foreign_key_failures} foreign-key violation(s)" + ))); + } + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![51, WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + +fn migrate_workspace_runtime_bindings_v51_to_v52(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 51 { + return Err(Error::Store(format!( + "expected schema version 51 before {RUNTIME_BINDING_AUDIT_MIGRATION_NAME} migration, found {current}" + ))); + } + + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch( + r#" + ALTER TABLE workspace_runtime_bindings + ADD COLUMN binding_revision INTEGER NOT NULL DEFAULT 1 CHECK (binding_revision > 0); + CREATE TABLE workspace_runtime_binding_audit ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + actor_account_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('created', 'replaced', 'reactivated', 'revoked')), + old_fingerprint TEXT, + new_fingerprint TEXT, + binding_revision INTEGER NOT NULL CHECK (binding_revision > 0), + at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, binding_revision), + FOREIGN KEY(workspace_id, runtime_id) + REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE RESTRICT, + FOREIGN KEY(actor_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT + ); + CREATE INDEX idx_workspace_runtime_binding_audit_recent + ON workspace_runtime_binding_audit(workspace_id, runtime_id, binding_revision DESC); + "#, + )?; + verify_workspace_runtime_binding_schema(&tx)?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![52, RUNTIME_BINDING_AUDIT_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + +fn migrate_workspace_deletion_v52_to_v53(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 52 { + return Err(Error::Store(format!( + "expected schema version 52 before {WORKSPACE_DELETION_MIGRATION_NAME} migration, found {current}" + ))); + } + + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch( + r#" + CREATE TABLE worker_create_reservations_v53 ( + workspace_id TEXT NOT NULL, + allocation_key TEXT NOT NULL, + worker_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + create_fingerprint TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('reserved', 'created', 'removed')), + request_fingerprint TEXT, + memory_settings_revision INTEGER, + memory_language TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, allocation_key), + UNIQUE (workspace_id, worker_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + CHECK ( + (request_fingerprint IS NULL AND memory_settings_revision IS NULL AND memory_language IS NULL) + OR + (request_fingerprint IS NOT NULL AND memory_settings_revision IS NOT NULL AND memory_settings_revision > 0 AND memory_language IS NOT NULL AND length(trim(memory_language)) > 0) + ) + ); + INSERT INTO worker_create_reservations_v53 ( + workspace_id, allocation_key, worker_id, runtime_id, create_fingerprint, + state, request_fingerprint, memory_settings_revision, memory_language, + created_at, updated_at + ) + SELECT workspace_id, allocation_key, worker_id, runtime_id, create_fingerprint, + state, + CASE WHEN request_fingerprint IS NOT NULL + AND memory_settings_revision IS NOT NULL + AND memory_settings_revision > 0 + AND memory_language IS NOT NULL + AND length(trim(memory_language)) > 0 + THEN request_fingerprint ELSE NULL END, + CASE WHEN request_fingerprint IS NOT NULL + AND memory_settings_revision IS NOT NULL + AND memory_settings_revision > 0 + AND memory_language IS NOT NULL + AND length(trim(memory_language)) > 0 + THEN memory_settings_revision ELSE NULL END, + CASE WHEN request_fingerprint IS NOT NULL + AND memory_settings_revision IS NOT NULL + AND memory_settings_revision > 0 + AND memory_language IS NOT NULL + AND length(trim(memory_language)) > 0 + THEN memory_language ELSE NULL END, + created_at, updated_at + FROM worker_create_reservations; + DROP TABLE worker_create_reservations; + ALTER TABLE worker_create_reservations_v53 RENAME TO worker_create_reservations; + + CREATE TABLE workspace_deletion_operations ( + operation_id TEXT PRIMARY KEY, + request_fingerprint TEXT NOT NULL, + workspace_id TEXT NOT NULL, + workspace_display_name TEXT NOT NULL, + workspace_revision TEXT NOT NULL, + owner_account_id TEXT NOT NULL, + actor_account_id TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('queued', 'running', 'blocked', 'failed', 'succeeded')), + resource_counts_json TEXT NOT NULL, + child_operation_ids_json TEXT NOT NULL, + blockers_json TEXT NOT NULL, + failure_category TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + FOREIGN KEY(owner_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT, + FOREIGN KEY(actor_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT + ); + CREATE INDEX workspace_deletion_operations_workspace_recent + ON workspace_deletion_operations(workspace_id, created_at DESC); + "#, + )?; + verify_workspace_deletion_schema(&tx)?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![53_i64, WORKSPACE_DELETION_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + +fn migrate_workspace_signing_identity_v53_to_v54(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 53 { + return Err(Error::Store(format!( + "expected schema version 53 before {WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME} migration, found {current}" + ))); + } + + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch( + r#" + CREATE TABLE workspace_signing_identities ( + workspace_id TEXT PRIMARY KEY, + key_id TEXT NOT NULL UNIQUE, + algorithm TEXT NOT NULL CHECK (algorithm = 'ed25519'), + public_key TEXT, + public_key_fingerprint TEXT, + private_material_ref TEXT NOT NULL UNIQUE, + revision INTEGER NOT NULL CHECK (revision >= 1), + state TEXT NOT NULL CHECK (state IN ('pending_provisioning', 'active')), + created_at TEXT NOT NULL, + provisioned_at TEXT, + updated_at TEXT NOT NULL, + CHECK ( + (state = 'pending_provisioning' AND public_key IS NULL AND public_key_fingerprint IS NULL AND provisioned_at IS NULL) + OR + (state = 'active' AND public_key IS NOT NULL AND public_key_fingerprint IS NOT NULL AND provisioned_at IS NOT NULL) + ), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); + CREATE TABLE workspace_signing_identity_provisioning_operations ( + operation_key TEXT PRIMARY KEY, + request_fingerprint TEXT NOT NULL, + operation_kind TEXT NOT NULL CHECK (operation_kind IN ('workspace_create', 'existing_workspace')), + workspace_id TEXT NOT NULL UNIQUE, + key_id TEXT NOT NULL UNIQUE, + private_material_ref TEXT NOT NULL UNIQUE, + revision INTEGER NOT NULL CHECK (revision >= 1), + actor_account_id TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'completed')), + created_at TEXT NOT NULL, + completed_at TEXT + ); + CREATE TABLE workspace_signing_identity_audit ( + event_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + key_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('provisioned')), + revision INTEGER NOT NULL CHECK (revision >= 1), + public_key_fingerprint TEXT NOT NULL, + actor_account_id TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); + CREATE INDEX workspace_signing_identity_audit_workspace_idx + ON workspace_signing_identity_audit(workspace_id, created_at DESC); + INSERT INTO workspace_signing_identities ( + workspace_id, key_id, algorithm, public_key, public_key_fingerprint, + private_material_ref, revision, state, created_at, provisioned_at, updated_at + ) + SELECT workspace_id, + 'WK-' || lower(hex(randomblob(16))), + 'ed25519', NULL, NULL, + 'workspace-signing/' || workspace_id || '/ed25519-v1', + 1, 'pending_provisioning', created_at, NULL, updated_at + FROM workspaces; + "#, + )?; + verify_workspace_signing_identity_schema(&tx)?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![54_i64, WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + +fn migrate_workspace_runtime_binding_state_v54_to_v55(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 54 { + return Err(Error::Store(format!( + "expected schema version 54 before {WORKSPACE_RUNTIME_BINDING_STATE_MIGRATION_NAME} migration, found {current}" + ))); + } + + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + let columns = table_columns(&tx, "workspace_runtime_bindings")?; + let lifecycle_columns = [ + "state", + "authentication_mode", + "workspace_key_id", + "workspace_key_generation", + ]; + let present = lifecycle_columns + .iter() + .filter(|column| columns.iter().any(|existing| existing == **column)) + .count(); + if present == 0 { + tx.execute_batch( + r#" + CREATE TABLE workspace_runtime_bindings_v55 ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + display_name TEXT NOT NULL, + base_url TEXT NOT NULL, + public_key TEXT NOT NULL, + public_key_fingerprint TEXT NOT NULL, + binding_revision INTEGER NOT NULL DEFAULT 1 CHECK (binding_revision > 0), + state TEXT NOT NULL CHECK (state IN ('configured', 'verified', 'revoked')), + authentication_mode TEXT NOT NULL CHECK (authentication_mode IN ('legacy_server_issuer', 'workspace_identity')), + workspace_key_id TEXT, + workspace_key_generation INTEGER CHECK (workspace_key_generation > 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revoked_at TEXT, + PRIMARY KEY (workspace_id, runtime_id), + UNIQUE (workspace_id, public_key_fingerprint), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT, + CHECK ( + (authentication_mode = 'legacy_server_issuer' AND workspace_key_id IS NULL AND workspace_key_generation IS NULL) + OR + (authentication_mode = 'workspace_identity' AND workspace_key_id IS NOT NULL AND workspace_key_generation IS NOT NULL) + ), + CHECK ( + (state = 'revoked' AND revoked_at IS NOT NULL) + OR + (state != 'revoked' AND revoked_at IS NULL) + ) + ); + INSERT INTO workspace_runtime_bindings_v55 ( + workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, binding_revision, state, authentication_mode, + workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at + ) + SELECT workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, binding_revision, + CASE WHEN revoked_at IS NULL THEN 'verified' ELSE 'revoked' END, + 'legacy_server_issuer', NULL, NULL, created_at, updated_at, revoked_at + FROM workspace_runtime_bindings; + DROP TABLE workspace_runtime_bindings; + ALTER TABLE workspace_runtime_bindings_v55 RENAME TO workspace_runtime_bindings; + CREATE INDEX idx_workspace_runtime_bindings_workspace + ON workspace_runtime_bindings(workspace_id, revoked_at, runtime_id); + "#, + )?; + } else if present != lifecycle_columns.len() { + return Err(Error::Store( + "workspace_runtime_bindings has a partially applied lifecycle schema".to_string(), + )); + } + verify_workspace_runtime_binding_schema(&tx)?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![55_i64, WORKSPACE_RUNTIME_BINDING_STATE_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + +fn migrate_workspace_runtime_verification_v55_to_v56(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 55 { + return Err(Error::Store(format!( + "expected schema version 55 before {WORKSPACE_RUNTIME_VERIFICATION_MIGRATION_NAME} migration, found {current}" + ))); + } + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS workspace_runtime_verifications ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + binding_revision INTEGER NOT NULL CHECK(binding_revision > 0), + workspace_key_id TEXT NOT NULL, + workspace_identity_revision INTEGER NOT NULL CHECK(workspace_identity_revision > 0), + workspace_trust_generation INTEGER NOT NULL CHECK(workspace_trust_generation > 0), + runtime_public_key_fingerprint TEXT NOT NULL, + runtime_identity_revision INTEGER NOT NULL CHECK(runtime_identity_revision > 0), + challenge_id TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('pending', 'verified', 'failed')), + last_outcome TEXT NOT NULL, + verified_at TEXT, + checked_at TEXT NOT NULL, + PRIMARY KEY(workspace_id, runtime_id), + FOREIGN KEY(workspace_id, runtime_id) + REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) + ON DELETE CASCADE, + CHECK((state = 'verified' AND verified_at IS NOT NULL) + OR (state != 'verified' AND verified_at IS NULL)) + ); + CREATE INDEX IF NOT EXISTS workspace_runtime_verifications_state_idx + ON workspace_runtime_verifications(workspace_id, state, checked_at DESC); + "#, + )?; + verify_workspace_runtime_verification_schema(&tx)?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![56_i64, WORKSPACE_RUNTIME_VERIFICATION_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + +fn migrate_legacy_external_runtime_bindings_v56_to_v57(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 56 { + return Err(Error::Store(format!( + "expected schema version 56 before {LEGACY_EXTERNAL_RUNTIME_BINDING_CUTOVER_MIGRATION_NAME} migration, found {current}" + ))); + } + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + let missing_workspace_identity_count = tx.query_row( + "SELECT COUNT(*) + FROM workspace_runtime_bindings binding + LEFT JOIN workspace_signing_identities identity + ON identity.workspace_id = binding.workspace_id + WHERE binding.authentication_mode = 'legacy_server_issuer' + AND binding.runtime_id <> ?1 + AND identity.workspace_id IS NULL", + params![crate::hosts::EMBEDDED_RUNTIME_ID], + |row| row.get::<_, i64>(0), + )?; + if missing_workspace_identity_count != 0 { + return Err(Error::Store( + "legacy external Runtime binding migration requires Workspace signing identity metadata" + .to_string(), + )); + } + tx.execute( + "DELETE FROM workspace_runtime_verifications + WHERE EXISTS ( + SELECT 1 FROM workspace_runtime_bindings binding + WHERE binding.workspace_id = workspace_runtime_verifications.workspace_id + AND binding.runtime_id = workspace_runtime_verifications.runtime_id + AND binding.authentication_mode = 'legacy_server_issuer' + AND binding.runtime_id <> ?1 + )", + params![crate::hosts::EMBEDDED_RUNTIME_ID], + )?; + tx.execute( + "UPDATE workspace_runtime_bindings + SET state = CASE WHEN state = 'verified' THEN 'configured' ELSE state END, + authentication_mode = 'workspace_identity', + workspace_key_id = ( + SELECT identity.key_id FROM workspace_signing_identities identity + WHERE identity.workspace_id = workspace_runtime_bindings.workspace_id + ), + workspace_key_generation = ( + SELECT identity.revision FROM workspace_signing_identities identity + WHERE identity.workspace_id = workspace_runtime_bindings.workspace_id + ) + WHERE authentication_mode = 'legacy_server_issuer' + AND runtime_id <> ?1", + params![crate::hosts::EMBEDDED_RUNTIME_ID], + )?; + let remaining_external_legacy_bindings = tx.query_row( + "SELECT COUNT(*) FROM workspace_runtime_bindings + WHERE authentication_mode = 'legacy_server_issuer' + AND runtime_id <> ?1", + params![crate::hosts::EMBEDDED_RUNTIME_ID], + |row| row.get::<_, i64>(0), + )?; + if remaining_external_legacy_bindings != 0 { + return Err(Error::Store( + "legacy Server-issued external Runtime bindings remain after schema-57 migration" + .to_string(), + )); + } + verify_workspace_runtime_binding_schema(&tx)?; + verify_workspace_runtime_verification_schema(&tx)?; + let foreign_key_violations = + tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get::<_, i64>(0) + })?; + if foreign_key_violations != 0 { + return Err(Error::Store(format!( + "schema-57 Runtime binding migration left {foreign_key_violations} foreign-key violation(s)" + ))); + } + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![ + 57_i64, + LEGACY_EXTERNAL_RUNTIME_BINDING_CUTOVER_MIGRATION_NAME + ], + )?; + tx.commit()?; + Ok(()) +} + +fn migrate_workdir_cache_generation_v57_to_v58(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 57 { + return Err(Error::Store(format!( + "expected schema version 57 before {REMOVE_WORKDIR_CACHE_GENERATION_MIGRATION_NAME} migration, found {current}" + ))); + } + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + if table_columns(&tx, "workdir_create_operations")?.contains(&"cache_generation".to_string()) { + tx.execute( + "ALTER TABLE workdir_create_operations DROP COLUMN cache_generation", + [], + )?; + } + if table_columns(&tx, "workdir_create_operations")?.contains(&"cache_generation".to_string()) { + return Err(Error::Store( + "obsolete Workdir cache generation remains after schema-58 migration".to_string(), + )); + } + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![58_i64, REMOVE_WORKDIR_CACHE_GENERATION_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + +fn migrate_runtime_removal_operations_v59_to_v60(conn: &Connection) -> Result<()> { + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch( + r#"CREATE TABLE runtime_removal_operations ( + operation_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + expected_binding_revision INTEGER NOT NULL, + config_revision INTEGER NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'cleanup_pending', 'succeeded', 'failed')), + failure_category TEXT, + binding_removed INTEGER NOT NULL CHECK (binding_removed IN (0, 1)), + runtime_registration_removed INTEGER CHECK (runtime_registration_removed IS NULL OR runtime_registration_removed IN (0, 1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); + CREATE UNIQUE INDEX runtime_removal_operations_one_active_runtime + ON runtime_removal_operations(runtime_id) + WHERE state IN ('pending', 'cleanup_pending'); + CREATE INDEX runtime_removal_operations_workspace_state + ON runtime_removal_operations(workspace_id, state, updated_at); + CREATE TRIGGER runtime_binding_insert_blocked_by_removal + BEFORE INSERT ON workspace_runtime_bindings + FOR EACH ROW + WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') + ) + BEGIN + SELECT RAISE(ABORT, 'runtime_removal_in_progress'); + END; + CREATE TRIGGER runtime_binding_update_blocked_by_removal + BEFORE UPDATE ON workspace_runtime_bindings + FOR EACH ROW + WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') + ) + BEGIN + SELECT RAISE(ABORT, 'runtime_removal_in_progress'); + END;"#, + )?; + tx.execute_batch( + r#" + CREATE TRIGGER worker_registry_insert_blocked_by_runtime_removal + BEFORE INSERT ON worker_registry FOR EACH ROW + WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending')) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + CREATE TRIGGER worker_registry_update_blocked_by_runtime_removal + BEFORE UPDATE ON worker_registry FOR EACH ROW + WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending')) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + CREATE TRIGGER workdir_registry_insert_blocked_by_runtime_removal + BEFORE INSERT ON workdir_registry FOR EACH ROW + WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending')) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + CREATE TRIGGER workdir_registry_update_blocked_by_runtime_removal + BEFORE UPDATE ON workdir_registry FOR EACH ROW + WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending')) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + CREATE TRIGGER worker_assignment_insert_blocked_by_runtime_removal + BEFORE INSERT ON ticket_current_worker_assignments FOR EACH ROW + WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending')) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + CREATE TRIGGER worker_assignment_update_blocked_by_runtime_removal + BEFORE UPDATE ON ticket_current_worker_assignments FOR EACH ROW + WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending')) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + CREATE TRIGGER workdir_attachment_insert_blocked_by_runtime_removal + BEFORE INSERT ON worker_workdir_links FOR EACH ROW + WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending')) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + CREATE TRIGGER workdir_attachment_update_blocked_by_runtime_removal + BEFORE UPDATE ON worker_workdir_links FOR EACH ROW + WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending')) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + CREATE TRIGGER worker_create_insert_blocked_by_runtime_removal + BEFORE INSERT ON worker_create_reservations FOR EACH ROW + WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending')) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + CREATE TRIGGER worker_create_update_blocked_by_runtime_removal + BEFORE UPDATE ON worker_create_reservations FOR EACH ROW + WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending')) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + CREATE TRIGGER workdir_create_insert_blocked_by_runtime_removal + BEFORE INSERT ON workdir_create_operations FOR EACH ROW + WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.resolved_runtime_id AND operation.state IN ('pending', 'cleanup_pending')) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + CREATE TRIGGER workdir_create_update_blocked_by_runtime_removal + BEFORE UPDATE ON workdir_create_operations FOR EACH ROW + WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.resolved_runtime_id AND operation.state IN ('pending', 'cleanup_pending')) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + CREATE TRIGGER worker_removal_insert_blocked_by_runtime_removal + BEFORE INSERT ON worker_removal_operations FOR EACH ROW + WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending')) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + CREATE TRIGGER worker_removal_update_blocked_by_runtime_removal + BEFORE UPDATE ON worker_removal_operations FOR EACH ROW + WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending')) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + CREATE TRIGGER workdir_removal_insert_blocked_by_runtime_removal + BEFORE INSERT ON workdir_removal_operations FOR EACH ROW + WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending')) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + CREATE TRIGGER workdir_removal_update_blocked_by_runtime_removal + BEFORE UPDATE ON workdir_removal_operations FOR EACH ROW + WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending')) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + "#, + )?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![60, RUNTIME_REMOVAL_OPERATION_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + +fn migrate_worker_run_generation_v60_to_v61(conn: &Connection) -> Result<()> { + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch("ALTER TABLE worker_removal_operations DROP COLUMN run_generation;")?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![61, REMOVE_WORKER_RUN_GENERATION_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + +fn migrate_worker_registry_projection_v61_to_v62(conn: &Connection) -> Result<()> { + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch(WORKER_REGISTRY_PROJECTION_SCHEMA)?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![62, WORKER_REGISTRY_PROJECTION_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + +fn migrate_multi_workdir_attachments_v62_to_v63(conn: &Connection) -> Result<()> { + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + if !column_exists(&tx, "workdir_registry", "display_name")? { + tx.execute_batch("ALTER TABLE workdir_registry ADD COLUMN display_name TEXT;")?; + } + if column_exists(&tx, "worker_workdir_links", "role")? + && !column_exists(&tx, "worker_workdir_links", "alias")? + { + tx.execute_batch("ALTER TABLE worker_workdir_links RENAME COLUMN role TO alias;")?; + } + tx.execute_batch( + "DROP INDEX IF EXISTS worker_workdir_links_active_worker_unique; + CREATE UNIQUE INDEX IF NOT EXISTS worker_workdir_links_active_alias_unique + ON worker_workdir_links(workspace_id, worker_id, alias) + WHERE unlinked_at IS NULL;", + )?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![63, MULTI_WORKDIR_ATTACHMENTS_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + +fn migrate_external_workdir_grants_v63_to_v64(conn: &Connection) -> Result<()> { + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + // Migration-chain tests and repaired databases may carry the canonical v64 + // shape while the durable history still stops at v63. In that case only + // advance the marker; rebuilding would discard External grant rows and + // collide with the already-created table. + if column_exists(&tx, "workdir_registry", "source_kind")? + && table_exists(&tx, "external_workdir_grants")? + { + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![64, EXTERNAL_WORKDIR_GRANTS_MIGRATION_NAME], + )?; + tx.commit()?; + return Ok(()); + } + tx.execute_batch( + r#" + PRAGMA defer_foreign_keys = ON; + DROP TRIGGER IF EXISTS workdir_registry_insert_blocked_by_runtime_removal; + DROP TRIGGER IF EXISTS workdir_registry_update_blocked_by_runtime_removal; + CREATE TEMP TABLE worker_workdir_attachment_reservations_v64 AS + SELECT workspace_id, workdir_id, reservation_id, reserved_at + FROM worker_workdir_attachment_reservations; + CREATE TEMP TABLE worker_workdir_links_v64 AS + SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at + FROM worker_workdir_links; + DROP TABLE worker_workdir_attachment_reservations; + DROP TABLE worker_workdir_links; + CREATE TABLE external_workdir_grants ( + grant_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + provider_instance_id TEXT NOT NULL, + display_name TEXT NOT NULL, + permissions TEXT NOT NULL CHECK (permissions = 'read_only'), + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + generation INTEGER NOT NULL CHECK (generation > 0), + status TEXT NOT NULL CHECK (status IN ('pending', 'online', 'offline', 'revoked', 'expired')), + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, grant_id), + UNIQUE (workspace_id, workdir_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, workdir_id) REFERENCES workdir_registry_v64(workspace_id, workdir_id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED + ); + CREATE TABLE workdir_registry_v64 ( + workspace_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + display_name TEXT, + source_kind TEXT NOT NULL CHECK (source_kind IN ('repository', 'external_grant')), + runtime_id TEXT, + repository_id TEXT, + external_grant_id TEXT, + creation_selector TEXT, + creation_ref TEXT, + creation_tree TEXT, + current_selector TEXT, + current_ref TEXT, + current_tree TEXT, + observed_at_epoch_seconds INTEGER, + materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'not_found', 'corrupted', 'unknown', 'failed')), + cleanliness TEXT NOT NULL CHECK (cleanliness IN ('clean', 'dirty', 'unknown')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, workdir_id), + CHECK ( + (source_kind = 'repository' AND runtime_id IS NOT NULL AND repository_id IS NOT NULL AND external_grant_id IS NULL) + OR (source_kind = 'external_grant' AND runtime_id IS NULL AND repository_id IS NULL AND external_grant_id IS NOT NULL) + ), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, repository_id) REFERENCES repositories(workspace_id, repository_id), + FOREIGN KEY (workspace_id, external_grant_id) REFERENCES external_workdir_grants(workspace_id, grant_id) DEFERRABLE INITIALLY DEFERRED + ); + INSERT INTO workdir_registry_v64 ( + workspace_id, workdir_id, display_name, source_kind, runtime_id, repository_id, + external_grant_id, creation_selector, creation_ref, creation_tree, current_selector, + current_ref, current_tree, observed_at_epoch_seconds, materialization_status, + cleanliness, created_at, updated_at + ) SELECT workspace_id, workdir_id, display_name, 'repository', runtime_id, repository_id, + NULL, creation_selector, creation_ref, creation_tree, current_selector, + current_ref, current_tree, observed_at_epoch_seconds, materialization_status, + cleanliness, created_at, updated_at + FROM workdir_registry; + DROP TABLE workdir_registry; + ALTER TABLE workdir_registry_v64 RENAME TO workdir_registry; + CREATE TABLE worker_workdir_attachment_reservations ( + workspace_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + reservation_id TEXT NOT NULL, + reserved_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, workdir_id), + FOREIGN KEY (workspace_id, workdir_id) + REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE + ); + INSERT INTO worker_workdir_attachment_reservations + (workspace_id, workdir_id, reservation_id, reserved_at) + SELECT workspace_id, workdir_id, reservation_id, reserved_at + FROM worker_workdir_attachment_reservations_v64; + CREATE TABLE worker_workdir_links ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + alias TEXT NOT NULL, + linked_at TEXT NOT NULL, + unlinked_at TEXT, + PRIMARY KEY (workspace_id, worker_id, workdir_id, alias), + FOREIGN KEY (workspace_id, worker_id) + REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, workdir_id) + REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE + ); + INSERT INTO worker_workdir_links + (workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at) + SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at + FROM worker_workdir_links_v64; + DROP TABLE worker_workdir_attachment_reservations_v64; + DROP TABLE worker_workdir_links_v64; + CREATE UNIQUE INDEX worker_workdir_links_active_workdir_unique + ON worker_workdir_links(workspace_id, workdir_id) WHERE unlinked_at IS NULL; + CREATE UNIQUE INDEX worker_workdir_links_active_alias_unique + ON worker_workdir_links(workspace_id, worker_id, alias) WHERE unlinked_at IS NULL; + CREATE INDEX worker_workdir_links_workdir + ON worker_workdir_links(workspace_id, workdir_id); + CREATE INDEX idx_workdir_registry_workspace_updated + ON workdir_registry(workspace_id, updated_at DESC); + CREATE INDEX idx_external_workdir_grants_status_expiry + ON external_workdir_grants(workspace_id, status, expires_at); + CREATE TRIGGER workdir_registry_insert_blocked_by_runtime_removal + BEFORE INSERT ON workdir_registry FOR EACH ROW + WHEN NEW.runtime_id IS NOT NULL AND EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') + ) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + CREATE TRIGGER workdir_registry_update_blocked_by_runtime_removal + BEFORE UPDATE ON workdir_registry FOR EACH ROW + WHEN NEW.runtime_id IS NOT NULL AND EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') + ) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + "#, + )?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![64, EXTERNAL_WORKDIR_GRANTS_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + +fn migrate_external_workdir_cleanup_v64_to_v65(conn: &Connection) -> Result<()> { + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + if !column_exists(&tx, "external_workdir_grants", "cleanup_state")? { + tx.execute_batch( + "ALTER TABLE external_workdir_grants ADD COLUMN cleanup_state TEXT NOT NULL DEFAULT 'not_required' CHECK (cleanup_state IN ('not_required', 'pending', 'retry_required', 'completed'));\ + ALTER TABLE external_workdir_grants ADD COLUMN cleanup_error TEXT;\ + ALTER TABLE external_workdir_grants ADD COLUMN cleanup_updated_at TEXT;", + )?; + tx.execute( + "UPDATE external_workdir_grants + SET cleanup_state = 'pending', cleanup_updated_at = updated_at + WHERE status IN ('revoked', 'expired')", + [], + )?; + } + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![65, EXTERNAL_WORKDIR_CLEANUP_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + +fn migrate_ticket_targets_and_workdir_capabilities_v65_to_v66(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 65 { + return Err(Error::Store(format!( + "expected schema version 65 before {TICKET_TARGETS_AND_WORKDIR_CAPABILITIES_MIGRATION_NAME} migration, found {current}" + ))); + } + apply_ticket_targets_and_workdir_capabilities_schema(conn) +} + +fn migrate_external_workdir_access_and_optional_expiry_v66_to_v67(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 66 { + return Err(Error::Store(format!( + "expected schema version 66 before {EXTERNAL_WORKDIR_ACCESS_AND_OPTIONAL_EXPIRY_MIGRATION_NAME} migration, found {current}" + ))); + } + let foreign_keys_enabled = + conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))? != 0; + if foreign_keys_enabled { + conn.execute_batch("PRAGMA foreign_keys = OFF;")?; + } + let result = (|| { + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch( + r#" + CREATE TABLE external_workdir_grants_v67 ( + grant_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + provider_instance_id TEXT NOT NULL, + display_name TEXT NOT NULL, + permissions TEXT NOT NULL CHECK (permissions IN ('read_only', 'read_write')), + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT, + generation INTEGER NOT NULL CHECK (generation > 0), + status TEXT NOT NULL CHECK (status IN ('pending', 'online', 'offline', 'revoked', 'expired')), + updated_at TEXT NOT NULL, + cleanup_state TEXT NOT NULL DEFAULT 'not_required' CHECK (cleanup_state IN ('not_required', 'pending', 'retry_required', 'completed')), + cleanup_error TEXT, + cleanup_updated_at TEXT, + PRIMARY KEY (workspace_id, grant_id), + UNIQUE (workspace_id, workdir_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, workdir_id) + REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED + ); + INSERT INTO external_workdir_grants_v67 ( + grant_id, workspace_id, workdir_id, provider_instance_id, display_name, + permissions, created_by, created_at, expires_at, generation, status, updated_at, + cleanup_state, cleanup_error, cleanup_updated_at + ) SELECT grant_id, workspace_id, workdir_id, provider_instance_id, display_name, + permissions, created_by, created_at, expires_at, generation, status, updated_at, + cleanup_state, cleanup_error, cleanup_updated_at + FROM external_workdir_grants; + DROP TABLE external_workdir_grants; + ALTER TABLE external_workdir_grants_v67 RENAME TO external_workdir_grants; + CREATE INDEX idx_external_workdir_grants_status_expiry + ON external_workdir_grants(workspace_id, status, expires_at); + CREATE TABLE worker_workdir_links_v67 ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + alias TEXT NOT NULL, + capabilities TEXT NOT NULL CHECK (capabilities IN ('all', 'read_only', 'read_write')), + linked_at TEXT NOT NULL, + unlinked_at TEXT, + PRIMARY KEY (workspace_id, worker_id, workdir_id, alias), + FOREIGN KEY (workspace_id, worker_id) + REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, workdir_id) + REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE + ); + INSERT INTO worker_workdir_links_v67 ( + workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, + linked_at, unlinked_at + ) SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, + linked_at, unlinked_at + FROM worker_workdir_links; + DROP TABLE worker_workdir_links; + ALTER TABLE worker_workdir_links_v67 RENAME TO worker_workdir_links; + CREATE UNIQUE INDEX worker_workdir_links_active_workdir_unique + ON worker_workdir_links(workspace_id, workdir_id) WHERE unlinked_at IS NULL; + CREATE UNIQUE INDEX worker_workdir_links_active_alias_unique + ON worker_workdir_links(workspace_id, worker_id, alias) WHERE unlinked_at IS NULL; + CREATE INDEX worker_workdir_links_workdir + ON worker_workdir_links(workspace_id, workdir_id); + CREATE TRIGGER workdir_attachment_insert_blocked_by_runtime_removal + BEFORE INSERT ON worker_workdir_links FOR EACH ROW + WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') + ) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + CREATE TRIGGER workdir_attachment_update_blocked_by_runtime_removal + BEFORE UPDATE ON worker_workdir_links FOR EACH ROW + WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') + ) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + "#, + )?; + let violations = + tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get::<_, i64>(0) + })?; + if violations != 0 { + return Err(Error::Store(format!( + "schema-67 migration left {violations} foreign-key violation(s)" + ))); + } + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![ + 67_i64, + EXTERNAL_WORKDIR_ACCESS_AND_OPTIONAL_EXPIRY_MIGRATION_NAME + ], + )?; + tx.commit()?; + Ok(()) + })(); + let restore = if foreign_keys_enabled { + conn.execute_batch("PRAGMA foreign_keys = ON;") + .map_err(Error::from) + } else { + Ok(()) + }; + match (result, restore) { + (Err(error), _) => Err(error), + (Ok(()), Err(error)) => Err(error), + (Ok(()), Ok(())) => Ok(()), + } +} + +fn migrate_external_workdir_command_permission_v67_to_v68(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 67 { + return Err(Error::Store(format!( + "expected schema version 67 before {EXTERNAL_WORKDIR_COMMAND_PERMISSION_MIGRATION_NAME} migration, found {current}" + ))); + } + let foreign_keys_enabled = + conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))? != 0; + if foreign_keys_enabled { + conn.execute_batch("PRAGMA foreign_keys = OFF;")?; + } + let result = (|| { + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch( + r#" + CREATE TABLE external_workdir_grants_v68 ( + grant_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + provider_instance_id TEXT NOT NULL, + display_name TEXT NOT NULL, + permissions TEXT NOT NULL CHECK (permissions IN ( + 'read_only', 'read_write', 'command_only', + 'read_command', 'read_write_command' + )), + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT, + generation INTEGER NOT NULL CHECK (generation > 0), + status TEXT NOT NULL CHECK (status IN ('pending', 'online', 'offline', 'revoked', 'expired')), + updated_at TEXT NOT NULL, + cleanup_state TEXT NOT NULL DEFAULT 'not_required' CHECK (cleanup_state IN ('not_required', 'pending', 'retry_required', 'completed')), + cleanup_error TEXT, + cleanup_updated_at TEXT, + PRIMARY KEY (workspace_id, grant_id), + UNIQUE (workspace_id, workdir_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, workdir_id) + REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED + ); + INSERT INTO external_workdir_grants_v68 ( + grant_id, workspace_id, workdir_id, provider_instance_id, display_name, + permissions, created_by, created_at, expires_at, generation, status, updated_at, + cleanup_state, cleanup_error, cleanup_updated_at + ) SELECT grant_id, workspace_id, workdir_id, provider_instance_id, display_name, + permissions, created_by, created_at, expires_at, generation, status, updated_at, + cleanup_state, cleanup_error, cleanup_updated_at + FROM external_workdir_grants; + DROP TABLE external_workdir_grants; + ALTER TABLE external_workdir_grants_v68 RENAME TO external_workdir_grants; + CREATE INDEX idx_external_workdir_grants_status_expiry + ON external_workdir_grants(workspace_id, status, expires_at); + "#, + )?; + let violations = + tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get::<_, i64>(0) + })?; + if violations != 0 { + return Err(Error::Store(format!( + "schema-68 migration left {violations} foreign-key violation(s)" + ))); + } + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![68_i64, EXTERNAL_WORKDIR_COMMAND_PERMISSION_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) + })(); + let restore = if foreign_keys_enabled { + conn.execute_batch("PRAGMA foreign_keys = ON;") + .map_err(Error::from) + } else { + Ok(()) + }; + match (result, restore) { + (Err(error), _) => Err(error), + (Ok(()), Err(error)) => Err(error), + (Ok(()), Ok(())) => Ok(()), + } +} + +fn migrate_workdir_command_attachment_capabilities_v68_to_v69(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 68 { + return Err(Error::Store(format!( + "expected schema version 68 before {WORKDIR_COMMAND_ATTACHMENT_CAPABILITIES_MIGRATION_NAME} migration, found {current}" + ))); + } + let foreign_keys_enabled = + conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))? != 0; + if foreign_keys_enabled { + conn.execute_batch("PRAGMA foreign_keys = OFF;")?; + } + let result = (|| { + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch( + r#" + CREATE TABLE worker_workdir_links_v69 ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + alias TEXT NOT NULL, + capabilities TEXT NOT NULL CHECK (capabilities IN ( + 'all', 'read_only', 'read_write', 'command_only', 'read_command' + )), + linked_at TEXT NOT NULL, + unlinked_at TEXT, + PRIMARY KEY (workspace_id, worker_id, workdir_id, alias), + FOREIGN KEY (workspace_id, worker_id) + REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, workdir_id) + REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE + ); + INSERT INTO worker_workdir_links_v69 ( + workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, + linked_at, unlinked_at + ) SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, + linked_at, unlinked_at + FROM worker_workdir_links; + DROP TABLE worker_workdir_links; + ALTER TABLE worker_workdir_links_v69 RENAME TO worker_workdir_links; + CREATE UNIQUE INDEX worker_workdir_links_active_workdir_unique + ON worker_workdir_links(workspace_id, workdir_id) WHERE unlinked_at IS NULL; + CREATE UNIQUE INDEX worker_workdir_links_active_alias_unique + ON worker_workdir_links(workspace_id, worker_id, alias) WHERE unlinked_at IS NULL; + CREATE INDEX worker_workdir_links_workdir + ON worker_workdir_links(workspace_id, workdir_id); + CREATE TRIGGER workdir_attachment_insert_blocked_by_runtime_removal + BEFORE INSERT ON worker_workdir_links FOR EACH ROW + WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') + ) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + CREATE TRIGGER workdir_attachment_update_blocked_by_runtime_removal + BEFORE UPDATE ON worker_workdir_links FOR EACH ROW + WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') + ) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + "#, + )?; + let violations = + tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get::<_, i64>(0) + })?; + if violations != 0 { + return Err(Error::Store(format!( + "schema-69 migration left {violations} foreign-key violation(s)" + ))); + } + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![ + 69_i64, + WORKDIR_COMMAND_ATTACHMENT_CAPABILITIES_MIGRATION_NAME + ], + )?; + tx.commit()?; + Ok(()) + })(); + let restore = if foreign_keys_enabled { + conn.execute_batch("PRAGMA foreign_keys = ON;") + .map_err(Error::from) + } else { + Ok(()) + }; + match (result, restore) { + (Err(error), _) => Err(error), + (Ok(()), Err(error)) => Err(error), + (Ok(()), Ok(())) => Ok(()), + } +} + +fn migrate_backend_job_runner_v69_to_v70(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 69 { + return Err(Error::Store(format!( + "expected schema version 69 before {BACKEND_JOB_RUNNER_MIGRATION_NAME} migration, found {current}" + ))); + } + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch( + r#" + CREATE TABLE backend_jobs ( + workspace_id TEXT NOT NULL, + job_id TEXT NOT NULL, + purpose TEXT NOT NULL, + input_revision TEXT NOT NULL, + input_ref TEXT NOT NULL, + request_json TEXT NOT NULL, + intent_fingerprint TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'completed', 'failed', 'unknown')), + current_attempt INTEGER NOT NULL CHECK (current_attempt > 0 AND current_attempt <= 3), + result_json TEXT, + result_digest TEXT, + failure_category TEXT, + failure_detail TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + PRIMARY KEY (workspace_id, job_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + CHECK ((result_json IS NULL) = (result_digest IS NULL)), + CHECK ((state = 'completed') = (result_json IS NOT NULL)) + ); + CREATE INDEX backend_jobs_active + ON backend_jobs(workspace_id, state, updated_at); + CREATE TABLE backend_job_attempts ( + workspace_id TEXT NOT NULL, + job_id TEXT NOT NULL, + attempt_id TEXT NOT NULL, + attempt INTEGER NOT NULL CHECK (attempt > 0 AND attempt <= 3), + input_revision TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('reserved', 'dispatched', 'completed', 'failed', 'unknown')), + runtime_id TEXT, + worker_id TEXT, + runtime_run_id TEXT, + dispatched_at TEXT, + deadline_at TEXT NOT NULL, + result_json TEXT, + result_digest TEXT, + failure_category TEXT, + failure_detail TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + PRIMARY KEY (workspace_id, job_id, attempt_id), + UNIQUE (workspace_id, job_id, attempt), + UNIQUE (workspace_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id, job_id) + REFERENCES backend_jobs(workspace_id, job_id) ON DELETE CASCADE, + CHECK ((runtime_id IS NULL) = (worker_id IS NULL)), + CHECK ((result_json IS NULL) = (result_digest IS NULL)), + CHECK ((state = 'completed') = (result_json IS NOT NULL)) + ); + CREATE INDEX backend_job_attempts_recovery + ON backend_job_attempts(workspace_id, state, deadline_at); + CREATE TABLE backend_job_deliveries ( + workspace_id TEXT NOT NULL, + delivery_id TEXT NOT NULL, + job_id TEXT NOT NULL, + attempt_id TEXT NOT NULL, + target_runtime_id TEXT NOT NULL, + target_worker_id TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'completed', 'failed')), + failure_category TEXT, + failure_detail TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + delivered_at TEXT, + PRIMARY KEY (workspace_id, delivery_id), + FOREIGN KEY (workspace_id, job_id, attempt_id) + REFERENCES backend_job_attempts(workspace_id, job_id, attempt_id) ON DELETE CASCADE + ); + CREATE INDEX backend_job_deliveries_pending + ON backend_job_deliveries(workspace_id, state, updated_at); + "#, + )?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![70_i64, BACKEND_JOB_RUNNER_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + +fn migrate_backend_job_delivery_claims_v70_to_v71(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 70 { + return Err(Error::Store(format!( + "expected schema version 70 before {BACKEND_JOB_DELIVERY_CLAIM_MIGRATION_NAME} migration, found {current}" + ))); + } + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch( + r#" + DROP INDEX backend_job_deliveries_pending; + ALTER TABLE backend_job_deliveries RENAME TO backend_job_deliveries_v70; + CREATE TABLE backend_job_deliveries ( + workspace_id TEXT NOT NULL, + delivery_id TEXT NOT NULL, + job_id TEXT NOT NULL, + attempt_id TEXT NOT NULL, + target_runtime_id TEXT NOT NULL, + target_worker_id TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'sending', 'completed', 'failed', 'unknown')), + failure_category TEXT, + failure_detail TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + delivered_at TEXT, + PRIMARY KEY (workspace_id, delivery_id), + FOREIGN KEY (workspace_id, job_id, attempt_id) + REFERENCES backend_job_attempts(workspace_id, job_id, attempt_id) ON DELETE CASCADE + ); + INSERT INTO backend_job_deliveries ( + workspace_id, delivery_id, job_id, attempt_id, target_runtime_id, + target_worker_id, state, failure_category, failure_detail, + created_at, updated_at, delivered_at + ) + SELECT workspace_id, delivery_id, job_id, attempt_id, target_runtime_id, + target_worker_id, state, failure_category, failure_detail, + created_at, updated_at, delivered_at + FROM backend_job_deliveries_v70; + DROP TABLE backend_job_deliveries_v70; + CREATE INDEX backend_job_deliveries_pending + ON backend_job_deliveries(workspace_id, state, updated_at); + "#, + )?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![71_i64, BACKEND_JOB_DELIVERY_CLAIM_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + +fn migrate_backend_job_dispatch_queue_v71_to_v72(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 71 { + return Err(Error::Store(format!( + "expected schema version 71 before {BACKEND_JOB_DISPATCH_QUEUE_MIGRATION_NAME} migration, found {current}" + ))); + } + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch( + r#" + DROP INDEX backend_job_deliveries_pending; + DROP INDEX backend_job_attempts_recovery; + ALTER TABLE backend_job_deliveries RENAME TO backend_job_deliveries_v71; + ALTER TABLE backend_job_attempts RENAME TO backend_job_attempts_v71; + CREATE TABLE backend_job_attempts ( + workspace_id TEXT NOT NULL, + job_id TEXT NOT NULL, + attempt_id TEXT NOT NULL, + attempt INTEGER NOT NULL CHECK (attempt > 0 AND attempt <= 3), + input_revision TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('reserved', 'dispatching', 'dispatched', 'completed', 'failed', 'unknown')), + runtime_id TEXT, + worker_id TEXT, + runtime_run_id TEXT, + dispatched_at TEXT, + deadline_at TEXT NOT NULL, + result_json TEXT, + result_digest TEXT, + failure_category TEXT, + failure_detail TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + PRIMARY KEY (workspace_id, job_id, attempt_id), + UNIQUE (workspace_id, job_id, attempt), + UNIQUE (workspace_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id, job_id) + REFERENCES backend_jobs(workspace_id, job_id) ON DELETE CASCADE, + CHECK ((runtime_id IS NULL) = (worker_id IS NULL)), + CHECK ((result_json IS NULL) = (result_digest IS NULL)), + CHECK ((state = 'completed') = (result_json IS NOT NULL)) + ); + INSERT INTO backend_job_attempts SELECT * FROM backend_job_attempts_v71; + CREATE INDEX backend_job_attempts_recovery + ON backend_job_attempts(workspace_id, state, deadline_at); + CREATE TABLE backend_job_deliveries ( + workspace_id TEXT NOT NULL, + delivery_id TEXT NOT NULL, + job_id TEXT NOT NULL, + attempt_id TEXT NOT NULL, + target_runtime_id TEXT NOT NULL, + target_worker_id TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'sending', 'completed', 'failed', 'unknown')), + failure_category TEXT, + failure_detail TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + delivered_at TEXT, + PRIMARY KEY (workspace_id, delivery_id), + FOREIGN KEY (workspace_id, job_id, attempt_id) + REFERENCES backend_job_attempts(workspace_id, job_id, attempt_id) ON DELETE CASCADE + ); + INSERT INTO backend_job_deliveries SELECT * FROM backend_job_deliveries_v71; + CREATE INDEX backend_job_deliveries_pending + ON backend_job_deliveries(workspace_id, state, updated_at); + DROP TABLE backend_job_deliveries_v71; + DROP TABLE backend_job_attempts_v71; + "#, + )?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![72_i64, BACKEND_JOB_DISPATCH_QUEUE_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + +fn migrate_worker_singleton_ownership_v72_to_v73(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 72 { + return Err(Error::Store(format!( + "expected schema version 72 before {WORKER_SINGLETON_OWNERSHIP_MIGRATION_NAME} migration, found {current}" + ))); + } + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + if !column_exists(&tx, "worker_create_reservations", "singleton_key")? { + tx.execute( + "ALTER TABLE worker_create_reservations ADD COLUMN singleton_key TEXT", + [], + )?; + } + if !column_exists(&tx, "worker_create_reservations", "singleton_generation")? { + tx.execute( + "ALTER TABLE worker_create_reservations ADD COLUMN singleton_generation INTEGER \ + CHECK (singleton_generation IS NULL OR singleton_generation > 0)", + [], + )?; + } + tx.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS worker_singleton_owners ( + workspace_id TEXT NOT NULL, + singleton_key TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + generation INTEGER NOT NULL CHECK (generation > 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, singleton_key), + UNIQUE (workspace_id, worker_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); + CREATE TRIGGER IF NOT EXISTS worker_create_reservation_singleton_lease_insert + BEFORE INSERT ON worker_create_reservations + WHEN (NEW.singleton_key IS NULL AND NEW.singleton_generation IS NOT NULL) + OR (NEW.singleton_key IS NOT NULL AND NEW.singleton_generation IS NULL) + BEGIN + SELECT RAISE(ABORT, 'worker_singleton_lease_incomplete'); + END; + CREATE TRIGGER IF NOT EXISTS worker_create_reservation_singleton_lease_update + BEFORE UPDATE OF singleton_key, singleton_generation ON worker_create_reservations + WHEN (NEW.singleton_key IS NULL AND NEW.singleton_generation IS NOT NULL) + OR (NEW.singleton_key IS NOT NULL AND NEW.singleton_generation IS NULL) + BEGIN + SELECT RAISE(ABORT, 'worker_singleton_lease_incomplete'); + END; + + -- Preserve the two historical singleton conventions as Backend ownership. If an old + -- database contains duplicates, deterministically fence every Worker except the oldest. + INSERT INTO worker_singleton_owners ( + workspace_id, singleton_key, runtime_id, worker_id, generation, created_at, updated_at + ) + SELECT worker.workspace_id, 'workspace-orchestrator', worker.runtime_id, worker.worker_id, + 1, worker.created_at, worker.updated_at + FROM worker_registry worker + WHERE worker.profile IN ('orchestrator', 'builtin:orchestrator') + AND worker.display_name = 'Workspace Orchestrator' + AND NOT EXISTS ( + SELECT 1 FROM worker_registry older + WHERE older.workspace_id = worker.workspace_id + AND older.profile IN ('orchestrator', 'builtin:orchestrator') + AND older.display_name = 'Workspace Orchestrator' + AND (older.created_at < worker.created_at + OR (older.created_at = worker.created_at AND older.worker_id < worker.worker_id)) + ); + INSERT INTO worker_singleton_owners ( + workspace_id, singleton_key, runtime_id, worker_id, generation, created_at, updated_at + ) + SELECT worker.workspace_id, 'workspace-memory-consolidation', worker.runtime_id, + worker.worker_id, 1, worker.created_at, worker.updated_at + FROM worker_registry worker + WHERE worker.profile IN ('memory-consolidation', 'builtin:memory-consolidation') + AND NOT EXISTS ( + SELECT 1 FROM worker_registry older + WHERE older.workspace_id = worker.workspace_id + AND older.profile IN ('memory-consolidation', 'builtin:memory-consolidation') + AND (older.created_at < worker.created_at + OR (older.created_at = worker.created_at AND older.worker_id < worker.worker_id)) + ); + INSERT INTO worker_create_reservations ( + workspace_id, allocation_key, worker_id, runtime_id, create_fingerprint, + state, created_at, updated_at, request_fingerprint, + memory_settings_revision, memory_language, singleton_key, singleton_generation + ) + SELECT worker.workspace_id, 'migration-v73-singleton:' || worker.worker_id, + worker.worker_id, worker.runtime_id, + 'migration-v73:' || CASE + WHEN worker.profile IN ('orchestrator', 'builtin:orchestrator') + AND worker.display_name = 'Workspace Orchestrator' + THEN 'workspace-orchestrator' + ELSE 'workspace-memory-consolidation' + END, + 'created', worker.created_at, worker.updated_at, NULL, NULL, NULL, + CASE + WHEN worker.profile IN ('orchestrator', 'builtin:orchestrator') + AND worker.display_name = 'Workspace Orchestrator' + THEN 'workspace-orchestrator' + ELSE 'workspace-memory-consolidation' + END, + 1 + FROM worker_registry worker + WHERE ( + ( + worker.profile IN ('orchestrator', 'builtin:orchestrator') + AND worker.display_name = 'Workspace Orchestrator' + ) + OR worker.profile IN ('memory-consolidation', 'builtin:memory-consolidation') + ) + AND NOT EXISTS ( + SELECT 1 FROM worker_create_reservations reservation + WHERE reservation.workspace_id = worker.workspace_id + AND reservation.worker_id = worker.worker_id + ); + UPDATE worker_create_reservations + SET singleton_key = ( + SELECT CASE + WHEN worker.profile IN ('orchestrator', 'builtin:orchestrator') + AND worker.display_name = 'Workspace Orchestrator' + THEN 'workspace-orchestrator' + ELSE 'workspace-memory-consolidation' + END + FROM worker_registry worker + WHERE worker.workspace_id = worker_create_reservations.workspace_id + AND worker.worker_id = worker_create_reservations.worker_id + AND ( + (worker.profile IN ('orchestrator', 'builtin:orchestrator') + AND worker.display_name = 'Workspace Orchestrator') + OR worker.profile IN ('memory-consolidation', 'builtin:memory-consolidation') + ) + ), + singleton_generation = 1 + WHERE EXISTS ( + SELECT 1 FROM worker_registry worker + WHERE worker.workspace_id = worker_create_reservations.workspace_id + AND worker.worker_id = worker_create_reservations.worker_id + AND ( + (worker.profile IN ('orchestrator', 'builtin:orchestrator') + AND worker.display_name = 'Workspace Orchestrator') + OR worker.profile IN ('memory-consolidation', 'builtin:memory-consolidation') + ) + ); + "#, + )?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![73_i64, WORKER_SINGLETON_OWNERSHIP_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + +fn migrate_ordered_worker_projection_v73_to_v74(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 73 { + return Err(Error::Store(format!( + "expected schema version 73 before {ORDERED_WORKER_PROJECTION_MIGRATION_NAME} migration, found {current}" + ))); + } + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch( + r#" + ALTER TABLE worker_registry_observations RENAME TO worker_registry_observations_v73; + CREATE TABLE worker_registry_observations ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + availability TEXT NOT NULL CHECK (availability IN ('observed', 'unavailable')), + worker_json TEXT, + observed_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id, worker_id) + REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE + ); + INSERT INTO worker_registry_observations ( + workspace_id, runtime_id, worker_id, availability, worker_json, observed_at + ) + SELECT workspace_id, runtime_id, worker_id, availability, worker_json, observed_at + FROM worker_registry_observations_v73; + DROP TABLE worker_registry_observations_v73; + + DROP TABLE IF EXISTS worker_registry_projection_cursors; + DROP TABLE IF EXISTS worker_registry_projection_revisions; + + ALTER TABLE worker_registry_projection_removals + RENAME TO worker_registry_projection_removals_v73; + CREATE TABLE worker_registry_projection_removals ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, worker_id) + ); + INSERT INTO worker_registry_projection_removals (workspace_id, runtime_id, worker_id) + SELECT workspace_id, runtime_id, worker_id + FROM worker_registry_projection_removals_v73; + DROP TABLE worker_registry_projection_removals_v73; + "#, + )?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![74_i64, ORDERED_WORKER_PROJECTION_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + +fn migrate_runtime_scoped_worker_identity_v74_to_v75(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 74 { + return Err(Error::Store(format!( + "expected schema version 74 before {RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME} migration, found {current}" + ))); + } + let foreign_keys_enabled = + conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))? != 0; + let legacy_alter_table_enabled = + conn.query_row("PRAGMA legacy_alter_table", [], |row| row.get::<_, i64>(0))? != 0; + if foreign_keys_enabled { + conn.execute_batch("PRAGMA foreign_keys = OFF;")?; + } + if !legacy_alter_table_enabled { + conn.execute_batch("PRAGMA legacy_alter_table = ON;")?; + } + let result = (|| { + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch( + r#" + DROP INDEX worker_control_grants_controller; + DROP INDEX worker_control_grants_subject; + DROP INDEX worker_workdir_links_active_workdir_unique; + DROP INDEX worker_workdir_links_active_alias_unique; + DROP INDEX worker_workdir_links_workdir; + DROP INDEX idx_worker_registry_workspace_runtime_worker; + DROP INDEX worker_registry_runtime; + + ALTER TABLE worker_registry_observations RENAME TO worker_registry_observations_v74; + ALTER TABLE worker_control_grants RENAME TO worker_control_grants_v74; + ALTER TABLE worker_workdir_links RENAME TO worker_workdir_links_v74; + ALTER TABLE worker_registry RENAME TO worker_registry_v74; + + CREATE TABLE worker_registry ( + workspace_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + display_name TEXT NOT NULL, + profile TEXT, + retention_state TEXT NOT NULL CHECK (retention_state IN ('normal', 'pinned')), + transcript_ref TEXT, + session_ref TEXT, + summary_ref TEXT, + diagnostics_ref TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); + INSERT INTO worker_registry + SELECT * FROM worker_registry_v74; + + UPDATE workspace_resource_keys AS resource + SET resource_id = COALESCE( + ( + SELECT hex(worker.runtime_id) || ':' || hex(worker.worker_id) + FROM worker_registry AS worker + WHERE worker.workspace_id = resource.workspace_id + AND worker.worker_id = resource.resource_id + LIMIT 1 + ), + ( + SELECT hex(reservation.runtime_id) || ':' || hex(reservation.worker_id) + FROM worker_create_reservations AS reservation + WHERE reservation.workspace_id = resource.workspace_id + AND reservation.worker_id = resource.resource_id + LIMIT 1 + ) + ) + WHERE resource.resource_kind = 'worker' + AND ( + EXISTS ( + SELECT 1 FROM worker_registry AS worker + WHERE worker.workspace_id = resource.workspace_id + AND worker.worker_id = resource.resource_id + ) + OR EXISTS ( + SELECT 1 FROM worker_create_reservations AS reservation + WHERE reservation.workspace_id = resource.workspace_id + AND reservation.worker_id = resource.resource_id + ) + ); + + CREATE TABLE worker_registry_observations ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + availability TEXT NOT NULL CHECK (availability IN ('observed', 'unavailable')), + worker_json TEXT, + observed_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id, runtime_id, worker_id) + REFERENCES worker_registry(workspace_id, runtime_id, worker_id) ON DELETE CASCADE + ); + INSERT INTO worker_registry_observations + SELECT * FROM worker_registry_observations_v74; + + CREATE TABLE worker_control_grants ( + grant_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + controller_runtime_id TEXT NOT NULL, + controller_worker_id TEXT NOT NULL, + subject_runtime_id TEXT NOT NULL, + subject_worker_id TEXT NOT NULL, + relation TEXT NOT NULL, + origin TEXT NOT NULL, + permissions_json TEXT NOT NULL, + operation_id TEXT NOT NULL, + created_at TEXT NOT NULL, + revoked_at TEXT, + PRIMARY KEY (workspace_id, grant_id), + UNIQUE (workspace_id, controller_runtime_id, controller_worker_id, operation_id), + FOREIGN KEY (workspace_id, controller_runtime_id, controller_worker_id) + REFERENCES worker_registry(workspace_id, runtime_id, worker_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, subject_runtime_id, subject_worker_id) + REFERENCES worker_registry(workspace_id, runtime_id, worker_id) ON DELETE CASCADE + ); + INSERT INTO worker_control_grants + SELECT * FROM worker_control_grants_v74; + + CREATE TABLE worker_workdir_links ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + alias TEXT NOT NULL, + linked_at TEXT NOT NULL, + unlinked_at TEXT, + capabilities TEXT NOT NULL CHECK (capabilities IN ( + 'all', 'read_only', 'read_write', 'command_only', 'read_command' + )), + PRIMARY KEY (workspace_id, runtime_id, worker_id, workdir_id, alias), + FOREIGN KEY (workspace_id, runtime_id, worker_id) + REFERENCES worker_registry(workspace_id, runtime_id, worker_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, workdir_id) + REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE + ); + INSERT INTO worker_workdir_links ( + workspace_id, runtime_id, worker_id, workdir_id, alias, + linked_at, unlinked_at, capabilities + ) + SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, + linked_at, unlinked_at, capabilities + FROM worker_workdir_links_v74; + + DROP TABLE worker_registry_observations_v74; + DROP TABLE worker_control_grants_v74; + DROP TABLE worker_workdir_links_v74; + DROP TABLE worker_registry_v74; + + CREATE INDEX worker_control_grants_controller ON worker_control_grants( + workspace_id, controller_runtime_id, controller_worker_id, revoked_at + ); + CREATE INDEX worker_control_grants_subject ON worker_control_grants( + workspace_id, subject_runtime_id, subject_worker_id, revoked_at + ); + CREATE UNIQUE INDEX worker_workdir_links_active_workdir_unique + ON worker_workdir_links(workspace_id, workdir_id) WHERE unlinked_at IS NULL; + CREATE UNIQUE INDEX worker_workdir_links_active_alias_unique + ON worker_workdir_links(workspace_id, runtime_id, worker_id, alias) + WHERE unlinked_at IS NULL; + CREATE INDEX worker_workdir_links_workdir + ON worker_workdir_links(workspace_id, workdir_id); + CREATE INDEX worker_registry_runtime + ON worker_registry(workspace_id, runtime_id, worker_id); + + CREATE TRIGGER worker_registry_insert_blocked_by_runtime_removal + BEFORE INSERT ON worker_registry FOR EACH ROW + WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') + ) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + + CREATE TRIGGER worker_registry_update_blocked_by_runtime_removal + BEFORE UPDATE ON worker_registry FOR EACH ROW + WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') + ) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + + CREATE TRIGGER workdir_attachment_insert_blocked_by_runtime_removal + BEFORE INSERT ON worker_workdir_links FOR EACH ROW + WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') + ) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + + CREATE TRIGGER workdir_attachment_update_blocked_by_runtime_removal + BEFORE UPDATE ON worker_workdir_links FOR EACH ROW + WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') + ) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + + CREATE TRIGGER ticket_assignment_worker_parent_tombstone_delete + BEFORE DELETE ON worker_registry + WHEN EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = OLD.workspace_id + AND assignment.principal_kind = 'worker' + AND assignment.runtime_id = OLD.runtime_id + AND assignment.worker_id = OLD.worker_id + ) + BEGIN + INSERT OR IGNORE INTO ticket_assignment_worker_tombstones ( + workspace_id, runtime_id, worker_id, deleted_at + ) VALUES (OLD.workspace_id, OLD.runtime_id, OLD.worker_id, CURRENT_TIMESTAMP); + END; + + CREATE TRIGGER ticket_assignment_worker_parent_tombstone_move + BEFORE UPDATE OF runtime_id ON worker_registry + WHEN OLD.runtime_id != NEW.runtime_id + AND EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = OLD.workspace_id + AND assignment.principal_kind = 'worker' + AND assignment.runtime_id = OLD.runtime_id + AND assignment.worker_id = OLD.worker_id + ) + BEGIN + INSERT OR IGNORE INTO ticket_assignment_worker_tombstones ( + workspace_id, runtime_id, worker_id, deleted_at + ) VALUES (OLD.workspace_id, OLD.runtime_id, OLD.worker_id, CURRENT_TIMESTAMP); + END; + "#, + )?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![75_i64, RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) + })(); + let restore_result = match (legacy_alter_table_enabled, foreign_keys_enabled) { + (false, true) => { + conn.execute_batch("PRAGMA legacy_alter_table = OFF; PRAGMA foreign_keys = ON;") + } + (false, false) => conn.execute_batch("PRAGMA legacy_alter_table = OFF;"), + (true, true) => conn.execute_batch("PRAGMA foreign_keys = ON;"), + (true, false) => Ok(()), + } + .map_err(Error::from); + match (result, restore_result) { + (Err(error), _) => Err(error), + (Ok(()), Err(error)) => Err(error), + (Ok(()), Ok(())) => { + let violations = conn + .prepare("PRAGMA foreign_key_check")? + .query_map([], |_| Ok(()))? + .count(); + if violations != 0 { + return Err(Error::Store(format!( + "Runtime-scoped Worker identity migration left {violations} foreign key violation(s)" + ))); + } + Ok(()) + } + } +} + +fn migrate_archive_observe_grants_v75_to_v76(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 75 { + return Err(Error::Store(format!( + "expected schema version 75 before {ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME} migration, found {current}" + ))); + } + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS worker_session_archive_observe_grants ( + workspace_id TEXT NOT NULL, + archive_id TEXT NOT NULL, + controller_runtime_id TEXT NOT NULL, + controller_worker_id TEXT NOT NULL, + subject_runtime_id TEXT NOT NULL, + subject_worker_id TEXT NOT NULL, + source_grant_id TEXT NOT NULL, + granted_at TEXT NOT NULL, + revoked_at TEXT, + PRIMARY KEY ( + workspace_id, archive_id, + controller_runtime_id, controller_worker_id, + source_grant_id + ), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY (archive_id) REFERENCES worker_session_archives(archive_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS worker_session_archive_observe_grants_controller + ON worker_session_archive_observe_grants( + workspace_id, controller_runtime_id, controller_worker_id, archive_id + ); + "#, + )?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![76_i64, ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + +fn migrate_assignment_work_v81_to_v82(conn: &Connection) -> Result<()> { + if current_schema_version(conn)? != 81 { + return Err(Error::Store( + "Ticket work separation requires schema 81".into(), + )); + } + let foreign_keys = + conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0))?; + let legacy = + conn.pragma_query_value(None, "legacy_alter_table", |row| row.get::<_, bool>(0))?; + conn.execute_batch("PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON;")?; + let result = (|| { + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + let dependents = { + let mut stmt = tx.prepare("SELECT sql FROM sqlite_schema WHERE tbl_name='ticket_current_worker_assignments' AND type IN ('index','trigger') AND sql IS NOT NULL AND name != 'ticket_current_worker_role_idx'")?; + let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; + rows.collect::>>()? + }; + // The retained v82 migration keeps its original coder role contract. + let schema = include_str!("frozen_schema_v85.sql") + .replace( + "role TEXT NOT NULL DEFAULT 'worker'", + "role TEXT NOT NULL DEFAULT 'coder'", + ) + .replace( + "role IN ('orchestrator', 'worker'", + "role IN ('orchestrator', 'coder'", + ); + let start = schema + .find("CREATE TABLE ticket_current_worker_assignments (") + .ok_or_else(|| Error::Store("Missing assignment schema".into()))?; + let end = schema[start..] + .find("CREATE TABLE ticket_worker_assignment_events") + .ok_or_else(|| Error::Store("Missing assignment schema end".into()))? + + start; + tx.execute_batch(&schema[start..end].replace( + "CREATE TABLE ticket_current_worker_assignments", + "CREATE TABLE ticket_current_worker_assignments_v82", + ))?; + tx.execute_batch("INSERT INTO ticket_current_worker_assignments_v82 SELECT * FROM ticket_current_worker_assignments; + DROP TABLE ticket_current_worker_assignments; + ALTER TABLE ticket_current_worker_assignments_v82 RENAME TO ticket_current_worker_assignments;")?; + for sql in dependents { + tx.execute_batch(&sql)?; + } + tx.execute_batch(include_str!("assignment_work.sql"))?; + // Existing done/closed + current-assignment data is preserved, and a + // durable release prevents a later reopen from resurrecting old work. + tx.execute_batch("INSERT OR IGNORE INTO ticket_assignment_work_releases + SELECT current.workspace_id, current.assignment_id, COALESCE(ticket.updated_at, current.updated_at) + FROM ticket_current_worker_assignments current JOIN typed_tickets ticket + ON ticket.workspace_id=current.workspace_id AND ticket.ticket_id=current.ticket_id + WHERE ticket.workflow_state IN ('done','closed');")?; + let legacy_plans = { + let mut statement = + tx.prepare("SELECT operation_id, blockers_json FROM worker_removal_operations")?; + let rows = statement.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })?; + rows.collect::>>()? + }; + for (operation_id, encoded) in legacy_plans { + let mut blockers: Vec = serde_json::from_str(&encoded) + .map_err(|error| Error::Store(format!("invalid removal blockers: {error}")))?; + let mut changed = false; + for blocker in &mut blockers { + if blocker.get("kind").and_then(serde_json::Value::as_str) + == Some("current_assignment") + { + blocker["kind"] = serde_json::Value::String("unfinished_work".into()); + changed = true; + } + } + if changed { + let encoded = serde_json::to_string(&blockers) + .map_err(|error| Error::Store(error.to_string()))?; + // Historical blocked plans stay blocked; issued fingerprints + // and identities are immutable. Fresh planning rereads active work. + tx.execute( + "UPDATE worker_removal_operations SET blockers_json=?2 WHERE operation_id=?1", + params![operation_id, encoded], + )?; + } + } + let broken: i64 = + tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + })?; + if broken != 0 { + return Err(Error::Store( + "Ticket work migration foreign-key verification failed".into(), + )); + } + tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES (82,'Ticket responsibility and unfinished work separation')", [])?; + tx.commit()?; + Ok(()) + })(); + conn.pragma_update(None, "legacy_alter_table", legacy)?; + conn.pragma_update(None, "foreign_keys", foreign_keys)?; + result +} + +fn migrate_ticket_worker_v82_to_v83(conn: &Connection) -> Result<()> { + if current_schema_version(conn)? != 82 { + return Err(Error::Store( + "Ticket Worker migration requires schema 82".into(), + )); + } + let foreign_keys = + conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0))?; + let legacy = + conn.pragma_query_value(None, "legacy_alter_table", |row| row.get::<_, bool>(0))?; + conn.execute_batch("PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON;")?; + let result = (|| { + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + // Rebuild all role-bearing assignment tables together: merely renaming + // the current role would break its historical composite foreign key. + let schema = include_str!("frozen_schema_v85.sql"); + let mut dependents = Vec::new(); + for table in [ + "ticket_assignment_operations", + "ticket_worker_assignments", + "ticket_current_worker_assignments", + "ticket_worker_assignment_events", + ] { + let mut stmt = tx.prepare("SELECT sql FROM sqlite_schema WHERE tbl_name=?1 AND type IN ('index','trigger') AND sql IS NOT NULL AND name != 'ticket_current_worker_role_idx'")?; + let rows = stmt.query_map([table], |row| row.get::<_, String>(0))?; + dependents.extend(rows.collect::>>()?); + drop(stmt); + let declaration = format!("CREATE TABLE {table} ("); + let start = schema + .find(&declaration) + .ok_or_else(|| Error::Store(format!("Missing assignment schema for {table}")))?; + let end = schema[start..].find(';').ok_or_else(|| { + Error::Store(format!("Missing assignment schema end for {table}")) + })? + start + + 1; + let replacement = format!("{table}_v83"); + tx.execute_batch(&schema[start..end].replacen( + &format!("CREATE TABLE {table}"), + &format!("CREATE TABLE {replacement}"), + 1, + ))?; + let columns = table_columns(&tx, table)?; + let values = columns + .iter() + .map(|column| { + if column == "role" { + "CASE role WHEN 'coder' THEN 'worker' ELSE role END".to_string() + } else { + format!("\"{column}\"") + } + }) + .collect::>() + .join(", "); + let column_names = columns + .iter() + .map(|column| format!("\"{column}\"")) + .collect::>() + .join(", "); + // Only role-bearing historical columns are copied. New receipts + // classify legacy unfinished assign operations conservatively. + tx.execute_batch(&format!( + "INSERT INTO {replacement} ({column_names}) SELECT {values} FROM {table}; + DROP TABLE {table}; ALTER TABLE {replacement} RENAME TO {table};" + ))?; + if table == "ticket_assignment_operations" + && !columns.iter().any(|c| c == "claim_state") + { + tx.execute( + "UPDATE ticket_assignment_operations SET claim_state='pending' + WHERE action IN ('assign','reassign') AND assignment_id IS NULL", + [], + )?; + } + } + for sql in dependents { + tx.execute_batch(&sql.replace("'coder'", "'worker'"))?; + } + tx.execute( + "UPDATE typed_ticket_event_attributes SET value='worker' + WHERE key='assignment_role' AND value='coder'", + [], + )?; + let broken: i64 = + tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + })?; + if broken != 0 { + return Err(Error::Store( + "Ticket work migration foreign-key verification failed".into(), + )); + } + tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES (83,'Generic Ticket Worker roles and durable claims')", [])?; + tx.commit()?; + Ok(()) + })(); + conn.pragma_update(None, "legacy_alter_table", legacy)?; + conn.pragma_update(None, "foreign_keys", foreign_keys)?; + result +} + +fn migrate_backend_job_resources_v80_to_v81(conn: &Connection) -> Result<()> { + if current_schema_version(conn)? != 80 { + return Err(Error::Store( + "Backend Job resource migration requires schema 80".into(), + )); + } + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch( + "ALTER TABLE backend_jobs ADD COLUMN resource_key TEXT; + CREATE INDEX backend_jobs_resource ON backend_jobs(workspace_id, resource_key, created_at);", + )?; + // Pre-grant requests had no resource lock. Preserve their exact stored JSON + // and fingerprint; serde defaults read them as empty grants / no key. + tx.execute( + "INSERT INTO __yoi_schema_migrations(version, name) VALUES (81, 'Backend Job immutable resource serialization')", + [], + )?; + tx.commit()?; + Ok(()) +} + +fn migrate_backend_job_worker_cleanup_v76_to_v77(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 76 { + return Err(Error::Store(format!( + "expected schema version 76 before {BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME} migration, found {current}" + ))); + } + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch( + r#" + ALTER TABLE backend_job_attempts ADD COLUMN worker_cleanup_state TEXT + CHECK (worker_cleanup_state IN ('pending', 'executing', 'completed', 'failed')); + ALTER TABLE backend_job_attempts ADD COLUMN worker_cleanup_failure_category TEXT; + ALTER TABLE backend_job_attempts ADD COLUMN worker_cleanup_failure_detail TEXT; + ALTER TABLE backend_job_attempts ADD COLUMN worker_cleanup_updated_at TEXT; + ALTER TABLE backend_job_attempts ADD COLUMN worker_cleanup_completed_at TEXT; + UPDATE backend_job_attempts + SET worker_cleanup_state = CASE + WHEN runtime_id IS NULL THEN 'completed' + ELSE 'pending' + END, + worker_cleanup_updated_at = updated_at, + worker_cleanup_completed_at = CASE + WHEN runtime_id IS NULL THEN updated_at + ELSE NULL + END + WHERE state IN ('completed', 'failed', 'unknown'); + CREATE INDEX backend_job_attempts_worker_cleanup + ON backend_job_attempts(workspace_id, worker_cleanup_state, updated_at); + "#, + )?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![77_i64, BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + +fn migrate_workspace_config_v78_to_v79(conn: &Connection) -> Result<()> { + if current_schema_version(conn)? != 78 { + return Err(Error::Store( + "Workspace config migration requires schema 78".into(), + )); + } + let foreign_keys = + conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0))?; + conn.execute_batch("PRAGMA foreign_keys = OFF;")?; + let result = (|| { + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + let dependents = { + let mut stmt = tx.prepare("SELECT sql FROM sqlite_schema WHERE tbl_name='workdir_registry' AND type IN ('index','trigger') AND sql IS NOT NULL")?; + let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; + rows.collect::>>()? + }; + let schema = include_str!("frozen_schema_v85.sql"); + let start = schema + .find("CREATE TABLE \"workdir_registry\"") + .ok_or_else(|| Error::Store("Missing Workdir schema".into()))?; + let end = schema[start..] + .find("CREATE TABLE external_workdir_grants") + .ok_or_else(|| Error::Store("Missing Workdir schema end".into()))? + + start; + if column_exists(&tx, "workdir_registry", "workspace_config_grant_id")? + && table_exists(&tx, "workspace_config_grants")? + { + tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES (79,'Workspace config grants and logical Workdirs')", [])?; + tx.commit()?; + return Ok(()); + } + tx.execute_batch(include_str!("workspace_config_grants.sql"))?; + tx.execute_batch(&schema[start..end].replace( + "CREATE TABLE \"workdir_registry\"", + "CREATE TABLE workdir_registry_v79", + ))?; + tx.execute_batch("INSERT INTO workdir_registry_v79 (workspace_id,workdir_id,display_name,source_kind,runtime_id,repository_id,external_grant_id,creation_selector,creation_ref,creation_tree,current_selector,current_ref,current_tree,observed_at_epoch_seconds,materialization_status,cleanliness,created_at,updated_at) SELECT workspace_id,workdir_id,display_name,source_kind,runtime_id,repository_id,external_grant_id,creation_selector,creation_ref,creation_tree,current_selector,current_ref,current_tree,observed_at_epoch_seconds,materialization_status,cleanliness,created_at,updated_at FROM workdir_registry; DROP TABLE workdir_registry; ALTER TABLE workdir_registry_v79 RENAME TO workdir_registry;")?; + for sql in dependents { + tx.execute_batch(&sql)?; + } + let broken: bool = { + let mut stmt = tx.prepare("PRAGMA foreign_key_check")?; + let mut rows = stmt.query([])?; + rows.next()?.is_some() + }; + if broken { + return Err(Error::Store( + "Workspace config migration foreign-key verification failed".into(), + )); + } + tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES (79,'Workspace config grants and logical Workdirs')", [])?; + tx.commit()?; + Ok(()) + })(); + if foreign_keys { + conn.execute_batch("PRAGMA foreign_keys = ON;")?; + } + result +} + +fn migrate_workdir_connection_id_v77_to_v78(conn: &Connection) -> Result<()> { + use rusqlite::TransactionBehavior; + let current = current_schema_version(conn)?; + if current != 77 { + return Err(Error::Store(format!( + "expected schema version 77 before {WORKDIR_CONNECTION_ID_MIGRATION_NAME} migration, found {current}" + ))); + } + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch( + "ALTER TABLE worker_workdir_links ADD COLUMN connection_id TEXT NOT NULL DEFAULT ''; + UPDATE worker_workdir_links SET connection_id = lower(hex(randomblob(16)));", + )?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![78_i64, WORKDIR_CONNECTION_ID_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + +fn migrate_workdir_credential_candidate_snapshots_v58_to_v59(conn: &Connection) -> Result<()> { + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch( + r#" + CREATE TABLE workdir_create_credential_candidates ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0 AND ordinal < 2), + role TEXT NOT NULL CHECK (role IN ('primary', 'workspace_default_fallback')), + credential_id TEXT NOT NULL CHECK (length(credential_id) BETWEEN 1 AND 128), + credential_revision INTEGER NOT NULL CHECK (credential_revision > 0), + PRIMARY KEY (workspace_id, operation_id, ordinal), + UNIQUE (workspace_id, operation_id, role), + UNIQUE (workspace_id, operation_id, credential_id), + FOREIGN KEY (workspace_id, operation_id) + REFERENCES workdir_create_operations(workspace_id, operation_id) + ON DELETE CASCADE + ); + CREATE INDEX idx_workdir_create_credential_candidates_revision + ON workdir_create_credential_candidates( + workspace_id, credential_id, credential_revision + ); + CREATE TABLE workdir_create_credential_revision_retentions ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + credential_id TEXT NOT NULL, + credential_revision INTEGER NOT NULL, + PRIMARY KEY (workspace_id, operation_id, ordinal), + FOREIGN KEY (workspace_id, operation_id, ordinal) + REFERENCES workdir_create_credential_candidates( + workspace_id, operation_id, ordinal + ) + ON DELETE CASCADE, + FOREIGN KEY (workspace_id, credential_id, credential_revision) + REFERENCES repository_ssh_credential_revisions( + workspace_id, credential_id, revision + ) + ON DELETE RESTRICT + ); + "#, + )?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![59_i64, WORKDIR_CREDENTIAL_CANDIDATE_SNAPSHOT_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} diff --git a/crates/workspace-server/src/frozen_repository_key_migration.rs b/crates/workspace-server/src/frozen_repository_key_migration.rs new file mode 100644 index 000000000..5d64a99fe --- /dev/null +++ b/crates/workspace-server/src/frozen_repository_key_migration.rs @@ -0,0 +1,400 @@ +// Frozen schema-86 SSH cutover. No ordinal is used as a new authority token. +fn migrate_repository_keys_v86_to_v87(conn: &Connection) -> Result<()> { + migrate_repository_keys_v86_to_v87_with_secret_source( + conn, + conn.path().filter(|path| !path.is_empty()).map(Path::new), + ) +} + +// Validation may run against an in-memory backup. Its secret authority remains +// the original database's master key, not the candidate connection's location. +fn migrate_repository_keys_v86_to_v87_with_secret_source( + conn: &Connection, + secret_source: Option<&Path>, +) -> Result<()> { + if current_schema_version(conn)? != 86 { + return Err(Error::Store( + "Repository key migration requires schema 86".into(), + )); + } + if !conn.is_autocommit() { + return Err(Error::Store( + "Repository key migration requires an autocommit connection".into(), + )); + } + let foreign_keys: bool = conn.query_row("PRAGMA foreign_keys", [], |r| r.get(0))?; + let legacy_alter: bool = conn.query_row("PRAGMA legacy_alter_table", [], |r| r.get(0))?; + conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;")?; + let result = (|| { + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Immediate)?; + // Each stored key must resolve to one actual mutation receipt. Ambiguous or + // missing evidence is not repaired by manufacturing an ID from its ordinal. + tx.execute_batch( + "CREATE TEMP TABLE repository_key_cutover ( + workspace_id TEXT NOT NULL, kind TEXT NOT NULL, resource_id TEXT NOT NULL, + legacy_revision INTEGER NOT NULL, operation_id TEXT NOT NULL, fingerprint TEXT NOT NULL, created_at TEXT NOT NULL, + PRIMARY KEY(workspace_id,kind,resource_id,legacy_revision));", + )?; + for (table, kind, id, fingerprint) in [ + ( + "repository_ssh_credential_revisions", + "credential", + "credential_id", + "public_key_fingerprint", + ), + ( + "repository_ssh_host_trust_revisions", + "host_trust", + "host_trust_id", + "fingerprint", + ), + ] { + let rows = { + let mut statement = tx.prepare(&format!( + "SELECT workspace_id,{id},revision,{fingerprint},created_at FROM {table}" + ))?; + statement + .query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, i64>(2)?, + r.get::<_, String>(3)?, + r.get::<_, String>(4)?, + )) + })? + .collect::>>()? + }; + for (workspace, resource, revision, fingerprint, created) in rows { + let mut statement = tx.prepare("SELECT operation_id FROM repository_secret_operations WHERE workspace_id=?1 AND resource_kind=?2 AND resource_id=?3 AND result_revision=?4 AND created_at=?5")?; + let matches = statement + .query_map(params![workspace, kind, resource, revision, created], |r| { + r.get::<_, String>(0) + })? + .collect::>>()?; + if matches.len() != 1 || matches[0].is_empty() { + return Err(Error::Store("Repository key cutover requires one unambiguous mutation receipt per stored key".into())); + } + tx.execute( + "INSERT INTO repository_key_cutover VALUES(?1,?2,?3,?4,?5,?6,?7)", + params![ + workspace, + kind, + resource, + revision, + matches[0], + fingerprint, + created + ], + )?; + } + } + // Current rows must agree with their pointed-to historical key, not merely + // resolve to a nonempty receipt. FK checks alone cannot prove this identity. + let inconsistent: bool = tx.query_row("SELECT EXISTS ( + SELECT 1 FROM repository_ssh_credentials c LEFT JOIN repository_ssh_credential_revisions k + ON k.workspace_id=c.workspace_id AND k.credential_id=c.credential_id AND k.revision=c.current_revision + WHERE k.revision IS NULL OR k.public_key_algorithm<>c.public_key_algorithm OR k.public_key_fingerprint<>c.public_key_fingerprint + UNION ALL + SELECT 1 FROM repository_ssh_host_trusts c LEFT JOIN repository_ssh_host_trust_revisions k + ON k.workspace_id=c.workspace_id AND k.host_trust_id=c.host_trust_id AND k.revision=c.current_revision + WHERE k.revision IS NULL OR k.hostname<>c.hostname OR k.port<>c.port OR k.key_algorithm<>c.key_algorithm OR k.host_key<>c.host_key OR k.fingerprint<>c.fingerprint + UNION ALL + SELECT 1 FROM workdir_create_credential_revision_retentions r LEFT JOIN workdir_create_credential_candidates c + ON c.workspace_id=r.workspace_id AND c.operation_id=r.operation_id AND c.ordinal=r.ordinal + WHERE c.ordinal IS NULL OR c.credential_id<>r.credential_id OR c.credential_revision<>r.credential_revision + )", [], |r| r.get(0))?; + if inconsistent { + return Err(Error::Store( + "Repository key cutover found inconsistent current or retained key evidence".into(), + )); + } + for table in [ + "repository_secret_audit_events", + "repository_secret_operations", + "repository_ssh_credentials", + "repository_ssh_credential_revisions", + "repository_ssh_host_trusts", + "repository_ssh_host_trust_revisions", + "server_secret_versions", + "workdir_create_operations", + "workdir_create_credential_candidates", + "workdir_create_credential_revision_retentions", + ] { + tx.execute_batch(&format!("ALTER TABLE {table} RENAME TO {table}_v86;"))?; + } + tx.execute_batch("DROP INDEX idx_workdir_create_credential_candidates_revision;")?; + tx.execute_batch(include_str!("frozen_repository_key_schema.sql"))?; + tx.execute_batch("INSERT INTO repository_secret_operations SELECT workspace_id,operation_id,request_fingerprint,resource_kind,resource_id,operation_id,created_at FROM repository_secret_operations_v86; + INSERT INTO repository_ssh_credentials SELECT c.workspace_id,c.credential_id,c.name,c.public_key_algorithm,c.public_key_fingerprint,m.operation_id,c.status,c.created_at,c.rotated_at FROM repository_ssh_credentials_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_revision=c.current_revision; + INSERT INTO repository_ssh_credential_keys SELECT c.workspace_id,c.credential_id,m.operation_id,c.public_key_algorithm,c.public_key_fingerprint,c.created_at FROM repository_ssh_credential_revisions_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_revision=c.revision; + INSERT INTO repository_ssh_host_trusts SELECT c.workspace_id,c.host_trust_id,c.hostname,c.port,c.key_algorithm,c.host_key,c.fingerprint,m.operation_id,c.created_at,c.updated_at FROM repository_ssh_host_trusts_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='host_trust' AND m.resource_id=c.host_trust_id AND m.legacy_revision=c.current_revision; + INSERT INTO repository_ssh_host_trust_keys SELECT c.workspace_id,c.host_trust_id,m.operation_id,c.hostname,c.port,c.key_algorithm,c.host_key,c.fingerprint,c.created_at FROM repository_ssh_host_trust_revisions_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='host_trust' AND m.resource_id=c.host_trust_id AND m.legacy_revision=c.revision;")?; + let secrets = { + let mut statement = tx.prepare("SELECT s.workspace_id,s.secret_id,s.revision,s.purpose,s.encryption_algorithm,s.nonce,s.ciphertext,s.created_at,m.operation_id FROM server_secret_versions_v86 s LEFT JOIN repository_key_cutover m ON m.workspace_id=s.workspace_id AND m.kind='credential' AND m.resource_id=s.secret_id AND m.legacy_revision=s.revision")?; + statement + .query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, u64>(2)?, + r.get::<_, String>(3)?, + r.get::<_, String>(4)?, + r.get::<_, Vec>(5)?, + r.get::<_, Vec>(6)?, + r.get::<_, String>(7)?, + r.get::<_, String>(8)?, + )) + })? + .collect::>>()? + }; + for (workspace, id, revision, purpose, algorithm, nonce, ciphertext, created, operation) in + secrets + { + if algorithm != "aes-256-gcm-v1" { + return Err(Error::Store( + "unsupported Repository secret envelope".into(), + )); + } + let database = secret_source.ok_or_else(|| { + Error::Store("Repository secret migration requires its database master key".into()) + })?; + let (nonce, ciphertext) = + crate::repository_access::migrate_legacy_repository_secret_envelope( + database, + &workspace, + &id, + revision, + &operation, + &purpose, + &nonce, + &ciphertext, + )?; + tx.execute( + "INSERT INTO server_secret_objects VALUES(?1,?2,?3,?4,?5,?6,?7,?8)", + params![ + workspace, id, operation, purpose, algorithm, nonce, ciphertext, created + ], + )?; + } + let missing_secret: i64 = tx.query_row("SELECT count(*) FROM repository_ssh_credential_keys k WHERE NOT EXISTS (SELECT 1 FROM server_secret_objects s WHERE s.workspace_id=k.workspace_id AND s.secret_id=k.credential_id AND s.operation_id=k.operation_id AND s.purpose='private_key')", [], |r| r.get(0))?; + if missing_secret != 0 { + return Err(Error::Store( + "Repository key migration found a missing private key envelope".into(), + )); + } + // Audit events also require an actual receipt, including deletions whose key + // material no longer exists. Keep their timestamps and actor attribution. + let audits = { + let mut statement=tx.prepare("SELECT workspace_id,event_id,kind,resource_id,revision,actor_account_id,created_at FROM repository_secret_audit_events_v86")?; + statement + .query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, String>(3)?, + r.get::<_, i64>(4)?, + r.get::<_, String>(5)?, + r.get::<_, String>(6)?, + )) + })? + .collect::>>()? + }; + for (workspace, event, kind, resource, revision, actor, created) in audits { + let resource_kind = if kind.starts_with("credential_") { + "credential" + } else if kind.starts_with("host_trust_") { + "host_trust" + } else { + return Err(Error::Store("unknown Repository audit kind".into())); + }; + let mut statement=tx.prepare("SELECT operation_id FROM repository_secret_operations_v86 WHERE workspace_id=?1 AND resource_kind=?2 AND resource_id=?3 AND result_revision=?4 AND created_at=?5")?; + let matches = statement + .query_map( + params![workspace, resource_kind, resource, revision, created], + |r| r.get::<_, String>(0), + )? + .collect::>>()?; + if matches.len() != 1 { + return Err(Error::Store( + "Repository audit cutover requires one unambiguous mutation receipt".into(), + )); + } + tx.execute( + "INSERT INTO repository_secret_audit_events VALUES(?1,?2,?3,?4,?5,?6,?7)", + params![workspace, event, kind, resource, matches[0], actor, created], + )?; + } + // Successful unretained snapshots are history, not future lease input. + // Archive all their SSH evidence, including counters, without interpreting + // a reused credential ID/ordinal as a newer key. Failed creates are retryable. + tx.execute_batch("CREATE TEMP TABLE workdir_terminal_archive_cutover AS + SELECT o.workspace_id,o.operation_id FROM workdir_create_operations_v86 o + WHERE o.state='succeeded' + AND NOT EXISTS(SELECT 1 FROM workdir_create_credential_revision_retentions_v86 r WHERE r.workspace_id=o.workspace_id AND r.operation_id=o.operation_id) + AND (o.credential_id IS NOT NULL OR o.host_trust_id IS NOT NULL OR EXISTS(SELECT 1 FROM workdir_create_credential_candidates_v86 c WHERE c.workspace_id=o.workspace_id AND c.operation_id=o.operation_id)); + INSERT INTO repository_secret_legacy_receipts + SELECT e.workspace_id,e.operation_id,e.request_fingerprint,e.resource_kind,e.resource_id,e.result_revision,e.created_at,e.mutation_kind, + CASE WHEN e.mutation_kind IN ('credential_created','host_trust_created') THEN 0 + WHEN e.mutation_kind IN ('credential_rotated','host_trust_rotated') AND e.result_revision>1 THEN e.result_revision-1 + WHEN e.mutation_kind IN ('credential_deleted','host_trust_deleted') THEN e.result_revision END, + m.operation_id,m.fingerprint + FROM (SELECT o.*, (SELECT min(a.kind) FROM repository_secret_audit_events_v86 a + WHERE a.workspace_id=o.workspace_id AND a.resource_id=o.resource_id AND a.revision=o.result_revision AND a.created_at=o.created_at + AND ((o.resource_kind='credential' AND a.kind LIKE 'credential_%') OR (o.resource_kind='host_trust' AND a.kind LIKE 'host_trust_%')) + HAVING count(*)=1) AS mutation_kind FROM repository_secret_operations_v86 o) e + LEFT JOIN repository_key_cutover m ON m.workspace_id=e.workspace_id AND m.kind=e.resource_kind AND m.resource_id=e.resource_id + AND m.created_at<=e.created_at + AND m.legacy_revision=CASE WHEN e.mutation_kind IN ('credential_rotated','host_trust_rotated') THEN e.result_revision-1 + WHEN e.mutation_kind IN ('credential_deleted','host_trust_deleted') THEN e.result_revision END; + INSERT INTO workdir_create_operations + SELECT o.workspace_id,o.operation_id,'workdir-create-v86:' || COALESCE(CAST(o.source_revision AS TEXT),'unknown') || ':' || o.request_fingerprint, + o.repository_id,o.selector,o.requested_runtime_id,o.resolved_runtime_id,o.config_projection_digest,o.working_directory_id,o.state,o.failure,o.created_at,o.updated_at, + o.source_kind,o.source_uri,o.source_fingerprint, + CASE WHEN a.operation_id IS NULL THEN o.credential_id END,CASE WHEN a.operation_id IS NULL THEN c.fingerprint END, + CASE WHEN a.operation_id IS NULL THEN o.host_trust_id END,CASE WHEN a.operation_id IS NULL THEN h.fingerprint END, + CASE WHEN a.operation_id IS NULL THEN o.repository_access_mode END + FROM workdir_create_operations_v86 o + LEFT JOIN workdir_terminal_archive_cutover a ON a.workspace_id=o.workspace_id AND a.operation_id=o.operation_id + LEFT JOIN repository_key_cutover c ON c.workspace_id=o.workspace_id AND c.kind='credential' AND c.resource_id=o.credential_id AND c.legacy_revision=o.credential_revision AND c.created_at<=o.updated_at + LEFT JOIN repository_key_cutover h ON h.workspace_id=o.workspace_id AND h.kind='host_trust' AND h.resource_id=o.host_trust_id AND h.legacy_revision=o.host_trust_revision AND h.created_at<=o.updated_at; + INSERT INTO workdir_create_credential_candidates + SELECT c.workspace_id,c.operation_id,c.ordinal,c.role,c.credential_id,m.fingerprint + FROM workdir_create_credential_candidates_v86 c JOIN workdir_create_operations_v86 o ON o.workspace_id=c.workspace_id AND o.operation_id=c.operation_id LEFT JOIN repository_key_cutover m + ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_revision=c.credential_revision AND m.created_at<=o.updated_at + WHERE NOT EXISTS(SELECT 1 FROM workdir_terminal_archive_cutover a WHERE a.workspace_id=c.workspace_id AND a.operation_id=c.operation_id); + INSERT INTO workdir_create_credential_retentions SELECT c.workspace_id,c.operation_id,c.ordinal,c.credential_id,m.fingerprint,m.operation_id FROM workdir_create_credential_revision_retentions_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_revision=c.credential_revision;")?; + let missing:i64=tx.query_row("SELECT count(*) FROM workdir_create_operations WHERE (credential_id IS NOT NULL AND credential_fingerprint IS NULL) OR (host_trust_id IS NOT NULL AND host_trust_fingerprint IS NULL)",[],|r|r.get(0))?; + if missing != 0 { + return Err(Error::Store( + "Workdir SSH snapshot is missing key identity evidence".into(), + )); + } + let archived = { + let mut statement = tx.prepare( + "SELECT workspace_id,operation_id FROM workdir_terminal_archive_cutover", + )?; + statement + .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))? + .collect::>>()? + }; + for (workspace, operation) in archived { + let old_operation=frozen_repository_json_rows(&tx,"SELECT * FROM workdir_create_operations_v86 WHERE workspace_id=?1 AND operation_id=?2",params![workspace,operation])?.pop().ok_or_else(|| Error::Store("archived Workdir operation disappeared".into()))?; + let old_candidates = frozen_repository_json_rows( + &tx, + "SELECT * FROM workdir_create_credential_candidates_v86 WHERE workspace_id=?1 AND operation_id=?2 ORDER BY ordinal", + params![workspace, operation], + )?; + tx.execute( + "INSERT INTO workdir_create_legacy_ssh_archives VALUES(?1,?2,?3,?4)", + params![ + workspace, + operation, + serde_json::to_string(&old_operation) + .map_err(|e| Error::Store(e.to_string()))?, + serde_json::to_string(&old_candidates) + .map_err(|e| Error::Store(e.to_string()))? + ], + )?; + } + tx.execute_batch("DROP TABLE workdir_terminal_archive_cutover;")?; + for table in [ + "workdir_create_credential_revision_retentions", + "workdir_create_credential_candidates", + "workdir_create_operations", + "server_secret_versions", + "repository_ssh_credential_revisions", + "repository_ssh_host_trust_revisions", + "repository_ssh_credentials", + "repository_ssh_host_trusts", + "repository_secret_operations", + "repository_secret_audit_events", + ] { + tx.execute_batch(&format!("DROP TABLE {table}_v86;"))?; + } + tx.execute_batch("DROP TABLE repository_key_cutover; + CREATE INDEX idx_repository_secret_audit_workspace_created ON repository_secret_audit_events(workspace_id,created_at,event_id); + CREATE INDEX idx_repository_ssh_credentials_workspace_status ON repository_ssh_credentials(workspace_id,status,credential_id); + CREATE INDEX idx_repository_ssh_host_trusts_workspace_host ON repository_ssh_host_trusts(workspace_id,hostname,port);")?; + tx.execute_batch( + r#"CREATE TRIGGER workdir_create_insert_blocked_by_runtime_removal +BEFORE INSERT ON workdir_create_operations FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.resolved_runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER workdir_create_update_blocked_by_runtime_removal +BEFORE UPDATE ON workdir_create_operations FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.resolved_runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +"#, + )?; + if tx.prepare("PRAGMA foreign_key_check")?.exists([])? { + return Err(Error::Store( + "Repository key migration foreign key check failed".into(), + )); + } + if table_exists(&tx, "repositories")? { + tx.execute_batch("ALTER TABLE repositories DROP COLUMN source_revision;")?; + } + tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES(87,'Repository SSH key identities and encrypted operation objects')",[])?; + tx.commit()?; + Ok(()) + })(); + let restore = conn + .execute_batch(&format!( + "PRAGMA legacy_alter_table={}; PRAGMA foreign_keys={};", + i32::from(legacy_alter), + i32::from(foreign_keys) + )) + .map_err(Error::from); + result.and(restore) +} + +// Preserve every SQLite field and NULL verbatim as typed JSON. Frozen archives +// are never read by credential lookup, lease construction, or a live CAS. +fn frozen_repository_json_rows( + conn: &Connection, + query: &str, + params: &[&dyn rusqlite::ToSql], +) -> Result> { + let mut statement = conn.prepare(query)?; + let columns = statement + .column_names() + .into_iter() + .map(str::to_string) + .collect::>(); + let mut rows = statement.query(params)?; + let mut result = Vec::new(); + while let Some(row) = rows.next()? { + let mut object = serde_json::Map::new(); + for (index, name) in columns.iter().enumerate() { + let value = match row.get_ref(index)? { + rusqlite::types::ValueRef::Null => serde_json::Value::Null, + rusqlite::types::ValueRef::Integer(value) => value.into(), + rusqlite::types::ValueRef::Text(value) => serde_json::Value::String( + std::str::from_utf8(value) + .map_err(|e| Error::Store(e.to_string()))? + .to_string(), + ), + _ => { + return Err(Error::Store( + "unexpected value in frozen Repository archive".into(), + )); + } + }; + object.insert(name.clone(), value); + } + result.push(object.into()); + } + Ok(result) +} diff --git a/crates/workspace-server/src/frozen_repository_key_schema.sql b/crates/workspace-server/src/frozen_repository_key_schema.sql new file mode 100644 index 000000000..96df686e8 --- /dev/null +++ b/crates/workspace-server/src/frozen_repository_key_schema.sql @@ -0,0 +1,188 @@ +CREATE TABLE repository_secret_audit_events ( + workspace_id TEXT NOT NULL, + event_id TEXT NOT NULL, + kind TEXT NOT NULL, + resource_id TEXT NOT NULL, + operation_id TEXT NOT NULL CHECK (length(operation_id) > 0), + actor_account_id TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, event_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE repository_secret_operations ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + resource_kind TEXT NOT NULL CHECK (resource_kind IN ('credential', 'host_trust')), + resource_id TEXT NOT NULL, + result_operation_id TEXT NOT NULL CHECK (length(result_operation_id) > 0), + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, operation_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE repository_ssh_credential_keys ( + workspace_id TEXT NOT NULL, + credential_id TEXT NOT NULL, + operation_id TEXT NOT NULL CHECK (length(operation_id) > 0), + public_key_algorithm TEXT NOT NULL, + public_key_fingerprint TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, credential_id, operation_id), + UNIQUE (workspace_id, credential_id, operation_id, public_key_fingerprint), + FOREIGN KEY (workspace_id, credential_id) + REFERENCES repository_ssh_credentials(workspace_id, credential_id) + ON DELETE CASCADE + ); +CREATE TABLE repository_ssh_credentials ( + workspace_id TEXT NOT NULL, + credential_id TEXT NOT NULL, + name TEXT NOT NULL, + public_key_algorithm TEXT NOT NULL, + public_key_fingerprint TEXT NOT NULL, + current_operation_id TEXT NOT NULL CHECK (length(current_operation_id) > 0), + status TEXT NOT NULL CHECK (status IN ('active', 'revoked')), + created_at TEXT NOT NULL, + rotated_at TEXT, + PRIMARY KEY (workspace_id, credential_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE repository_ssh_host_trust_keys ( + workspace_id TEXT NOT NULL, + host_trust_id TEXT NOT NULL, + operation_id TEXT NOT NULL CHECK (length(operation_id) > 0), + hostname TEXT NOT NULL, + port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535), + key_algorithm TEXT NOT NULL, + host_key TEXT NOT NULL, + fingerprint TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, host_trust_id, operation_id), + FOREIGN KEY (workspace_id, host_trust_id) + REFERENCES repository_ssh_host_trusts(workspace_id, host_trust_id) + ON DELETE CASCADE + ); +CREATE TABLE repository_ssh_host_trusts ( + workspace_id TEXT NOT NULL, + host_trust_id TEXT NOT NULL, + hostname TEXT NOT NULL, + port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535), + key_algorithm TEXT NOT NULL, + host_key TEXT NOT NULL, + fingerprint TEXT NOT NULL, + current_operation_id TEXT NOT NULL CHECK (length(current_operation_id) > 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, host_trust_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE server_secret_objects ( + workspace_id TEXT NOT NULL, + secret_id TEXT NOT NULL, + operation_id TEXT NOT NULL CHECK (length(operation_id) > 0), + purpose TEXT NOT NULL CHECK (purpose IN ('private_key', 'passphrase')), + encryption_algorithm TEXT NOT NULL CHECK (encryption_algorithm = 'aes-256-gcm-v1'), + nonce BLOB NOT NULL CHECK (length(nonce) = 12), + ciphertext BLOB NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, secret_id, operation_id, purpose), + FOREIGN KEY (workspace_id, secret_id, operation_id) + REFERENCES repository_ssh_credential_keys(workspace_id, credential_id, operation_id) + ON DELETE CASCADE + ); +CREATE TABLE workdir_create_operations ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + repository_id TEXT NOT NULL, + selector TEXT, + requested_runtime_id TEXT, + resolved_runtime_id TEXT NOT NULL, + config_projection_digest TEXT NOT NULL, + working_directory_id TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'succeeded', 'failed')), + failure TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, source_kind TEXT, source_uri TEXT, source_fingerprint TEXT, credential_id TEXT, credential_fingerprint TEXT, host_trust_id TEXT, host_trust_fingerprint TEXT, repository_access_mode TEXT, + PRIMARY KEY (workspace_id, operation_id), + UNIQUE (workspace_id, working_directory_id) + ); +CREATE TABLE workdir_create_credential_candidates ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0 AND ordinal < 2), + role TEXT NOT NULL CHECK (role IN ('primary', 'workspace_default_fallback')), + credential_id TEXT NOT NULL CHECK (length(credential_id) BETWEEN 1 AND 128), + credential_fingerprint TEXT NOT NULL CHECK (length(credential_fingerprint) > 0), + PRIMARY KEY (workspace_id, operation_id, ordinal), + UNIQUE (workspace_id, operation_id, ordinal, credential_id, credential_fingerprint), + UNIQUE (workspace_id, operation_id, role), + UNIQUE (workspace_id, operation_id, credential_id), + FOREIGN KEY (workspace_id, operation_id) + REFERENCES workdir_create_operations(workspace_id, operation_id) + ON DELETE CASCADE +); +CREATE INDEX idx_workdir_create_credential_candidates_fingerprint + ON workdir_create_credential_candidates( + workspace_id, credential_id, credential_fingerprint + ); +CREATE TABLE workdir_create_credential_retentions ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + credential_id TEXT NOT NULL, + credential_fingerprint TEXT NOT NULL, + credential_operation_id TEXT NOT NULL, + PRIMARY KEY (workspace_id, operation_id, ordinal), + FOREIGN KEY (workspace_id, operation_id, ordinal, credential_id, credential_fingerprint) + REFERENCES workdir_create_credential_candidates( + workspace_id, operation_id, ordinal, credential_id, credential_fingerprint + ) + ON DELETE CASCADE, + FOREIGN KEY (workspace_id, credential_id, credential_operation_id, credential_fingerprint) + REFERENCES repository_ssh_credential_keys(workspace_id, credential_id, operation_id, public_key_fingerprint) + ON DELETE RESTRICT +); +-- Frozen evidence only. Neither table is a key, lease, or mutation authority. +CREATE TABLE workdir_create_legacy_ssh_archives ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + operation_json TEXT NOT NULL CHECK (json_valid(operation_json)), + candidates_json TEXT NOT NULL CHECK (json_valid(candidates_json)), + PRIMARY KEY (workspace_id, operation_id), + FOREIGN KEY (workspace_id, operation_id) + REFERENCES workdir_create_operations(workspace_id, operation_id) ON DELETE CASCADE +); +CREATE TRIGGER workdir_create_legacy_archive_cannot_authorize_update +BEFORE UPDATE ON workdir_create_operations FOR EACH ROW +WHEN EXISTS (SELECT 1 FROM workdir_create_legacy_ssh_archives a + WHERE a.workspace_id=OLD.workspace_id AND a.operation_id=OLD.operation_id) +AND (NEW.state <> 'succeeded' OR NEW.credential_id IS NOT NULL + OR NEW.credential_fingerprint IS NOT NULL OR NEW.host_trust_id IS NOT NULL + OR NEW.host_trust_fingerprint IS NOT NULL OR NEW.repository_access_mode IS NOT NULL) +BEGIN SELECT RAISE(ABORT, 'archived_workdir_create_cannot_authorize'); END; +CREATE TRIGGER workdir_create_legacy_archive_blocks_candidates +BEFORE INSERT ON workdir_create_credential_candidates FOR EACH ROW +WHEN EXISTS (SELECT 1 FROM workdir_create_legacy_ssh_archives a + WHERE a.workspace_id=NEW.workspace_id AND a.operation_id=NEW.operation_id) +BEGIN SELECT RAISE(ABORT, 'archived_workdir_create_cannot_authorize'); END; +CREATE TRIGGER workdir_create_legacy_archive_blocks_candidate_updates +BEFORE UPDATE ON workdir_create_credential_candidates FOR EACH ROW +WHEN EXISTS (SELECT 1 FROM workdir_create_legacy_ssh_archives a + WHERE a.workspace_id=NEW.workspace_id AND a.operation_id=NEW.operation_id) +BEGIN SELECT RAISE(ABORT, 'archived_workdir_create_cannot_authorize'); END; +CREATE TABLE repository_secret_legacy_receipts ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + resource_kind TEXT NOT NULL, + resource_id TEXT NOT NULL, + result_revision INTEGER NOT NULL, + created_at TEXT NOT NULL, + mutation_kind TEXT, + expected_revision INTEGER, + expected_operation_id TEXT, + expected_key_fingerprint TEXT, + PRIMARY KEY (workspace_id, operation_id), + FOREIGN KEY (workspace_id, operation_id) + REFERENCES repository_secret_operations(workspace_id, operation_id) ON DELETE CASCADE +); diff --git a/crates/workspace-server/src/frozen_repository_key_tests.rs b/crates/workspace-server/src/frozen_repository_key_tests.rs new file mode 100644 index 000000000..37762b339 --- /dev/null +++ b/crates/workspace-server/src/frozen_repository_key_tests.rs @@ -0,0 +1,1055 @@ +use super::*; +use ring::aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey}; +use sha2::{Digest, Sha256}; + +// Frozen v85 contains the same SSH/Workdir tables as v86. Only the tables at +// this migration boundary are needed; unrelated authorities are not fixtures. +fn legacy_repository_keys(path: &std::path::Path) -> Connection { + let conn = Connection::open(path).unwrap(); + conn.execute_batch( + "PRAGMA foreign_keys=ON; + CREATE TABLE __yoi_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL); + INSERT INTO __yoi_schema_migrations VALUES(86,'workspace schema baseline'); + CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY); + INSERT INTO workspaces VALUES('space'); + CREATE TABLE runtime_removal_operations(runtime_id TEXT,state TEXT); + CREATE TABLE repositories(repository_id TEXT PRIMARY KEY, source_revision INTEGER NOT NULL, source_fingerprint TEXT NOT NULL); + INSERT INTO repositories VALUES('repo',9,'source-content');", + ) + .unwrap(); + let frozen = include_str!("frozen_schema_v85.sql"); + for (start, end) in [ + ( + "CREATE TABLE repository_secret_audit_events", + "CREATE TABLE ticket_assignment_operations", + ), + ( + "CREATE TABLE workdir_create_operations", + "CREATE TABLE \"workdir_registry\"", + ), + ] { + let start = frozen.find(start).unwrap(); + let end = start + frozen[start..].find(end).unwrap(); + conn.execute_batch(&frozen[start..end]).unwrap(); + } + conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','create-key','intent','credential','key',7,'created'); + INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519','fingerprint',7,'active','created',NULL); + INSERT INTO repository_ssh_credential_revisions VALUES('space','key',7,'ssh-ed25519','fingerprint','created'); + INSERT INTO repository_secret_audit_events VALUES('space','audit','credential_created','key',7,'actor','created'); + INSERT INTO workdir_create_operations(workspace_id,operation_id,request_fingerprint,repository_id,resolved_runtime_id,config_revision,config_projection_digest,working_directory_id,state,created_at,updated_at,credential_id,credential_revision) + VALUES('space','create-workdir','intent','repo','runtime',3,'projection','workdir','pending','created','created','key',7); + INSERT INTO workdir_create_credential_candidates VALUES('space','create-workdir',0,'primary','key',7); + INSERT INTO workdir_create_credential_revision_retentions VALUES('space','create-workdir',0,'key',7); + CREATE TRIGGER workdir_create_insert_blocked_by_runtime_removal + BEFORE INSERT ON workdir_create_operations WHEN EXISTS(SELECT 1 FROM runtime_removal_operations WHERE runtime_id=NEW.resolved_runtime_id AND state IN ('pending','cleanup_pending')) + BEGIN SELECT RAISE(ABORT,'runtime_removal_in_progress'); END; + CREATE TRIGGER workdir_create_update_blocked_by_runtime_removal + BEFORE UPDATE ON workdir_create_operations WHEN EXISTS(SELECT 1 FROM runtime_removal_operations WHERE runtime_id=NEW.resolved_runtime_id AND state IN ('pending','cleanup_pending')) + BEGIN SELECT RAISE(ABORT,'runtime_removal_in_progress'); END;").unwrap(); + let key = LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &[42; 32]).unwrap()); + for (purpose, nonce, body) in [ + ("private_key", [1; 12], b"private bytes".as_slice()), + ("passphrase", [2; 12], b"passphrase bytes".as_slice()), + ] { + let mut ciphertext = body.to_vec(); + let aad = format!("yoi/repository-secret/v1/space/key/7/{purpose}"); + key.seal_in_place_append_tag( + Nonce::assume_unique_for_key(nonce), + Aad::from(aad.as_bytes()), + &mut ciphertext, + ) + .unwrap(); + conn.execute("INSERT INTO server_secret_versions VALUES('space','key',7,?1,'aes-256-gcm-v1',?2,?3,'created')",params![purpose,nonce.as_slice(),ciphertext]).unwrap(); + } + conn +} + +#[test] +fn repository_key_cutover_reseals_secrets_and_preserves_retention_and_audit() { + let dir = tempfile::tempdir().unwrap(); + let conn = legacy_repository_keys(&dir.path().join("server.db")); + std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap(); + migrate_repository_keys_v86_to_v87(&conn).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 87); + assert!( + conn.prepare("SELECT source_revision FROM repositories") + .is_err() + ); + assert_eq!( + conn.query_row("SELECT source_fingerprint FROM repositories", [], |r| { + r.get::<_, String>(0) + }) + .unwrap(), + "source-content" + ); + let key = LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &[42; 32]).unwrap()); + for (purpose, body) in [ + ("private_key", b"private bytes".as_slice()), + ("passphrase", b"passphrase bytes".as_slice()), + ] { + let (nonce,mut ciphertext):(Vec,Vec)=conn.query_row("SELECT nonce,ciphertext FROM server_secret_objects WHERE workspace_id='space' AND secret_id='key' AND operation_id='create-key' AND purpose=?1",[purpose],|r|Ok((r.get(0)?,r.get(1)?))).unwrap(); + let aad = serde_json::to_string(&( + "yoi/repository-secret/operation", + "space", + "key", + "create-key", + purpose, + )) + .unwrap(); + let plaintext = key + .open_in_place( + Nonce::assume_unique_for_key(nonce.try_into().unwrap()), + Aad::from(aad.as_bytes()), + &mut ciphertext, + ) + .unwrap(); + assert_eq!(plaintext, body); + } + let audit: (String, String) = conn + .query_row( + "SELECT operation_id,actor_account_id FROM repository_secret_audit_events", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(audit, ("create-key".into(), "actor".into())); + let fingerprint: String = conn + .query_row( + "SELECT credential_fingerprint FROM workdir_create_credential_retentions", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(fingerprint, "fingerprint"); + assert!( + conn.execute( + "UPDATE workdir_create_credential_retentions SET credential_fingerprint='different'", + [] + ) + .is_err() + ); + assert!( + conn.execute( + "UPDATE workdir_create_credential_candidates SET credential_fingerprint='different'", + [] + ) + .is_err() + ); + assert!( + conn.execute( + "UPDATE repository_ssh_credential_keys SET public_key_fingerprint='different'", + [] + ) + .is_err() + ); + assert!( + conn.execute("DELETE FROM repository_ssh_credentials", []) + .is_err() + ); + conn.execute_batch("INSERT INTO runtime_removal_operations VALUES('runtime','pending')") + .unwrap(); + assert!( + conn.execute( + "UPDATE workdir_create_operations SET updated_at='later'", + [] + ) + .is_err() + ); + assert!(conn.execute("INSERT INTO workdir_create_operations(workspace_id,operation_id,request_fingerprint,repository_id,resolved_runtime_id,config_projection_digest,working_directory_id,state,created_at,updated_at) VALUES('space','blocked','intent','repo','runtime','projection','other-workdir','pending','created','created')", []).is_err()); + assert!( + !conn + .prepare("PRAGMA foreign_key_check") + .unwrap() + .exists([]) + .unwrap() + ); +} + +#[test] +fn migrated_workdir_request_replays_and_retries_without_accepting_changed_display_name() { + let dir = tempfile::tempdir().unwrap(); + let conn = legacy_repository_keys(&dir.path().join("server.db")); + std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap(); + let old_digest = "sha256:db15419814117c33c089a36c5348bda85d01a53fccd8d4d427685daf5983353b"; + conn.execute("UPDATE workdir_create_operations SET request_fingerprint=?1, selector='main', source_kind='local_path',source_uri='/tmp/repo',source_fingerprint='source',source_revision=9", [old_digest]).unwrap(); + conn.execute_batch( + "CREATE TABLE workdir_removal_operations(workspace_id TEXT,workdir_id TEXT,state TEXT)", + ) + .unwrap(); + migrate_repository_keys_v86_to_v87(&conn).unwrap(); + let store = SqliteWorkspaceStore { + conn: Arc::new(Mutex::new(conn)), + }; + let migrated = store + .load_workdir_create_operation("space", "create-workdir") + .unwrap() + .unwrap(); + let replay = crate::workdir_create_operations::request_fingerprint_for_replay( + "repo", + Some("main"), + None, + Some("Name"), + "source", + Some(&migrated.request_fingerprint), + ) + .unwrap(); + assert_eq!(replay, format!("workdir-create-v86:9:{old_digest}")); + assert_eq!( + store.reserve_workdir_create_operation(&migrated).unwrap(), + migrated + ); + assert!( + crate::workdir_create_operations::request_fingerprint_for_replay( + "repo", + Some("main"), + None, + Some("Changed"), + "source", + Some(&migrated.request_fingerprint) + ) + .is_err() + ); + let failed = store + .finish_workdir_create_operation( + "space", + "create-workdir", + &replay, + false, + Some("provider failed"), + "failed", + ) + .unwrap(); + let retried = store + .begin_failed_workdir_create_retry("space", "create-workdir", &replay, "retried") + .unwrap(); + assert_eq!(failed.state, "failed"); + assert_eq!(retried.state, "pending"); + assert_eq!(retried.request_fingerprint, replay); + assert_eq!( + retried.credential_candidates, + migrated.credential_candidates + ); + let succeeded = store + .finish_workdir_create_operation( + "space", + "create-workdir", + &replay, + true, + None, + "succeeded", + ) + .unwrap(); + assert_eq!( + store.reserve_workdir_create_operation(&migrated).unwrap(), + succeeded + ); +} + +#[test] +fn repository_key_cutover_rejects_active_transaction_without_changing_pragmas() { + let dir = tempfile::tempdir().unwrap(); + let conn = legacy_repository_keys(&dir.path().join("server.db")); + conn.execute_batch("BEGIN IMMEDIATE").unwrap(); + assert!(migrate_repository_keys_v86_to_v87(&conn).is_err()); + assert!(!conn.is_autocommit()); + assert_eq!( + conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, i64>(0)) + .unwrap(), + 1 + ); + assert_eq!( + conn.query_row("PRAGMA legacy_alter_table", [], |r| r.get::<_, i64>(0)) + .unwrap(), + 0 + ); + conn.execute_batch("ROLLBACK").unwrap(); +} + +#[test] +fn repository_key_cutover_preserves_host_endpoints_deleted_audit_and_nondefault_pragmas() { + for foreign_keys in [0, 1] { + for legacy_alter in [0, 1] { + let dir = tempfile::tempdir().unwrap(); + let conn = legacy_repository_keys(&dir.path().join("server.db")); + std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap(); + conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','host-create','intent','host_trust','host',3,'created'); + INSERT INTO repository_secret_operations VALUES('space','host-move','intent','host_trust','host',4,'updated'); + INSERT INTO repository_ssh_host_trusts VALUES('space','host','new.example.test',2222,'ssh-ed25519','host-key','host-fingerprint',4,'created','updated'); + INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',3,'old.example.test',22,'ssh-ed25519','host-key','host-fingerprint','created'); + INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',4,'new.example.test',2222,'ssh-ed25519','host-key','host-fingerprint','updated'); + INSERT INTO repository_secret_audit_events VALUES('space','host-audit','host_trust_rotated','host',4,'host-actor','updated'); + INSERT INTO repository_secret_operations VALUES('space','delete-gone','intent','credential','gone',5,'deleted'); + INSERT INTO repository_secret_audit_events VALUES('space','delete-audit','credential_deleted','gone',5,'delete-actor','deleted'); + UPDATE workdir_create_operations SET host_trust_id='host',host_trust_revision=3;").unwrap(); + conn.execute_batch(&format!( + "PRAGMA foreign_keys={foreign_keys}; PRAGMA legacy_alter_table={legacy_alter};" + )) + .unwrap(); + migrate_repository_keys_v86_to_v87(&conn).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 87); + assert_eq!( + conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, i64>(0)) + .unwrap(), + foreign_keys + ); + assert_eq!( + conn.query_row("PRAGMA legacy_alter_table", [], |r| r.get::<_, i64>(0)) + .unwrap(), + legacy_alter + ); + let old: (String, i64) = conn.query_row("SELECT hostname,port FROM repository_ssh_host_trust_keys WHERE operation_id='host-create'", [], |r| Ok((r.get(0)?, r.get(1)?))).unwrap(); + assert_eq!(old, ("old.example.test".into(), 22)); + let current: (String, i64, String) = conn + .query_row( + "SELECT hostname,port,current_operation_id FROM repository_ssh_host_trusts", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .unwrap(); + assert_eq!( + current, + ("new.example.test".into(), 2222, "host-move".into()) + ); + let deleted: (String, String) = conn.query_row("SELECT operation_id,actor_account_id FROM repository_secret_audit_events WHERE event_id='delete-audit'", [], |r| Ok((r.get(0)?, r.get(1)?))).unwrap(); + assert_eq!(deleted, ("delete-gone".into(), "delete-actor".into())); + assert_eq!( + conn.query_row( + "SELECT host_trust_fingerprint FROM workdir_create_operations", + [], + |r| r.get::<_, String>(0) + ) + .unwrap(), + "host-fingerprint" + ); + assert!( + !conn + .prepare("PRAGMA foreign_key_check") + .unwrap() + .exists([]) + .unwrap() + ); + } + } +} + +#[test] +fn deleted_successful_workdir_archives_verbatim_history_without_authorizing_retry_or_new_key() { + let dir = tempfile::tempdir().unwrap(); + let database = dir.path().join("server.db"); + let conn = legacy_repository_keys(&database); + let digest = "sha256:db15419814117c33c089a36c5348bda85d01a53fccd8d4d427685daf5983353b"; + conn.execute("UPDATE workdir_create_operations SET state='succeeded',request_fingerprint=?1,selector='main',source_kind='local_path',source_uri='/tmp/repo',source_revision=9,source_fingerprint='source'",[digest]).unwrap(); + conn.execute_batch("DELETE FROM workdir_create_credential_revision_retentions; + INSERT INTO repository_secret_operations VALUES('space','delete-key','delete-intent','credential','key',7,'deleted'); + INSERT INTO repository_secret_audit_events VALUES('space','delete-audit','credential_deleted','key',7,'deleting-actor','deleted'); + DELETE FROM repository_ssh_credentials;").unwrap(); + let original = + frozen_repository_json_rows(&conn, "SELECT * FROM workdir_create_operations", &[]) + .unwrap() + .pop() + .unwrap(); + let candidates = serde_json::json!([{"workspace_id":"space","operation_id":"create-workdir","ordinal":0,"role":"primary","credential_id":"key","credential_revision":7}]); + migrate_repository_keys_v86_to_v87(&conn).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 87); + assert!(!dir.path().join("repository-secrets.master-key").exists()); + assert_eq!( + conn.query_row("SELECT count(*) FROM server_secret_objects", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 0 + ); + let store = Arc::new(SqliteWorkspaceStore { + conn: Arc::new(Mutex::new(conn)), + }); + let archived = store + .load_workdir_create_legacy_ssh_archive("space", "create-workdir") + .unwrap() + .unwrap(); + assert_eq!(archived.0, original); + assert_eq!(archived.1, candidates); + assert_eq!(archived.0["config_revision"], 3); + assert_eq!(archived.0["credential_revision"], 7); + let result = store + .load_workdir_create_operation("space", "create-workdir") + .unwrap() + .unwrap(); + assert_eq!(result.state, "succeeded"); + assert_eq!(result.working_directory_id, "workdir"); + assert!(result.credential_id.is_none()); + assert!(result.credential_fingerprint.is_none()); + assert!(result.credential_candidates.is_empty()); + let replay = crate::workdir_create_operations::request_fingerprint_for_replay( + "repo", + Some("main"), + None, + Some("Name"), + "source", + Some(&result.request_fingerprint), + ) + .unwrap(); + assert_eq!( + store.reserve_workdir_create_operation(&result).unwrap(), + result + ); + assert!( + store + .begin_failed_workdir_create_retry("space", "create-workdir", &replay, "retry") + .is_err() + ); + assert!( + store + .finish_workdir_create_operation( + "space", + "create-workdir", + &replay, + false, + Some("failed"), + "later" + ) + .is_err() + ); + // An explicit new mutation may recreate the same ID. The archived ordinal + // must neither become its fingerprint nor be rebound to the completed create. + let service = + crate::repository_access::RepositorySecretService::open(store.clone(), &database).unwrap(); + let key = ssh_key::PrivateKey::from(ssh_key::private::Ed25519Keypair::from_seed(&[61; 32])); + let recreated = service + .create_credential( + "space", + server_api::CreateRepositorySshCredentialRequest { + operation_id: "new-key".into(), + credential_id: "key".into(), + name: "New key".into(), + private_key: key.to_openssh(ssh_key::LineEnding::LF).unwrap().to_string(), + passphrase: None, + }, + "actor", + ) + .unwrap(); + let host = service + .put_host_trust( + "space", + server_api::PutRepositorySshHostTrustRequest { + operation_id: "new-host".into(), + host_trust_id: "host".into(), + hostname: "example.test".into(), + port: 22, + host_key: key.public_key().to_openssh().unwrap(), + expected_fingerprint: None, + }, + "actor", + ) + .unwrap(); + let new_candidate = crate::store::WorkdirCreateCredentialCandidate { + role: crate::store::WorkdirCreateCredentialCandidateRole::Primary, + credential_id: "key".into(), + credential_fingerprint: recreated.public_key_fingerprint.clone(), + }; + assert!( + store + .bind_workdir_create_repository_access( + "space", + "create-workdir", + &replay, + "key", + &recreated.public_key_fingerprint, + "host", + &host.fingerprint, + "read_only", + &[new_candidate], + "later" + ) + .is_err() + ); + assert!( + service + .lease_ssh_materialization_access_fingerprints( + "space", + "key", + "7", + "host", + &host.fingerprint + ) + .is_err() + ); + assert!( + service + .lease_ssh_materialization_access_fingerprints( + "space", + "key", + "fingerprint", + "host", + &host.fingerprint + ) + .is_err() + ); + store.with_conn(|conn| { + assert!(conn.execute("UPDATE workdir_create_operations SET state='failed'",[]).is_err()); + assert!(conn.execute("INSERT INTO workdir_create_credential_candidates VALUES('space','create-workdir',0,'primary','key',?1)",[&recreated.public_key_fingerprint]).is_err()); + assert!(!conn.prepare("PRAGMA foreign_key_check")?.exists([])?); Ok(()) + }).unwrap(); + let mut unrelated = result.clone(); + unrelated.operation_id = "unrelated-create".into(); + unrelated.working_directory_id = "unrelated-workdir".into(); + unrelated.state = "pending".into(); + store.reserve_workdir_create_operation(&unrelated).unwrap(); + store.with_conn(|conn| { + conn.execute("INSERT INTO workdir_create_credential_candidates VALUES('space','unrelated-create',0,'primary','key',?1)",[&recreated.public_key_fingerprint])?; + let error = conn.execute("UPDATE workdir_create_credential_candidates SET operation_id='create-workdir' WHERE operation_id='unrelated-create'",[]).unwrap_err(); + assert!(error.to_string().contains("archived_workdir_create_cannot_authorize"),"{error}"); + Ok(()) + }).unwrap(); + assert_eq!( + store + .load_workdir_create_operation("space", "create-workdir") + .unwrap() + .unwrap(), + result + ); + assert_eq!( + store + .load_workdir_create_legacy_ssh_archive("space", "create-workdir") + .unwrap() + .unwrap(), + archived + ); + let status = service + .operation_status("space", "delete-key") + .unwrap() + .unwrap(); + assert_eq!(status.state, "committed"); + assert_eq!(status.mutation_kind.as_deref(), Some("credential_deleted")); + assert_eq!(status.legacy_precondition_proven, Some(false)); + assert!( + service + .operation_status("other-space", "delete-key") + .unwrap() + .is_none() + ); + let deleted_request = server_api::DeleteRepositorySshCredentialRequest { + operation_id: "delete-key".into(), + expected_public_key_fingerprint: recreated.public_key_fingerprint.clone(), + }; + let projection = server_api::RepositoryAccessProjection { + workspace_id: "space".into(), + projection_digest: "empty".into(), + bindings: Vec::new(), + }; + assert!( + service + .delete_credential("space", "key", deleted_request, "actor", &projection) + .unwrap_err() + .to_string() + .contains("operation_status") + ); + assert!( + service + .get_credential("space", "key", &[]) + .unwrap() + .is_some() + ); +} + +#[test] +fn missing_retryable_or_retained_workdir_key_rolls_back_instead_of_archiving() { + for state in ["pending", "failed", "succeeded"] { + let dir = tempfile::tempdir().unwrap(); + let conn = legacy_repository_keys(&dir.path().join("server.db")); + conn.execute("UPDATE workdir_create_operations SET state=?1", [state]) + .unwrap(); + if state != "succeeded" { + conn.execute( + "DELETE FROM workdir_create_credential_revision_retentions", + [], + ) + .unwrap(); + } + conn.execute_batch("PRAGMA foreign_keys=OFF; DELETE FROM repository_ssh_credentials; PRAGMA foreign_keys=ON;").unwrap(); + assert!( + migrate_repository_keys_v86_to_v87(&conn).is_err(), + "{state}" + ); + assert_eq!(current_schema_version(&conn).unwrap(), 86); + assert!(!table_exists(&conn, "workdir_create_legacy_ssh_archives").unwrap()); + assert_eq!( + conn.query_row("SELECT state FROM workdir_create_operations", [], |r| { + r.get::<_, String>(0) + }) + .unwrap(), + state + ); + assert_eq!( + conn.query_row("SELECT count(*) FROM repository_ssh_credentials", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 0 + ); + } +} + +// This fixture uses the schema-86 wire digest, not the new replay implementation. +fn frozen_credential_request_digest(kind: &str, name: &str, counter: u64, private: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"yoi repository credential operation v1"); + hasher.update(kind.as_bytes()); + hasher.update(b"key"); + hasher.update(name.as_bytes()); + hasher.update(counter.to_be_bytes()); + hasher.update(Sha256::digest(private.as_bytes())); + let hex: String = hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + format!("sha256:{hex}") +} + +fn frozen_host_request_digest(hostname: &str, counter: u64, key: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"yoi repository host trust operation v1"); + hasher.update(b"host"); + hasher.update(hostname.as_bytes()); + hasher.update(22u16.to_be_bytes()); + hasher.update(key.as_bytes()); + hasher.update(counter.to_be_bytes()); + let hex: String = hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + format!("sha256:{hex}") +} + +#[test] +fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_receipt_rewrite() { + let dir = tempfile::tempdir().unwrap(); + let database = dir.path().join("server.db"); + let conn = legacy_repository_keys(&database); + std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap(); + conn.execute_batch( + "DELETE FROM workdir_create_operations; DELETE FROM repository_ssh_credentials; + DELETE FROM repository_secret_operations; DELETE FROM repository_secret_audit_events;", + ) + .unwrap(); + let old_key = ssh_key::PrivateKey::from(ssh_key::private::Ed25519Keypair::from_seed(&[62; 32])); + let next_key = + ssh_key::PrivateKey::from(ssh_key::private::Ed25519Keypair::from_seed(&[63; 32])); + let old_private = old_key + .to_openssh(ssh_key::LineEnding::LF) + .unwrap() + .to_string(); + let next_private = next_key + .to_openssh(ssh_key::LineEnding::LF) + .unwrap() + .to_string(); + let old_fp = old_key + .public_key() + .fingerprint(ssh_key::HashAlg::Sha256) + .to_string(); + let next_fp = next_key + .public_key() + .fingerprint(ssh_key::HashAlg::Sha256) + .to_string(); + let public = old_key.public_key().to_openssh().unwrap(); + let created_digest = frozen_credential_request_digest("create", "Key", 0, &old_private); + let rotated_digest = frozen_credential_request_digest("rotate", "", 1, &next_private); + let host_created_digest = frozen_host_request_digest("old.example.test", 0, &old_fp); + let host_rotated_digest = frozen_host_request_digest("new.example.test", 1, &old_fp); + for (operation, digest, kind, resource, revision, timestamp, audit) in [ + ( + "create-key", + &created_digest, + "credential", + "key", + 1, + "2026-01-01", + "credential_created", + ), + ( + "rotate-key", + &rotated_digest, + "credential", + "key", + 2, + "2026-01-02", + "credential_rotated", + ), + ( + "create-host", + &host_created_digest, + "host_trust", + "host", + 1, + "2026-01-01", + "host_trust_created", + ), + ( + "rotate-host", + &host_rotated_digest, + "host_trust", + "host", + 2, + "2026-01-02", + "host_trust_rotated", + ), + ] { + conn.execute( + "INSERT INTO repository_secret_operations VALUES('space',?1,?2,?3,?4,?5,?6)", + params![operation, digest, kind, resource, revision, timestamp], + ) + .unwrap(); + conn.execute( + "INSERT INTO repository_secret_audit_events VALUES('space',?1,?2,?3,?4,'actor',?5)", + params![operation, audit, resource, revision, timestamp], + ) + .unwrap(); + } + conn.execute("INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519',?1,2,'active','2026-01-01','2026-01-02')",[&next_fp]).unwrap(); + let encryption_key = LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &[42; 32]).unwrap()); + for (revision, fingerprint, private, timestamp) in [ + (1u64, &old_fp, &old_private, "2026-01-01"), + (2, &next_fp, &next_private, "2026-01-02"), + ] { + conn.execute("INSERT INTO repository_ssh_credential_revisions VALUES('space','key',?1,'ssh-ed25519',?2,?3)",params![revision,fingerprint,timestamp]).unwrap(); + let nonce = [revision as u8; 12]; + let mut ciphertext = private.as_bytes().to_vec(); + let aad = format!("yoi/repository-secret/v1/space/key/{revision}/private_key"); + encryption_key + .seal_in_place_append_tag( + Nonce::assume_unique_for_key(nonce), + Aad::from(aad.as_bytes()), + &mut ciphertext, + ) + .unwrap(); + conn.execute("INSERT INTO server_secret_versions VALUES('space','key',?1,'private_key','aes-256-gcm-v1',?2,?3,?4)",params![revision,nonce.as_slice(),ciphertext,timestamp]).unwrap(); + } + conn.execute("INSERT INTO repository_ssh_host_trusts VALUES('space','host','new.example.test',22,'ssh-ed25519',?1,?2,2,'2026-01-01','2026-01-02')",params![public,old_fp]).unwrap(); + for (revision, hostname, timestamp) in [ + (1, "old.example.test", "2026-01-01"), + (2, "new.example.test", "2026-01-02"), + ] { + conn.execute("INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',?1,?2,22,'ssh-ed25519',?3,?4,?5)",params![revision,hostname,public,old_fp,timestamp]).unwrap(); + } + migrate_repository_keys_v86_to_v87(&conn).unwrap(); + let store = Arc::new(SqliteWorkspaceStore { + conn: Arc::new(Mutex::new(conn)), + }); + let service = + crate::repository_access::RepositorySecretService::open(store.clone(), &database).unwrap(); + let create = server_api::CreateRepositorySshCredentialRequest { + operation_id: "create-key".into(), + credential_id: "key".into(), + name: "Key".into(), + private_key: old_private, + passphrase: None, + }; + let rotate = server_api::RotateRepositorySshCredentialRequest { + operation_id: "rotate-key".into(), + expected_public_key_fingerprint: old_fp.clone(), + private_key: next_private, + passphrase: None, + }; + let current = service + .get_credential("space", "key", &[]) + .unwrap() + .unwrap(); + assert_eq!( + service + .create_credential("space", create.clone(), "another-actor") + .unwrap(), + current + ); + assert_eq!( + service + .rotate_credential("space", "key", rotate.clone(), "another-actor") + .unwrap(), + current + ); + let mut changed = create.clone(); + changed.name = "Changed".into(); + assert!( + service + .create_credential("space", changed, "actor") + .is_err() + ); + let mut changed = rotate.clone(); + changed.expected_public_key_fingerprint = next_fp.clone(); + assert!( + service + .rotate_credential("space", "key", changed, "actor") + .is_err() + ); + let mut changed = rotate.clone(); + changed.private_key = create.private_key.clone(); + assert!( + service + .rotate_credential("space", "key", changed, "actor") + .is_err() + ); + for (operation, hostname, expected) in [ + ("create-host", "old.example.test", None), + ("rotate-host", "new.example.test", Some(old_fp.clone())), + ] { + let request = server_api::PutRepositorySshHostTrustRequest { + operation_id: operation.into(), + host_trust_id: "host".into(), + hostname: hostname.into(), + port: 22, + host_key: public.clone(), + expected_fingerprint: expected, + }; + assert_eq!( + service + .put_host_trust("space", request.clone(), "actor") + .unwrap() + .hostname, + "new.example.test" + ); + let mut changed = request; + changed.port = 2222; + assert!(service.put_host_trust("space", changed, "actor").is_err()); + } + // Same host key at different historical endpoints is not fingerprint-only lease authority. + assert!( + service + .lease_ssh_materialization_access_fingerprints( + "space", "key", &next_fp, "host", &old_fp + ) + .is_err() + ); + let live = service + .lease_ssh_materialization_access( + "space", + &server_api::RepositorySshAccessBinding { + repository_key: "repo".into(), + credential_id: "key".into(), + host_trust_id: "host".into(), + access: server_api::RepositoryAccessMode::ReadOnly, + }, + ) + .unwrap(); + assert!(live.known_hosts_entry.starts_with("new.example.test ")); + store.with_conn(|conn| { + assert_eq!(conn.query_row("SELECT count(*) FROM repository_secret_audit_events",[],|r|r.get::<_,i64>(0))?,4); + for (operation,digest) in [("create-key",&created_digest),("rotate-key",&rotated_digest),("create-host",&host_created_digest),("rotate-host",&host_rotated_digest)] { + assert_eq!(&conn.query_row("SELECT request_fingerprint FROM repository_secret_operations WHERE operation_id=?1",[operation],|r|r.get::<_,String>(0))?,digest); + } + Ok(()) + }).unwrap(); + let newer = service + .rotate_credential( + "space", + "key", + server_api::RotateRepositorySshCredentialRequest { + operation_id: "new-rotation".into(), + expected_public_key_fingerprint: next_fp, + private_key: create.private_key.clone(), + passphrase: None, + }, + "actor", + ) + .unwrap(); + assert_eq!( + service + .rotate_credential("space", "key", rotate, "actor") + .unwrap(), + newer + ); + assert_eq!( + service.create_credential("space", create, "actor").unwrap(), + newer + ); + assert_eq!( + service + .operation_status("space", "rotate-key") + .unwrap() + .unwrap() + .legacy_precondition_proven, + Some(true) + ); +} + +#[test] +fn deleted_legacy_receipt_does_not_bind_to_later_reused_key_ordinal() { + let dir = tempfile::tempdir().unwrap(); + let database = dir.path().join("server.db"); + let conn = legacy_repository_keys(&database); + std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap(); + // The old deleted ID has been recreated using the same ordinal. Its earlier + // delete receipt must remain history, not acquire proof from this new key. + conn.execute_batch("DELETE FROM workdir_create_operations; + INSERT INTO repository_secret_operations VALUES('space','old-delete','intent','credential','key',7,'2025-01-01'); + INSERT INTO repository_secret_audit_events VALUES('space','old-delete-audit','credential_deleted','key',7,'actor','2025-01-01');").unwrap(); + migrate_repository_keys_v86_to_v87(&conn).unwrap(); + let store = Arc::new(SqliteWorkspaceStore { + conn: Arc::new(Mutex::new(conn)), + }); + let service = + crate::repository_access::RepositorySecretService::open(store.clone(), &database).unwrap(); + let status = service + .operation_status("space", "old-delete") + .unwrap() + .unwrap(); + assert_eq!(status.legacy_precondition_proven, Some(false)); + store.with_conn(|conn| { + let evidence:(Option,Option) = conn.query_row("SELECT expected_operation_id,expected_key_fingerprint FROM repository_secret_legacy_receipts WHERE operation_id='old-delete'",[],|r|Ok((r.get(0)?,r.get(1)?)))?; + assert_eq!(evidence,(None,None)); + assert_eq!(conn.query_row("SELECT count(*) FROM repository_ssh_credentials",[],|r|r.get::<_,i64>(0))?,1); + Ok(()) + }).unwrap(); +} + +#[test] +fn repository_key_cutover_failure_preserves_schema_ciphertext_and_connection_pragmas() { + for failure in [ + "missing_master", + "wrong_master", + "corrupt_ciphertext", + "missing_receipt", + "ambiguous_receipt", + "missing_private_key", + "inconsistent_current_key", + "inconsistent_retention", + "missing_audit_receipt", + "missing_host_receipt", + "foreign_key_violation", + ] { + let dir = tempfile::tempdir().unwrap(); + let conn = legacy_repository_keys(&dir.path().join("server.db")); + if failure != "missing_master" { + std::fs::write( + dir.path().join("repository-secrets.master-key"), + if failure == "wrong_master" { + [43; 32] + } else { + [42; 32] + }, + ) + .unwrap(); + } + match failure { + "corrupt_ciphertext" => { + conn.execute( + "UPDATE server_secret_versions SET ciphertext=x'00' WHERE purpose='passphrase'", + [], + ) + .unwrap(); + } + "missing_receipt" => { + conn.execute("DELETE FROM repository_secret_operations", []) + .unwrap(); + } + "ambiguous_receipt" => { + conn.execute("INSERT INTO repository_secret_operations SELECT workspace_id,'another',request_fingerprint,resource_kind,resource_id,result_revision,created_at FROM repository_secret_operations",[]).unwrap(); + } + "missing_private_key" => { + conn.execute( + "DELETE FROM server_secret_versions WHERE purpose='private_key'", + [], + ) + .unwrap(); + } + "inconsistent_current_key" => { + conn.execute( + "UPDATE repository_ssh_credentials SET public_key_fingerprint='different'", + [], + ) + .unwrap(); + } + "inconsistent_retention" => { + // A different valid retained key passes the ordinal-only candidate FK. + conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','another-key','intent','credential','key',8,'later'); + INSERT INTO repository_ssh_credential_revisions VALUES('space','key',8,'ssh-ed25519','other','later'); + UPDATE workdir_create_credential_revision_retentions SET credential_revision=8;").unwrap(); + } + "missing_audit_receipt" => { + conn.execute( + "UPDATE repository_secret_audit_events SET created_at='other'", + [], + ) + .unwrap(); + } + "missing_host_receipt" => { + conn.execute_batch("INSERT INTO repository_ssh_host_trusts VALUES('space','host','example.test',22,'ssh-ed25519','key','host-fingerprint',4,'created','created'); + INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',4,'example.test',22,'ssh-ed25519','key','host-fingerprint','created');").unwrap(); + } + "foreign_key_violation" => { + conn.execute_batch("PRAGMA foreign_keys=OFF; + INSERT INTO repository_secret_operations VALUES('missing-space','orphan','intent','credential','deleted',1,'created'); + PRAGMA foreign_keys=ON;").unwrap(); + } + _ => {} + } + let before: Vec = conn + .query_row( + "SELECT ciphertext FROM server_secret_versions WHERE purpose='passphrase'", + [], + |r| r.get(0), + ) + .unwrap(); + let error = migrate_repository_keys_v86_to_v87(&conn).unwrap_err(); + if matches!( + failure, + "inconsistent_current_key" | "inconsistent_retention" + ) { + assert!( + error + .to_string() + .contains("inconsistent current or retained key evidence"), + "{failure}: {error}" + ); + } + assert_eq!(current_schema_version(&conn).unwrap(), 86, "{failure}"); + let after: Vec = conn + .query_row( + "SELECT ciphertext FROM server_secret_versions WHERE purpose='passphrase'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(after, before, "{failure}"); + assert_eq!( + conn.query_row( + "SELECT request_fingerprint FROM workdir_create_operations", + [], + |r| r.get::<_, String>(0) + ) + .unwrap(), + "intent", + "{failure}" + ); + assert_eq!( + conn.query_row( + "SELECT count(*) FROM sqlite_temp_master WHERE name='repository_key_cutover'", + [], + |r| r.get::<_, i64>(0) + ) + .unwrap(), + 0, + "{failure}" + ); + let triggers: i64 = conn.query_row("SELECT count(*) FROM sqlite_master WHERE type='trigger' AND tbl_name='workdir_create_operations'", [], |r| r.get(0)).unwrap(); + assert_eq!(triggers, 2, "{failure}"); + assert!( + table_exists(&conn, "repository_ssh_credential_revisions").unwrap(), + "{failure}" + ); + assert!( + !table_exists(&conn, "repository_ssh_credential_keys").unwrap(), + "{failure}" + ); + assert_eq!( + conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, i64>(0)) + .unwrap(), + 1 + ); + assert_eq!( + conn.query_row("PRAGMA legacy_alter_table", [], |r| r.get::<_, i64>(0)) + .unwrap(), + 0 + ); + if failure == "missing_master" { + assert!(!dir.path().join("repository-secrets.master-key").exists()); + } + } +} diff --git a/crates/workspace-server/src/frozen_runtime_binding_migration.rs b/crates/workspace-server/src/frozen_runtime_binding_migration.rs new file mode 100644 index 000000000..cfc107f42 --- /dev/null +++ b/crates/workspace-server/src/frozen_runtime_binding_migration.rs @@ -0,0 +1,764 @@ +// Frozen schema-87 -> 88 authority cutover. Old counters are never new IDs. +// Existing private material is unchanged; v1 material has its own frozen decoder. +fn migrate_runtime_bindings_v87_to_v88(conn: &Connection) -> Result<()> { + if current_schema_version(conn)? != 87 { + return Err(Error::Store( + "Runtime binding cutover requires schema 87".into(), + )); + } + let foreign_keys: bool = conn.query_row("PRAGMA foreign_keys", [], |r| r.get(0))?; + let legacy_alter: bool = conn.query_row("PRAGMA legacy_alter_table", [], |r| r.get(0))?; + conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;")?; + let result = (|| -> Result<()> { + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + // An executing/failed removal may already have a durable Runtime receipt, + // with the source gone but Backend metadata not yet committed. Its legacy + // request fingerprint includes ordinal policy/Worker evidence that cannot + // be reconstructed as a new content contract. Never retire that recovery + // authority, regenerate the request, or treat failure as proof of no effect. + let unresolved_removal: Option<(String, String)> = tx + .query_row( + "SELECT operation_id,state FROM worker_removal_operations \ + WHERE state IN ('executing','failed') ORDER BY operation_id LIMIT 1", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional()?; + if let Some((operation_id, state)) = unresolved_removal { + return Err(Error::Store(format!( + "Runtime binding cutover requires old-version reconciliation of Worker removal `{operation_id}` ({state}); reconcile using schema 87 before retrying the upgrade" + ))); + } + // Triggers may refer to a table while it is being replaced. Preserve all of them, + // including cross-domain removal fences, and restore before checking constraints. + let triggers = { + let mut stmt = tx.prepare("SELECT name,sql FROM sqlite_schema WHERE type='trigger'")?; + stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))? + .collect::>>()? + }; + let indexes = { + let mut stmt = tx.prepare("SELECT name,tbl_name,sql FROM sqlite_schema WHERE type='index' AND sql IS NOT NULL")?; + stmt.query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + )) + })? + .collect::>>()? + }; + for (name, _) in &triggers { + tx.execute_batch(&format!("DROP TRIGGER \"{}\"", name.replace('"', "\"\"")))?; + } + for table in [ + "workspace_signing_identities", + "workspace_signing_identity_provisioning_operations", + "workspace_signing_identity_audit", + ] { + let unsupported: bool = tx.query_row( + &format!("SELECT EXISTS(SELECT 1 FROM {table} WHERE revision != 1)"), + [], + |r| r.get(0), + )?; + if unsupported { + return Err(Error::Store( + "unsupported legacy Workspace signing identity revision".into(), + )); + } + } + tx.execute_batch("CREATE TEMP TABLE runtime_binding_cutover_ids(workspace_id TEXT,runtime_id TEXT,ordinal INTEGER,binding_id TEXT,PRIMARY KEY(workspace_id,runtime_id,ordinal)); + CREATE TEMP TABLE retention_cutover_digests(workspace_id TEXT,policy_id TEXT,ordinal INTEGER,policy_digest TEXT,PRIMARY KEY(workspace_id,policy_id,ordinal));")?; + let identities = { + let mut stmt=tx.prepare("SELECT workspace_id,runtime_id,binding_revision FROM workspace_runtime_bindings UNION SELECT workspace_id,runtime_id,binding_revision FROM workspace_runtime_binding_audit")?; + stmt.query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, i64>(2)?, + )) + })? + .collect::>>()? + }; + for (workspace, runtime, ordinal) in identities { + if ordinal <= 0 { + return Err(Error::Store("invalid legacy binding ordinal".into())); + } + tx.execute( + "INSERT INTO runtime_binding_cutover_ids VALUES(?1,?2,?3,?4)", + params![workspace, runtime, ordinal, new_runtime_binding_id()], + )?; + } + let policies = { + let mut stmt=tx.prepare("SELECT workspace_id,policy_id,revision,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds FROM workspace_worker_retention_policy_revisions")?; + stmt.query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, i64>(2)?, + r.get::<_, String>(3)?, + r.get::<_, String>(4)?, + r.get::<_, String>(5)?, + r.get::<_, Option>(6)?, + r.get::<_, String>(7)?, + r.get::<_, Option>(8)?, + )) + })? + .collect::>>()? + }; + for ( + workspace, + id, + ordinal, + session, + metadata, + archive, + seconds, + diagnostics, + diagnostics_seconds, + ) in policies + { + let archive = match (archive.as_str(), seconds) { + ("forever", None) => serde_json::json!({"kind":"forever"}), + ("for_seconds", Some(seconds)) if seconds > 0 => { + serde_json::json!({"kind":"for_seconds","seconds":seconds}) + } + _ => return Err(Error::Store("invalid legacy retention duration".into())), + }; + let policy: crate::retention::WorkerRetentionPolicyUpdate=serde_json::from_value(serde_json::json!({ + "policy_id":id,"session_disposition":session,"metadata_disposition":metadata, + "archive_retention":archive,"diagnostics_disposition":diagnostics,"diagnostics_retention_seconds":diagnostics_seconds + })).map_err(|e|Error::Store(format!("invalid legacy retention policy: {e}")))?; + let digest = crate::retention::worker_retention_policy_digest(&policy); + tx.execute( + "INSERT INTO retention_cutover_digests VALUES(?1,?2,?3,?4)", + params![workspace, policy.policy_id, ordinal, digest], + )?; + } + tx.execute_batch(r#"CREATE TABLE workspace_runtime_bindings_v88 ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + display_name TEXT NOT NULL, + base_url TEXT NOT NULL, + public_key TEXT NOT NULL, + public_key_fingerprint TEXT NOT NULL, + binding_id TEXT NOT NULL CHECK (length(binding_id) > 0), + state TEXT NOT NULL CHECK (state IN ('configured', 'verified', 'revoked')), + authentication_mode TEXT NOT NULL CHECK (authentication_mode IN ('legacy_server_issuer', 'workspace_identity')), + workspace_key_id TEXT, + workspace_public_key_fingerprint TEXT, + workspace_trust_id TEXT CHECK (length(workspace_trust_id) > 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revoked_at TEXT, + PRIMARY KEY (workspace_id, runtime_id), + UNIQUE (workspace_id, public_key_fingerprint), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT, + CHECK ( + (authentication_mode = 'legacy_server_issuer' AND workspace_key_id IS NULL AND workspace_public_key_fingerprint IS NULL AND workspace_trust_id IS NULL) + OR + (authentication_mode = 'workspace_identity' AND workspace_key_id IS NOT NULL AND (state != 'verified' OR (workspace_trust_id IS NOT NULL AND workspace_public_key_fingerprint IS NOT NULL))) + ), + CHECK ( + (state = 'revoked' AND revoked_at IS NOT NULL) + OR + (state != 'revoked' AND revoked_at IS NULL) + ) +);"#)?; + tx.execute_batch(r#"CREATE TABLE workspace_runtime_verifications_v88 ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + binding_id TEXT NOT NULL CHECK(length(binding_id) > 0), + workspace_key_id TEXT NOT NULL, + workspace_public_key_fingerprint TEXT NOT NULL CHECK(length(workspace_public_key_fingerprint) > 0), + workspace_trust_id TEXT NOT NULL CHECK(length(workspace_trust_id) > 0), + runtime_public_key_fingerprint TEXT NOT NULL, + challenge_id TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('pending', 'verified', 'failed')), + last_outcome TEXT NOT NULL, + verified_at TEXT, + checked_at TEXT NOT NULL, + PRIMARY KEY(workspace_id, runtime_id), + FOREIGN KEY(workspace_id, runtime_id) + REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE CASCADE, + CHECK((state = 'verified' AND verified_at IS NOT NULL) + OR (state != 'verified' AND verified_at IS NULL)) +);"#)?; + tx.execute_batch( + r#"CREATE TABLE workspace_runtime_binding_audit_v88 ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + actor_account_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('created', 'replaced', 'reactivated', 'revoked')), + old_fingerprint TEXT, + new_fingerprint TEXT, + binding_id TEXT NOT NULL CHECK (length(binding_id) > 0), + at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, binding_id), + FOREIGN KEY(workspace_id, runtime_id) + REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE RESTRICT, + FOREIGN KEY(actor_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT +);"#, + )?; + tx.execute_batch(r#"CREATE TABLE workspace_signing_identities_v88 ( + workspace_id TEXT PRIMARY KEY, + key_id TEXT NOT NULL UNIQUE, + algorithm TEXT NOT NULL CHECK (algorithm = 'ed25519'), + public_key TEXT, + public_key_fingerprint TEXT, + private_material_ref TEXT NOT NULL UNIQUE, + state TEXT NOT NULL CHECK (state IN ('pending_provisioning', 'active')), + created_at TEXT NOT NULL, + provisioned_at TEXT, + updated_at TEXT NOT NULL, + CHECK ( + (state = 'pending_provisioning' AND public_key IS NULL AND public_key_fingerprint IS NULL AND provisioned_at IS NULL) + OR + (state = 'active' AND public_key IS NOT NULL AND public_key_fingerprint IS NOT NULL AND provisioned_at IS NOT NULL) + ), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + );"#)?; + tx.execute_batch(r#"CREATE TABLE workspace_signing_identity_provisioning_operations_v88 ( + operation_key TEXT PRIMARY KEY, + request_fingerprint TEXT NOT NULL, + operation_kind TEXT NOT NULL CHECK (operation_kind IN ('workspace_create', 'existing_workspace')), + workspace_id TEXT NOT NULL UNIQUE, + key_id TEXT NOT NULL UNIQUE, + private_material_ref TEXT NOT NULL UNIQUE, + actor_account_id TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'completed')), + created_at TEXT NOT NULL, + completed_at TEXT + );"#)?; + tx.execute_batch( + r#"CREATE TABLE workspace_signing_identity_audit_v88 ( + event_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + key_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('provisioned')), + public_key_fingerprint TEXT NOT NULL, + actor_account_id TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + );"#, + )?; + tx.execute_batch(r#"CREATE TABLE runtime_removal_operations_v88 ( + operation_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + expected_binding_id TEXT NOT NULL CHECK(length(expected_binding_id)>0), + config_digest TEXT NOT NULL CHECK(length(config_digest)>0), + state TEXT NOT NULL CHECK (state IN ('pending', 'cleanup_pending', 'succeeded', 'failed')), + failure_category TEXT, + binding_removed INTEGER NOT NULL CHECK (binding_removed IN (0, 1)), + runtime_registration_removed INTEGER CHECK (runtime_registration_removed IS NULL OR runtime_registration_removed IN (0, 1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +);"#)?; + tx.execute_batch(r#"CREATE TABLE workspace_worker_retention_policies_v88 ( + workspace_id TEXT PRIMARY KEY, policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL, updated_at TEXT NOT NULL, + FOREIGN KEY(workspace_id,policy_id,policy_digest) REFERENCES workspace_worker_retention_policy_snapshots(workspace_id,policy_id,policy_digest), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);"#)?; + tx.execute_batch(r#"CREATE TABLE workspace_worker_retention_policy_snapshots_v88 ( + workspace_id TEXT NOT NULL, policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL CHECK(length(policy_digest)>0), + session_disposition TEXT NOT NULL CHECK(session_disposition IN ('archive','purge')), + metadata_disposition TEXT NOT NULL CHECK(metadata_disposition IN ('tombstone','purge')), + archive_retention_kind TEXT NOT NULL CHECK(archive_retention_kind IN ('forever','for_seconds')), + archive_retention_seconds INTEGER, + diagnostics_disposition TEXT NOT NULL CHECK(diagnostics_disposition IN ('purge','retain')), + diagnostics_retention_seconds INTEGER, created_at TEXT NOT NULL, + PRIMARY KEY(workspace_id,policy_id,policy_digest), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);"#)?; + tx.execute_batch(r#"CREATE TABLE worker_removal_operations_v88 ( + operation_id TEXT PRIMARY KEY, plan_id TEXT NOT NULL UNIQUE, input_fingerprint TEXT NOT NULL, + workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, + worker_updated_at TEXT NOT NULL, + policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL, + session_disposition TEXT NOT NULL, metadata_disposition TEXT NOT NULL, + archive_retention_kind TEXT NOT NULL, archive_retention_seconds INTEGER, + diagnostics_disposition TEXT NOT NULL, + diagnostics_retention_seconds INTEGER, archive_id TEXT UNIQUE, blockers_json TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('planned','blocked','executing','failed','stale','succeeded')), + reason TEXT NOT NULL, failure_category TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);"#)?; + tx.execute_batch(r#"CREATE TABLE worker_session_archives_v88 ( + archive_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, + session_id TEXT NOT NULL, checksum_sha256 TEXT NOT NULL, content_bytes INTEGER NOT NULL, + policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL, operation_id TEXT NOT NULL UNIQUE, + committed_at TEXT NOT NULL, expires_at TEXT, + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id));"#)?; + tx.execute_batch( + r#"CREATE TABLE worker_diagnostics_archives_v88 ( + operation_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL, + committed_at TEXT NOT NULL, expires_at TEXT NOT NULL, + FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);"#, + )?; + tx.execute_batch(r#"CREATE TABLE worker_tombstones_v88 ( + workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, + display_name TEXT NOT NULL, profile TEXT, worker_created_at TEXT NOT NULL, removed_at TEXT NOT NULL, + archive_id TEXT, policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL, operation_id TEXT NOT NULL UNIQUE, + PRIMARY KEY(workspace_id,runtime_id,worker_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY(archive_id) REFERENCES worker_session_archives(archive_id), + FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id));"#)?; + tx.execute_batch( + r#"CREATE TABLE workdir_removal_operations_v88 ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + workdir_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + materialization_fingerprint TEXT NOT NULL, + source_actor TEXT NOT NULL, + reason TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'failed', 'completed')), + attempt_id TEXT, + retryable INTEGER NOT NULL CHECK (retryable IN (0, 1)), + disposition TEXT CHECK (disposition IN ('removed', 'retained', 'attention_required')), + failure_category TEXT, + attempt_owner_pid INTEGER CHECK (attempt_owner_pid > 0), + attempt_owner_start_marker INTEGER CHECK (attempt_owner_start_marker >= 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + PRIMARY KEY (workspace_id, operation_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +);"#, + )?; + tx.execute_batch( + r#"CREATE TABLE workspace_deletion_operations_v88 ( + operation_id TEXT PRIMARY KEY, + request_fingerprint TEXT NOT NULL, + workspace_id TEXT NOT NULL, + workspace_display_name TEXT NOT NULL, + workspace_updated_at TEXT NOT NULL, + owner_account_id TEXT NOT NULL, + actor_account_id TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('queued', 'running', 'blocked', 'failed', 'succeeded')), + resource_counts_json TEXT NOT NULL, + child_operation_ids_json TEXT NOT NULL, + blockers_json TEXT NOT NULL, + failure_category TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + FOREIGN KEY(owner_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT, + FOREIGN KEY(actor_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT +);"#, + )?; + { + let columns = table_columns(&tx, "workspace_runtime_bindings_v88")?; + let mut expressions = Vec::new(); + for column in &columns { + let expression=match column.as_str() { + "binding_id" => "(SELECT binding_id FROM runtime_binding_cutover_ids m WHERE m.workspace_id=o.workspace_id AND m.runtime_id=o.runtime_id AND m.ordinal=o.binding_revision)".to_string(), + "workspace_trust_id" => "NULL".to_string(), + "workspace_public_key_fingerprint" => "(SELECT public_key_fingerprint FROM workspace_signing_identities i WHERE i.workspace_id=o.workspace_id AND i.key_id=o.workspace_key_id)".to_string(), + "state" => "CASE WHEN state='verified' AND authentication_mode='workspace_identity' THEN 'configured' ELSE state END".to_string(), + _ => format!("o.{column}"), + }; + expressions.push(expression); + } + tx.execute_batch(&format!("INSERT INTO workspace_runtime_bindings_v88 ({}) SELECT {} FROM workspace_runtime_bindings o",columns.join(","),expressions.join(",")))?; + } + // Legacy proof cannot attest to a new binding/trust identity. + { + let columns = table_columns(&tx, "workspace_runtime_binding_audit_v88")?; + let mut expressions = Vec::new(); + for column in &columns { + let expression=match column.as_str() { + "binding_id" => "(SELECT binding_id FROM runtime_binding_cutover_ids m WHERE m.workspace_id=o.workspace_id AND m.runtime_id=o.runtime_id AND m.ordinal=o.binding_revision)".to_string(), + _ => format!("o.{column}"), + }; + expressions.push(expression); + } + tx.execute_batch(&format!("INSERT INTO workspace_runtime_binding_audit_v88 ({}) SELECT {} FROM workspace_runtime_binding_audit o",columns.join(","),expressions.join(",")))?; + } + { + let columns = table_columns(&tx, "workspace_signing_identities_v88")?; + let mut expressions = Vec::new(); + for column in &columns { + let expression = match column.as_str() { + _ => format!("o.{column}"), + }; + expressions.push(expression); + } + tx.execute_batch(&format!("INSERT INTO workspace_signing_identities_v88 ({}) SELECT {} FROM workspace_signing_identities o",columns.join(","),expressions.join(",")))?; + } + { + let columns = table_columns( + &tx, + "workspace_signing_identity_provisioning_operations_v88", + )?; + let mut expressions = Vec::new(); + for column in &columns { + let expression = match column.as_str() { + _ => format!("o.{column}"), + }; + expressions.push(expression); + } + tx.execute_batch(&format!("INSERT INTO workspace_signing_identity_provisioning_operations_v88 ({}) SELECT {} FROM workspace_signing_identity_provisioning_operations o",columns.join(","),expressions.join(",")))?; + migrate_existing_workspace_provisioning_receipts_v87(&tx)?; + } + { + let columns = table_columns(&tx, "workspace_signing_identity_audit_v88")?; + let mut expressions = Vec::new(); + for column in &columns { + let expression = match column.as_str() { + _ => format!("o.{column}"), + }; + expressions.push(expression); + } + tx.execute_batch(&format!("INSERT INTO workspace_signing_identity_audit_v88 ({}) SELECT {} FROM workspace_signing_identity_audit o",columns.join(","),expressions.join(",")))?; + } + { + let columns = table_columns(&tx, "runtime_removal_operations_v88")?; + let mut expressions = Vec::new(); + for column in &columns { + let expression=match column.as_str() { + "expected_binding_id" => "COALESCE((SELECT binding_id FROM runtime_binding_cutover_ids m WHERE m.workspace_id=o.workspace_id AND m.runtime_id=o.runtime_id AND m.ordinal=o.expected_binding_revision),'RB-retired-' || lower(hex(randomblob(16))))".to_string(), + "config_digest" => "(SELECT content_digest FROM workspace_config_trees t WHERE t.workspace_id=o.workspace_id)".to_string(), + "state" => "CASE WHEN state IN ('pending','failed') THEN 'failed' ELSE state END".to_string(), + "failure_category" => "CASE WHEN state IN ('pending','failed') THEN 'binding_identity_cutover' ELSE failure_category END".to_string(), + _ => format!("o.{column}"), + }; + expressions.push(expression); + } + tx.execute_batch(&format!("INSERT INTO runtime_removal_operations_v88 ({}) SELECT {} FROM runtime_removal_operations o",columns.join(","),expressions.join(",")))?; + } + { + let columns = table_columns(&tx, "workspace_worker_retention_policies_v88")?; + let mut expressions = Vec::new(); + for column in &columns { + let expression=match column.as_str() { + "policy_digest" => "(SELECT policy_digest FROM retention_cutover_digests m WHERE m.workspace_id=o.workspace_id AND m.policy_id=o.policy_id AND m.ordinal=o.revision)".to_string(), + _ => format!("o.{column}"), + }; + expressions.push(expression); + } + tx.execute_batch(&format!("INSERT INTO workspace_worker_retention_policies_v88 ({}) SELECT {} FROM workspace_worker_retention_policies o",columns.join(","),expressions.join(",")))?; + } + { + let columns = table_columns(&tx, "workspace_worker_retention_policy_snapshots_v88")?; + let mut expressions = Vec::new(); + for column in &columns { + let expression=match column.as_str() { + "policy_digest" => "(SELECT policy_digest FROM retention_cutover_digests m WHERE m.workspace_id=o.workspace_id AND m.policy_id=o.policy_id AND m.ordinal=o.revision)".to_string(), + _ => format!("o.{column}"), + }; + expressions.push(expression); + } + tx.execute_batch(&format!("INSERT INTO workspace_worker_retention_policy_snapshots_v88 ({}) SELECT {} FROM workspace_worker_retention_policy_revisions o WHERE 1 ON CONFLICT(workspace_id,policy_id,policy_digest) DO NOTHING",columns.join(","),expressions.join(",")))?; + } + { + let columns = table_columns(&tx, "worker_removal_operations_v88")?; + let mut expressions = Vec::new(); + for column in &columns { + let expression=match column.as_str() { + "policy_digest" => "(SELECT policy_digest FROM retention_cutover_digests m WHERE m.workspace_id=o.workspace_id AND m.policy_id=o.policy_id AND m.ordinal=o.policy_revision)".to_string(), + "worker_updated_at" => "worker_revision".to_string(), + // Only unexecuted plans lose admission authority. Executing/failed + // rows were rejected above; succeeded and already-stale evidence stays intact. + "state" => "CASE WHEN state IN ('planned','blocked') THEN 'stale' ELSE state END".to_string(), + "failure_category" => "CASE WHEN state IN ('planned','blocked') THEN 'policy_content_cutover' ELSE failure_category END".to_string(), + _ => format!("o.{column}"), + }; + expressions.push(expression); + } + tx.execute_batch(&format!("INSERT INTO worker_removal_operations_v88 ({}) SELECT {} FROM worker_removal_operations o",columns.join(","),expressions.join(",")))?; + } + { + let columns = table_columns(&tx, "worker_session_archives_v88")?; + let mut expressions = Vec::new(); + for column in &columns { + let expression=match column.as_str() { + "policy_digest" => "(SELECT policy_digest FROM retention_cutover_digests m WHERE m.workspace_id=o.workspace_id AND m.policy_id=o.policy_id AND m.ordinal=o.policy_revision)".to_string(), + _ => format!("o.{column}"), + }; + expressions.push(expression); + } + tx.execute_batch(&format!("INSERT INTO worker_session_archives_v88 ({}) SELECT {} FROM worker_session_archives o",columns.join(","),expressions.join(",")))?; + } + { + let columns = table_columns(&tx, "worker_diagnostics_archives_v88")?; + let mut expressions = Vec::new(); + for column in &columns { + let expression=match column.as_str() { + "policy_digest" => "(SELECT policy_digest FROM retention_cutover_digests m WHERE m.workspace_id=o.workspace_id AND m.policy_id=o.policy_id AND m.ordinal=o.policy_revision)".to_string(), + _ => format!("o.{column}"), + }; + expressions.push(expression); + } + tx.execute_batch(&format!("INSERT INTO worker_diagnostics_archives_v88 ({}) SELECT {} FROM worker_diagnostics_archives o",columns.join(","),expressions.join(",")))?; + } + { + let columns = table_columns(&tx, "worker_tombstones_v88")?; + let mut expressions = Vec::new(); + for column in &columns { + let expression=match column.as_str() { + "policy_digest" => "(SELECT policy_digest FROM retention_cutover_digests m WHERE m.workspace_id=o.workspace_id AND m.policy_id=o.policy_id AND m.ordinal=o.policy_revision)".to_string(), + _ => format!("o.{column}"), + }; + expressions.push(expression); + } + tx.execute_batch(&format!( + "INSERT INTO worker_tombstones_v88 ({}) SELECT {} FROM worker_tombstones o", + columns.join(","), + expressions.join(",") + ))?; + } + { + let columns = table_columns(&tx, "workdir_removal_operations_v88")?; + let mut expressions = Vec::new(); + for column in &columns { + let expression=match column.as_str() { + // This is a Backend-local callback fence, not Runtime receipt + // identity. Recovery still proves the prior owner is orphaned, + // observes the exact materialization (or authoritative absence), + // and uses the unchanged request/materialization fingerprints. + "attempt_id" => "CASE WHEN attempt_count>0 THEN 'WDRA-' || lower(hex(randomblob(16))) ELSE NULL END".to_string(), + _ => format!("o.{column}"), + }; + expressions.push(expression); + } + tx.execute_batch(&format!("INSERT INTO workdir_removal_operations_v88 ({}) SELECT {} FROM workdir_removal_operations o",columns.join(","),expressions.join(",")))?; + } + { + let columns = table_columns(&tx, "workspace_deletion_operations_v88")?; + let mut expressions = Vec::new(); + for column in &columns { + let expression = match column.as_str() { + "workspace_updated_at" => "workspace_revision".to_string(), + _ => format!("o.{column}"), + }; + expressions.push(expression); + } + tx.execute_batch(&format!("INSERT INTO workspace_deletion_operations_v88 ({}) SELECT {} FROM workspace_deletion_operations o",columns.join(","),expressions.join(",")))?; + } + tx.execute_batch("DROP TABLE workspace_runtime_bindings; ALTER TABLE workspace_runtime_bindings_v88 RENAME TO workspace_runtime_bindings;")?; + tx.execute_batch("DROP TABLE workspace_runtime_verifications; ALTER TABLE workspace_runtime_verifications_v88 RENAME TO workspace_runtime_verifications;")?; + tx.execute_batch("DROP TABLE workspace_runtime_binding_audit; ALTER TABLE workspace_runtime_binding_audit_v88 RENAME TO workspace_runtime_binding_audit;")?; + tx.execute_batch("DROP TABLE workspace_signing_identities; ALTER TABLE workspace_signing_identities_v88 RENAME TO workspace_signing_identities;")?; + tx.execute_batch("DROP TABLE workspace_signing_identity_provisioning_operations; ALTER TABLE workspace_signing_identity_provisioning_operations_v88 RENAME TO workspace_signing_identity_provisioning_operations;")?; + tx.execute_batch("DROP TABLE workspace_signing_identity_audit; ALTER TABLE workspace_signing_identity_audit_v88 RENAME TO workspace_signing_identity_audit;")?; + tx.execute_batch("DROP TABLE runtime_removal_operations; ALTER TABLE runtime_removal_operations_v88 RENAME TO runtime_removal_operations;")?; + tx.execute_batch("DROP TABLE workspace_worker_retention_policies; ALTER TABLE workspace_worker_retention_policies_v88 RENAME TO workspace_worker_retention_policies;")?; + tx.execute_batch("DROP TABLE workspace_worker_retention_policy_revisions; ALTER TABLE workspace_worker_retention_policy_snapshots_v88 RENAME TO workspace_worker_retention_policy_snapshots;")?; + tx.execute_batch("DROP TABLE worker_removal_operations; ALTER TABLE worker_removal_operations_v88 RENAME TO worker_removal_operations;")?; + tx.execute_batch("DROP TABLE worker_session_archives; ALTER TABLE worker_session_archives_v88 RENAME TO worker_session_archives;")?; + tx.execute_batch("DROP TABLE worker_diagnostics_archives; ALTER TABLE worker_diagnostics_archives_v88 RENAME TO worker_diagnostics_archives;")?; + tx.execute_batch("DROP TABLE worker_tombstones; ALTER TABLE worker_tombstones_v88 RENAME TO worker_tombstones;")?; + tx.execute_batch("DROP TABLE workdir_removal_operations; ALTER TABLE workdir_removal_operations_v88 RENAME TO workdir_removal_operations;")?; + tx.execute_batch("DROP TABLE workspace_deletion_operations; ALTER TABLE workspace_deletion_operations_v88 RENAME TO workspace_deletion_operations;")?; + tx.execute_batch( + r#"CREATE INDEX workspace_runtime_verifications_state_idx + ON workspace_runtime_verifications(workspace_id, state, checked_at DESC);"#, + )?; + tx.execute_batch( + r#"CREATE INDEX idx_workspace_runtime_binding_audit_recent + ON workspace_runtime_binding_audit(workspace_id, runtime_id, at DESC, binding_id DESC);"#, + )?; + tx.execute_batch( + r#"CREATE INDEX idx_workspace_runtime_bindings_workspace + ON workspace_runtime_bindings(workspace_id, revoked_at, runtime_id);"#, + )?; + tx.execute_batch( + r#"CREATE INDEX workspace_deletion_operations_workspace_recent + ON workspace_deletion_operations(workspace_id, created_at DESC);"#, + )?; + tx.execute_batch( + r#"CREATE UNIQUE INDEX idx_workdir_removal_operations_one_pending + ON workdir_removal_operations(workspace_id, workdir_id) + WHERE state = 'pending';"#, + )?; + // Config content can be evaluated under multiple toolchains/schema bundles. + // Preserve their distinct projection evidence rather than overwriting same-content rows. + let has_history: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name='workspace_config_tree_history')", [], |r|r.get(0))?; + if has_history { + tx.execute_batch(r#"CREATE TABLE workspace_config_tree_history_v88 ( + workspace_id TEXT NOT NULL, content_digest TEXT NOT NULL, + toolchain_fingerprint TEXT NOT NULL, projection_digest TEXT NOT NULL, + manifest_json TEXT NOT NULL, created_at TEXT NOT NULL, schema_bundle_json TEXT NOT NULL DEFAULT '{"contributions":[],"source":"{}","fingerprint":""}', + PRIMARY KEY(workspace_id,content_digest,toolchain_fingerprint,projection_digest), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE); + INSERT INTO workspace_config_tree_history_v88 SELECT workspace_id,content_digest,toolchain_fingerprint,projection_digest,manifest_json,created_at,schema_bundle_json FROM workspace_config_tree_history; + DROP TABLE workspace_config_tree_history; + ALTER TABLE workspace_config_tree_history_v88 RENAME TO workspace_config_tree_history;"#)?; + } + for (name, table, sql) in indexes { + let exists: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='index' AND name=?1)", + [&name], + |r| r.get(0), + )?; + if !exists { + let sql = if table == "workspace_runtime_binding_audit" { + sql.replace("binding_revision DESC", "at DESC, binding_id DESC") + .replace("binding_revision", "binding_id") + } else { + sql + }; + tx.execute_batch(&sql)?; + } + } + for (name, sql) in triggers { + if name == "seed_worker_retention_policy_after_workspace_insert" { + continue; + } + tx.execute_batch(&sql)?; + } + tx.execute_batch(r#"CREATE TRIGGER seed_worker_retention_policy_after_workspace_insert AFTER INSERT ON workspaces BEGIN + INSERT INTO workspace_worker_retention_policy_snapshots + (workspace_id,policy_id,policy_digest,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,created_at) + VALUES(NEW.workspace_id,'workspace-default-conservative','sha256:30d45d5d58a10dadf95c3bf831e589ea10c81dc76da9575bfd9fb13942b3d198','archive','tombstone','forever',NULL,'purge',NULL,NEW.created_at); + INSERT INTO workspace_worker_retention_policies(workspace_id,policy_id,policy_digest,updated_at) + VALUES(NEW.workspace_id,'workspace-default-conservative','sha256:30d45d5d58a10dadf95c3bf831e589ea10c81dc76da9575bfd9fb13942b3d198',NEW.created_at); + END;"#)?; + tx.execute_batch( + "DROP TABLE runtime_binding_cutover_ids; DROP TABLE retention_cutover_digests;", + )?; + let violation: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM pragma_foreign_key_check)", + [], + |r| r.get(0), + )?; + if violation { + return Err(Error::Store( + "Runtime binding cutover foreign-key violation".into(), + )); + } + verify_runtime_binding_content_schema(&tx)?; + tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES(88,'Runtime binding identities and retention content contracts')",[])?; + tx.commit()?; + Ok(()) + })(); + let restore = conn + .execute_batch(&format!( + "PRAGMA legacy_alter_table={}; PRAGMA foreign_keys={};", + i32::from(legacy_alter), + i32::from(foreign_keys) + )) + .map_err(Error::from); + result.and(restore) +} + +// Only the reservation lookup contract changes. Keep the same reserved key, +// material reference, actor, state and timestamps, including completed receipts. +// workspace_create receipts have an independent request contract and stay opaque. +fn migrate_existing_workspace_provisioning_receipts_v87(conn: &Connection) -> Result<()> { + let receipts = { + let mut stmt = conn.prepare( + "SELECT operation_key,request_fingerprint,workspace_id,key_id,private_material_ref,state,completed_at \ + FROM workspace_signing_identity_provisioning_operations WHERE operation_kind='existing_workspace'", + )?; + stmt.query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, String>(3)?, + r.get::<_, String>(4)?, + r.get::<_, String>(5)?, + r.get::<_, Option>(6)?, + )) + })? + .collect::>>()? + }; + for (old_operation_key, old_fingerprint, workspace, key, material_ref, state, completed_at) in + receipts + { + let identity: Option<(String, String, String)> = conn + .query_row( + "SELECT key_id,private_material_ref,state FROM workspace_signing_identities WHERE workspace_id=?1", + [&workspace], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .optional()?; + let expected_state = if state == "pending" { + "pending_provisioning" + } else { + "active" + }; + if old_operation_key != format!("existing-workspace:{workspace}:revision-1") + || old_fingerprint + != frozen_existing_workspace_provisioning_fingerprint_v1(&workspace, &key) + || identity != Some((key.clone(), material_ref.clone(), expected_state.into())) + || (state == "completed") != completed_at.is_some() + { + return Err(Error::Store(format!( + "inconsistent legacy Workspace signing identity provisioning receipt `{old_operation_key}`" + ))); + } + conn.execute( + "UPDATE workspace_signing_identity_provisioning_operations_v88 SET operation_key=?2,request_fingerprint=?3 WHERE operation_key=?1", + params![ + old_operation_key, + format!("existing-workspace:{workspace}:key-{key}"), + crate::workspace_signing_identity::provisioning_fingerprint(&workspace, &key, &material_ref), + ], + )?; + } + Ok(()) +} + +fn frozen_existing_workspace_provisioning_fingerprint_v1(workspace: &str, key: &str) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(workspace.as_bytes()); + hasher.update([0]); + hasher.update(key.as_bytes()); + hasher.update([0]); + hasher.update(1_u64.to_be_bytes()); + let digest: String = hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + format!("sha256:{digest}") +} + +fn verify_runtime_binding_content_schema(conn: &Connection) -> Result<()> { + for (table, required, forbidden) in [ + ( + "workspace_runtime_bindings", + "binding_id", + "binding_revision", + ), + ( + "workspace_runtime_verifications", + "workspace_public_key_fingerprint", + "runtime_identity_revision", + ), + ("workspace_signing_identities", "key_id", "revision"), + ( + "runtime_removal_operations", + "config_digest", + "config_revision", + ), + ( + "workspace_worker_retention_policy_snapshots", + "policy_digest", + "revision", + ), + ("workdir_removal_operations", "attempt_id", "attempt_count"), + ( + "workspace_deletion_operations", + "workspace_updated_at", + "workspace_revision", + ), + ] { + let columns = table_columns(conn, table)?; + if !columns.iter().any(|v| v == required) || columns.iter().any(|v| v == forbidden) { + return Err(Error::Store(format!( + "{table} does not match content/identity authority schema" + ))); + } + } + Ok(()) +} diff --git a/crates/workspace-server/src/frozen_runtime_binding_tests.rs b/crates/workspace-server/src/frozen_runtime_binding_tests.rs new file mode 100644 index 000000000..3ea64b2ab --- /dev/null +++ b/crates/workspace-server/src/frozen_runtime_binding_tests.rs @@ -0,0 +1,999 @@ +use super::*; + +// Frozen schema-87 tables: use only the legacy authorities at this cutover. +fn legacy_binding_database() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch("PRAGMA foreign_keys=ON; + CREATE TABLE __yoi_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL); + INSERT INTO __yoi_schema_migrations VALUES(87,'workspace schema baseline'); + CREATE TABLE accounts(account_id TEXT PRIMARY KEY); + INSERT INTO accounts VALUES('owner'); + CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY); + INSERT INTO workspaces VALUES('space'); + CREATE TABLE workspace_config_trees(workspace_id TEXT PRIMARY KEY,content_digest TEXT NOT NULL); + INSERT INTO workspace_config_trees VALUES('space','sha256:config-content'); + CREATE TABLE workspace_config_tree_history(workspace_id TEXT,content_digest TEXT,toolchain_fingerprint TEXT,projection_digest TEXT,manifest_json TEXT,created_at TEXT,schema_bundle_json TEXT,PRIMARY KEY(workspace_id,content_digest)); + INSERT INTO workspace_config_tree_history VALUES('space','sha256:config-content','toolchain-original','projection-original','{}','created','{}');").unwrap(); + let frozen = include_str!("frozen_schema_v85.sql"); + for table in [ + "workspace_runtime_bindings", + "workspace_runtime_verifications", + "workspace_runtime_binding_audit", + "workspace_signing_identities", + "workspace_signing_identity_provisioning_operations", + "workspace_signing_identity_audit", + "runtime_removal_operations", + "workspace_worker_retention_policies", + "workspace_worker_retention_policy_revisions", + "worker_removal_operations", + "worker_session_archives", + "worker_diagnostics_archives", + "worker_tombstones", + "workdir_removal_operations", + "workspace_deletion_operations", + ] { + let marker = format!("CREATE TABLE {table} ("); + let start = frozen.find(&marker).unwrap(); + let end = start + frozen[start..].find(");").unwrap() + 2; + conn.execute_batch(&frozen[start..end]).unwrap(); + } + for index in [ + "runtime_removal_operations_one_active_runtime", + "runtime_removal_operations_workspace_state", + "idx_workdir_removal_operations_recovery", + "idx_workdir_removal_operations_workdir", + "workspace_signing_identity_audit_workspace_idx", + ] { + let start = frozen + .find(&format!("CREATE INDEX {index}")) + .or_else(|| frozen.find(&format!("CREATE UNIQUE INDEX {index}"))) + .unwrap(); + let end = start + frozen[start..].find(';').unwrap() + 1; + conn.execute_batch(&frozen[start..end]).unwrap(); + } + conn.execute_batch("INSERT INTO workspace_signing_identities(workspace_id,key_id,algorithm,public_key,public_key_fingerprint,private_material_ref,revision,state,created_at,provisioned_at,updated_at) + VALUES('space','workspace-key','ed25519','public','sha256:workspace','material',1,'active','created','provisioned','updated'); + INSERT INTO workspace_runtime_bindings(workspace_id,runtime_id,display_name,base_url,public_key,public_key_fingerprint,binding_revision,state,authentication_mode,workspace_key_id,workspace_key_generation,created_at,updated_at) + VALUES('space','remote','Remote','https://remote.test','public','sha256:runtime',8,'verified','workspace_identity','workspace-key',6,'created','updated'); + INSERT INTO workspace_runtime_binding_audit VALUES('space','remote','owner','created',NULL,'sha256:runtime',8,'created'); + INSERT INTO workspace_runtime_verifications VALUES('space','remote',8,'workspace-key',1,6,'sha256:runtime',2,'challenge','verified','verified','verified-at','checked-at'); + INSERT INTO workspace_worker_retention_policy_revisions VALUES('space','policy',1,'archive','tombstone','forever',NULL,'purge',NULL,'first'); + INSERT INTO workspace_worker_retention_policy_revisions VALUES('space','policy',9,'archive','tombstone','forever',NULL,'purge',NULL,'ninth'); + INSERT INTO workspace_worker_retention_policies VALUES('space','policy',9,'updated'); + INSERT INTO runtime_removal_operations VALUES('removal','space','remote','request',8,4,'pending',NULL,0,NULL,'created','updated',NULL); + CREATE TRIGGER runtime_binding_cutover_guard BEFORE UPDATE ON workspace_runtime_bindings + WHEN EXISTS(SELECT 1 FROM runtime_removal_operations WHERE runtime_id=NEW.runtime_id AND state IN ('pending','cleanup_pending')) + BEGIN SELECT RAISE(ABORT,'runtime_removal_in_progress'); END;").unwrap(); + conn +} + +#[test] +fn binding_cutover_preserves_key_facts_and_indices_but_requires_runtime_issued_trust() { + let conn = legacy_binding_database(); + migrate_runtime_bindings_v87_to_v88(&conn).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 88); + let (id,state,trust,key,fingerprint): (String,String,Option,String,String) = conn.query_row( + "SELECT binding_id,state,workspace_trust_id,workspace_key_id,workspace_public_key_fingerprint FROM workspace_runtime_bindings", [], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?))).unwrap(); + assert!(id.starts_with("RB-")); + assert_eq!(state, "configured"); + assert_eq!(trust, None); + assert_eq!(key, "workspace-key"); + assert_eq!(fingerprint, "sha256:workspace"); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM workspace_runtime_verifications", + [], + |r| r.get::<_, i64>(0) + ) + .unwrap(), + 0 + ); + let (expected, state, digest): (String, String, String) = conn + .query_row( + "SELECT expected_binding_id,state,config_digest FROM runtime_removal_operations", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .unwrap(); + assert_eq!(expected, id); + assert_eq!(state, "failed"); + assert_eq!(digest, "sha256:config-content"); + let audit: String = conn + .query_row( + "SELECT binding_id FROM workspace_runtime_binding_audit", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(audit, id); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM workspace_worker_retention_policy_snapshots", + [], + |r| r.get::<_, i64>(0) + ) + .unwrap(), + 1 + ); + let update = crate::retention::WorkerRetentionPolicyUpdate { + policy_id: "policy".into(), + session_disposition: worker_runtime::retention::SessionDisposition::Archive, + metadata_disposition: crate::retention::MetadataDisposition::Tombstone, + archive_retention: crate::retention::ArchiveRetention::Forever, + diagnostics_disposition: worker_runtime::retention::DiagnosticsDisposition::Purge, + diagnostics_retention_seconds: None, + }; + let digest: String = conn + .query_row( + "SELECT policy_digest FROM workspace_worker_retention_policies", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + digest, + crate::retention::worker_retention_policy_digest(&update) + ); + for index in [ + "runtime_removal_operations_one_active_runtime", + "runtime_removal_operations_workspace_state", + "idx_workdir_removal_operations_recovery", + "idx_workdir_removal_operations_workdir", + "workspace_signing_identity_audit_workspace_idx", + ] { + assert!( + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='index' AND name=?1)", + [index], + |r| r.get::<_, bool>(0) + ) + .unwrap(), + "missing index {index}" + ); + } + conn.execute( + "UPDATE runtime_removal_operations SET state='cleanup_pending'", + [], + ) + .unwrap(); + assert!(conn.execute("INSERT INTO runtime_removal_operations SELECT 'other',workspace_id,runtime_id,request_fingerprint,expected_binding_id,config_digest,'pending',NULL,0,NULL,created_at,updated_at,NULL FROM runtime_removal_operations", []).is_err()); + assert!( + conn.execute( + "UPDATE workspace_runtime_bindings SET display_name='Blocked'", + [] + ) + .is_err() + ); + assert_eq!( + conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, i64>(0)) + .unwrap(), + 1 + ); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| r + .get::<_, i64>( + 0 + )) + .unwrap(), + 0 + ); +} + +#[test] +fn same_config_content_keeps_distinct_toolchain_and_projection_history_after_cutover() { + let conn = legacy_binding_database(); + migrate_runtime_bindings_v87_to_v88(&conn).unwrap(); + conn.execute("INSERT INTO workspace_config_tree_history VALUES('space','sha256:config-content','toolchain-new','projection-new','{}','later','{}')", []).unwrap(); + assert_eq!(conn.query_row("SELECT COUNT(*) FROM workspace_config_tree_history WHERE content_digest='sha256:config-content'", [], |r| r.get::<_, i64>(0)).unwrap(), 2); + let original: String = conn.query_row("SELECT projection_digest FROM workspace_config_tree_history WHERE toolchain_fingerprint='toolchain-original'", [], |r|r.get(0)).unwrap(); + assert_eq!(original, "projection-original"); +} + +#[test] +fn invalid_legacy_signing_or_policy_reference_rolls_back_all_cutover_changes() { + for invalid in [ + "UPDATE workspace_signing_identities SET revision=2", + "PRAGMA foreign_keys=OFF; DELETE FROM workspace_worker_retention_policy_revisions WHERE revision=9; PRAGMA foreign_keys=ON;", + "DELETE FROM workspace_config_trees", + ] { + let conn = legacy_binding_database(); + conn.execute_batch(invalid).unwrap(); + assert!( + migrate_runtime_bindings_v87_to_v88(&conn).is_err(), + "accepted {invalid}" + ); + assert_eq!(current_schema_version(&conn).unwrap(), 87); + assert!(column_exists(&conn, "workspace_runtime_bindings", "binding_revision").unwrap()); + assert!(!column_exists(&conn, "workspace_runtime_bindings", "binding_id").unwrap()); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM workspace_runtime_verifications", + [], + |r| r.get::<_, i64>(0) + ) + .unwrap(), + 1 + ); + assert_eq!( + conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, i64>(0)) + .unwrap(), + 1 + ); + } +} + +fn seed_legacy_worker_removal(conn: &Connection, state: &str) { + conn.execute( + "INSERT INTO worker_removal_operations \ + (operation_id,plan_id,input_fingerprint,workspace_id,runtime_id,worker_id,worker_revision,\ + policy_id,policy_revision,session_disposition,metadata_disposition,archive_retention_kind,\ + archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,archive_id,\ + blockers_json,state,reason,failure_category,created_at,updated_at) \ + VALUES('worker-removal','worker-plan','legacy-receipt-fingerprint','space','remote','worker',\ + 'worker-updated','policy',9,'archive','tombstone','forever',NULL,'retain',3600,\ + 'archive','[]',?1,'cleanup','legacy-outcome','created','updated')", + [state], + ).unwrap(); +} + +#[test] +fn unresolved_worker_removal_requires_old_version_reconciliation_without_losing_receipt_authority() +{ + for state in ["executing", "failed"] { + let conn = legacy_binding_database(); + seed_legacy_worker_removal(&conn, state); + let schema = || { + conn.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL ORDER BY name") + .unwrap() + .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))) + .unwrap() + .collect::>>() + .unwrap() + }; + let schema_before = schema(); + let message = migrate_runtime_bindings_v87_to_v88(&conn) + .unwrap_err() + .to_string(); + assert!(message.contains("old-version reconciliation"), "{message}"); + assert!(message.contains("worker-removal"), "{message}"); + assert!(message.contains("schema 87"), "{message}"); + assert_eq!(current_schema_version(&conn).unwrap(), 87); + let evidence: (String, String, String, i64, String, String, String, String) = conn.query_row( + "SELECT state,input_fingerprint,worker_revision,policy_revision,archive_id,failure_category,created_at,updated_at FROM worker_removal_operations", [], + |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?,r.get(6)?,r.get(7)?))).unwrap(); + assert_eq!( + evidence, + ( + state.into(), + "legacy-receipt-fingerprint".into(), + "worker-updated".into(), + 9, + "archive".into(), + "legacy-outcome".into(), + "created".into(), + "updated".into() + ) + ); + assert_eq!(schema(), schema_before); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM workspace_runtime_verifications", + [], + |r| r.get::<_, i64>(0) + ) + .unwrap(), + 1 + ); + assert_eq!( + conn.query_row( + "SELECT binding_revision FROM workspace_runtime_bindings", + [], + |r| r.get::<_, i64>(0) + ) + .unwrap(), + 8 + ); + assert!( + conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, bool>(0)) + .unwrap() + ); + assert!( + !conn + .query_row("PRAGMA legacy_alter_table", [], |r| r.get::<_, bool>(0)) + .unwrap() + ); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM sqlite_temp_schema", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 0 + ); + + // Simulate the old Server committing its recovered receipt. The same + // connection can upgrade without dropping or reissuing the operation. + conn.execute( + "UPDATE worker_removal_operations SET state='succeeded',failure_category=NULL", + [], + ) + .unwrap(); + migrate_runtime_bindings_v87_to_v88(&conn).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 88); + assert_eq!( + conn.query_row( + "SELECT input_fingerprint FROM worker_removal_operations", + [], + |r| r.get::<_, String>(0) + ) + .unwrap(), + "legacy-receipt-fingerprint" + ); + } +} + +#[test] +fn unexecuted_and_terminal_worker_removal_history_migrates_without_new_execution_authority() { + for state in ["planned", "blocked", "stale", "succeeded"] { + let conn = legacy_binding_database(); + seed_legacy_worker_removal(&conn, state); + if state == "succeeded" { + conn.execute_batch("INSERT INTO worker_session_archives VALUES('archive','space','remote','worker','session','checksum',42,'policy',9,'worker-removal','committed',NULL); + INSERT INTO worker_diagnostics_archives VALUES('worker-removal','space','remote','worker','policy',9,'committed','expires'); + INSERT INTO worker_tombstones VALUES('space','remote','worker','Worker','profile','worker-created','removed','archive','policy',9,'worker-removal');").unwrap(); + } + migrate_runtime_bindings_v87_to_v88(&conn).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 88); + let (actual_state, category, fingerprint, digest): (String, Option, String, String) = conn.query_row( + "SELECT state,failure_category,input_fingerprint,policy_digest FROM worker_removal_operations", [], + |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?))).unwrap(); + let unexecuted = matches!(state, "planned" | "blocked"); + assert_eq!(actual_state, if unexecuted { "stale" } else { state }); + assert_eq!( + category.as_deref(), + Some(if unexecuted { + "policy_content_cutover" + } else { + "legacy-outcome" + }) + ); + assert_eq!(fingerprint, "legacy-receipt-fingerprint"); + assert_eq!( + conn.query_row( + "SELECT policy_digest FROM workspace_worker_retention_policies", + [], + |r| r.get::<_, String>(0) + ) + .unwrap(), + digest + ); + if state == "succeeded" { + for table in [ + "worker_session_archives", + "worker_diagnostics_archives", + "worker_tombstones", + ] { + let (operation, policy): (String, String) = conn + .query_row( + &format!("SELECT operation_id,policy_digest FROM {table}"), + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(operation, "worker-removal"); + assert_eq!(policy, digest); + } + assert_eq!( + conn.query_row( + "SELECT checksum_sha256,content_bytes FROM worker_session_archives", + [], + |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)) + ) + .unwrap(), + ("checksum".into(), 42) + ); + } + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| r + .get::<_, i64>( + 0 + )) + .unwrap(), + 0 + ); + } +} + +#[test] +fn workdir_removal_cutover_preserves_recovery_state_owner_and_materialization_evidence() { + for (state, attempts, retryable, owner) in [ + ("pending", 0, true, None), + ("pending", 2, true, Some((1234_i64, 5678_i64))), + ("failed", 2, true, None), + ("failed", 2, false, None), + ("completed", 2, false, None), + ] { + let conn = legacy_binding_database(); + conn.execute("INSERT INTO workdir_removal_operations VALUES('space','workdir-removal','workdir-request','workdir','remote','repository','materialization','owner','cleanup',?1,?2,?3,?4,?5,?6,?7,'created','updated',?8)", + params![state,attempts,retryable,if state == "completed" { Some("removed") } else { None }, + if state == "failed" { Some("provider_cleanup_outcome_unknown") } else { None }, + owner.map(|o|o.0),owner.map(|o|o.1),if state == "completed" { Some("completed") } else { None }]).unwrap(); + migrate_runtime_bindings_v87_to_v88(&conn).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 88); + let (actual_state,attempt,actual_retryable,pid,marker,request,materialization): + (String,Option,bool,Option,Option,String,String) = conn.query_row( + "SELECT state,attempt_id,retryable,attempt_owner_pid,attempt_owner_start_marker,request_fingerprint,materialization_fingerprint FROM workdir_removal_operations", [], + |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?,r.get(6)?))).unwrap(); + assert_eq!(actual_state, state); + assert_eq!(attempt.is_some(), attempts > 0); + assert_eq!(actual_retryable, retryable); + assert_eq!(pid.zip(marker), owner); + assert_eq!(request, "workdir-request"); + assert_eq!(materialization, "materialization"); + let (category, disposition): (Option, Option) = conn + .query_row( + "SELECT failure_category,disposition FROM workdir_removal_operations", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!( + category.as_deref(), + if state == "failed" { + Some("provider_cleanup_outcome_unknown") + } else { + None + } + ); + assert_eq!( + disposition.as_deref(), + if state == "completed" { + Some("removed") + } else { + None + } + ); + assert_eq!(conn.query_row("SELECT COUNT(*) FROM workdir_removal_operations WHERE state='pending' OR (state='failed' AND retryable=1)", [], |r| r.get::<_, i64>(0)).unwrap(), i64::from(state == "pending" || (state == "failed" && retryable))); + } +} + +#[test] +fn runtime_removal_cutover_keeps_post_binding_commit_cleanup_resumable() { + for state in ["cleanup_pending", "succeeded"] { + let conn = legacy_binding_database(); + conn.execute("UPDATE runtime_removal_operations SET state=?1,binding_removed=1,runtime_registration_removed=?2,completed_at=?3", + params![state,if state == "succeeded" { Some(1) } else { None },if state == "succeeded" { Some("completed") } else { None }]).unwrap(); + conn.execute_batch( + "DELETE FROM workspace_runtime_binding_audit; DELETE FROM workspace_runtime_bindings;", + ) + .unwrap(); + migrate_runtime_bindings_v87_to_v88(&conn).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 88); + let (actual_state,binding_removed,registration_removed,completed,fingerprint): (String,bool,Option,Option,String) = conn.query_row( + "SELECT state,binding_removed,runtime_registration_removed,completed_at,request_fingerprint FROM runtime_removal_operations", [], + |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?))).unwrap(); + assert_eq!(actual_state, state); + assert!(binding_removed); + assert_eq!( + registration_removed, + if state == "succeeded" { + Some(true) + } else { + None + } + ); + assert_eq!( + completed.as_deref(), + if state == "succeeded" { + Some("completed") + } else { + None + } + ); + assert_eq!(fingerprint, "request"); + assert_eq!(conn.query_row("SELECT COUNT(*) FROM runtime_removal_operations WHERE state IN ('pending','cleanup_pending')", [], |r| r.get::<_, i64>(0)).unwrap(), i64::from(state == "cleanup_pending")); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM workspace_runtime_bindings", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 0 + ); + } +} + +mod existing_workspace_provisioning { + use super::*; + use crate::workspace_signing_identity::{ + FsWorkspaceSigningMaterialStore, WorkspaceSigningIdentityService, + WorkspaceSigningMaterialStore, WorkspaceSigningPrivateMaterial, + }; + + const OLD_OPERATION_KEY: &str = "existing-workspace:space:revision-1"; + // Frozen v1 fingerprint: SHA256(workspace || NUL || key || NUL || 1u64_be). + const OLD_FINGERPRINT: &str = + "sha256:913cd2ceeefdd81fcaf0363b0f5db66d39a0b9040440ca564892f3e0c93a9edd"; + const NEW_OPERATION_KEY: &str = "existing-workspace:space:key-workspace-key"; + + fn legacy_reserved_database() -> Connection { + let conn = legacy_binding_database(); + conn.execute_batch("UPDATE workspace_signing_identities SET state='pending_provisioning',public_key=NULL,public_key_fingerprint=NULL,provisioned_at=NULL;").unwrap(); + conn.execute("INSERT INTO workspace_signing_identity_provisioning_operations VALUES(?1,?2,'existing_workspace','space','workspace-key','material',1,'owner','pending','reserved',NULL)", + params![OLD_OPERATION_KEY, OLD_FINGERPRINT]).unwrap(); + conn + } + + fn reopen_store(conn: Connection, path: &Path) -> Arc { + let mut persisted = Connection::open(path).unwrap(); + Backup::new(&conn, &mut persisted) + .unwrap() + .run_to_completion(128, Duration::from_millis(1), None) + .unwrap(); + drop(persisted); + drop(conn); + let conn = Connection::open(path).unwrap(); + conn.execute_batch("PRAGMA foreign_keys=ON").unwrap(); + // This fixture contains only the schema-87 cutover authorities, not the + // unrelated full Server schema. Exercise the real service/store methods. + Arc::new(SqliteWorkspaceStore { + conn: Arc::new(Mutex::new(conn)), + }) + } + + fn install_frozen_material(root: &Path) -> String { + let material = WorkspaceSigningPrivateMaterial::generate("space", "workspace-key").unwrap(); + let public_key = material + .validate_and_public_key("space", "workspace-key") + .unwrap(); + let mut old = serde_json::to_value(material).unwrap(); + old["version"] = 1.into(); + old["revision"] = 1.into(); + std::fs::create_dir_all(root).unwrap(); + std::fs::write( + root.join("material.json"), + serde_json::to_vec(&old).unwrap(), + ) + .unwrap(); + public_key + } + + fn assert_receipt(store: &SqliteWorkspaceStore, state: &str) { + store.with_conn(|conn| { + let receipt: (String,String,String,String,String,String,String,String) = conn.query_row( + "SELECT operation_key,operation_kind,workspace_id,key_id,private_material_ref,actor_account_id,state,created_at FROM workspace_signing_identity_provisioning_operations", [], + |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?,r.get(6)?,r.get(7)?)))?; + assert_eq!(receipt, (NEW_OPERATION_KEY.into(),"existing_workspace".into(),"space".into(),"workspace-key".into(),"material".into(),"owner".into(),state.into(),"reserved".into())); + assert_eq!(conn.query_row("SELECT COUNT(*) FROM workspace_signing_identity_provisioning_operations", [], |r| r.get::<_, i64>(0))?, 1); + Ok(()) + }).unwrap(); + } + + #[test] + fn legacy_reserved_workspace_resumes_same_key_and_receipt_after_cutover_and_restart() { + for material_was_published in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("identities"); + let old_public_key = material_was_published.then(|| install_frozen_material(&root)); + let old_bytes = + material_was_published.then(|| std::fs::read(root.join("material.json")).unwrap()); + let conn = legacy_reserved_database(); + migrate_runtime_bindings_v87_to_v88(&conn).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 88); + let store = reopen_store(conn, &temp.path().join("server.db")); + let materials = Arc::new(FsWorkspaceSigningMaterialStore::new(root.clone())); + let service = WorkspaceSigningIdentityService::new(store.clone(), materials.clone()); + // A recovery caller need not be the original actor: the reserved + // actor remains the authority attributed to the activation audit. + let recovered = service + .provision_existing("space", "recovery-caller") + .unwrap(); + assert_eq!(recovered.key_id, "workspace-key"); + assert_eq!(recovered.private_material_ref, "material"); + assert_eq!(recovered.state, "active"); + if let Some(public_key) = old_public_key { + assert_eq!(recovered.public_key.as_deref(), Some(public_key.as_str())); + assert_eq!( + std::fs::read(root.join("material.json")).unwrap(), + old_bytes.unwrap() + ); + } + assert_receipt(&store, "completed"); + assert_eq!( + service + .provision_existing("space", "recovery-caller") + .unwrap(), + recovered + ); + store.with_conn(|conn| { + assert_eq!(conn.query_row("SELECT COUNT(*) FROM workspace_signing_identity_audit WHERE actor_account_id='owner' AND key_id='workspace-key'", [], |r|r.get::<_, i64>(0))?, 1); + assert!(conn.query_row("SELECT completed_at FROM workspace_signing_identity_provisioning_operations", [], |r|r.get::<_, Option>(0))?.is_some()); + Ok(()) + }).unwrap(); + assert!(materials.load("material").unwrap().is_some()); + } + } + + #[test] + fn legacy_reserved_workspace_activation_failure_rolls_back_and_retries_same_material() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("identities"); + let old_public_key = install_frozen_material(&root); + let old_bytes = std::fs::read(root.join("material.json")).unwrap(); + let conn = legacy_reserved_database(); + migrate_runtime_bindings_v87_to_v88(&conn).unwrap(); + conn.execute_batch("CREATE TRIGGER fail_provisioning_audit BEFORE INSERT ON workspace_signing_identity_audit BEGIN SELECT RAISE(ABORT,'injected audit failure'); END;").unwrap(); + let path = temp.path().join("server.db"); + let store = reopen_store(conn, &path); + let service = WorkspaceSigningIdentityService::new( + store.clone(), + Arc::new(FsWorkspaceSigningMaterialStore::new(root.clone())), + ); + assert!(service.provision_existing("space", "owner").is_err()); + assert_receipt(&store, "pending"); + assert_eq!( + store + .get_workspace_signing_identity("space") + .unwrap() + .unwrap() + .state, + "pending_provisioning" + ); + store.with_conn(|conn| { + assert_eq!(conn.query_row("SELECT COUNT(*) FROM workspace_signing_identity_audit", [], |r|r.get::<_, i64>(0))?, 0); + assert_eq!(conn.query_row("SELECT completed_at FROM workspace_signing_identity_provisioning_operations", [], |r|r.get::<_, Option>(0))?, None); + conn.execute_batch("DROP TRIGGER fail_provisioning_audit")?; + Ok(()) + }).unwrap(); + drop(service); + drop(store); + let store = Arc::new(SqliteWorkspaceStore { + conn: Arc::new(Mutex::new(Connection::open(&path).unwrap())), + }); + let service = WorkspaceSigningIdentityService::new( + store.clone(), + Arc::new(FsWorkspaceSigningMaterialStore::new(root.clone())), + ); + let recovered = service.provision_existing("space", "owner").unwrap(); + assert_eq!( + recovered.public_key.as_deref(), + Some(old_public_key.as_str()) + ); + assert_eq!(recovered.key_id, "workspace-key"); + assert_receipt(&store, "completed"); + assert_eq!( + std::fs::read(root.join("material.json")).unwrap(), + old_bytes + ); + } + + #[test] + fn completed_existing_receipt_keeps_completion_evidence_and_workspace_create_contract_stays_opaque() + { + let conn = legacy_reserved_database(); + conn.execute_batch("UPDATE workspace_signing_identities SET state='active',public_key='public',public_key_fingerprint='sha256:workspace',provisioned_at='provisioned'; + UPDATE workspace_signing_identity_provisioning_operations SET state='completed',completed_at='completed'; + INSERT INTO workspace_signing_identity_provisioning_operations VALUES('workspace-create:request','create-request-fingerprint','workspace_create','new-space','new-key','new-material',1,'owner','pending','create-reserved',NULL);").unwrap(); + migrate_runtime_bindings_v87_to_v88(&conn).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 88); + let completed: (String,String,String,String,String,String,String) = conn.query_row( + "SELECT operation_key,key_id,private_material_ref,actor_account_id,state,created_at,completed_at FROM workspace_signing_identity_provisioning_operations WHERE operation_kind='existing_workspace'", [], + |r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?,r.get(6)?))).unwrap(); + assert_eq!( + completed, + ( + NEW_OPERATION_KEY.into(), + "workspace-key".into(), + "material".into(), + "owner".into(), + "completed".into(), + "reserved".into(), + "completed".into() + ) + ); + let create: (String,String,String,String,String,String,String,Option) = conn.query_row( + "SELECT operation_key,request_fingerprint,workspace_id,key_id,private_material_ref,state,created_at,completed_at FROM workspace_signing_identity_provisioning_operations WHERE operation_kind='workspace_create'", [], + |r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?,r.get(6)?,r.get(7)?))).unwrap(); + assert_eq!( + create, + ( + "workspace-create:request".into(), + "create-request-fingerprint".into(), + "new-space".into(), + "new-key".into(), + "new-material".into(), + "pending".into(), + "create-reserved".into(), + None + ) + ); + } + + #[test] + fn late_cutover_failure_keeps_old_reservation_contract_for_upgrade_retry() { + let conn = legacy_reserved_database(); + // The missing config fails the runtime-removal copy after the signing + // reservation has been converted inside the cutover transaction. + conn.execute("DELETE FROM workspace_config_trees", []) + .unwrap(); + assert!(migrate_runtime_bindings_v87_to_v88(&conn).is_err()); + assert_eq!(current_schema_version(&conn).unwrap(), 87); + let receipt: (String,String,String,String) = conn.query_row( + "SELECT operation_key,request_fingerprint,key_id,private_material_ref FROM workspace_signing_identity_provisioning_operations", [], + |r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?))).unwrap(); + assert_eq!( + receipt, + ( + OLD_OPERATION_KEY.into(), + OLD_FINGERPRINT.into(), + "workspace-key".into(), + "material".into() + ) + ); + assert!( + column_exists( + &conn, + "workspace_signing_identity_provisioning_operations", + "revision" + ) + .unwrap() + ); + conn.execute( + "INSERT INTO workspace_config_trees VALUES('space','sha256:config-content')", + [], + ) + .unwrap(); + migrate_runtime_bindings_v87_to_v88(&conn).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 88); + assert_eq!( + conn.query_row( + "SELECT operation_key FROM workspace_signing_identity_provisioning_operations", + [], + |r| r.get::<_, String>(0) + ) + .unwrap(), + NEW_OPERATION_KEY + ); + } + + #[test] + fn mismatched_legacy_provisioning_receipt_rolls_back_cutover_without_replacing_reservation() { + for invalid in [ + "UPDATE workspace_signing_identity_provisioning_operations SET key_id='different-key'", + "UPDATE workspace_signing_identity_provisioning_operations SET private_material_ref='different-material'", + "UPDATE workspace_signing_identity_provisioning_operations SET workspace_id='different-workspace'", + "UPDATE workspace_signing_identity_provisioning_operations SET operation_key='different-operation'", + "UPDATE workspace_signing_identity_provisioning_operations SET request_fingerprint='different-input'", + "UPDATE workspace_signing_identity_provisioning_operations SET state='completed',completed_at='completed'", + ] { + let conn = legacy_reserved_database(); + conn.execute_batch(invalid).unwrap(); + let before = || { + conn.prepare("SELECT operation_key,request_fingerprint,workspace_id,key_id,private_material_ref,state,completed_at FROM workspace_signing_identity_provisioning_operations").unwrap() + .query_row([], |r| Ok((r.get::<_, String>(0)?,r.get::<_, String>(1)?,r.get::<_, String>(2)?,r.get::<_, String>(3)?,r.get::<_, String>(4)?,r.get::<_, String>(5)?,r.get::<_, Option>(6)?))).unwrap() + }; + let receipt = before(); + let error = migrate_runtime_bindings_v87_to_v88(&conn) + .unwrap_err() + .to_string(); + assert!( + error.contains("legacy Workspace signing identity provisioning"), + "{invalid}: {error}" + ); + assert_eq!(current_schema_version(&conn).unwrap(), 87); + assert_eq!(before(), receipt); + assert!( + column_exists( + &conn, + "workspace_signing_identity_provisioning_operations", + "revision" + ) + .unwrap() + ); + assert!( + column_exists(&conn, "workspace_runtime_bindings", "binding_revision").unwrap() + ); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM workspace_runtime_verifications", + [], + |r| r.get::<_, i64>(0) + ) + .unwrap(), + 1 + ); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM sqlite_temp_schema", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 0 + ); + assert!( + conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, bool>(0)) + .unwrap() + ); + } + } +} + +mod current_authority { + use super::*; + + fn binding_store() -> (SqliteWorkspaceStore, WorkspaceRuntimeBinding) { + let store = SqliteWorkspaceStore::in_memory().unwrap(); + let workspace_key = + worker_runtime::auth::RuntimeIdentityMaterial::generate("workspace-key").unwrap(); + let runtime_key = + worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-key").unwrap(); + let workspace_fingerprint = normalize_runtime_public_key(&workspace_key.public_key) + .unwrap() + .1; + store.with_conn(|conn| { + conn.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('owner','user','owner','Owner','created','updated'); + INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('space','owner','Space','active','created','updated');")?; + conn.execute("INSERT INTO workspace_signing_identities(workspace_id,key_id,algorithm,public_key,public_key_fingerprint,private_material_ref,state,created_at,provisioned_at,updated_at) VALUES('space','workspace-key','ed25519',?1,?2,'material','active','created','provisioned','updated')", params![workspace_key.public_key,workspace_fingerprint])?; + Ok(()) + }).unwrap(); + let request = WorkspaceRuntimeBinding { + workspace_id: "space".into(), + runtime_id: "remote".into(), + display_name: "Remote".into(), + base_url: "https://remote.test".into(), + public_key: runtime_key.public_key, + public_key_fingerprint: String::new(), + binding_id: String::new(), + state: WorkspaceRuntimeBindingState::Configured, + authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity, + workspace_key_id: Some("workspace-key".into()), + workspace_public_key_fingerprint: Some(workspace_fingerprint), + workspace_trust_id: Some("runtime-issued-trust".into()), + created_at: "created".into(), + updated_at: "updated".into(), + revoked_at: None, + }; + let (_, binding) = store + .put_workspace_runtime_binding_key(request, None, "owner") + .unwrap(); + (store, binding) + } + + fn evidence(binding: &WorkspaceRuntimeBinding) -> WorkspaceRuntimeVerificationEvidence { + WorkspaceRuntimeVerificationEvidence { + workspace_id: binding.workspace_id.clone(), + runtime_id: binding.runtime_id.clone(), + binding_id: binding.binding_id.clone(), + workspace_key_id: binding.workspace_key_id.clone().unwrap(), + workspace_public_key_fingerprint: binding + .workspace_public_key_fingerprint + .clone() + .unwrap(), + workspace_trust_id: binding.workspace_trust_id.clone().unwrap(), + runtime_public_key_fingerprint: binding.public_key_fingerprint.clone(), + challenge_id: "challenge".into(), + state: "verified".into(), + last_outcome: "verified".into(), + verified_at: Some("verified-at".into()), + checked_at: "checked-at".into(), + } + } + + #[test] + fn same_key_reactivation_fences_old_cas_and_verification_evidence() { + let (store, binding) = binding_store(); + let old_proof = evidence(&binding); + store + .record_workspace_runtime_verification_attempt(&old_proof) + .unwrap(); + store + .complete_workspace_runtime_verification(&old_proof) + .unwrap(); + let (_, revoked) = store + .revoke_workspace_runtime_binding_key( + "space", + "remote", + &binding.binding_id, + "owner", + "revoked-at", + ) + .unwrap(); + assert_ne!(binding.binding_id, revoked.binding_id); + let mut replacement = binding.clone(); + replacement.state = WorkspaceRuntimeBindingState::Configured; + let (_, reactivated) = store + .put_workspace_runtime_binding_key(replacement, Some(&revoked.binding_id), "owner") + .unwrap(); + assert_eq!( + binding.public_key_fingerprint, + reactivated.public_key_fingerprint + ); + assert_ne!(binding.binding_id, reactivated.binding_id); + assert!(matches!( + store.put_workspace_runtime_binding_key( + reactivated.clone(), + Some(&binding.binding_id), + "owner" + ), + Err(Error::RuntimeBindingIdConflict { .. }) + )); + assert!(matches!( + store.revoke_workspace_runtime_binding_key( + "space", + "remote", + &binding.binding_id, + "owner", + "later" + ), + Err(Error::RuntimeBindingIdConflict { .. }) + )); + assert!( + store + .record_workspace_runtime_verification_attempt(&old_proof) + .is_err() + ); + assert!( + store + .complete_workspace_runtime_verification(&old_proof) + .is_err() + ); + assert!( + !store + .workspace_runtime_verification_matches( + &reactivated, + &old_proof.workspace_public_key_fingerprint, + &old_proof.workspace_trust_id + ) + .unwrap() + ); + } + + #[test] + fn endpoint_aba_and_workspace_key_change_cannot_reuse_verification_authority() { + let (store, binding) = binding_store(); + let proof = evidence(&binding); + store + .record_workspace_runtime_verification_attempt(&proof) + .unwrap(); + store + .complete_workspace_runtime_verification(&proof) + .unwrap(); + let moved = store + .update_workspace_runtime_binding_metadata( + "space", + "remote", + "Remote", + "https://other.test", + "changed", + ) + .unwrap(); + let returned = store + .update_workspace_runtime_binding_metadata( + "space", + "remote", + "Remote", + "https://remote.test", + "changed-back", + ) + .unwrap(); + assert_ne!(moved.binding_id, binding.binding_id); + assert_ne!(returned.binding_id, binding.binding_id); + assert!( + store + .record_workspace_runtime_verification_attempt(&proof) + .is_err() + ); + let current = evidence(&returned); + store + .record_workspace_runtime_verification_attempt(¤t) + .unwrap(); + store.with_conn(|conn| { conn.execute("UPDATE workspace_signing_identities SET public_key_fingerprint='sha256:replacement' WHERE workspace_id='space'",[])?; Ok(()) }).unwrap(); + assert!( + store + .complete_workspace_runtime_verification(¤t) + .is_err() + ); + assert_eq!( + store + .get_workspace_runtime_binding("space", "remote") + .unwrap() + .unwrap() + .state, + WorkspaceRuntimeBindingState::Configured + ); + } +} diff --git a/crates/workspace-server/src/frozen_schema_v85.sql b/crates/workspace-server/src/frozen_schema_v85.sql new file mode 100644 index 000000000..3257a860c --- /dev/null +++ b/crates/workspace-server/src/frozen_schema_v85.sql @@ -0,0 +1,1654 @@ +-- Canonical composed Workspace Server schema. New databases are created directly at this generation. +-- Ticket creates its standalone schema first; replace those empty tables with the composed authority. +DROP TABLE typed_ticket_event_attributes; +DROP TABLE typed_ticket_event_references; +DROP TABLE typed_ticket_artifacts; +DROP TABLE typed_ticket_labels; +DROP TABLE typed_ticket_orchestration_plans; +DROP TABLE typed_ticket_raw_frontmatter; +DROP TABLE typed_ticket_relations; +DROP TABLE typed_ticket_risk_flags; +DROP TABLE typed_ticket_events; +DROP TABLE typed_tickets; +DROP TABLE workspace_resource_key_counters; +DROP TABLE workspace_resource_keys; +CREATE TABLE accounts ( + account_id TEXT PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('user', 'organization')), + handle TEXT NOT NULL, + display_name TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (kind, handle) +); +CREATE TABLE api_tokens ( + token_id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + label TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT, + revoked_at TEXT, + last_used_at TEXT +); +CREATE TABLE "artifacts" ( + workspace_id TEXT NOT NULL, + artifact_id TEXT NOT NULL, + kind TEXT NOT NULL, + uri TEXT NOT NULL, + media_type TEXT, + sha256 TEXT, + size_bytes INTEGER, + summary TEXT, + created_at TEXT NOT NULL, + created_by_kind TEXT NOT NULL, + created_by_key TEXT NOT NULL, + created_by_display TEXT NOT NULL, + created_by_source_kind TEXT, + created_by_source_key TEXT, + ticket_id TEXT, + objective_id TEXT, + event_id TEXT, + worker_ref_kind TEXT, + worker_ref_key TEXT, + worker_display TEXT, + repository_id TEXT, + source_kind TEXT, + source_revision TEXT, + PRIMARY KEY (workspace_id, artifact_id), + CHECK ((worker_ref_kind IS NULL) = (worker_ref_key IS NULL)), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, ticket_id) + REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, objective_id) + REFERENCES objectives(workspace_id, objective_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, repository_id) + REFERENCES repositories(workspace_id, repository_id) ON DELETE RESTRICT +); +CREATE TABLE audit_events ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + audit_event_id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + actor_kind TEXT NOT NULL, + actor_key TEXT NOT NULL, + actor_display TEXT NOT NULL, + actor_source_kind TEXT, + actor_source_key TEXT, + action TEXT NOT NULL, + target_kind TEXT NOT NULL, + target_id TEXT, + outcome TEXT NOT NULL, + request_id TEXT, + summary TEXT +); +CREATE TABLE auth_challenges ( + challenge_id TEXT PRIMARY KEY, + ceremony TEXT NOT NULL CHECK (ceremony IN ('passkey_registration', 'passkey_login')), + challenge TEXT NOT NULL UNIQUE, + user_id TEXT REFERENCES users(user_id) ON DELETE CASCADE, + rp_id TEXT NOT NULL, + origin TEXT NOT NULL, + state_json TEXT, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + consumed_at TEXT +); +CREATE TABLE browser_sessions ( + session_id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT +); +CREATE TABLE device_login_flows ( + device_code TEXT PRIMARY KEY, + user_code TEXT NOT NULL UNIQUE, + verification_uri TEXT NOT NULL, + client_name TEXT, + user_id TEXT REFERENCES users(user_id) ON DELETE SET NULL, + api_token_id TEXT REFERENCES api_tokens(token_id) ON DELETE SET NULL, + issued_access_token TEXT, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + approved_at TEXT, + consumed_at TEXT +); +CREATE TABLE flow_source_contents ( + workspace_id TEXT NOT NULL, + flow_id TEXT NOT NULL, + content TEXT NOT NULL, + content_digest TEXT NOT NULL, + definition_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, flow_id, content_digest), + FOREIGN KEY (workspace_id, flow_id) + REFERENCES flow_sources(workspace_id, flow_id) ON DELETE CASCADE +); +CREATE TABLE flow_sources ( + workspace_id TEXT NOT NULL, + flow_id TEXT NOT NULL, + source_kind TEXT NOT NULL CHECK (source_kind IN ('builtin', 'workspace')), + name TEXT NOT NULL, + path TEXT NOT NULL, + content TEXT NOT NULL, + content_digest TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, flow_id), + UNIQUE (workspace_id, source_kind, name), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +CREATE TABLE memory_staging_records ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + candidate_id TEXT NOT NULL, + raw_json TEXT NOT NULL, + source_path TEXT, + imported_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, candidate_id) +); +CREATE TABLE memory_staging_resolutions ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + candidate_id TEXT NOT NULL, + action TEXT NOT NULL, + reason TEXT NOT NULL, + affected_refs_json TEXT NOT NULL, + staging_raw_json TEXT NOT NULL, + source_path TEXT, + imported_at TEXT NOT NULL, + resolved_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, candidate_id, resolved_at) +); +CREATE TABLE "objective_events" ( + workspace_id TEXT NOT NULL, + objective_id TEXT NOT NULL, + event_id TEXT NOT NULL, + kind TEXT NOT NULL, + body_md TEXT, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, event_id), + FOREIGN KEY (workspace_id, objective_id) + REFERENCES objectives(workspace_id, objective_id) ON DELETE CASCADE +); +CREATE TABLE "objective_resources" ( + workspace_id TEXT NOT NULL, + objective_id TEXT NOT NULL, + resource_path TEXT NOT NULL, + body TEXT NOT NULL, + media_type TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, objective_id, resource_path), + FOREIGN KEY (workspace_id, objective_id) + REFERENCES objectives(workspace_id, objective_id) ON DELETE CASCADE +); +CREATE TABLE "objective_ticket_links" ( + workspace_id TEXT NOT NULL, + objective_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + kind TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, objective_id, ticket_id, kind), + FOREIGN KEY (workspace_id, objective_id) + REFERENCES objectives(workspace_id, objective_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, ticket_id) + REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE "objectives" ( + workspace_id TEXT NOT NULL, + objective_id TEXT NOT NULL, + title TEXT NOT NULL, + state TEXT NOT NULL, + body_md TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, objective_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +CREATE TABLE passkey_credentials ( + credential_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + public_key_cose TEXT NOT NULL, + transports_json TEXT, + sign_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + last_used_at TEXT +); +CREATE TABLE "repositories" ( + workspace_id TEXT NOT NULL, + repository_id TEXT NOT NULL PRIMARY KEY, + repository_key TEXT NOT NULL + CHECK(length(repository_key) BETWEEN 1 AND 64) + CHECK(repository_key NOT GLOB '*[^a-z0-9-]*') + CHECK(substr(repository_key, 1, 1) <> '-') + CHECK(substr(repository_key, -1, 1) <> '-'), + kind TEXT NOT NULL, + provider TEXT, + uri TEXT NOT NULL, + default_ref TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + source_kind TEXT NOT NULL, + source_uri TEXT NOT NULL, + source_revision INTEGER NOT NULL DEFAULT 1, + source_fingerprint TEXT NOT NULL, + observed_status TEXT NOT NULL DEFAULT 'unverified', + observed_at TEXT, + UNIQUE(workspace_id, repository_key), + UNIQUE(workspace_id, repository_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE repository_secret_audit_events ( + workspace_id TEXT NOT NULL, + event_id TEXT NOT NULL, + kind TEXT NOT NULL, + resource_id TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision >= 1), + actor_account_id TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, event_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE repository_secret_operations ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + resource_kind TEXT NOT NULL CHECK (resource_kind IN ('credential', 'host_trust')), + resource_id TEXT NOT NULL, + result_revision INTEGER NOT NULL CHECK (result_revision >= 1), + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, operation_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE repository_ssh_credential_revisions ( + workspace_id TEXT NOT NULL, + credential_id TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision >= 1), + public_key_algorithm TEXT NOT NULL, + public_key_fingerprint TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, credential_id, revision), + FOREIGN KEY (workspace_id, credential_id) + REFERENCES repository_ssh_credentials(workspace_id, credential_id) + ON DELETE CASCADE + ); +CREATE TABLE repository_ssh_credentials ( + workspace_id TEXT NOT NULL, + credential_id TEXT NOT NULL, + name TEXT NOT NULL, + public_key_algorithm TEXT NOT NULL, + public_key_fingerprint TEXT NOT NULL, + current_revision INTEGER NOT NULL CHECK (current_revision >= 1), + status TEXT NOT NULL CHECK (status IN ('active', 'revoked')), + created_at TEXT NOT NULL, + rotated_at TEXT, + PRIMARY KEY (workspace_id, credential_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE repository_ssh_host_trust_revisions ( + workspace_id TEXT NOT NULL, + host_trust_id TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision >= 1), + hostname TEXT NOT NULL, + port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535), + key_algorithm TEXT NOT NULL, + host_key TEXT NOT NULL, + fingerprint TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, host_trust_id, revision), + FOREIGN KEY (workspace_id, host_trust_id) + REFERENCES repository_ssh_host_trusts(workspace_id, host_trust_id) + ON DELETE CASCADE + ); +CREATE TABLE repository_ssh_host_trusts ( + workspace_id TEXT NOT NULL, + host_trust_id TEXT NOT NULL, + hostname TEXT NOT NULL, + port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535), + key_algorithm TEXT NOT NULL, + host_key TEXT NOT NULL, + fingerprint TEXT NOT NULL, + current_revision INTEGER NOT NULL CHECK (current_revision >= 1), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, host_trust_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE server_secret_versions ( + workspace_id TEXT NOT NULL, + secret_id TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision >= 1), + purpose TEXT NOT NULL CHECK (purpose IN ('private_key', 'passphrase')), + encryption_algorithm TEXT NOT NULL CHECK (encryption_algorithm = 'aes-256-gcm-v1'), + nonce BLOB NOT NULL CHECK (length(nonce) = 12), + ciphertext BLOB NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, secret_id, revision, purpose), + FOREIGN KEY (workspace_id, secret_id, revision) + REFERENCES repository_ssh_credential_revisions(workspace_id, credential_id, revision) + ON DELETE CASCADE + ); +CREATE TABLE ticket_assignment_operations ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('assign', 'reassign', 'unassign')), + ticket_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'worker' + CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')), + principal_kind TEXT NOT NULL DEFAULT 'worker' + CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')), + principal_id TEXT, + runtime_id TEXT, + worker_id TEXT, + assignment_id TEXT, + expected_assignment_id TEXT, + created_at TEXT NOT NULL, + request_fingerprint TEXT, + claim_state TEXT NOT NULL DEFAULT 'committed' + CHECK(claim_state IN ('pending', 'failed', 'committed')), + failure_reason TEXT, + binding_recovery_json TEXT, + PRIMARY KEY (workspace_id, operation_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + CHECK(claim_state = 'committed' OR assignment_id IS NULL), + CHECK((claim_state = 'failed') = (failure_reason IS NOT NULL)) + ); +CREATE TABLE ticket_assignment_ticket_tombstones ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + deleted_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +CREATE TABLE ticket_assignment_worker_tombstones ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + deleted_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +CREATE TABLE ticket_current_worker_assignments ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'worker' + CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')), + assignment_id TEXT NOT NULL, + principal_kind TEXT NOT NULL DEFAULT 'worker' + CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')), + principal_id TEXT, + runtime_id TEXT, + worker_id TEXT, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, role, assignment_id), + UNIQUE (workspace_id, assignment_id), + FOREIGN KEY (workspace_id, ticket_id, role, assignment_id) + REFERENCES ticket_worker_assignments(workspace_id, ticket_id, role, assignment_id) + ON DELETE CASCADE, + FOREIGN KEY (workspace_id, ticket_id) + REFERENCES typed_tickets(workspace_id, ticket_id) + ON DELETE CASCADE, + CHECK( + (principal_kind = 'worker' AND runtime_id IS NOT NULL AND worker_id IS NOT NULL AND principal_id IS NULL) + OR + (principal_kind != 'worker' AND runtime_id IS NULL AND worker_id IS NULL AND principal_id IS NOT NULL AND length(trim(principal_id)) > 0) + ) + ); +CREATE TABLE ticket_worker_assignment_events ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'worker' + CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')), + event_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('assigned', 'reassigned', 'unassigned')), + assignment_id TEXT, + previous_assignment_id TEXT, + actor TEXT NOT NULL, + created_at TEXT NOT NULL, + operation_id TEXT, + reason TEXT, + PRIMARY KEY (workspace_id, event_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE ticket_worker_assignments ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + assignment_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'worker' + CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')), + principal_kind TEXT NOT NULL DEFAULT 'worker' + CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')), + principal_id TEXT, + runtime_id TEXT, + worker_id TEXT, + assigned_by TEXT NOT NULL, + assigned_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, assignment_id), + UNIQUE (workspace_id, ticket_id, assignment_id), + UNIQUE (workspace_id, ticket_id, role, assignment_id), + UNIQUE (workspace_id, ticket_id, role, assignment_id, principal_kind, principal_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + CHECK( + (principal_kind = 'worker' AND runtime_id IS NOT NULL AND worker_id IS NOT NULL AND principal_id IS NULL) + OR + (principal_kind != 'worker' AND runtime_id IS NULL AND worker_id IS NULL AND principal_id IS NOT NULL AND length(trim(principal_id)) > 0) + ) + ); +CREATE TABLE workspace_runtime_bindings ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + display_name TEXT NOT NULL, + base_url TEXT NOT NULL, + public_key TEXT NOT NULL, + public_key_fingerprint TEXT NOT NULL, + binding_revision INTEGER NOT NULL DEFAULT 1 CHECK (binding_revision > 0), + state TEXT NOT NULL CHECK (state IN ('configured', 'verified', 'revoked')), + authentication_mode TEXT NOT NULL CHECK (authentication_mode IN ('legacy_server_issuer', 'workspace_identity')), + workspace_key_id TEXT, + workspace_key_generation INTEGER CHECK (workspace_key_generation > 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revoked_at TEXT, + PRIMARY KEY (workspace_id, runtime_id), + UNIQUE (workspace_id, public_key_fingerprint), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT, + CHECK ( + (authentication_mode = 'legacy_server_issuer' AND workspace_key_id IS NULL AND workspace_key_generation IS NULL) + OR + (authentication_mode = 'workspace_identity' AND workspace_key_id IS NOT NULL AND workspace_key_generation IS NOT NULL) + ), + CHECK ( + (state = 'revoked' AND revoked_at IS NOT NULL) + OR + (state != 'revoked' AND revoked_at IS NULL) + ) +); +CREATE TABLE workspace_runtime_verifications ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + binding_revision INTEGER NOT NULL CHECK(binding_revision > 0), + workspace_key_id TEXT NOT NULL, + workspace_identity_revision INTEGER NOT NULL CHECK(workspace_identity_revision > 0), + workspace_trust_generation INTEGER NOT NULL CHECK(workspace_trust_generation > 0), + runtime_public_key_fingerprint TEXT NOT NULL, + runtime_identity_revision INTEGER NOT NULL CHECK(runtime_identity_revision > 0), + challenge_id TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('pending', 'verified', 'failed')), + last_outcome TEXT NOT NULL, + verified_at TEXT, + checked_at TEXT NOT NULL, + PRIMARY KEY(workspace_id, runtime_id), + FOREIGN KEY(workspace_id, runtime_id) + REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE CASCADE, + CHECK((state = 'verified' AND verified_at IS NOT NULL) + OR (state != 'verified' AND verified_at IS NULL)) +); +CREATE INDEX workspace_runtime_verifications_state_idx + ON workspace_runtime_verifications(workspace_id, state, checked_at DESC); +CREATE TABLE workspace_runtime_binding_audit ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + actor_account_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('created', 'replaced', 'reactivated', 'revoked')), + old_fingerprint TEXT, + new_fingerprint TEXT, + binding_revision INTEGER NOT NULL CHECK (binding_revision > 0), + at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, binding_revision), + FOREIGN KEY(workspace_id, runtime_id) + REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE RESTRICT, + FOREIGN KEY(actor_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT +); +CREATE INDEX idx_workspace_runtime_binding_audit_recent + ON workspace_runtime_binding_audit(workspace_id, runtime_id, binding_revision DESC); +CREATE TABLE typed_ticket_artifacts ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, relative_path), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_event_attributes ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, event_index, key), + FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_event_references ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, ordinal INTEGER NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, event_index, ordinal), + FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_events ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + event_index INTEGER NOT NULL, + kind TEXT NOT NULL, + author TEXT, + at TEXT, + status TEXT, + from_state TEXT, + to_state TEXT, + reason TEXT, + state_field TEXT, + heading TEXT, + body TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, event_index), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_labels ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, label TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, ordinal), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_orchestration_plans ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + record_id TEXT NOT NULL, + kind TEXT NOT NULL, + related_ticket TEXT, + note TEXT, + accepted_summary TEXT, + accepted_branch TEXT, + accepted_worktree TEXT, + accepted_role_plan TEXT, + author TEXT NOT NULL, + at TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, record_id), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_raw_frontmatter ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, key), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE "typed_ticket_relations" ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + kind TEXT NOT NULL, + target TEXT NOT NULL, + note TEXT, + author TEXT NOT NULL, + at TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, kind, target), + FOREIGN KEY (workspace_id, ticket_id) + REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, target) + REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_risk_flags ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, risk_flag TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, ordinal), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE "typed_tickets" ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + slug TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL, + kind TEXT NOT NULL, + priority TEXT NOT NULL, + body TEXT NOT NULL, + created_at TEXT, + updated_at TEXT, + assignee TEXT, + readiness TEXT, + workflow_state TEXT NOT NULL, + workflow_state_explicit INTEGER NOT NULL, + queued_by TEXT, + queued_at TEXT, + resolution TEXT, + repository_id TEXT, + ref_selector TEXT, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, repository_id) + REFERENCES repositories(workspace_id, repository_id) ON DELETE RESTRICT +); +CREATE TABLE users ( + user_id TEXT PRIMARY KEY, + account_id TEXT NOT NULL UNIQUE REFERENCES accounts(account_id) ON DELETE CASCADE, + handle TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE TABLE workdir_create_operations ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + repository_id TEXT NOT NULL, + selector TEXT, + requested_runtime_id TEXT, + resolved_runtime_id TEXT NOT NULL, + config_revision INTEGER NOT NULL, + config_projection_digest TEXT NOT NULL, + working_directory_id TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'succeeded', 'failed')), + failure TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, source_kind TEXT, source_uri TEXT, source_revision INTEGER, source_fingerprint TEXT, credential_id TEXT, credential_revision INTEGER, host_trust_id TEXT, host_trust_revision INTEGER, repository_access_mode TEXT, + PRIMARY KEY (workspace_id, operation_id), + UNIQUE (workspace_id, working_directory_id) + ); +CREATE TABLE workdir_create_credential_candidates ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0 AND ordinal < 2), + role TEXT NOT NULL CHECK (role IN ('primary', 'workspace_default_fallback')), + credential_id TEXT NOT NULL CHECK (length(credential_id) BETWEEN 1 AND 128), + credential_revision INTEGER NOT NULL CHECK (credential_revision > 0), + PRIMARY KEY (workspace_id, operation_id, ordinal), + UNIQUE (workspace_id, operation_id, role), + UNIQUE (workspace_id, operation_id, credential_id), + FOREIGN KEY (workspace_id, operation_id) + REFERENCES workdir_create_operations(workspace_id, operation_id) + ON DELETE CASCADE +); +CREATE INDEX idx_workdir_create_credential_candidates_revision + ON workdir_create_credential_candidates( + workspace_id, credential_id, credential_revision + ); +CREATE TABLE workdir_create_credential_revision_retentions ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + credential_id TEXT NOT NULL, + credential_revision INTEGER NOT NULL, + PRIMARY KEY (workspace_id, operation_id, ordinal), + FOREIGN KEY (workspace_id, operation_id, ordinal) + REFERENCES workdir_create_credential_candidates( + workspace_id, operation_id, ordinal + ) + ON DELETE CASCADE, + FOREIGN KEY (workspace_id, credential_id, credential_revision) + REFERENCES repository_ssh_credential_revisions( + workspace_id, credential_id, revision + ) + ON DELETE RESTRICT +); +CREATE TABLE "workdir_registry" ( + workspace_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + display_name TEXT, + source_kind TEXT NOT NULL CHECK (source_kind IN ('repository', 'external_grant', 'workspace_config')), + runtime_id TEXT, + repository_id TEXT, + external_grant_id TEXT, + creation_selector TEXT, + creation_ref TEXT, + materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'not_found', 'corrupted', 'unknown', 'failed')), + cleanliness TEXT NOT NULL CHECK (cleanliness IN ('clean', 'dirty', 'unknown')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + current_selector TEXT, + current_ref TEXT, creation_tree TEXT, current_tree TEXT, observed_at_epoch_seconds INTEGER, + workspace_config_grant_id TEXT, + PRIMARY KEY (workspace_id, workdir_id), + CHECK ( + (source_kind = 'repository' AND runtime_id IS NOT NULL AND repository_id IS NOT NULL AND external_grant_id IS NULL AND workspace_config_grant_id IS NULL) + OR (source_kind = 'external_grant' AND runtime_id IS NULL AND repository_id IS NULL AND external_grant_id IS NOT NULL AND workspace_config_grant_id IS NULL) + OR (source_kind = 'workspace_config' AND runtime_id IS NULL AND repository_id IS NULL AND external_grant_id IS NULL AND workspace_config_grant_id IS NOT NULL) + ), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, repository_id) + REFERENCES "repositories"(workspace_id, repository_id), + FOREIGN KEY (workspace_id, external_grant_id) + REFERENCES external_workdir_grants(workspace_id, grant_id) DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY (workspace_id, workspace_config_grant_id) + REFERENCES workspace_config_grants(workspace_id, grant_id) DEFERRABLE INITIALLY DEFERRED +); +CREATE TABLE external_workdir_grants ( + grant_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + provider_instance_id TEXT NOT NULL, + display_name TEXT NOT NULL, + permissions TEXT NOT NULL CHECK (permissions IN ( + 'read_only', 'read_write', 'command_only', + 'read_command', 'read_write_command' + )), + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT, + generation INTEGER NOT NULL CHECK (generation > 0), + status TEXT NOT NULL CHECK (status IN ('pending', 'online', 'offline', 'revoked', 'expired')), + updated_at TEXT NOT NULL, + cleanup_state TEXT NOT NULL DEFAULT 'not_required' CHECK (cleanup_state IN ('not_required', 'pending', 'retry_required', 'completed')), + cleanup_error TEXT, + cleanup_updated_at TEXT, + PRIMARY KEY (workspace_id, grant_id), + UNIQUE (workspace_id, workdir_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, workdir_id) + REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED +); +CREATE TABLE workdir_removal_operations ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + workdir_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + materialization_fingerprint TEXT NOT NULL, + source_actor TEXT NOT NULL, + reason TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'failed', 'completed')), + attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), + retryable INTEGER NOT NULL CHECK (retryable IN (0, 1)), + disposition TEXT CHECK (disposition IN ('removed', 'retained', 'attention_required')), + failure_category TEXT, + attempt_owner_pid INTEGER CHECK (attempt_owner_pid > 0), + attempt_owner_start_marker INTEGER CHECK (attempt_owner_start_marker >= 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + PRIMARY KEY (workspace_id, operation_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +CREATE TABLE "worker_control_grants" ( + grant_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + controller_runtime_id TEXT NOT NULL, + controller_worker_id TEXT NOT NULL, + subject_runtime_id TEXT NOT NULL, + subject_worker_id TEXT NOT NULL, + relation TEXT NOT NULL, + origin TEXT NOT NULL, + permissions_json TEXT NOT NULL, + operation_id TEXT NOT NULL, + created_at TEXT NOT NULL, + revoked_at TEXT, + PRIMARY KEY (workspace_id, grant_id), + UNIQUE (workspace_id, controller_runtime_id, controller_worker_id, operation_id), + FOREIGN KEY (workspace_id, controller_runtime_id, controller_worker_id) + REFERENCES "worker_registry"(workspace_id, runtime_id, worker_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, subject_runtime_id, subject_worker_id) + REFERENCES "worker_registry"(workspace_id, runtime_id, worker_id) ON DELETE CASCADE + ); +CREATE TABLE worker_create_reservations ( + workspace_id TEXT NOT NULL, + allocation_key TEXT NOT NULL, + worker_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + create_fingerprint TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('reserved', 'created', 'removed')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, request_fingerprint TEXT, memory_settings_revision INTEGER, memory_language TEXT, + singleton_key TEXT, + singleton_generation INTEGER CHECK (singleton_generation IS NULL OR singleton_generation > 0), + PRIMARY KEY (workspace_id, allocation_key), + UNIQUE (workspace_id, worker_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + CHECK ((singleton_key IS NULL) = (singleton_generation IS NULL)) + ); +CREATE TRIGGER worker_create_reservation_singleton_lease_insert +BEFORE INSERT ON worker_create_reservations +WHEN (NEW.singleton_key IS NULL AND NEW.singleton_generation IS NOT NULL) + OR (NEW.singleton_key IS NOT NULL AND NEW.singleton_generation IS NULL) +BEGIN + SELECT RAISE(ABORT, 'worker_singleton_lease_incomplete'); +END; +CREATE TRIGGER worker_create_reservation_singleton_lease_update +BEFORE UPDATE OF singleton_key, singleton_generation ON worker_create_reservations +WHEN (NEW.singleton_key IS NULL AND NEW.singleton_generation IS NOT NULL) + OR (NEW.singleton_key IS NOT NULL AND NEW.singleton_generation IS NULL) +BEGIN + SELECT RAISE(ABORT, 'worker_singleton_lease_incomplete'); +END; +CREATE TABLE worker_singleton_owners ( + workspace_id TEXT NOT NULL, + singleton_key TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + generation INTEGER NOT NULL CHECK (generation > 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, singleton_key), + UNIQUE (workspace_id, worker_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE worker_diagnostics_archives ( + operation_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL, + committed_at TEXT NOT NULL, expires_at TEXT NOT NULL, + FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE); +CREATE TABLE worker_mutation_source_proof_jtis ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + jti TEXT NOT NULL, + expires_at INTEGER NOT NULL, + consumed_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, jti) + ); +CREATE TABLE worker_orphan_diagnostics ( + diagnostic_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, + category TEXT NOT NULL, detail TEXT NOT NULL, observed_at TEXT NOT NULL, + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE); +CREATE TABLE "worker_registry" ( + workspace_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + display_name TEXT NOT NULL, + profile TEXT, + retention_state TEXT NOT NULL CHECK (retention_state IN ('normal', 'pinned')), + transcript_ref TEXT, + session_ref TEXT, + summary_ref TEXT, + diagnostics_ref TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE backend_jobs ( + workspace_id TEXT NOT NULL, + job_id TEXT NOT NULL, + purpose TEXT NOT NULL, + input_revision TEXT NOT NULL, + input_ref TEXT NOT NULL, + resource_key TEXT, + request_json TEXT NOT NULL, + intent_fingerprint TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'completed', 'failed', 'unknown')), + current_attempt INTEGER NOT NULL CHECK (current_attempt > 0 AND current_attempt <= 3), + result_json TEXT, + result_digest TEXT, + failure_category TEXT, + failure_detail TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + PRIMARY KEY (workspace_id, job_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + CHECK ((result_json IS NULL) = (result_digest IS NULL)), + CHECK ((state = 'completed') = (result_json IS NOT NULL)) + ); +CREATE INDEX backend_jobs_active + ON backend_jobs(workspace_id, state, updated_at); +CREATE INDEX backend_jobs_resource + ON backend_jobs(workspace_id, resource_key, created_at); +CREATE TABLE backend_job_attempts ( + workspace_id TEXT NOT NULL, + job_id TEXT NOT NULL, + attempt_id TEXT NOT NULL, + attempt INTEGER NOT NULL CHECK (attempt > 0 AND attempt <= 3), + input_revision TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('reserved', 'dispatching', 'dispatched', 'completed', 'failed', 'unknown')), + runtime_id TEXT, + worker_id TEXT, + runtime_run_id TEXT, + dispatched_at TEXT, + deadline_at TEXT NOT NULL, + result_json TEXT, + result_digest TEXT, + failure_category TEXT, + failure_detail TEXT, + worker_cleanup_state TEXT CHECK ( + worker_cleanup_state IN ('pending', 'executing', 'completed', 'failed') + ), + worker_cleanup_failure_category TEXT, + worker_cleanup_failure_detail TEXT, + worker_cleanup_updated_at TEXT, + worker_cleanup_completed_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + PRIMARY KEY (workspace_id, job_id, attempt_id), + UNIQUE (workspace_id, job_id, attempt), + UNIQUE (workspace_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id, job_id) + REFERENCES backend_jobs(workspace_id, job_id) ON DELETE CASCADE, + CHECK ((runtime_id IS NULL) = (worker_id IS NULL)), + CHECK ((result_json IS NULL) = (result_digest IS NULL)), + CHECK ((state = 'completed') = (result_json IS NOT NULL)) + ); +CREATE INDEX backend_job_attempts_recovery + ON backend_job_attempts(workspace_id, state, deadline_at); +CREATE INDEX backend_job_attempts_worker_cleanup + ON backend_job_attempts(workspace_id, worker_cleanup_state, updated_at); +CREATE TABLE backend_job_deliveries ( + workspace_id TEXT NOT NULL, + delivery_id TEXT NOT NULL, + job_id TEXT NOT NULL, + attempt_id TEXT NOT NULL, + target_runtime_id TEXT NOT NULL, + target_worker_id TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'sending', 'completed', 'failed', 'unknown')), + failure_category TEXT, + failure_detail TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + delivered_at TEXT, + PRIMARY KEY (workspace_id, delivery_id), + FOREIGN KEY (workspace_id, job_id, attempt_id) + REFERENCES backend_job_attempts(workspace_id, job_id, attempt_id) ON DELETE CASCADE + ); +CREATE INDEX backend_job_deliveries_pending + ON backend_job_deliveries(workspace_id, state, updated_at); +CREATE TABLE worker_registry_observations ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + availability TEXT NOT NULL CHECK (availability IN ('observed', 'unavailable')), + worker_json TEXT, + observed_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id, runtime_id, worker_id) + REFERENCES worker_registry(workspace_id, runtime_id, worker_id) ON DELETE CASCADE +); +CREATE TABLE worker_registry_projection_removals ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, worker_id) +); +CREATE TABLE worker_registry_projection_diagnostics ( + diagnostic_id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + reason TEXT NOT NULL, + observed_at TEXT NOT NULL +); +CREATE TABLE worker_removal_operations ( + operation_id TEXT PRIMARY KEY, plan_id TEXT NOT NULL UNIQUE, input_fingerprint TEXT NOT NULL, + workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, + worker_revision TEXT NOT NULL, + policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL, + session_disposition TEXT NOT NULL, metadata_disposition TEXT NOT NULL, + archive_retention_kind TEXT NOT NULL, archive_retention_seconds INTEGER, + diagnostics_disposition TEXT NOT NULL, + diagnostics_retention_seconds INTEGER, archive_id TEXT UNIQUE, blockers_json TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('planned','blocked','executing','failed','stale','succeeded')), + reason TEXT NOT NULL, failure_category TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE); +CREATE TABLE worker_retention_audit_events ( + event_id TEXT PRIMARY KEY, operation_id TEXT NOT NULL, workspace_id TEXT NOT NULL, + event_kind TEXT NOT NULL, detail TEXT NOT NULL, created_at TEXT NOT NULL, + FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE); +CREATE TABLE worker_session_archives ( + archive_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, + session_id TEXT NOT NULL, checksum_sha256 TEXT NOT NULL, content_bytes INTEGER NOT NULL, + policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL, operation_id TEXT NOT NULL UNIQUE, + committed_at TEXT NOT NULL, expires_at TEXT, + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id)); +CREATE TABLE worker_session_archive_observe_grants ( + workspace_id TEXT NOT NULL, archive_id TEXT NOT NULL, + controller_runtime_id TEXT NOT NULL, controller_worker_id TEXT NOT NULL, + subject_runtime_id TEXT NOT NULL, subject_worker_id TEXT NOT NULL, + source_grant_id TEXT NOT NULL, granted_at TEXT NOT NULL, revoked_at TEXT, + PRIMARY KEY(workspace_id,archive_id,controller_runtime_id,controller_worker_id,source_grant_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY(archive_id) REFERENCES worker_session_archives(archive_id) ON DELETE CASCADE); +CREATE INDEX worker_session_archive_observe_grants_controller + ON worker_session_archive_observe_grants( + workspace_id,controller_runtime_id,controller_worker_id,archive_id); +CREATE TABLE worker_tombstones ( + workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, + display_name TEXT NOT NULL, profile TEXT, worker_created_at TEXT NOT NULL, removed_at TEXT NOT NULL, + archive_id TEXT, policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL, operation_id TEXT NOT NULL UNIQUE, + PRIMARY KEY(workspace_id,runtime_id,worker_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY(archive_id) REFERENCES worker_session_archives(archive_id), + FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id)); +CREATE TABLE "worker_workdir_attachment_reservations" ( + workspace_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + reservation_id TEXT NOT NULL, + reserved_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, workdir_id), + FOREIGN KEY (workspace_id, workdir_id) + REFERENCES "workdir_registry"(workspace_id, workdir_id) ON DELETE CASCADE +); +CREATE TABLE "worker_workdir_links" ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + alias TEXT NOT NULL, + linked_at TEXT NOT NULL, + unlinked_at TEXT, + PRIMARY KEY (workspace_id, runtime_id, worker_id, workdir_id, alias), + FOREIGN KEY (workspace_id, runtime_id, worker_id) + REFERENCES "worker_registry"(workspace_id, runtime_id, worker_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, workdir_id) + REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE + ); +CREATE TABLE workspace_config_entries ( + workspace_id TEXT NOT NULL, + path TEXT NOT NULL, + content_type TEXT NOT NULL, + content TEXT NOT NULL, + content_digest TEXT NOT NULL, + PRIMARY KEY (workspace_id, path), + FOREIGN KEY (workspace_id) REFERENCES workspace_config_trees(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE workspace_config_tree_revisions ( + workspace_id TEXT NOT NULL, + revision INTEGER NOT NULL, + tree_digest TEXT NOT NULL, + toolchain_fingerprint TEXT NOT NULL, + projection_digest TEXT NOT NULL, + manifest_json TEXT NOT NULL, + created_at TEXT NOT NULL, schema_bundle_json TEXT NOT NULL DEFAULT '{"contributions":[],"source":"{}","fingerprint":""}', + PRIMARY KEY (workspace_id, revision), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE workspace_config_trees ( + workspace_id TEXT PRIMARY KEY, + revision INTEGER NOT NULL CHECK (revision >= 0), + tree_digest TEXT NOT NULL, + schema_version INTEGER NOT NULL, + entrypoints_json TEXT NOT NULL, + decodal_version TEXT NOT NULL, + import_policy_version INTEGER NOT NULL, + toolchain_fingerprint TEXT NOT NULL, + projection_digest TEXT NOT NULL, + updated_at TEXT NOT NULL, schema_bundle_json TEXT NOT NULL DEFAULT '{"contributions":[],"source":"{}","fingerprint":""}', + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE workspace_create_operations ( + operation_key TEXT PRIMARY KEY, + request_fingerprint TEXT NOT NULL, + workspace_id TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE workspace_signing_identities ( + workspace_id TEXT PRIMARY KEY, + key_id TEXT NOT NULL UNIQUE, + algorithm TEXT NOT NULL CHECK (algorithm = 'ed25519'), + public_key TEXT, + public_key_fingerprint TEXT, + private_material_ref TEXT NOT NULL UNIQUE, + revision INTEGER NOT NULL CHECK (revision >= 1), + state TEXT NOT NULL CHECK (state IN ('pending_provisioning', 'active')), + created_at TEXT NOT NULL, + provisioned_at TEXT, + updated_at TEXT NOT NULL, + CHECK ( + (state = 'pending_provisioning' AND public_key IS NULL AND public_key_fingerprint IS NULL AND provisioned_at IS NULL) + OR + (state = 'active' AND public_key IS NOT NULL AND public_key_fingerprint IS NOT NULL AND provisioned_at IS NOT NULL) + ), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE workspace_signing_identity_provisioning_operations ( + operation_key TEXT PRIMARY KEY, + request_fingerprint TEXT NOT NULL, + operation_kind TEXT NOT NULL CHECK (operation_kind IN ('workspace_create', 'existing_workspace')), + workspace_id TEXT NOT NULL UNIQUE, + key_id TEXT NOT NULL UNIQUE, + private_material_ref TEXT NOT NULL UNIQUE, + revision INTEGER NOT NULL CHECK (revision >= 1), + actor_account_id TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'completed')), + created_at TEXT NOT NULL, + completed_at TEXT + ); +CREATE TABLE workspace_signing_identity_audit ( + event_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + key_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('provisioned')), + revision INTEGER NOT NULL CHECK (revision >= 1), + public_key_fingerprint TEXT NOT NULL, + actor_account_id TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE INDEX workspace_signing_identity_audit_workspace_idx + ON workspace_signing_identity_audit(workspace_id, created_at DESC); +CREATE TABLE workspace_memory_documents ( + workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + body_md TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE TABLE workspace_memory_settings ( + workspace_id TEXT PRIMARY KEY NOT NULL, + settings_revision INTEGER NOT NULL CHECK(settings_revision >= 1), + language TEXT NOT NULL CHECK(length(trim(language)) > 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE "workspace_resource_key_counters" ( + workspace_id TEXT NOT NULL, + resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')), + next_sequence INTEGER NOT NULL CHECK (next_sequence > 0), + PRIMARY KEY (workspace_id, resource_kind), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +CREATE TABLE "workspace_resource_keys" ( + workspace_id TEXT NOT NULL, + resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')), + resource_id TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK (sequence > 0), + resource_key TEXT NOT NULL, + allocated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, resource_kind, resource_id), + UNIQUE (workspace_id, resource_kind, sequence), + UNIQUE (workspace_id, resource_key), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +CREATE TABLE workspace_worker_retention_policies ( + workspace_id TEXT PRIMARY KEY, policy_id TEXT NOT NULL, revision INTEGER NOT NULL, updated_at TEXT NOT NULL, + FOREIGN KEY(workspace_id,policy_id,revision) REFERENCES workspace_worker_retention_policy_revisions(workspace_id,policy_id,revision), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE); +CREATE TABLE workspace_worker_retention_policy_revisions ( + workspace_id TEXT NOT NULL, policy_id TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>0), + session_disposition TEXT NOT NULL CHECK(session_disposition IN ('archive','purge')), + metadata_disposition TEXT NOT NULL CHECK(metadata_disposition IN ('tombstone','purge')), + archive_retention_kind TEXT NOT NULL CHECK(archive_retention_kind IN ('forever','for_seconds')), + archive_retention_seconds INTEGER, + diagnostics_disposition TEXT NOT NULL CHECK(diagnostics_disposition IN ('purge','retain')), + diagnostics_retention_seconds INTEGER, created_at TEXT NOT NULL, + PRIMARY KEY(workspace_id,policy_id,revision), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE); +CREATE TABLE "workspaces" ( + workspace_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + state TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + owner_account_id TEXT NOT NULL, + FOREIGN KEY (owner_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT + ); +CREATE INDEX idx_api_tokens_token_hash ON api_tokens(token_hash); +CREATE INDEX idx_artifacts_workspace_created + ON artifacts(workspace_id, created_at DESC); +CREATE INDEX idx_browser_sessions_token_hash ON browser_sessions(token_hash); +CREATE INDEX idx_device_login_user_code ON device_login_flows(user_code); +CREATE INDEX idx_objective_events_workspace_created + ON objective_events(workspace_id, created_at DESC); +CREATE INDEX idx_objective_resources_workspace_objective + ON objective_resources(workspace_id, objective_id); +CREATE INDEX idx_objective_ticket_links_workspace_objective + ON objective_ticket_links(workspace_id, objective_id); +CREATE INDEX idx_objective_ticket_links_workspace_ticket + ON objective_ticket_links(workspace_id, ticket_id); +CREATE INDEX idx_objectives_workspace_updated + ON objectives(workspace_id, updated_at DESC); +CREATE INDEX idx_repository_secret_audit_workspace_created + ON repository_secret_audit_events(workspace_id, created_at, event_id); +CREATE INDEX idx_repository_ssh_credentials_workspace_status + ON repository_ssh_credentials(workspace_id, status, credential_id); +CREATE INDEX idx_repository_ssh_host_trusts_workspace_host + ON repository_ssh_host_trusts(workspace_id, hostname, port); +CREATE INDEX idx_ticket_assignment_operations_ticket + ON ticket_assignment_operations(workspace_id, ticket_id, role, created_at DESC); +CREATE INDEX idx_ticket_worker_assignment_events_ticket + ON ticket_worker_assignment_events(workspace_id, ticket_id, role, created_at DESC); +CREATE INDEX idx_ticket_worker_assignments_principal + ON ticket_worker_assignments(workspace_id, role, principal_kind, principal_id, runtime_id, worker_id, assigned_at DESC); +CREATE INDEX idx_ticket_worker_assignments_ticket + ON ticket_worker_assignments(workspace_id, ticket_id, role, assigned_at DESC); +CREATE INDEX idx_workspace_runtime_bindings_workspace + ON workspace_runtime_bindings(workspace_id, revoked_at, runtime_id); +CREATE INDEX idx_typed_ticket_relations_workspace_target + ON typed_ticket_relations(workspace_id, target, at DESC); +CREATE INDEX idx_typed_tickets_workspace_state_updated + ON typed_tickets(workspace_id, workflow_state, updated_at DESC, ticket_id); +CREATE INDEX idx_typed_tickets_workspace_updated + ON typed_tickets(workspace_id, updated_at DESC, ticket_id); +CREATE INDEX idx_workdir_registry_workspace_updated + ON workdir_registry(workspace_id, updated_at DESC); +CREATE INDEX idx_external_workdir_grants_status_expiry + ON external_workdir_grants(workspace_id, status, expires_at); +CREATE UNIQUE INDEX idx_workdir_removal_operations_one_pending + ON workdir_removal_operations(workspace_id, workdir_id) + WHERE state = 'pending'; +CREATE INDEX idx_workdir_removal_operations_recovery + ON workdir_removal_operations(workspace_id, state, retryable, updated_at); +CREATE INDEX idx_workdir_removal_operations_workdir + ON workdir_removal_operations(workspace_id, workdir_id, created_at DESC); +CREATE INDEX idx_worker_mutation_source_proof_jtis_expiry + ON worker_mutation_source_proof_jtis(expires_at); +CREATE UNIQUE INDEX idx_worker_registry_workspace_runtime_worker + ON worker_registry(workspace_id, runtime_id, worker_id); +CREATE INDEX idx_workspace_config_entries_prefix + ON workspace_config_entries(workspace_id, path); +CREATE INDEX idx_workspace_resource_keys_reverse + ON workspace_resource_keys(workspace_id, resource_kind, resource_key); +CREATE INDEX repositories_workspace_provider_idx + ON repositories(workspace_id, provider); +CREATE INDEX ticket_current_principal_idx + ON ticket_current_worker_assignments(workspace_id, principal_kind, principal_id, runtime_id, worker_id); +CREATE UNIQUE INDEX ticket_current_singleton_role_idx + ON ticket_current_worker_assignments(workspace_id, ticket_id, role) + WHERE role IN ('orchestrator', 'worker'); +CREATE INDEX typed_ticket_events_workspace_kind_ticket + ON typed_ticket_events(workspace_id, kind, ticket_id, event_index); +CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id + ON worker_workdir_attachment_reservations(workspace_id, reservation_id); +CREATE INDEX worker_control_grants_controller + ON worker_control_grants( + workspace_id, controller_runtime_id, controller_worker_id, revoked_at + ); +CREATE INDEX worker_control_grants_subject + ON worker_control_grants( + workspace_id, subject_runtime_id, subject_worker_id, revoked_at + ); +CREATE INDEX worker_create_reservations_worker + ON worker_create_reservations(workspace_id, worker_id); +CREATE INDEX worker_registry_runtime + ON worker_registry(workspace_id, runtime_id, worker_id); +CREATE INDEX worker_removal_operations_worker_idx ON worker_removal_operations(workspace_id,runtime_id,worker_id,created_at); +CREATE UNIQUE INDEX worker_workdir_links_active_workdir_unique + ON worker_workdir_links(workspace_id, workdir_id) + WHERE unlinked_at IS NULL; +CREATE UNIQUE INDEX worker_workdir_links_active_alias_unique + ON worker_workdir_links(workspace_id, runtime_id, worker_id, alias) + WHERE unlinked_at IS NULL; +CREATE INDEX worker_workdir_links_workdir + ON worker_workdir_links(workspace_id, workdir_id); +CREATE TABLE runtime_removal_operations ( + operation_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + expected_binding_revision INTEGER NOT NULL, + config_revision INTEGER NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'cleanup_pending', 'succeeded', 'failed')), + failure_category TEXT, + binding_removed INTEGER NOT NULL CHECK (binding_removed IN (0, 1)), + runtime_registration_removed INTEGER CHECK (runtime_registration_removed IS NULL OR runtime_registration_removed IN (0, 1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); + +CREATE UNIQUE INDEX runtime_removal_operations_one_active_runtime +ON runtime_removal_operations(runtime_id) +WHERE state IN ('pending', 'cleanup_pending'); + +CREATE INDEX runtime_removal_operations_workspace_state +ON runtime_removal_operations(workspace_id, state, updated_at); + +CREATE TRIGGER runtime_binding_insert_blocked_by_removal +BEFORE INSERT ON workspace_runtime_bindings +FOR EACH ROW +WHEN EXISTS ( + SELECT 1 + FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN + SELECT RAISE(ABORT, 'runtime_removal_in_progress'); +END; + +CREATE TRIGGER runtime_binding_update_blocked_by_removal +BEFORE UPDATE ON workspace_runtime_bindings +FOR EACH ROW +WHEN EXISTS ( + SELECT 1 + FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN + SELECT RAISE(ABORT, 'runtime_removal_in_progress'); +END; + +CREATE TRIGGER worker_registry_insert_blocked_by_runtime_removal +BEFORE INSERT ON worker_registry FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER worker_registry_update_blocked_by_runtime_removal +BEFORE UPDATE ON worker_registry FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER workdir_registry_insert_blocked_by_runtime_removal +BEFORE INSERT ON workdir_registry FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER workdir_registry_update_blocked_by_runtime_removal +BEFORE UPDATE ON workdir_registry FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER worker_assignment_insert_blocked_by_runtime_removal +BEFORE INSERT ON ticket_current_worker_assignments FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER worker_assignment_update_blocked_by_runtime_removal +BEFORE UPDATE ON ticket_current_worker_assignments FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER workdir_attachment_insert_blocked_by_runtime_removal +BEFORE INSERT ON worker_workdir_links FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER workdir_attachment_update_blocked_by_runtime_removal +BEFORE UPDATE ON worker_workdir_links FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER worker_create_insert_blocked_by_runtime_removal +BEFORE INSERT ON worker_create_reservations FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER worker_create_update_blocked_by_runtime_removal +BEFORE UPDATE ON worker_create_reservations FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER workdir_create_insert_blocked_by_runtime_removal +BEFORE INSERT ON workdir_create_operations FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.resolved_runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER workdir_create_update_blocked_by_runtime_removal +BEFORE UPDATE ON workdir_create_operations FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.resolved_runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER worker_removal_insert_blocked_by_runtime_removal +BEFORE INSERT ON worker_removal_operations FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER worker_removal_update_blocked_by_runtime_removal +BEFORE UPDATE ON worker_removal_operations FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER workdir_removal_insert_blocked_by_runtime_removal +BEFORE INSERT ON workdir_removal_operations FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER workdir_removal_update_blocked_by_runtime_removal +BEFORE UPDATE ON workdir_removal_operations FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TABLE workspace_deletion_operations ( + operation_id TEXT PRIMARY KEY, + request_fingerprint TEXT NOT NULL, + workspace_id TEXT NOT NULL, + workspace_display_name TEXT NOT NULL, + workspace_revision TEXT NOT NULL, + owner_account_id TEXT NOT NULL, + actor_account_id TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('queued', 'running', 'blocked', 'failed', 'succeeded')), + resource_counts_json TEXT NOT NULL, + child_operation_ids_json TEXT NOT NULL, + blockers_json TEXT NOT NULL, + failure_category TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + FOREIGN KEY(owner_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT, + FOREIGN KEY(actor_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT +); +CREATE INDEX workspace_deletion_operations_workspace_recent + ON workspace_deletion_operations(workspace_id, created_at DESC); + +CREATE TRIGGER seed_worker_retention_policy_after_workspace_insert AFTER INSERT ON workspaces BEGIN + INSERT INTO workspace_worker_retention_policy_revisions + (workspace_id,policy_id,revision,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,created_at) + VALUES(NEW.workspace_id,'workspace-default-conservative',1,'archive','tombstone','forever',NULL,'purge',NULL,NEW.created_at); + INSERT INTO workspace_worker_retention_policies(workspace_id,policy_id,revision,updated_at) + VALUES(NEW.workspace_id,'workspace-default-conservative',1,NEW.created_at); + END; +CREATE TRIGGER ticket_assignment_operations_validate_insert + BEFORE INSERT ON ticket_assignment_operations + WHEN NOT EXISTS ( + SELECT 1 FROM typed_tickets AS ticket + WHERE ticket.workspace_id = NEW.workspace_id AND ticket.ticket_id = NEW.ticket_id + ) + BEGIN + SELECT RAISE(ABORT, 'Ticket assignment operation must reference a Ticket in the same Workspace'); + END; +CREATE TRIGGER ticket_assignment_ticket_parent_tombstone + BEFORE DELETE ON typed_tickets + WHEN EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = OLD.workspace_id + AND assignment.ticket_id = OLD.ticket_id + ) + BEGIN + INSERT OR IGNORE INTO ticket_assignment_ticket_tombstones ( + workspace_id, ticket_id, deleted_at + ) VALUES (OLD.workspace_id, OLD.ticket_id, CURRENT_TIMESTAMP); + END; +CREATE TRIGGER ticket_assignment_user_principal_insert + BEFORE INSERT ON ticket_current_worker_assignments + WHEN NEW.principal_kind = 'user' + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM accounts + WHERE account_id = NEW.principal_id AND kind = 'user' + ) THEN RAISE(ABORT, 'Ticket assignment user principal is not a valid Account user') END; + END; +CREATE TRIGGER ticket_assignment_user_principal_update + BEFORE UPDATE ON ticket_current_worker_assignments + WHEN NEW.principal_kind = 'user' + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM accounts + WHERE account_id = NEW.principal_id AND kind = 'user' + ) THEN RAISE(ABORT, 'Ticket assignment user principal is not a valid Account user') END; + END; +CREATE TRIGGER ticket_assignment_worker_parent_tombstone_delete + BEFORE DELETE ON worker_registry + WHEN EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = OLD.workspace_id + AND assignment.principal_kind = 'worker' + AND assignment.runtime_id = OLD.runtime_id + AND assignment.worker_id = OLD.worker_id + ) + BEGIN + INSERT OR IGNORE INTO ticket_assignment_worker_tombstones ( + workspace_id, runtime_id, worker_id, deleted_at + ) VALUES (OLD.workspace_id, OLD.runtime_id, OLD.worker_id, CURRENT_TIMESTAMP); + END; +CREATE TRIGGER ticket_assignment_worker_parent_tombstone_move + BEFORE UPDATE OF runtime_id ON worker_registry + WHEN OLD.runtime_id != NEW.runtime_id + AND EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = OLD.workspace_id + AND assignment.principal_kind = 'worker' + AND assignment.runtime_id = OLD.runtime_id + AND assignment.worker_id = OLD.worker_id + ) + BEGIN + INSERT OR IGNORE INTO ticket_assignment_worker_tombstones ( + workspace_id, runtime_id, worker_id, deleted_at + ) VALUES (OLD.workspace_id, OLD.runtime_id, OLD.worker_id, CURRENT_TIMESTAMP); + END; +CREATE TRIGGER ticket_assignment_workspace_agent_insert + BEFORE INSERT ON ticket_current_worker_assignments + WHEN NEW.principal_kind = 'workspace_agent' + BEGIN + SELECT CASE WHEN NEW.principal_id != 'workspace-orchestrator' + THEN RAISE(ABORT, 'Ticket assignment Workspace agent is not registered') END; + END; +CREATE TRIGGER ticket_assignment_workspace_agent_update + BEFORE UPDATE ON ticket_current_worker_assignments + WHEN NEW.principal_kind = 'workspace_agent' + BEGIN + SELECT CASE WHEN NEW.principal_id != 'workspace-orchestrator' + THEN RAISE(ABORT, 'Ticket assignment Workspace agent is not registered') END; + END; +CREATE TRIGGER ticket_worker_assignment_events_validate_insert + BEFORE INSERT ON ticket_worker_assignment_events + WHEN (NEW.assignment_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = NEW.workspace_id + AND assignment.ticket_id = NEW.ticket_id + AND assignment.role = NEW.role + AND assignment.assignment_id = NEW.assignment_id + )) + OR (NEW.previous_assignment_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = NEW.workspace_id + AND assignment.ticket_id = NEW.ticket_id + AND assignment.role = NEW.role + AND assignment.assignment_id = NEW.previous_assignment_id + )) + BEGIN + SELECT RAISE(ABORT, 'Ticket assignment event must reference the same Ticket role'); + END; +CREATE TRIGGER ticket_worker_assignments_validate_insert + BEFORE INSERT ON ticket_worker_assignments + WHEN NOT EXISTS ( + SELECT 1 FROM typed_tickets AS ticket + WHERE ticket.workspace_id = NEW.workspace_id AND ticket.ticket_id = NEW.ticket_id + ) + OR (NEW.principal_kind = 'worker' AND NOT EXISTS ( + SELECT 1 FROM worker_registry AS worker + WHERE worker.workspace_id = NEW.workspace_id + AND worker.runtime_id = NEW.runtime_id + AND worker.worker_id = NEW.worker_id + )) + OR (NEW.principal_kind = 'user' AND NOT EXISTS ( + SELECT 1 FROM accounts + WHERE account_id = NEW.principal_id AND kind = 'user' + )) + OR (NEW.principal_kind = 'workspace_agent' AND NEW.principal_id != 'workspace-orchestrator') + BEGIN + SELECT RAISE(ABORT, 'Ticket assignment principal is not valid in this Workspace'); + END; +CREATE TRIGGER ticket_worker_assignments_validate_update + BEFORE UPDATE OF workspace_id, ticket_id, principal_kind, principal_id, runtime_id, worker_id + ON ticket_worker_assignments + WHEN NOT EXISTS ( + SELECT 1 FROM typed_tickets AS ticket + WHERE ticket.workspace_id = NEW.workspace_id AND ticket.ticket_id = NEW.ticket_id + ) + OR (NEW.principal_kind = 'worker' AND NOT EXISTS ( + SELECT 1 FROM worker_registry AS worker + WHERE worker.workspace_id = NEW.workspace_id + AND worker.runtime_id = NEW.runtime_id + AND worker.worker_id = NEW.worker_id + )) + OR (NEW.principal_kind = 'user' AND NOT EXISTS ( + SELECT 1 FROM accounts + WHERE account_id = NEW.principal_id AND kind = 'user' + )) + OR (NEW.principal_kind = 'workspace_agent' AND NEW.principal_id != 'workspace-orchestrator') + BEGIN + SELECT RAISE(ABORT, 'Ticket assignment principal is not valid in this Workspace'); + END; + +CREATE TABLE workspace_config_grants ( + workspace_id TEXT NOT NULL, + grant_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + access TEXT NOT NULL CHECK(access IN ('read_only', 'read_write')), + revoked INTEGER NOT NULL CHECK(revoked IN (0, 1)), + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY(workspace_id, grant_id), + UNIQUE(workspace_id, workdir_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +CREATE UNIQUE INDEX workspace_config_grants_active_worker + ON workspace_config_grants(workspace_id, runtime_id, worker_id) WHERE revoked = 0; + +CREATE TABLE workspace_drive_grants ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + grant_id INTEGER PRIMARY KEY AUTOINCREMENT, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + access TEXT NOT NULL CHECK(access IN ('read_only', 'read_write')), + revoked INTEGER NOT NULL DEFAULT 0 CHECK(revoked IN (0, 1)), + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + revoked_by TEXT, + revoked_at TEXT, + CHECK((revoked=0 AND revoked_by IS NULL AND revoked_at IS NULL) + OR (revoked=1 AND revoked_by IS NOT NULL AND revoked_at IS NOT NULL)) +); +CREATE UNIQUE INDEX workspace_drive_grants_active_worker + ON workspace_drive_grants(workspace_id, runtime_id, worker_id) WHERE revoked=0; +-- Retain grant audit across Worker deletion, but never carry its authority into +-- a later lifetime reusing the same Runtime/Worker identity. +CREATE TRIGGER workspace_drive_grants_worker_deleted BEFORE DELETE ON worker_registry BEGIN + UPDATE workspace_drive_grants SET revoked=1, revoked_by='server:worker-removal', + revoked_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE workspace_id=OLD.workspace_id AND runtime_id=OLD.runtime_id + AND worker_id=OLD.worker_id AND revoked=0; +END; diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index b2efc5bb9..8a5e3aa2c 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -564,7 +564,7 @@ pub enum WorkerSpawnIntent { job_id: String, attempt_id: String, purpose: String, - input_revision: String, + input_digest: String, subjektiv_consolidation: bool, }, TicketRole { @@ -1723,8 +1723,8 @@ impl RuntimeRegistry { "workspace_prompt_projection_notification_failed", HostDiagnosticSeverity::Warning, format!( - "runtime '{}' rejected Workspace Prompt projection revision {}: {message}", - runtime.runtime_id(), projection.config_revision + "runtime '{}' rejected Workspace Prompt projection digest {}: {message}", + runtime.runtime_id(), projection.projection_digest ), ) }) @@ -3614,22 +3614,33 @@ impl WorkspaceRuntimeAuthorization { "Workspace Runtime binding is missing its Workspace key".to_string(), ) })?; - let trust_generation = binding.workspace_key_generation.ok_or_else(|| { + let workspace_public_key_fingerprint = binding + .workspace_public_key_fingerprint + .as_deref() + .ok_or_else(|| { + diagnostic( + "workspace_runtime_authorization_invalid", + HostDiagnosticSeverity::Error, + "Workspace Runtime binding is missing its Workspace public key fingerprint" + .to_string(), + ) + })?; + let workspace_trust_id = binding.workspace_trust_id.as_deref().ok_or_else(|| { diagnostic( "workspace_runtime_authorization_invalid", HostDiagnosticSeverity::Error, - "Workspace Runtime binding is missing its trust generation".to_string(), + "Workspace Runtime binding is missing its Workspace trust ID".to_string(), ) })?; if identity.state != "active" || identity.key_id != workspace_key_id - || identity.revision != trust_generation + || identity.public_key_fingerprint.as_deref() != Some(workspace_public_key_fingerprint) || !self .store .workspace_runtime_verification_matches( &binding, - identity.revision, - trust_generation, + workspace_public_key_fingerprint, + workspace_trust_id, ) .map_err(|error| { diagnostic( @@ -3650,9 +3661,9 @@ impl WorkspaceRuntimeAuthorization { issuer: self.backend_url.clone(), issuer_workspace_id: binding.workspace_id.clone(), issuer_key_id: identity.key_id, - issuer_identity_revision: identity.revision, - trust_generation, - binding_revision: binding.binding_revision, + issuer_public_key_fingerprint: workspace_public_key_fingerprint.to_string(), + trust_id: workspace_trust_id.to_string(), + binding_id: binding.binding_id.clone(), runtime_id: binding.runtime_id.clone(), worker_id: worker_id.map(str::to_string), operation: operation.to_string(), @@ -5726,13 +5737,13 @@ fn runtime_create_worker_request( WorkerSpawnIntent::BackendJob { job_id, attempt_id, - input_revision, + input_digest, subjektiv_consolidation, .. } => Some(worker_runtime::catalog::BackendJobExecutionBinding { job_id: job_id.clone(), attempt_id: attempt_id.clone(), - input_revision: Some(input_revision.clone()), + input_digest: Some(input_digest.clone()), subjektiv_consolidation: *subjektiv_consolidation, }), _ => None, @@ -5824,7 +5835,6 @@ fn builtin_profile_config_bundle( metadata: ConfigBundleMetadata { id, digest: String::new(), - revision: "workspace-runtime-v0".to_string(), workspace_id: workspace_id.to_string(), created_at: "runtime-generated".to_string(), provenance: ConfigBundleProvenance { @@ -6771,7 +6781,6 @@ mod tests { fn test_memory_settings() -> manifest::WorkspaceMemorySettingsSnapshot { manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "workspace-test".to_string(), - settings_revision: 1, language: "English".to_string(), } } @@ -7040,7 +7049,6 @@ mod tests { metadata: worker_runtime::config_bundle::ConfigBundleMetadata { id: "bundle-1".to_string(), digest: String::new(), - revision: "rev-1".to_string(), workspace_id: "local:test".to_string(), created_at: "2026-06-26T00:00:00Z".to_string(), provenance: worker_runtime::config_bundle::ConfigBundleProvenance { @@ -7137,10 +7145,9 @@ mod tests { let observed = Arc::new(Mutex::new(Vec::new())); let expected = RepositoryRefObservation { repository_id: "repository-1".to_string(), - source_revision: 7, source_fingerprint: "sha256:source".to_string(), selector: "refs/heads/published".to_string(), - revision_ref: "0123456789012345678901234567890123456789".to_string(), + resolved_ref: "0123456789012345678901234567890123456789".to_string(), observed_at_epoch_seconds: 42, }; let runtime = EmbeddedWorkerRuntime::new_memory_with_execution_backend( @@ -7159,7 +7166,6 @@ mod tests { kind: server_api::RepositorySourceKind::LocalPath, uri: "/provider/repository.git".to_string(), }, - source_revision: 7, source_fingerprint: "sha256:source".to_string(), selector: None, }, @@ -7259,7 +7265,7 @@ mod tests { runtime_id: String, host_id: String, workers: Vec, - observed_prompt_revisions: Arc>>, + observed_prompt_digests: Arc>>, } impl FixtureRuntime { @@ -7295,7 +7301,7 @@ mod tests { workdir_attachments: Vec::new(), diagnostics: Vec::new(), }], - observed_prompt_revisions: Arc::new(Mutex::new(Vec::new())), + observed_prompt_digests: Arc::new(Mutex::new(Vec::new())), } } } @@ -7309,10 +7315,10 @@ mod tests { &self, projection: worker::WorkspacePromptProjection, ) -> Result<(), String> { - self.observed_prompt_revisions + self.observed_prompt_digests .lock() .map_err(|_| "prompt projection observations poisoned".to_string())? - .push(projection.config_revision); + .push(projection.projection_digest); Ok(()) } @@ -7404,14 +7410,14 @@ mod tests { fn registry_gate_rejects_cached_runtime_immediately_after_binding_revocation() { let remote = FixtureRuntime::with_worker("runtime-a", "host-a", "worker-a", "worker from runtime a"); - let remote_observed = remote.observed_prompt_revisions.clone(); + let remote_observed = remote.observed_prompt_digests.clone(); let embedded = FixtureRuntime::with_worker( EMBEDDED_RUNTIME_ID, "embedded-host", "embedded-worker", "embedded worker", ); - let embedded_observed = embedded.observed_prompt_revisions.clone(); + let embedded_observed = embedded.observed_prompt_digests.clone(); let registry = RuntimeRegistry::new(vec![Arc::new(remote), Arc::new(embedded)]); let active = Arc::new(Mutex::new(true)); let gate_state = active.clone(); @@ -7437,14 +7443,14 @@ mod tests { "default".to_string(), "workspace prompt".to_string(), )]), - 12, "schema", "toolchain", ) .unwrap(); + let expected_digest = catalog.catalog_digest.clone(); let projection = worker::WorkspacePromptProjection::new( "workspace-a", - "source-12", + "source-content", catalog.catalog_digest.clone(), catalog, ) @@ -7455,28 +7461,28 @@ mod tests { .is_empty() ); assert!(remote_observed.lock().unwrap().is_empty()); - assert_eq!(*embedded_observed.lock().unwrap(), vec![12]); + assert_eq!(*embedded_observed.lock().unwrap(), vec![expected_digest]); } #[test] - fn registry_broadcasts_workspace_prompt_projection_revisions() { + fn registry_broadcasts_workspace_prompt_projection_digests() { let runtime = FixtureRuntime::with_worker("runtime-a", "host-a", "worker-a", "worker from runtime a"); - let observed = runtime.observed_prompt_revisions.clone(); + let observed = runtime.observed_prompt_digests.clone(); let registry = RuntimeRegistry::new(vec![Arc::new(runtime)]); let catalog = worker::EffectivePromptCatalog::new( std::collections::BTreeMap::from([( "default".to_string(), "workspace prompt".to_string(), )]), - 12, "schema", "toolchain", ) .unwrap(); + let expected_digest = catalog.catalog_digest.clone(); let projection = worker::WorkspacePromptProjection::new( "workspace-a", - "source-12", + "source-content", catalog.catalog_digest.clone(), catalog, ) @@ -7485,7 +7491,7 @@ mod tests { let diagnostics = registry.observe_workspace_prompt_projection(projection); assert!(diagnostics.is_empty()); - assert_eq!(*observed.lock().unwrap(), vec![12]); + assert_eq!(*observed.lock().unwrap(), vec![expected_digest]); } #[test] diff --git a/crates/workspace-server/src/hosts/tests/restore_auth_tests.rs b/crates/workspace-server/src/hosts/tests/restore_auth_tests.rs index 1782abb5f..82a9db0b9 100644 --- a/crates/workspace-server/src/hosts/tests/restore_auth_tests.rs +++ b/crates/workspace-server/src/hosts/tests/restore_auth_tests.rs @@ -22,17 +22,20 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_ let runtime_identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap(); let signer = RuntimeVerificationSigner::from_identity(&runtime_identity).unwrap(); let now = Utc::now().timestamp(); + let workspace_public_key_fingerprint = + crate::workspace_signing_identity::public_key_fingerprint(&workspace_identity.public_key) + .unwrap(); + let binding_id = "binding-restore-test".to_string(); let verifications = Arc::new(InMemoryWorkspaceRuntimeVerificationAuthority::default()); verifications .record(WorkspaceRuntimeVerificationRecord { workspace_id: "workspace-test".into(), runtime_id: "runtime-test".into(), - binding_revision: 1, + binding_id: binding_id.clone(), + workspace_trust_id: "trust-restore-test".to_string(), workspace_key_id: "workspace-key".into(), - workspace_identity_revision: 1, - workspace_trust_generation: 1, + workspace_public_key_fingerprint: workspace_public_key_fingerprint.clone(), runtime_public_key_fingerprint: signer.public_key_fingerprint().to_owned(), - runtime_identity_revision: 1, verified_at: now, }) .unwrap(); @@ -52,8 +55,7 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_ .map(|byte| format!("{byte:02x}")) .collect::() ), - identity_revision: 1, - trust_generation: 1, + trust_id: "trust-restore-test".to_string(), state: WorkspaceIssuerTrustState::Active, registered_at_unix: now, updated_at_unix: now, @@ -73,7 +75,7 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_ ); let worker_id = EmbeddedWorkerId::from_legacy_u64(1).to_string(); let body = serde_json::to_vec(&runtime_api::WorkerRestoreCoordinationRequest { - expected_observation_token: "observed-generation".into(), + expected_observation_token: "observed-runtime-state".into(), request_id: "restore-request".into(), preparation: None, }) @@ -90,14 +92,35 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_ "wrong-operation", "wrong-worker", "wrong-body", + "wrong-key-fingerprint", + "wrong-binding-id", + "wrong-trust-id", ] { let claims = WorkspaceCapabilityClaims { issuer: "https://backend.test".into(), issuer_workspace_id: "workspace-test".into(), issuer_key_id: "workspace-key".into(), - issuer_identity_revision: 1, - trust_generation: 1, - binding_revision: 1, + issuer_public_key_fingerprint: if case == "wrong-key-fingerprint" { + format!( + "sha256:{}", + Sha256::digest(b"another-workspace-key") + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ) + } else { + workspace_public_key_fingerprint.clone() + }, + binding_id: if case == "wrong-binding-id" { + "binding-retired".to_string() + } else { + binding_id.clone() + }, + trust_id: if case == "wrong-trust-id" { + "trust-retired".to_string() + } else { + "trust-restore-test".to_string() + }, runtime_id: "runtime-test".into(), worker_id: if case == "wrong-worker" { Some(EmbeddedWorkerId::from_legacy_u64(2).to_string()) @@ -153,22 +176,36 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_ } else { assert_eq!( status, - StatusCode::UNAUTHORIZED, + if case == "wrong-binding-id" { + StatusCode::FORBIDDEN + } else { + StatusCode::UNAUTHORIZED + }, "{path} {case}: {}", error.error.message ); - assert_eq!(error.error.code, "unauthorized"); + assert_eq!( + error.error.code, + if case == "wrong-binding-id" { + "workspace_runtime_verification_stale" + } else { + "unauthorized" + } + ); let expected = match case { - "wrong-operation" => "does not authorize this operation", - "wrong-worker" => "targets another Worker", - "wrong-body" => "does not bind this request body", + "wrong-operation" => Some("does not authorize this operation"), + "wrong-worker" => Some("targets another Worker"), + "wrong-body" => Some("does not bind this request body"), + "wrong-key-fingerprint" | "wrong-binding-id" | "wrong-trust-id" => None, _ => unreachable!(), }; - assert!( - error.error.message.contains(expected), - "{path} {case}: {}", - error.error.message - ); + if let Some(expected) = expected { + assert!( + error.error.message.contains(expected), + "{path} {case}: {}", + error.error.message + ); + } } } } diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index 886ec21ef..bea40661c 100644 --- a/crates/workspace-server/src/latest_schema.sql +++ b/crates/workspace-server/src/latest_schema.sql @@ -114,15 +114,14 @@ CREATE TABLE device_login_flows ( approved_at TEXT, consumed_at TEXT ); -CREATE TABLE flow_source_revisions ( +CREATE TABLE flow_source_contents ( workspace_id TEXT NOT NULL, flow_id TEXT NOT NULL, - revision INTEGER NOT NULL CHECK (revision > 0), content TEXT NOT NULL, content_digest TEXT NOT NULL, definition_json TEXT NOT NULL, created_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, flow_id, revision), + PRIMARY KEY (workspace_id, flow_id, content_digest), FOREIGN KEY (workspace_id, flow_id) REFERENCES flow_sources(workspace_id, flow_id) ON DELETE CASCADE ); @@ -134,7 +133,6 @@ CREATE TABLE flow_sources ( path TEXT NOT NULL, content TEXT NOT NULL, content_digest TEXT NOT NULL, - revision INTEGER NOT NULL CHECK (revision > 0), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (workspace_id, flow_id), @@ -232,7 +230,6 @@ CREATE TABLE "repositories" ( updated_at TEXT NOT NULL, source_kind TEXT NOT NULL, source_uri TEXT NOT NULL, - source_revision INTEGER NOT NULL DEFAULT 1, source_fingerprint TEXT NOT NULL, observed_status TEXT NOT NULL DEFAULT 'unverified', observed_at TEXT, @@ -245,7 +242,7 @@ CREATE TABLE repository_secret_audit_events ( event_id TEXT NOT NULL, kind TEXT NOT NULL, resource_id TEXT NOT NULL, - revision INTEGER NOT NULL CHECK (revision >= 1), + operation_id TEXT NOT NULL CHECK (length(operation_id) > 0), actor_account_id TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (workspace_id, event_id), @@ -257,19 +254,20 @@ CREATE TABLE repository_secret_operations ( request_fingerprint TEXT NOT NULL, resource_kind TEXT NOT NULL CHECK (resource_kind IN ('credential', 'host_trust')), resource_id TEXT NOT NULL, - result_revision INTEGER NOT NULL CHECK (result_revision >= 1), + result_operation_id TEXT NOT NULL CHECK (length(result_operation_id) > 0), created_at TEXT NOT NULL, PRIMARY KEY (workspace_id, operation_id), FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ); -CREATE TABLE repository_ssh_credential_revisions ( +CREATE TABLE repository_ssh_credential_keys ( workspace_id TEXT NOT NULL, credential_id TEXT NOT NULL, - revision INTEGER NOT NULL CHECK (revision >= 1), + operation_id TEXT NOT NULL CHECK (length(operation_id) > 0), public_key_algorithm TEXT NOT NULL, public_key_fingerprint TEXT NOT NULL, created_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, credential_id, revision), + PRIMARY KEY (workspace_id, credential_id, operation_id), + UNIQUE (workspace_id, credential_id, operation_id, public_key_fingerprint), FOREIGN KEY (workspace_id, credential_id) REFERENCES repository_ssh_credentials(workspace_id, credential_id) ON DELETE CASCADE @@ -280,24 +278,24 @@ CREATE TABLE repository_ssh_credentials ( name TEXT NOT NULL, public_key_algorithm TEXT NOT NULL, public_key_fingerprint TEXT NOT NULL, - current_revision INTEGER NOT NULL CHECK (current_revision >= 1), + current_operation_id TEXT NOT NULL CHECK (length(current_operation_id) > 0), status TEXT NOT NULL CHECK (status IN ('active', 'revoked')), created_at TEXT NOT NULL, rotated_at TEXT, PRIMARY KEY (workspace_id, credential_id), FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ); -CREATE TABLE repository_ssh_host_trust_revisions ( +CREATE TABLE repository_ssh_host_trust_keys ( workspace_id TEXT NOT NULL, host_trust_id TEXT NOT NULL, - revision INTEGER NOT NULL CHECK (revision >= 1), + operation_id TEXT NOT NULL CHECK (length(operation_id) > 0), hostname TEXT NOT NULL, port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535), key_algorithm TEXT NOT NULL, host_key TEXT NOT NULL, fingerprint TEXT NOT NULL, created_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, host_trust_id, revision), + PRIMARY KEY (workspace_id, host_trust_id, operation_id), FOREIGN KEY (workspace_id, host_trust_id) REFERENCES repository_ssh_host_trusts(workspace_id, host_trust_id) ON DELETE CASCADE @@ -310,24 +308,24 @@ CREATE TABLE repository_ssh_host_trusts ( key_algorithm TEXT NOT NULL, host_key TEXT NOT NULL, fingerprint TEXT NOT NULL, - current_revision INTEGER NOT NULL CHECK (current_revision >= 1), + current_operation_id TEXT NOT NULL CHECK (length(current_operation_id) > 0), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (workspace_id, host_trust_id), FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ); -CREATE TABLE server_secret_versions ( +CREATE TABLE server_secret_objects ( workspace_id TEXT NOT NULL, secret_id TEXT NOT NULL, - revision INTEGER NOT NULL CHECK (revision >= 1), + operation_id TEXT NOT NULL CHECK (length(operation_id) > 0), purpose TEXT NOT NULL CHECK (purpose IN ('private_key', 'passphrase')), encryption_algorithm TEXT NOT NULL CHECK (encryption_algorithm = 'aes-256-gcm-v1'), nonce BLOB NOT NULL CHECK (length(nonce) = 12), ciphertext BLOB NOT NULL, created_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, secret_id, revision, purpose), - FOREIGN KEY (workspace_id, secret_id, revision) - REFERENCES repository_ssh_credential_revisions(workspace_id, credential_id, revision) + PRIMARY KEY (workspace_id, secret_id, operation_id, purpose), + FOREIGN KEY (workspace_id, secret_id, operation_id) + REFERENCES repository_ssh_credential_keys(workspace_id, credential_id, operation_id) ON DELETE CASCADE ); CREATE TABLE ticket_assignment_operations ( @@ -443,11 +441,12 @@ CREATE TABLE workspace_runtime_bindings ( base_url TEXT NOT NULL, public_key TEXT NOT NULL, public_key_fingerprint TEXT NOT NULL, - binding_revision INTEGER NOT NULL DEFAULT 1 CHECK (binding_revision > 0), + binding_id TEXT NOT NULL CHECK (length(binding_id) > 0), state TEXT NOT NULL CHECK (state IN ('configured', 'verified', 'revoked')), authentication_mode TEXT NOT NULL CHECK (authentication_mode IN ('legacy_server_issuer', 'workspace_identity')), workspace_key_id TEXT, - workspace_key_generation INTEGER CHECK (workspace_key_generation > 0), + workspace_public_key_fingerprint TEXT, + workspace_trust_id TEXT CHECK (length(workspace_trust_id) > 0), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revoked_at TEXT, @@ -455,9 +454,9 @@ CREATE TABLE workspace_runtime_bindings ( UNIQUE (workspace_id, public_key_fingerprint), FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT, CHECK ( - (authentication_mode = 'legacy_server_issuer' AND workspace_key_id IS NULL AND workspace_key_generation IS NULL) + (authentication_mode = 'legacy_server_issuer' AND workspace_key_id IS NULL AND workspace_public_key_fingerprint IS NULL AND workspace_trust_id IS NULL) OR - (authentication_mode = 'workspace_identity' AND workspace_key_id IS NOT NULL AND workspace_key_generation IS NOT NULL) + (authentication_mode = 'workspace_identity' AND workspace_key_id IS NOT NULL AND (state != 'verified' OR (workspace_trust_id IS NOT NULL AND workspace_public_key_fingerprint IS NOT NULL))) ), CHECK ( (state = 'revoked' AND revoked_at IS NOT NULL) @@ -468,12 +467,11 @@ CREATE TABLE workspace_runtime_bindings ( CREATE TABLE workspace_runtime_verifications ( workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, - binding_revision INTEGER NOT NULL CHECK(binding_revision > 0), + binding_id TEXT NOT NULL CHECK(length(binding_id) > 0), workspace_key_id TEXT NOT NULL, - workspace_identity_revision INTEGER NOT NULL CHECK(workspace_identity_revision > 0), - workspace_trust_generation INTEGER NOT NULL CHECK(workspace_trust_generation > 0), + workspace_public_key_fingerprint TEXT NOT NULL CHECK(length(workspace_public_key_fingerprint) > 0), + workspace_trust_id TEXT NOT NULL CHECK(length(workspace_trust_id) > 0), runtime_public_key_fingerprint TEXT NOT NULL, - runtime_identity_revision INTEGER NOT NULL CHECK(runtime_identity_revision > 0), challenge_id TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('pending', 'verified', 'failed')), last_outcome TEXT NOT NULL, @@ -494,15 +492,15 @@ CREATE TABLE workspace_runtime_binding_audit ( action TEXT NOT NULL CHECK (action IN ('created', 'replaced', 'reactivated', 'revoked')), old_fingerprint TEXT, new_fingerprint TEXT, - binding_revision INTEGER NOT NULL CHECK (binding_revision > 0), + binding_id TEXT NOT NULL CHECK (length(binding_id) > 0), at TEXT NOT NULL, - PRIMARY KEY (workspace_id, runtime_id, binding_revision), + PRIMARY KEY (workspace_id, runtime_id, binding_id), FOREIGN KEY(workspace_id, runtime_id) REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE RESTRICT, FOREIGN KEY(actor_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT ); CREATE INDEX idx_workspace_runtime_binding_audit_recent - ON workspace_runtime_binding_audit(workspace_id, runtime_id, binding_revision DESC); + ON workspace_runtime_binding_audit(workspace_id, runtime_id, at DESC, binding_id DESC); CREATE TABLE typed_ticket_artifacts ( workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL, PRIMARY KEY (workspace_id, ticket_id, relative_path), @@ -621,13 +619,12 @@ CREATE TABLE workdir_create_operations ( selector TEXT, requested_runtime_id TEXT, resolved_runtime_id TEXT NOT NULL, - config_revision INTEGER NOT NULL, config_projection_digest TEXT NOT NULL, working_directory_id TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('pending', 'succeeded', 'failed')), failure TEXT, created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, source_kind TEXT, source_uri TEXT, source_revision INTEGER, source_fingerprint TEXT, credential_id TEXT, credential_revision INTEGER, host_trust_id TEXT, host_trust_revision INTEGER, repository_access_mode TEXT, + updated_at TEXT NOT NULL, source_kind TEXT, source_uri TEXT, source_fingerprint TEXT, credential_id TEXT, credential_fingerprint TEXT, host_trust_id TEXT, host_trust_fingerprint TEXT, repository_access_mode TEXT, PRIMARY KEY (workspace_id, operation_id), UNIQUE (workspace_id, working_directory_id) ); @@ -637,34 +634,34 @@ CREATE TABLE workdir_create_credential_candidates ( ordinal INTEGER NOT NULL CHECK (ordinal >= 0 AND ordinal < 2), role TEXT NOT NULL CHECK (role IN ('primary', 'workspace_default_fallback')), credential_id TEXT NOT NULL CHECK (length(credential_id) BETWEEN 1 AND 128), - credential_revision INTEGER NOT NULL CHECK (credential_revision > 0), + credential_fingerprint TEXT NOT NULL CHECK (length(credential_fingerprint) > 0), PRIMARY KEY (workspace_id, operation_id, ordinal), + UNIQUE (workspace_id, operation_id, ordinal, credential_id, credential_fingerprint), UNIQUE (workspace_id, operation_id, role), UNIQUE (workspace_id, operation_id, credential_id), FOREIGN KEY (workspace_id, operation_id) REFERENCES workdir_create_operations(workspace_id, operation_id) ON DELETE CASCADE ); -CREATE INDEX idx_workdir_create_credential_candidates_revision +CREATE INDEX idx_workdir_create_credential_candidates_fingerprint ON workdir_create_credential_candidates( - workspace_id, credential_id, credential_revision + workspace_id, credential_id, credential_fingerprint ); -CREATE TABLE workdir_create_credential_revision_retentions ( +CREATE TABLE workdir_create_credential_retentions ( workspace_id TEXT NOT NULL, operation_id TEXT NOT NULL, ordinal INTEGER NOT NULL, credential_id TEXT NOT NULL, - credential_revision INTEGER NOT NULL, + credential_fingerprint TEXT NOT NULL, + credential_operation_id TEXT NOT NULL, PRIMARY KEY (workspace_id, operation_id, ordinal), - FOREIGN KEY (workspace_id, operation_id, ordinal) + FOREIGN KEY (workspace_id, operation_id, ordinal, credential_id, credential_fingerprint) REFERENCES workdir_create_credential_candidates( - workspace_id, operation_id, ordinal + workspace_id, operation_id, ordinal, credential_id, credential_fingerprint ) ON DELETE CASCADE, - FOREIGN KEY (workspace_id, credential_id, credential_revision) - REFERENCES repository_ssh_credential_revisions( - workspace_id, credential_id, revision - ) + FOREIGN KEY (workspace_id, credential_id, credential_operation_id, credential_fingerprint) + REFERENCES repository_ssh_credential_keys(workspace_id, credential_id, operation_id, public_key_fingerprint) ON DELETE RESTRICT ); CREATE TABLE "workdir_registry" ( @@ -734,7 +731,7 @@ CREATE TABLE workdir_removal_operations ( source_actor TEXT NOT NULL, reason TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('pending', 'failed', 'completed')), - attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), + attempt_id TEXT, retryable INTEGER NOT NULL CHECK (retryable IN (0, 1)), disposition TEXT CHECK (disposition IN ('removed', 'retained', 'attention_required')), failure_category TEXT, @@ -774,7 +771,7 @@ CREATE TABLE worker_create_reservations ( create_fingerprint TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('reserved', 'created', 'removed')), created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, request_fingerprint TEXT, memory_settings_revision INTEGER, memory_language TEXT, + updated_at TEXT NOT NULL, request_fingerprint TEXT, memory_language TEXT, singleton_key TEXT, singleton_generation INTEGER CHECK (singleton_generation IS NULL OR singleton_generation > 0), PRIMARY KEY (workspace_id, allocation_key), @@ -810,7 +807,7 @@ CREATE TABLE worker_singleton_owners ( ); CREATE TABLE worker_diagnostics_archives ( operation_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, - worker_id TEXT NOT NULL, policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL, + worker_id TEXT NOT NULL, policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL, committed_at TEXT NOT NULL, expires_at TEXT NOT NULL, FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id), FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE); @@ -846,7 +843,7 @@ CREATE TABLE backend_jobs ( workspace_id TEXT NOT NULL, job_id TEXT NOT NULL, purpose TEXT NOT NULL, - input_revision TEXT NOT NULL, + input_digest TEXT NOT NULL, input_ref TEXT NOT NULL, resource_key TEXT, request_json TEXT NOT NULL, @@ -874,7 +871,7 @@ CREATE TABLE backend_job_attempts ( job_id TEXT NOT NULL, attempt_id TEXT NOT NULL, attempt INTEGER NOT NULL CHECK (attempt > 0 AND attempt <= 3), - input_revision TEXT NOT NULL, + input_digest TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('reserved', 'dispatching', 'dispatched', 'completed', 'failed', 'unknown')), runtime_id TEXT, worker_id TEXT, @@ -955,8 +952,8 @@ CREATE TABLE worker_registry_projection_diagnostics ( CREATE TABLE worker_removal_operations ( operation_id TEXT PRIMARY KEY, plan_id TEXT NOT NULL UNIQUE, input_fingerprint TEXT NOT NULL, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, - worker_revision TEXT NOT NULL, - policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL, + worker_updated_at TEXT NOT NULL, + policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL, session_disposition TEXT NOT NULL, metadata_disposition TEXT NOT NULL, archive_retention_kind TEXT NOT NULL, archive_retention_seconds INTEGER, diagnostics_disposition TEXT NOT NULL, @@ -972,7 +969,7 @@ CREATE TABLE worker_retention_audit_events ( CREATE TABLE worker_session_archives ( archive_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, session_id TEXT NOT NULL, checksum_sha256 TEXT NOT NULL, content_bytes INTEGER NOT NULL, - policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL, operation_id TEXT NOT NULL UNIQUE, + policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL, operation_id TEXT NOT NULL UNIQUE, committed_at TEXT NOT NULL, expires_at TEXT, FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id)); @@ -990,7 +987,7 @@ CREATE INDEX worker_session_archive_observe_grants_controller CREATE TABLE worker_tombstones ( workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, display_name TEXT NOT NULL, profile TEXT, worker_created_at TEXT NOT NULL, removed_at TEXT NOT NULL, - archive_id TEXT, policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL, operation_id TEXT NOT NULL UNIQUE, + archive_id TEXT, policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL, operation_id TEXT NOT NULL UNIQUE, PRIMARY KEY(workspace_id,runtime_id,worker_id), FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, FOREIGN KEY(archive_id) REFERENCES worker_session_archives(archive_id), @@ -1027,21 +1024,19 @@ CREATE TABLE workspace_config_entries ( PRIMARY KEY (workspace_id, path), FOREIGN KEY (workspace_id) REFERENCES workspace_config_trees(workspace_id) ON DELETE CASCADE ); -CREATE TABLE workspace_config_tree_revisions ( +CREATE TABLE workspace_config_tree_history ( workspace_id TEXT NOT NULL, - revision INTEGER NOT NULL, - tree_digest TEXT NOT NULL, + content_digest TEXT NOT NULL, toolchain_fingerprint TEXT NOT NULL, projection_digest TEXT NOT NULL, manifest_json TEXT NOT NULL, created_at TEXT NOT NULL, schema_bundle_json TEXT NOT NULL DEFAULT '{"contributions":[],"source":"{}","fingerprint":""}', - PRIMARY KEY (workspace_id, revision), + PRIMARY KEY (workspace_id, content_digest, toolchain_fingerprint, projection_digest), FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ); CREATE TABLE workspace_config_trees ( workspace_id TEXT PRIMARY KEY, - revision INTEGER NOT NULL CHECK (revision >= 0), - tree_digest TEXT NOT NULL, + content_digest TEXT NOT NULL, schema_version INTEGER NOT NULL, entrypoints_json TEXT NOT NULL, decodal_version TEXT NOT NULL, @@ -1065,7 +1060,6 @@ CREATE TABLE workspace_signing_identities ( public_key TEXT, public_key_fingerprint TEXT, private_material_ref TEXT NOT NULL UNIQUE, - revision INTEGER NOT NULL CHECK (revision >= 1), state TEXT NOT NULL CHECK (state IN ('pending_provisioning', 'active')), created_at TEXT NOT NULL, provisioned_at TEXT, @@ -1084,7 +1078,6 @@ CREATE TABLE workspace_signing_identity_provisioning_operations ( workspace_id TEXT NOT NULL UNIQUE, key_id TEXT NOT NULL UNIQUE, private_material_ref TEXT NOT NULL UNIQUE, - revision INTEGER NOT NULL CHECK (revision >= 1), actor_account_id TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('pending', 'completed')), created_at TEXT NOT NULL, @@ -1095,7 +1088,6 @@ CREATE TABLE workspace_signing_identity_audit ( workspace_id TEXT NOT NULL, key_id TEXT NOT NULL, action TEXT NOT NULL CHECK (action IN ('provisioned')), - revision INTEGER NOT NULL CHECK (revision >= 1), public_key_fingerprint TEXT NOT NULL, actor_account_id TEXT NOT NULL, created_at TEXT NOT NULL, @@ -1111,7 +1103,6 @@ CREATE TABLE workspace_memory_documents ( ); CREATE TABLE workspace_memory_settings ( workspace_id TEXT PRIMARY KEY NOT NULL, - settings_revision INTEGER NOT NULL CHECK(settings_revision >= 1), language TEXT NOT NULL CHECK(length(trim(language)) > 0), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, @@ -1137,18 +1128,18 @@ CREATE TABLE "workspace_resource_keys" ( FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ); CREATE TABLE workspace_worker_retention_policies ( - workspace_id TEXT PRIMARY KEY, policy_id TEXT NOT NULL, revision INTEGER NOT NULL, updated_at TEXT NOT NULL, - FOREIGN KEY(workspace_id,policy_id,revision) REFERENCES workspace_worker_retention_policy_revisions(workspace_id,policy_id,revision), + workspace_id TEXT PRIMARY KEY, policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL, updated_at TEXT NOT NULL, + FOREIGN KEY(workspace_id,policy_id,policy_digest) REFERENCES workspace_worker_retention_policy_snapshots(workspace_id,policy_id,policy_digest), FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE); -CREATE TABLE workspace_worker_retention_policy_revisions ( - workspace_id TEXT NOT NULL, policy_id TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>0), +CREATE TABLE workspace_worker_retention_policy_snapshots ( + workspace_id TEXT NOT NULL, policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL CHECK(length(policy_digest)>0), session_disposition TEXT NOT NULL CHECK(session_disposition IN ('archive','purge')), metadata_disposition TEXT NOT NULL CHECK(metadata_disposition IN ('tombstone','purge')), archive_retention_kind TEXT NOT NULL CHECK(archive_retention_kind IN ('forever','for_seconds')), archive_retention_seconds INTEGER, diagnostics_disposition TEXT NOT NULL CHECK(diagnostics_disposition IN ('purge','retain')), diagnostics_retention_seconds INTEGER, created_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,policy_id,revision), + PRIMARY KEY(workspace_id,policy_id,policy_digest), FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE); CREATE TABLE "workspaces" ( workspace_id TEXT PRIMARY KEY, @@ -1252,8 +1243,8 @@ CREATE TABLE runtime_removal_operations ( workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, request_fingerprint TEXT NOT NULL, - expected_binding_revision INTEGER NOT NULL, - config_revision INTEGER NOT NULL, + expected_binding_id TEXT NOT NULL CHECK(length(expected_binding_id)>0), + config_digest TEXT NOT NULL CHECK(length(config_digest)>0), state TEXT NOT NULL CHECK (state IN ('pending', 'cleanup_pending', 'succeeded', 'failed')), failure_category TEXT, binding_removed INTEGER NOT NULL CHECK (binding_removed IN (0, 1)), @@ -1446,7 +1437,7 @@ CREATE TABLE workspace_deletion_operations ( request_fingerprint TEXT NOT NULL, workspace_id TEXT NOT NULL, workspace_display_name TEXT NOT NULL, - workspace_revision TEXT NOT NULL, + workspace_updated_at TEXT NOT NULL, owner_account_id TEXT NOT NULL, actor_account_id TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('queued', 'running', 'blocked', 'failed', 'succeeded')), @@ -1464,11 +1455,11 @@ CREATE INDEX workspace_deletion_operations_workspace_recent ON workspace_deletion_operations(workspace_id, created_at DESC); CREATE TRIGGER seed_worker_retention_policy_after_workspace_insert AFTER INSERT ON workspaces BEGIN - INSERT INTO workspace_worker_retention_policy_revisions - (workspace_id,policy_id,revision,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,created_at) - VALUES(NEW.workspace_id,'workspace-default-conservative',1,'archive','tombstone','forever',NULL,'purge',NULL,NEW.created_at); - INSERT INTO workspace_worker_retention_policies(workspace_id,policy_id,revision,updated_at) - VALUES(NEW.workspace_id,'workspace-default-conservative',1,NEW.created_at); + INSERT INTO workspace_worker_retention_policy_snapshots + (workspace_id,policy_id,policy_digest,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,created_at) + VALUES(NEW.workspace_id,'workspace-default-conservative','sha256:30d45d5d58a10dadf95c3bf831e589ea10c81dc76da9575bfd9fb13942b3d198','archive','tombstone','forever',NULL,'purge',NULL,NEW.created_at); + INSERT INTO workspace_worker_retention_policies(workspace_id,policy_id,policy_digest,updated_at) + VALUES(NEW.workspace_id,'workspace-default-conservative','sha256:30d45d5d58a10dadf95c3bf831e589ea10c81dc76da9575bfd9fb13942b3d198',NEW.created_at); END; CREATE TRIGGER ticket_assignment_operations_validate_insert BEFORE INSERT ON ticket_assignment_operations @@ -1654,3 +1645,47 @@ CREATE TRIGGER workspace_drive_grants_worker_deleted BEFORE DELETE ON worker_reg WHERE workspace_id=OLD.workspace_id AND runtime_id=OLD.runtime_id AND worker_id=OLD.worker_id AND revoked=0; END; +-- Frozen evidence only. Neither table is a key, lease, or mutation authority. +CREATE TABLE workdir_create_legacy_ssh_archives ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + operation_json TEXT NOT NULL CHECK (json_valid(operation_json)), + candidates_json TEXT NOT NULL CHECK (json_valid(candidates_json)), + PRIMARY KEY (workspace_id, operation_id), + FOREIGN KEY (workspace_id, operation_id) + REFERENCES workdir_create_operations(workspace_id, operation_id) ON DELETE CASCADE +); +CREATE TRIGGER workdir_create_legacy_archive_cannot_authorize_update +BEFORE UPDATE ON workdir_create_operations FOR EACH ROW +WHEN EXISTS (SELECT 1 FROM workdir_create_legacy_ssh_archives a + WHERE a.workspace_id=OLD.workspace_id AND a.operation_id=OLD.operation_id) +AND (NEW.state <> 'succeeded' OR NEW.credential_id IS NOT NULL + OR NEW.credential_fingerprint IS NOT NULL OR NEW.host_trust_id IS NOT NULL + OR NEW.host_trust_fingerprint IS NOT NULL OR NEW.repository_access_mode IS NOT NULL) +BEGIN SELECT RAISE(ABORT, 'archived_workdir_create_cannot_authorize'); END; +CREATE TRIGGER workdir_create_legacy_archive_blocks_candidates +BEFORE INSERT ON workdir_create_credential_candidates FOR EACH ROW +WHEN EXISTS (SELECT 1 FROM workdir_create_legacy_ssh_archives a + WHERE a.workspace_id=NEW.workspace_id AND a.operation_id=NEW.operation_id) +BEGIN SELECT RAISE(ABORT, 'archived_workdir_create_cannot_authorize'); END; +CREATE TRIGGER workdir_create_legacy_archive_blocks_candidate_updates +BEFORE UPDATE ON workdir_create_credential_candidates FOR EACH ROW +WHEN EXISTS (SELECT 1 FROM workdir_create_legacy_ssh_archives a + WHERE a.workspace_id=NEW.workspace_id AND a.operation_id=NEW.operation_id) +BEGIN SELECT RAISE(ABORT, 'archived_workdir_create_cannot_authorize'); END; +CREATE TABLE repository_secret_legacy_receipts ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + resource_kind TEXT NOT NULL, + resource_id TEXT NOT NULL, + result_revision INTEGER NOT NULL, + created_at TEXT NOT NULL, + mutation_kind TEXT, + expected_revision INTEGER, + expected_operation_id TEXT, + expected_key_fingerprint TEXT, + PRIMARY KEY (workspace_id, operation_id), + FOREIGN KEY (workspace_id, operation_id) + REFERENCES repository_secret_operations(workspace_id, operation_id) ON DELETE CASCADE +); diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index cb605799c..bd46bb686 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -136,10 +136,10 @@ pub enum Error { WorkspaceConfigConflict(String), #[error("Runtime binding conflict: {0}")] RuntimeBindingConflict(String), - #[error("Runtime binding revision conflict: expected {expected:?}, current {actual:?}")] - RuntimeBindingRevisionConflict { - expected: Option, - actual: Option, + #[error("Runtime binding identity conflict: expected {expected:?}, current {actual:?}")] + RuntimeBindingIdConflict { + expected: Option, + actual: Option, }, #[error("Runtime public key fingerprint is already bound in this Workspace: {fingerprint}")] RuntimeBindingFingerprintConflict { fingerprint: String }, diff --git a/crates/workspace-server/src/main.rs b/crates/workspace-server/src/main.rs index b712941e8..b7ae4a9a9 100644 --- a/crates/workspace-server/src/main.rs +++ b/crates/workspace-server/src/main.rs @@ -786,11 +786,12 @@ mod tests { base_url: "https://runtime.example.test".to_owned(), public_key: "unused".to_owned(), public_key_fingerprint: "unused".to_owned(), - binding_revision: 1, + binding_id: "binding-test".to_owned(), state: WorkspaceRuntimeBindingState::Verified, authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer, workspace_key_id: None, - workspace_key_generation: None, + workspace_public_key_fingerprint: None, + workspace_trust_id: None, created_at: "2026-09-01T00:00:00Z".to_owned(), updated_at: "2026-09-01T00:00:00Z".to_owned(), revoked_at: None, @@ -813,11 +814,12 @@ mod tests { base_url: "http://127.0.0.1:8788".to_owned(), public_key: "unused".to_owned(), public_key_fingerprint: "unused".to_owned(), - binding_revision: 1, + binding_id: "binding-test".to_owned(), state: WorkspaceRuntimeBindingState::Verified, authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity, workspace_key_id: Some("WK-test".to_owned()), - workspace_key_generation: Some(1), + workspace_public_key_fingerprint: Some("sha256:workspace-key".to_owned()), + workspace_trust_id: Some("trust-test".to_owned()), created_at: "2026-09-01T00:00:00Z".to_owned(), updated_at: "2026-09-01T00:00:00Z".to_owned(), revoked_at: None, diff --git a/crates/workspace-server/src/memory_staging.rs b/crates/workspace-server/src/memory_staging.rs index ccc62c1db..ff8caadf7 100644 --- a/crates/workspace-server/src/memory_staging.rs +++ b/crates/workspace-server/src/memory_staging.rs @@ -176,7 +176,7 @@ fn memory_evidence_origin_projection(origin: EvidenceOrigin) -> MemoryEvidenceOr worker_id: origin.worker_id, flow_selector: origin.flow_selector, flow_definition_id: origin.flow_definition_id, - flow_definition_revision: origin.flow_definition_revision, + flow_definition_fingerprint: origin.flow_definition_fingerprint, } } @@ -313,7 +313,7 @@ mod tests { worker_id: Some("worker-1".to_string()), flow_selector: Some("builtin:coder-review".to_string()), flow_definition_id: Some("flow-1".to_string()), - flow_definition_revision: Some(7), + flow_definition_fingerprint: Some("sha256:flow-content".into()), }), ..SourceEvidenceRef::default() }); @@ -328,7 +328,10 @@ mod tests { Some("builtin:coder-review") ); assert_eq!(origin.flow_definition_id.as_deref(), Some("flow-1")); - assert_eq!(origin.flow_definition_revision, Some(7)); + assert_eq!( + origin.flow_definition_fingerprint, + Some("sha256:flow-content".into()) + ); } } diff --git a/crates/workspace-server/src/migration_entrypoint_tests.rs b/crates/workspace-server/src/migration_entrypoint_tests.rs new file mode 100644 index 000000000..7601633e0 --- /dev/null +++ b/crates/workspace-server/src/migration_entrypoint_tests.rs @@ -0,0 +1,124 @@ +// Public migration entrypoints must preserve the old authority on refusal and +// validate encrypted data using the original database's secret context. +fn seed_migration_workspace(conn: &Connection) { + conn.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) + VALUES('owner','user','owner','Owner','1','1'); + INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) + VALUES('space','owner','Space','active','1','1');").unwrap(); +} + +#[test] +fn opening_schema_84_with_uncertain_removal_preserves_old_server_recovery() { + for state in ["executing", "failed"] { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("server.db"); + prepare_retained_schema(&path, 84); + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + seed_migration_workspace(&conn); + conn.execute_batch("INSERT INTO workspace_worker_retention_policy_revisions VALUES('space','policy',1,'archive','tombstone','forever',NULL,'retain',3600,'created'); + UPDATE workspace_worker_retention_policies SET policy_id='policy',revision=1,updated_at='updated' WHERE workspace_id='space';").unwrap(); + conn.execute("INSERT INTO worker_removal_operations + (operation_id,plan_id,input_fingerprint,workspace_id,runtime_id,worker_id,worker_revision, + policy_id,policy_revision,session_disposition,metadata_disposition,archive_retention_kind, + archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,archive_id, + blockers_json,state,reason,failure_category,created_at,updated_at) + VALUES('removal','plan','legacy-receipt','space','runtime','worker','updated', + 'policy',1,'archive','tombstone','forever',NULL,'retain',3600,'archive', + '[]',?1,'cleanup','uncertain','created','updated')",[state]).unwrap(); + let schema_before: String = conn.query_row( + "SELECT group_concat(sql, char(10)) FROM (SELECT sql FROM sqlite_schema ORDER BY type,name)", + [], |row| row.get(0)).unwrap(); + drop(conn); + for attempt in 0..3 { + let error = match attempt { + 0 => SqliteWorkspaceStore::open(&path).err().unwrap(), + 1 => SqliteWorkspaceStore::migration_plan(&path).unwrap_err(), + _ => SqliteWorkspaceStore::migrate_database(&path).unwrap_err(), + }; + assert!( + error.to_string().contains("receipt reconciliation"), + "{error}" + ); + let conn = Connection::open(&path).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 84); + let schema_after: String = conn.query_row( + "SELECT group_concat(sql, char(10)) FROM (SELECT sql FROM sqlite_schema ORDER BY type,name)", + [], |row| row.get(0)).unwrap(); + assert_eq!(schema_after, schema_before); + let receipt: (String,String,String) = conn.query_row( + "SELECT input_fingerprint,archive_id,state FROM worker_removal_operations WHERE operation_id='removal'", + [], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?))).unwrap(); + assert_eq!( + receipt, + ("legacy-receipt".into(), "archive".into(), state.into()) + ); + } + // Simulate the old Server settling its receipt, then upgrade normally. + let conn = Connection::open(&path).unwrap(); + conn.execute( + "UPDATE worker_removal_operations SET state='succeeded' WHERE operation_id='removal'", + [], + ) + .unwrap(); + drop(conn); + let store = SqliteWorkspaceStore::open(&path).unwrap(); + store.with_conn(|conn| { + assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION); + assert_eq!(conn.query_row("SELECT input_fingerprint FROM worker_removal_operations WHERE operation_id='removal'",[],|r|r.get::<_,String>(0))?,"legacy-receipt"); + Ok(()) + }).unwrap(); + } +} + +#[test] +fn migration_dry_run_and_apply_share_the_original_repository_secret_authority() { + use ring::aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey}; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("server.db"); + prepare_retained_schema(&path, 84); + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + seed_migration_workspace(&conn); + conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','create-key','intent','credential','key',7,'created'); + INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519','fingerprint',7,'active','created',NULL); + INSERT INTO repository_ssh_credential_revisions VALUES('space','key',7,'ssh-ed25519','fingerprint','created'); + INSERT INTO repository_secret_audit_events VALUES('space','audit','credential_created','key',7,'owner','created');").unwrap(); + let key = LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &[42; 32]).unwrap()); + let mut ciphertext = b"private bytes".to_vec(); + key.seal_in_place_append_tag( + Nonce::assume_unique_for_key([1; 12]), + Aad::from(b"yoi/repository-secret/v1/space/key/7/private_key".as_slice()), + &mut ciphertext, + ) + .unwrap(); + conn.execute("INSERT INTO server_secret_versions VALUES('space','key',7,'private_key','aes-256-gcm-v1',?1,?2,'created')",params![[1u8;12].as_slice(),ciphertext]).unwrap(); + drop(conn); + let master_key = dir.path().join("repository-secrets.master-key"); + std::fs::write(&master_key, [42; 32]).unwrap(); + let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap(); + assert_eq!(plan.current_schema_version, 84); + let conn = Connection::open(&path).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 84); + let unchanged: Vec = conn + .query_row("SELECT ciphertext FROM server_secret_versions", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(unchanged, ciphertext); + assert!(!table_exists(&conn, "server_secret_objects").unwrap()); + drop(conn); + assert_eq!(SqliteWorkspaceStore::migrate_database(&path).unwrap(), plan); + let store = SqliteWorkspaceStore::open(&path).unwrap(); + store.with_conn(|conn| { + assert_eq!(current_schema_version(conn)?,LATEST_SCHEMA_VERSION); + let (nonce, mut sealed): (Vec,Vec) = conn.query_row( + "SELECT nonce,ciphertext FROM server_secret_objects WHERE workspace_id='space' AND secret_id='key' AND operation_id='create-key'", + [],|r|Ok((r.get(0)?,r.get(1)?)))?; + let aad = serde_json::to_string(&("yoi/repository-secret/operation","space","key","create-key","private_key")).unwrap(); + let plaintext = key.open_in_place(Nonce::assume_unique_for_key(nonce.try_into().unwrap()),Aad::from(aad.as_bytes()),&mut sealed).unwrap(); + assert_eq!(plaintext,b"private bytes"); + Ok(()) + }).unwrap(); + assert_eq!(std::fs::read(master_key).unwrap(), [42; 32]); +} diff --git a/crates/workspace-server/src/profile_settings.rs b/crates/workspace-server/src/profile_settings.rs index d88cb9bb6..3141de655 100644 --- a/crates/workspace-server/src/profile_settings.rs +++ b/crates/workspace-server/src/profile_settings.rs @@ -145,7 +145,7 @@ pub(crate) fn project_profiles_from_evaluation( resolve_profile_artifact_value( entry.profile.clone(), ProfileSource::Archive { - archive_id: format!("workspace-config-r{}", state.snapshot.revision), + archive_id: format!("workspace-config:{}", state.snapshot.digest), source: entry.selector.clone(), }, Path::new("/"), @@ -172,7 +172,6 @@ pub(crate) fn project_profiles_from_evaluation( content_digest: config_source::digest_bytes(&value_bytes), provenance: WorkspaceProfileSourceProvenance::ProjectProfileSourceTree, editable: false, - revision: state.snapshot.revision.to_string(), size_bytes: value_bytes.len() as u64, diagnostics: Vec::new(), }); @@ -190,8 +189,6 @@ pub(crate) fn project_profiles_from_evaluation( Ok(ProfileConfigProjection { settings: ProfileSettingsResponse { workspace_id: workspace_id.to_string(), - registry_revision: format!("config:{}", state.snapshot.revision), - config_revision: Some(state.snapshot.revision), tree_digest: Some(state.snapshot.digest.clone()), projection_digest: Some(evaluation.projection_digest.clone()), default_profile: Some(config.profile.default_profile), @@ -259,10 +256,6 @@ fn validate_prompt_projection_matches_state( projection.projection_digest != prompt_catalog.catalog_digest, "projection digest", ), - ( - prompt_catalog.config_revision != state.snapshot.revision, - "config revision", - ), ( prompt_catalog.schema_fingerprint != state.contract.schema_bundle.fingerprint, "schema fingerprint", @@ -296,8 +289,6 @@ fn virtual_profile_bundle_id( hasher.update(b"workspace-profile-launch-v1\0"); hasher.update(workspace_id.as_bytes()); hasher.update(b"\0"); - hasher.update(state.snapshot.revision.to_le_bytes()); - hasher.update(b"\0"); hasher.update(state.snapshot.digest.as_bytes()); hasher.update(b"\0"); hasher.update(state.projection_digest.as_bytes()); @@ -320,10 +311,7 @@ fn virtual_profile_bundle_id( .iter() .map(|byte| format!("{byte:02x}")) .collect::(); - Ok(format!( - "workspace-config-profile-r{}-{identity}", - state.snapshot.revision - )) + Ok(format!("workspace-config-profile-{identity}")) } pub fn build_virtual_profile_config_bundle( @@ -386,7 +374,6 @@ pub fn build_virtual_profile_config_bundle_with_prompt_projection( prompt_projection: &worker::WorkspacePromptProjection, ) -> Result> { if projection.settings.workspace_id != workspace_id - || projection.settings.config_revision != Some(state.snapshot.revision) || projection.settings.tree_digest.as_deref() != Some(state.snapshot.digest.as_str()) || projection.settings.projection_digest.as_deref() != Some(state.projection_digest.as_str()) @@ -418,14 +405,13 @@ pub fn build_virtual_profile_config_bundle_with_prompt_projection( metadata: ConfigBundleMetadata { id: bundle_id, digest: String::new(), - revision: state.snapshot.revision.to_string(), workspace_id: workspace_id.to_string(), created_at: workspace_created_at.to_string(), provenance: ConfigBundleProvenance { source: "workspace_config".to_string(), detail: Some(format!( - "revision={} tree={} projection={}", - state.snapshot.revision, state.snapshot.digest, state.projection_digest + "content={} projection={}", + state.snapshot.digest, state.projection_digest )), }, }, @@ -474,7 +460,7 @@ fn build_virtual_profile_archive( ) })?; ProfileSourceArchive::build_evaluated_profile_with_builtin_sources( - format!("workspace-config-profile-r{}", state.snapshot.revision), + format!("workspace-config-profile:{}", state.snapshot.digest), selector.to_string(), entry.profile.clone(), builtin_sources, @@ -489,7 +475,7 @@ pub fn workspace_metadata_settings( workspace_id: workspace.workspace_id.clone(), display_name: workspace.display_name.clone(), created_at: workspace.created_at.clone(), - revision: workspace.updated_at.clone(), + updated_at: workspace.updated_at.clone(), source: "server_db".to_string(), diagnostics: Vec::new(), } @@ -586,7 +572,7 @@ mod tests { assert_eq!(settings.workspace_id, workspace.workspace_id); assert_eq!(settings.display_name, workspace.display_name); assert_eq!(settings.created_at, workspace.created_at); - assert_eq!(settings.revision, workspace.updated_at); + assert_eq!(settings.updated_at, workspace.updated_at); assert_eq!(settings.source, "server_db"); assert!(settings.diagnostics.is_empty()); } @@ -606,7 +592,7 @@ mod tests { } fn virtual_state(entries: Vec) -> WorkspaceConfigState { - let snapshot = config_source::ConfigTreeSnapshot::from_entries(7, entries).unwrap(); + let snapshot = config_source::ConfigTreeSnapshot::from_entries(entries).unwrap(); let schema_bundle = config_source::WorkspaceConfigSchemaBundle::compose([ ProfileConfigSchemaProvider.contribution().unwrap(), crate::prompt_settings::PromptConfigSchemaProvider @@ -632,7 +618,7 @@ mod tests { } #[test] - fn virtual_config_projection_builds_archive_from_active_revision() { + fn virtual_config_projection_builds_archive_from_active_content() { let state = virtual_state(vec![ config_source::ConfigEntry::new( VirtualPath::parse("main.dcdl").unwrap(), @@ -657,9 +643,12 @@ mod tests { ) .unwrap() .unwrap(); - assert_eq!(projection.settings.config_revision, Some(7)); + assert_eq!( + projection.settings.tree_digest.as_deref(), + Some(state.snapshot.digest.as_str()) + ); let prompt_catalog = bundle.prompt_catalog.as_ref().unwrap(); - assert_eq!(prompt_catalog.config_revision, 7); + assert_eq!(prompt_catalog.source_digest, state.snapshot.digest); assert!(!prompt_catalog.templates.is_empty()); assert!( bundle @@ -667,7 +656,7 @@ mod tests { .provenance .detail .unwrap() - .contains("revision=7") + .contains(&format!("content={}", state.snapshot.digest)) ); let archive = bundle.profile_source_archive.unwrap(); assert_eq!( @@ -767,18 +756,13 @@ mod tests { companion .metadata .id - .starts_with("workspace-config-profile-r7-") - ); - assert!( - coder - .metadata - .id - .starts_with("workspace-config-profile-r7-") + .starts_with("workspace-config-profile-") ); + assert!(coder.metadata.id.starts_with("workspace-config-profile-")); } #[test] - fn virtual_config_launch_bundle_rejects_prompt_projection_from_other_revision() { + fn virtual_config_launch_bundle_rejects_prompt_projection_from_other_content() { let state = virtual_state(vec![ config_source::ConfigEntry::new( VirtualPath::parse("main.dcdl").unwrap(), @@ -792,7 +776,14 @@ mod tests { crate::prompt_settings::project_workspace_prompt_projection("workspace-test", &state) .unwrap(); let mut mismatched_state = state.clone(); - mismatched_state.snapshot.revision += 1; + mismatched_state.snapshot = state + .snapshot + .apply(&[config_source::ConfigTreeChange::Create { + path: VirtualPath::parse("changed.txt").unwrap(), + content_type: ConfigContentType::Text, + content: "different source tree".into(), + }]) + .unwrap(); let error = build_virtual_profile_config_bundle_with_prompt_projection( &projection, @@ -866,7 +857,7 @@ mod tests { .contribution() .unwrap()]) .unwrap(); - let snapshot = config_source::ConfigTreeSnapshot::from_entries(1, [config_source::ConfigEntry::new( + let snapshot = config_source::ConfigTreeSnapshot::from_entries([config_source::ConfigEntry::new( VirtualPath::parse("main.dcdl").unwrap(), ConfigContentType::Decodal, r#"{ profile = { entries = [{ selector = "project:alpha"; profile = {}; }]; }; } as WorkspaceConfigSchema"#, ).unwrap()]).unwrap(); diff --git a/crates/workspace-server/src/prompt_settings.rs b/crates/workspace-server/src/prompt_settings.rs index 81f1415ba..d8c38e314 100644 --- a/crates/workspace-server/src/prompt_settings.rs +++ b/crates/workspace-server/src/prompt_settings.rs @@ -12,7 +12,6 @@ use crate::{Error, Result}; #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] struct PromptProjectionCacheKey { workspace_id: String, - config_revision: u64, source_digest: String, projection_digest: String, schema_fingerprint: String, @@ -23,7 +22,6 @@ impl PromptProjectionCacheKey { fn new(workspace_id: &str, state: &WorkspaceConfigState) -> Self { Self { workspace_id: workspace_id.to_string(), - config_revision: state.snapshot.revision, source_digest: state.snapshot.digest.clone(), projection_digest: state.projection_digest.clone(), schema_fingerprint: state.contract.schema_bundle.fingerprint.clone(), @@ -37,15 +35,15 @@ type PromptProjectionCell = OnceLock>, - active: BTreeMap, } /// WorkspaceApi-shared immutable Prompt projections keyed by authoritative Workspace config /// identity. /// /// This cache is an evaluation optimization only. Callers must load the active -/// [`WorkspaceConfigState`] from Server DB authority before resolving an entry. Advancing a -/// Workspace replaces only its active cache entry; in-flight users retain their immutable `Arc`. +/// [`WorkspaceConfigState`] from Server DB authority before resolving an entry. +/// Content and evaluation contracts are distinct keys; in-flight users retain their +/// immutable `Arc`. No ordering or freshness is inferred from a digest. #[derive(Debug, Clone, Default)] pub struct WorkspacePromptProjectionCache { inner: Arc>, @@ -58,33 +56,25 @@ impl WorkspacePromptProjectionCache { state: &WorkspaceConfigState, ) -> Result> { let key = PromptProjectionCacheKey::new(workspace_id, state); - let (cell, cached) = { + let cell = { let mut cache = self.lock()?; - if let Some(active) = cache.active.get(workspace_id) { - if key.config_revision == active.config_revision && key != *active { - return Err(Error::RegistryInconsistency(format!( - "Workspace Prompt projection identity changed without a config revision transition: workspace={workspace_id} revision={}", - key.config_revision - ))); - } - if key.config_revision < active.config_revision { - (Arc::new(PromptProjectionCell::new()), false) - } else { - let cell = cache - .entries - .entry(key.clone()) - .or_insert_with(|| Arc::new(PromptProjectionCell::new())) - .clone(); - (cell, true) - } - } else { - let cell = cache + // Bound retained cells without treating any content identity as newer. + // In-flight evaluation cells are never evicted, preserving single flight. + if !cache.entries.contains_key(&key) && cache.entries.len() >= 64 { + let evict = cache .entries - .entry(key.clone()) - .or_insert_with(|| Arc::new(PromptProjectionCell::new())) - .clone(); - (cell, true) + .iter() + .find(|(_, cell)| Arc::strong_count(cell) == 1) + .map(|(key, _)| key.clone()); + if let Some(evict) = evict { + cache.entries.remove(&evict); + } } + cache + .entries + .entry(key.clone()) + .or_insert_with(|| Arc::new(PromptProjectionCell::new())) + .clone() }; let resolved = cell @@ -97,54 +87,29 @@ impl WorkspacePromptProjectionCache { let catalog = match resolved { Ok(catalog) => catalog, Err(error) => { - if cached { - self.lock()?.entries.remove(&key); - } + self.lock()?.entries.remove(&key); return Err(Error::Config(error)); } }; - if cached { - self.record_resolved(workspace_id, &key, &cell)?; + // Concurrent distinct evaluations may temporarily exceed the retention + // limit. Trim completed cells after resolution without evicting in-flight + // work or assigning a temporal order to content identities. + let mut cache = self.lock()?; + while cache.entries.len() > 64 { + let evict = cache + .entries + .iter() + .find(|(_, cell)| Arc::strong_count(cell) == 1) + .map(|(key, _)| key.clone()); + let Some(evict) = evict else { + break; + }; + cache.entries.remove(&evict); } Ok(catalog) } - fn record_resolved( - &self, - workspace_id: &str, - key: &PromptProjectionCacheKey, - cell: &Arc, - ) -> Result<()> { - let mut cache = self.lock()?; - let active = cache.active.get(workspace_id).cloned(); - match active { - Some(active) if active.config_revision > key.config_revision => { - cache.entries.remove(key); - } - Some(active) if active.config_revision == key.config_revision => { - if active != *key { - cache.entries.remove(key); - return Err(Error::RegistryInconsistency(format!( - "Workspace Prompt projection identity changed without a config revision transition: workspace={workspace_id} revision={}", - key.config_revision - ))); - } - cache.entries.entry(key.clone()).or_insert(cell.clone()); - } - _ => { - cache.entries.insert(key.clone(), cell.clone()); - cache.active.insert(workspace_id.to_string(), key.clone()); - cache.entries.retain(|existing, _| { - existing.workspace_id != workspace_id - || existing == key - || existing.config_revision > key.config_revision - }); - } - } - Ok(()) - } - fn lock(&self) -> Result> { self.inner.lock().map_err(|_| { Error::RegistryInconsistency("Prompt projection cache lock was poisoned".to_string()) @@ -192,7 +157,7 @@ pub fn validate_evaluated_prompt_catalog( let prompts = projection.data_json.get("prompts").ok_or_else(|| { Error::InvalidInput("Workspace config projection has no prompts namespace".to_string()) })?; - EffectivePromptCatalog::from_projection(prompts, 0, "preview", "preview") + EffectivePromptCatalog::from_projection(prompts, "preview", "preview") .map(|_| ()) .map_err(|error| Error::InvalidInput(format!("invalid Prompt catalog: {error}"))) } @@ -203,7 +168,7 @@ pub fn project_prompts_from_workspace_config( let evaluation = evaluate_workspace_config_state(state, state.contract.schema_bundle.clone())?; if evaluation.projection_digest != state.projection_digest { return Err(Error::RegistryInconsistency( - "Prompt projection digest does not match the active Workspace config revision" + "Prompt projection digest does not match the active Workspace config content" .to_string(), )); } @@ -217,7 +182,6 @@ pub fn project_prompts_from_workspace_config( })?; let mut catalog = EffectivePromptCatalog::from_projection( prompts, - state.snapshot.revision, state.contract.schema_bundle.fingerprint.clone(), state.contract.fingerprint.clone(), ) @@ -249,23 +213,16 @@ mod tests { }; fn state(source: &str) -> WorkspaceConfigState { - state_at(7, source) - } - - fn state_at(revision: u64, source: &str) -> WorkspaceConfigState { let schema = WorkspaceConfigSchemaBundle::compose([PromptConfigSchemaProvider .contribution() .unwrap()]) .unwrap(); - let snapshot = ConfigTreeSnapshot::from_entries( - revision, - [ConfigEntry::new( - VirtualPath::parse("main.dcdl").unwrap(), - ConfigContentType::Decodal, - source, - ) - .unwrap()], + let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new( + VirtualPath::parse("main.dcdl").unwrap(), + ConfigContentType::Decodal, + source, ) + .unwrap()]) .unwrap(); let contract = ToolchainContract::with_schema_bundle( config_source::DEFAULT_SCHEMA_VERSION, @@ -285,134 +242,74 @@ mod tests { } #[test] - fn prompt_projection_cache_shares_immutable_entry_and_replaces_workspace_revision() { + fn prompt_projection_cache_reuses_content_and_separates_workspaces() { let cache = WorkspacePromptProjectionCache::default(); let initial = state("{}"); let first = cache.resolve("workspace-a", &initial).unwrap(); - let retry = cache.resolve("workspace-a", &initial).unwrap(); + let retry = cache.resolve("workspace-a", &state("{}")).unwrap(); assert!(Arc::ptr_eq(&first, &retry)); - assert_eq!(cache.len(), 1); - - let updated = state_at( - 8, - r#"{ prompts = { common = { language = "UPDATED"; }; }; }"#, - ); + let updated = state(r#"{ prompts = { common = { language = "UPDATED"; }; }; }"#); let replacement = cache.resolve("workspace-a", &updated).unwrap(); assert!(!Arc::ptr_eq(&first, &replacement)); assert_eq!( replacement.catalog().templates["common.language"], "UPDATED" ); - assert_eq!(cache.len(), 1); + assert!(Arc::ptr_eq( + &first, + &cache.resolve("workspace-a", &initial).unwrap() + )); assert_ne!(first.catalog().templates["common.language"], "UPDATED"); + let other = cache.resolve("workspace-b", &updated).unwrap(); + assert!(!Arc::ptr_eq(&replacement, &other)); + assert_eq!(cache.len(), 3); + } - let other_workspace = cache.resolve("workspace-b", &updated).unwrap(); - assert!(!Arc::ptr_eq(&replacement, &other_workspace)); + #[test] + fn prompt_projection_cache_separates_contracts_for_same_content() { + let cache = WorkspacePromptProjectionCache::default(); + let initial = state("{}"); + let mut changed = initial.clone(); + let bundle = WorkspaceConfigSchemaBundle::compose([ + PromptConfigSchemaProvider.contribution().unwrap(), + ConfigSchemaContribution::new( + "builtin:extra", + "extra", + "1", + "{ extra = Bool default false; }", + ) + .unwrap(), + ]) + .unwrap(); + changed.contract = ToolchainContract::with_schema_bundle( + config_source::DEFAULT_SCHEMA_VERSION, + changed.contract.entrypoints.clone(), + config_source::DEFAULT_IMPORT_POLICY_VERSION, + bundle, + ); + changed.projection_digest = SnapshotEnvironment::new(changed.snapshot.clone()) + .evaluate_contract(&changed.contract) + .unwrap() + .projection_digest; + assert_eq!(initial.snapshot.digest, changed.snapshot.digest); + let first = cache.resolve("workspace-a", &initial).unwrap(); + let second = cache.resolve("workspace-a", &changed).unwrap(); + assert!(!Arc::ptr_eq(&first, &second)); + assert!(Arc::ptr_eq( + &first, + &cache.resolve("workspace-a", &initial).unwrap() + )); assert_eq!(cache.len(), 2); } #[test] - fn prompt_projection_cache_does_not_let_stale_revision_evict_active_entry() { + fn prompt_projection_cache_bounds_retained_content_cells() { let cache = WorkspacePromptProjectionCache::default(); - let current = state_at( - 8, - r#"{ prompts = { common = { language = "CURRENT"; }; }; }"#, - ); - let stale = state_at(7, "{}"); - - let current_catalog = cache.resolve("workspace-a", ¤t).unwrap(); - let stale_catalog = cache.resolve("workspace-a", &stale).unwrap(); - let current_retry = cache.resolve("workspace-a", ¤t).unwrap(); - - assert_eq!(stale_catalog.catalog().config_revision, 7); - assert!(Arc::ptr_eq(¤t_catalog, ¤t_retry)); - assert_eq!(cache.len(), 1); - } - - #[test] - fn prompt_projection_cache_rejects_same_revision_reinterpretation() { - let cache = WorkspacePromptProjectionCache::default(); - let first = state_at(7, "{}"); - let changed = state_at( - 7, - r#"{ prompts = { common = { language = "CHANGED"; }; }; }"#, - ); - - cache.resolve("workspace-a", &first).unwrap(); - let error = cache.resolve("workspace-a", &changed).unwrap_err(); - assert!( - error - .to_string() - .contains("without a config revision transition") - ); - assert_eq!(cache.len(), 1); - } - - #[test] - fn prompt_projection_cache_post_init_rejects_concurrent_same_revision_identity() { - let cache = WorkspacePromptProjectionCache::default(); - let first = PromptProjectionCacheKey::new("workspace-a", &state_at(7, "{}")); - let conflicting = PromptProjectionCacheKey::new( - "workspace-a", - &state_at( - 7, - r#"{ prompts = { common = { language = "CONFLICT"; }; }; }"#, - ), - ); - let first_cell = Arc::new(PromptProjectionCell::new()); - let conflicting_cell = Arc::new(PromptProjectionCell::new()); - { - let mut state = cache.lock().unwrap(); - state.entries.insert(first.clone(), first_cell.clone()); - state - .entries - .insert(conflicting.clone(), conflicting_cell.clone()); + for index in 0..80 { + let source = format!("{{ prompts = {{ common = {{ language = \"{index}\"; }}; }}; }}"); + cache.resolve("workspace-a", &state(&source)).unwrap(); } - - cache - .record_resolved("workspace-a", &first, &first_cell) - .unwrap(); - let error = cache - .record_resolved("workspace-a", &conflicting, &conflicting_cell) - .unwrap_err(); - assert!( - error - .to_string() - .contains("without a config revision transition") - ); - let state = cache.lock().unwrap(); - assert_eq!(state.active["workspace-a"], first); - assert!(!state.entries.contains_key(&conflicting)); - } - - #[test] - fn prompt_projection_cache_keeps_newer_inflight_entry_when_older_finishes_first() { - let cache = WorkspacePromptProjectionCache::default(); - let older = PromptProjectionCacheKey::new("workspace-a", &state_at(7, "{}")); - let newer = PromptProjectionCacheKey::new( - "workspace-a", - &state_at(8, r#"{ prompts = { common = { language = "NEW"; }; }; }"#), - ); - let older_cell = Arc::new(PromptProjectionCell::new()); - let newer_cell = Arc::new(PromptProjectionCell::new()); - { - let mut state = cache.lock().unwrap(); - state.entries.insert(older.clone(), older_cell.clone()); - state.entries.insert(newer.clone(), newer_cell.clone()); - } - - cache - .record_resolved("workspace-a", &older, &older_cell) - .unwrap(); - assert!(cache.lock().unwrap().entries.contains_key(&newer)); - - cache - .record_resolved("workspace-a", &newer, &newer_cell) - .unwrap(); - let state = cache.lock().unwrap(); - assert_eq!(state.active["workspace-a"], newer); - assert_eq!(state.entries.len(), 1); - assert!(state.entries.contains_key(&newer)); + assert_eq!(cache.len(), 64); } #[test] @@ -442,7 +339,7 @@ mod tests { let baseline = project_prompts_from_workspace_config(&state("{}")).unwrap(); let state = state(r#"{ prompts = { common = { language = "OVERRIDE"; }; }; }"#); let catalog = project_prompts_from_workspace_config(&state).unwrap(); - assert_eq!(catalog.config_revision, 7); + assert_eq!(catalog.source_digest, state.snapshot.digest); assert_eq!(catalog.templates["common.language"], "OVERRIDE"); for (key, value) in baseline.templates { if key != "common.language" { @@ -462,15 +359,12 @@ mod tests { r#"{ prompts = { common = { language = "{%- include \"missing\" -%}"; }; }; }"#, r#"{ prompts = { common = { language = "{% include \"common.workspace\" %}"; workspace = "{% include \"common.language\" %}"; }; }; }"#, ] { - let snapshot = ConfigTreeSnapshot::from_entries( - 0, - [ConfigEntry::new( - VirtualPath::parse("main.dcdl").unwrap(), - ConfigContentType::Decodal, - source, - ) - .unwrap()], + let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new( + VirtualPath::parse("main.dcdl").unwrap(), + ConfigContentType::Decodal, + source, ) + .unwrap()]) .unwrap(); let contract = ToolchainContract::with_schema_bundle( config_source::DEFAULT_SCHEMA_VERSION, @@ -496,15 +390,12 @@ mod tests { "{ prompts = { common = { unknown = \"bad\"; }; }; }", "{ prompts = { common = { language = 42; }; }; }", ] { - let snapshot = ConfigTreeSnapshot::from_entries( - 0, - [ConfigEntry::new( - VirtualPath::parse("main.dcdl").unwrap(), - ConfigContentType::Decodal, - source, - ) - .unwrap()], + let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new( + VirtualPath::parse("main.dcdl").unwrap(), + ConfigContentType::Decodal, + source, ) + .unwrap()]) .unwrap(); let contract = ToolchainContract::with_schema_bundle( config_source::DEFAULT_SCHEMA_VERSION, diff --git a/crates/workspace-server/src/repositories.rs b/crates/workspace-server/src/repositories.rs index 9da499791..dbba49efe 100644 --- a/crates/workspace-server/src/repositories.rs +++ b/crates/workspace-server/src/repositories.rs @@ -18,7 +18,6 @@ pub struct ConfiguredRepository { pub repository_key: String, pub provider: String, pub source: RepositorySource, - pub source_revision: u64, pub source_fingerprint: String, pub observed_status: RepositoryObservedStatus, pub observed_at: Option, @@ -321,7 +320,6 @@ impl RepositoryRegistryReader { kind: repository.provider.clone(), provider: repository.provider.clone(), source: repository.source.clone(), - source_revision: repository.source_revision, source_fingerprint: repository.source_fingerprint.clone(), observed_status: repository.observed_status, observed_at: repository.observed_at.clone(), @@ -606,7 +604,6 @@ mod tests { provider: "git".into(), source_fingerprint: crate::repository_source::repository_source_fingerprint(&source), source, - source_revision: 1, observed_status: RepositoryObservedStatus::Unverified, observed_at: None, path: None, @@ -638,7 +635,6 @@ mod tests { provider: "git".into(), source_fingerprint: crate::repository_source::repository_source_fingerprint(&source), source, - source_revision: 1, observed_status: RepositoryObservedStatus::Unverified, observed_at: None, path: None, @@ -648,6 +644,10 @@ mod tests { let projection = reader.list(); let summary = &projection.items[0]; assert_eq!(summary.source.kind, server_api::RepositorySourceKind::Ssh); + assert_eq!( + summary.source_fingerprint, + crate::repository_source::repository_source_fingerprint(&summary.source) + ); assert_eq!( summary.observed_status, RepositoryObservedStatus::Unverified @@ -761,7 +761,6 @@ mod tests { &source_descriptor, ), source: source_descriptor, - source_revision: 1, observed_status: RepositoryObservedStatus::Unverified, observed_at: None, path: Some(path.to_path_buf()), diff --git a/crates/workspace-server/src/repository_access.rs b/crates/workspace-server/src/repository_access.rs index 9a916a780..b272913aa 100644 --- a/crates/workspace-server/src/repository_access.rs +++ b/crates/workspace-server/src/repository_access.rs @@ -94,7 +94,6 @@ pub fn project_repository_access_candidate( store, secrets, workspace_id, - candidate.base_revision + 1, &candidate.evaluation.projection_digest, &candidate.evaluation, ) @@ -115,7 +114,6 @@ pub fn project_repository_access_state( if !has_schema { return Ok(RepositoryAccessProjection { workspace_id: workspace_id.to_string(), - config_revision: state.snapshot.revision, projection_digest: state.projection_digest.clone(), bindings: Vec::new(), }); @@ -130,7 +128,6 @@ pub fn project_repository_access_state( store, secrets, workspace_id, - state.snapshot.revision, &state.projection_digest, &evaluation, ) @@ -189,7 +186,6 @@ fn project_repository_access_evaluation( store: &dyn ControlPlaneStore, secrets: &RepositorySecretService, workspace_id: &str, - config_revision: u64, projection_digest: &str, evaluation: &config_source::EvaluationResult, ) -> Result { @@ -264,22 +260,37 @@ fn project_repository_access_evaluation( bindings.sort_by(|left, right| left.repository_key.cmp(&right.repository_key)); Ok(RepositoryAccessProjection { workspace_id: workspace_id.to_string(), - config_revision, projection_digest: projection_digest.to_string(), bindings, }) } +/// An immutable SSH materialization selection. Operation IDs name the actual +/// mutations that stored these envelopes/endpoints; fingerprints name key identity. #[derive(Clone)] pub struct LeasedRepositorySshAccess { pub credential_id: String, - pub credential_revision: u64, + pub credential_fingerprint: String, pub host_trust_id: String, - pub host_trust_revision: u64, + pub host_trust_fingerprint: String, pub private_key: zeroize::Zeroizing, pub known_hosts_entry: String, } +/// A durable acknowledgement, not a credential or a lease. A removed resource +/// remains recoverable here even when its original typed request cannot be proved. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct RepositorySecretOperationStatus { + pub operation_id: String, + pub state: String, + pub resource_kind: String, + pub resource_id: String, + pub result_operation_id: String, + pub created_at: String, + pub mutation_kind: Option, + pub legacy_precondition_proven: Option, +} + #[derive(Clone)] pub struct RepositorySecretService { store: Arc, @@ -287,6 +298,34 @@ pub struct RepositorySecretService { } impl RepositorySecretService { + pub fn operation_status( + &self, + workspace_id: &str, + operation_id: &str, + ) -> Result> { + let operation_id = validate_identifier("operation_id", operation_id)?; + self.store.with_conn(|conn| { + let Some((_,resource_kind,resource_id,result_operation_id))=read_operation(conn,workspace_id,&operation_id)? else { return Ok(None); }; + let created_at=conn.query_row("SELECT created_at FROM repository_secret_operations WHERE workspace_id=?1 AND operation_id=?2",params![workspace_id,operation_id],|r|r.get(0))?; + let legacy: Option<(Option,Option,Option,Option)>=conn.query_row( + "SELECT mutation_kind,expected_revision,expected_operation_id,expected_key_fingerprint FROM repository_secret_legacy_receipts WHERE workspace_id=?1 AND operation_id=?2", + params![workspace_id,operation_id],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?)), + ).optional()?; + let (mutation_kind,legacy_precondition_proven)=if let Some((kind,counter,prior,key))=legacy { + let proven=match kind.as_deref() { + Some("credential_created"|"host_trust_created") => counter==Some(0), + Some("credential_rotated"|"host_trust_rotated"|"credential_deleted"|"host_trust_deleted") => counter.is_some_and(|c|c>0) && prior.is_some_and(|p|!p.is_empty()) && key.is_some_and(|k|!k.is_empty()), + _ => false, + }; + (kind,Some(proven)) + } else { + let kind=conn.query_row("SELECT min(kind) FROM repository_secret_audit_events WHERE workspace_id=?1 AND operation_id=?2 HAVING count(*)=1",params![workspace_id,operation_id],|r|r.get::<_,Option>(0)).optional()?.flatten(); + (kind,None) + }; + Ok(Some(RepositorySecretOperationStatus { operation_id,state:"committed".into(),resource_kind,resource_id,result_operation_id,created_at,mutation_kind,legacy_precondition_proven })) + }) + } + pub fn open(store: Arc, database_path: &Path) -> Result { let key_path = master_key_path(database_path)?; let key = load_or_create_master_key(&key_path)?; @@ -379,16 +418,20 @@ impl RepositorySecretService { credential_id: &str, ) -> Result> { let credential_id = validate_identifier("credential_id", credential_id)?; - let Some((credential, private_secret, passphrase_secret)) = + let Some((credential, operation_id, private_secret, passphrase_secret)) = self.store.with_conn(|conn| { let Some(credential) = read_credential(conn, workspace_id, &credential_id)? else { return Ok(None); }; + let operation_id: String = conn.query_row( + "SELECT current_operation_id FROM repository_ssh_credentials WHERE workspace_id=?1 AND credential_id=?2", + params![workspace_id, credential_id], |row| row.get(0), + )?; let private_secret = read_sealed_secret( conn, workspace_id, &credential_id, - credential.current_revision, + &operation_id, "private_key", )? .ok_or_else(|| Error::Store("credential private key is missing".to_string()))?; @@ -396,10 +439,10 @@ impl RepositorySecretService { conn, workspace_id, &credential_id, - credential.current_revision, + &operation_id, "passphrase", )?; - Ok(Some((credential, private_secret, passphrase_secret))) + Ok(Some((credential, operation_id, private_secret, passphrase_secret))) })? else { return Ok(None); @@ -407,7 +450,7 @@ impl RepositorySecretService { let private_key = zeroize::Zeroizing::new(self.unseal( workspace_id, &credential_id, - credential.current_revision, + &operation_id, "private_key", private_secret, )?); @@ -416,7 +459,7 @@ impl RepositorySecretService { self.unseal( workspace_id, &credential_id, - credential.current_revision, + &operation_id, "passphrase", secret, ) @@ -433,9 +476,13 @@ impl RepositorySecretService { let parsed = parse_private_key(private_key, passphrase).map_err(|err| { Error::Store(format!("stored credential private key is invalid: {err}")) })?; + if parsed.fingerprint != credential.public_key_fingerprint { + return Err(Error::RegistryInconsistency( + "Repository SSH credential key identity mismatch".to_string(), + )); + } Ok(Some(RepositorySshPublicKey { credential_id, - current_revision: credential.current_revision, public_key_algorithm: parsed.algorithm, public_key_fingerprint: parsed.fingerprint, public_key: parsed.public_key, @@ -456,14 +503,14 @@ impl RepositorySecretService { "create", &credential_id, &name, - 0, + "", &request.private_key, request.passphrase.as_deref(), ); let private_secret = self.seal( workspace_id, &credential_id, - 1, + &operation_id, "private_key", request.private_key.as_bytes(), )?; @@ -474,7 +521,7 @@ impl RepositorySecretService { self.seal( workspace_id, &credential_id, - 1, + &operation_id, "passphrase", value.as_bytes(), ) @@ -489,6 +536,13 @@ impl RepositorySecretService { &operation_id, &fingerprint, &credential_id, + LegacySecretRequest::Credential { + mutation_kind: "credential_created", + name: &name, + expected_key: None, + private_key: &request.private_key, + passphrase: request.passphrase.as_deref(), + }, )? { tx.commit()?; return Ok(replayed); @@ -502,25 +556,27 @@ impl RepositorySecretService { tx.execute( r#"INSERT INTO repository_ssh_credentials ( workspace_id, credential_id, name, public_key_algorithm, - public_key_fingerprint, current_revision, status, created_at, rotated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, 1, 'active', ?6, NULL)"#, + public_key_fingerprint, current_operation_id, status, created_at, rotated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'active', ?7, NULL)"#, params![ workspace_id, credential_id, name, parsed.algorithm, parsed.fingerprint, + operation_id, now ], )?; tx.execute( - r#"INSERT INTO repository_ssh_credential_revisions ( - workspace_id, credential_id, revision, public_key_algorithm, + r#"INSERT INTO repository_ssh_credential_keys ( + workspace_id, credential_id, operation_id, public_key_algorithm, public_key_fingerprint, created_at - ) VALUES (?1, ?2, 1, ?3, ?4, ?5)"#, + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#, params![ workspace_id, credential_id, + operation_id, parsed.algorithm, parsed.fingerprint, now @@ -530,7 +586,7 @@ impl RepositorySecretService { &tx, workspace_id, &credential_id, - 1, + &operation_id, "private_key", &private_secret, &now, @@ -540,7 +596,7 @@ impl RepositorySecretService { &tx, workspace_id, &credential_id, - 1, + &operation_id, "passphrase", secret, &now, @@ -551,7 +607,7 @@ impl RepositorySecretService { workspace_id, "credential_created", &credential_id, - 1, + &operation_id, actor_account_id, &now, )?; @@ -562,7 +618,7 @@ impl RepositorySecretService { &fingerprint, "credential", &credential_id, - 1, + &operation_id, &now, )?; let record = read_credential(&tx, workspace_id, &credential_id)?.ok_or_else(|| { @@ -588,22 +644,19 @@ impl RepositorySecretService { } let operation_id = validate_identifier("operation_id", &request.operation_id)?; let parsed = parse_private_key(&request.private_key, request.passphrase.as_deref())?; - let next_revision = request - .expected_revision - .checked_add(1) - .ok_or_else(|| Error::InvalidInput("credential revision overflow".to_string()))?; + validate_key_fingerprint(&request.expected_public_key_fingerprint)?; let fingerprint = credential_fingerprint( "rotate", &credential_id, "", - request.expected_revision, + &request.expected_public_key_fingerprint, &request.private_key, request.passphrase.as_deref(), ); let private_secret = self.seal( workspace_id, &credential_id, - next_revision, + &operation_id, "private_key", request.private_key.as_bytes(), )?; @@ -614,7 +667,7 @@ impl RepositorySecretService { self.seal( workspace_id, &credential_id, - next_revision, + &operation_id, "passphrase", value.as_bytes(), ) @@ -629,26 +682,35 @@ impl RepositorySecretService { &operation_id, &fingerprint, &credential_id, + LegacySecretRequest::Credential { + mutation_kind: "credential_rotated", + name: "", + expected_key: Some(&request.expected_public_key_fingerprint), + private_key: &request.private_key, + passphrase: request.passphrase.as_deref(), + }, )? { tx.commit()?; return Ok(replayed); } let current = read_credential(&tx, workspace_id, &credential_id)? .ok_or_else(|| Error::InvalidRecordId(credential_id.clone()))?; - if current.current_revision != request.expected_revision || current.status != "active" { + if current.public_key_fingerprint != request.expected_public_key_fingerprint + || current.status != "active" + { return Err(Error::WorkspaceConfigConflict(format!( - "credential `{credential_id}` revision/status changed" + "credential `{credential_id}` key fingerprint/status changed" ))); } tx.execute( - r#"INSERT INTO repository_ssh_credential_revisions ( - workspace_id, credential_id, revision, public_key_algorithm, + r#"INSERT INTO repository_ssh_credential_keys ( + workspace_id, credential_id, operation_id, public_key_algorithm, public_key_fingerprint, created_at ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#, params![ workspace_id, credential_id, - next_revision, + &operation_id, parsed.algorithm, parsed.fingerprint, now @@ -658,7 +720,7 @@ impl RepositorySecretService { &tx, workspace_id, &credential_id, - next_revision, + &operation_id, "private_key", &private_secret, &now, @@ -668,7 +730,7 @@ impl RepositorySecretService { &tx, workspace_id, &credential_id, - next_revision, + &operation_id, "passphrase", secret, &now, @@ -677,22 +739,22 @@ impl RepositorySecretService { let updated = tx.execute( r#"UPDATE repository_ssh_credentials SET public_key_algorithm = ?4, public_key_fingerprint = ?5, - current_revision = ?3, rotated_at = ?6 + current_operation_id = ?3, rotated_at = ?6 WHERE workspace_id = ?1 AND credential_id = ?2 - AND current_revision = ?7 AND status = 'active'"#, + AND public_key_fingerprint = ?7 AND status = 'active'"#, params![ workspace_id, credential_id, - next_revision, + &operation_id, parsed.algorithm, parsed.fingerprint, now, - request.expected_revision + request.expected_public_key_fingerprint ], )?; if updated != 1 { return Err(Error::WorkspaceConfigConflict(format!( - "credential `{credential_id}` revision changed" + "credential `{credential_id}` key fingerprint changed" ))); } insert_audit( @@ -700,7 +762,7 @@ impl RepositorySecretService { workspace_id, "credential_rotated", &credential_id, - next_revision, + &operation_id, actor_account_id, &now, )?; @@ -711,7 +773,7 @@ impl RepositorySecretService { &fingerprint, "credential", &credential_id, - next_revision, + &operation_id, &now, )?; let record = read_credential(&tx, workspace_id, &credential_id)?.ok_or_else(|| { @@ -737,6 +799,7 @@ impl RepositorySecretService { )); } let operation_id = validate_identifier("operation_id", &request.operation_id)?; + validate_key_fingerprint(&request.expected_public_key_fingerprint)?; let references = credential_references(projection, &credential_id); if !references.is_empty() { return Err(Error::WorkspaceConfigConflict(format!( @@ -746,26 +809,26 @@ impl RepositorySecretService { let fingerprint = simple_operation_fingerprint( "delete_credential", &credential_id, - request.expected_revision, + &request.expected_public_key_fingerprint, ); let now = now(); self.store.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; - if replay_deleted_operation(&tx, workspace_id, &operation_id, &fingerprint, "credential", &credential_id)? { + if replay_deleted_operation(&tx, workspace_id, &operation_id, &fingerprint, "credential", &credential_id, LegacySecretRequest::Delete { mutation_kind: "credential_deleted", expected_key: &request.expected_public_key_fingerprint })? { tx.commit()?; return Ok(()); } let current = read_credential(&tx, workspace_id, &credential_id)? .ok_or_else(|| Error::InvalidRecordId(credential_id.clone()))?; - if current.current_revision != request.expected_revision { + if current.public_key_fingerprint != request.expected_public_key_fingerprint { return Err(Error::WorkspaceConfigConflict(format!( - "credential `{credential_id}` revision changed" + "credential `{credential_id}` key fingerprint changed" ))); } let retained_by_workdir_create: bool = tx.query_row( r#"SELECT EXISTS( SELECT 1 - FROM workdir_create_credential_revision_retentions + FROM workdir_create_credential_retentions WHERE workspace_id = ?1 AND credential_id = ?2 )"#, params![workspace_id, credential_id], @@ -776,17 +839,17 @@ impl RepositorySecretService { "credential `{credential_id}` is retained by a retryable Workdir create operation" ))); } - insert_audit(&tx, workspace_id, "credential_deleted", &credential_id, current.current_revision, actor_account_id, &now)?; + insert_audit(&tx, workspace_id, "credential_deleted", &credential_id, &operation_id, actor_account_id, &now)?; let deleted = tx.execute( - "DELETE FROM repository_ssh_credentials WHERE workspace_id = ?1 AND credential_id = ?2 AND current_revision = ?3", - params![workspace_id, credential_id, request.expected_revision], + "DELETE FROM repository_ssh_credentials WHERE workspace_id = ?1 AND credential_id = ?2 AND public_key_fingerprint = ?3", + params![workspace_id, credential_id, request.expected_public_key_fingerprint], )?; if deleted != 1 { return Err(Error::WorkspaceConfigConflict(format!( - "credential `{credential_id}` revision changed" + "credential `{credential_id}` key fingerprint changed" ))); } - insert_operation(&tx, workspace_id, &operation_id, &fingerprint, "credential", &credential_id, request.expected_revision, &now)?; + insert_operation(&tx, workspace_id, &operation_id, &fingerprint, "credential", &credential_id, &operation_id, &now)?; tx.commit()?; Ok(()) }) @@ -800,7 +863,7 @@ impl RepositorySecretService { self.store.with_conn(|conn| { let mut statement = conn.prepare( r#"SELECT workspace_id, credential_id, name, public_key_algorithm, - public_key_fingerprint, current_revision, status, created_at, rotated_at + public_key_fingerprint, current_operation_id, status, created_at, rotated_at FROM repository_ssh_credentials WHERE workspace_id = ?1 ORDER BY credential_id"#, )?; @@ -840,6 +903,9 @@ impl RepositorySecretService { ) -> Result { let operation_id = validate_identifier("operation_id", &request.operation_id)?; let host_trust_id = validate_identifier("host_trust_id", &request.host_trust_id)?; + if let Some(expected) = request.expected_fingerprint.as_deref() { + validate_key_fingerprint(expected)?; + } let hostname = normalize_hostname(&request.hostname)?; if request.port == 0 { return Err(Error::InvalidInput( @@ -857,35 +923,32 @@ impl RepositorySecretService { &operation_id, &fingerprint, &host_trust_id, + LegacySecretRequest::Host { + hostname: &hostname, + port: request.port, + key_fingerprint: &parsed.fingerprint, + expected_key: request.expected_fingerprint.as_deref(), + }, )? { tx.commit()?; return Ok(replayed); } ensure_workspace_exists(&tx, workspace_id)?; let current = read_host_trust(&tx, workspace_id, &host_trust_id)?; - let next_revision = match (current.as_ref(), request.expected_revision) { - (None, None) => 1, - (Some(current), Some(expected)) if current.current_revision == expected => { - expected.checked_add(1).ok_or_else(|| { - Error::InvalidInput("host trust revision overflow".to_string()) - })? - } - (None, Some(_)) | (Some(_), None) => { + match (current.as_ref(), request.expected_fingerprint.as_deref()) { + (None, None) => {} + (Some(current), Some(expected)) if current.fingerprint == expected => {} + _ => { return Err(Error::WorkspaceConfigConflict(format!( - "host trust `{host_trust_id}` create/update precondition failed" + "host trust `{host_trust_id}` create/update operation precondition failed" ))); } - (Some(_), Some(_)) => { - return Err(Error::WorkspaceConfigConflict(format!( - "host trust `{host_trust_id}` revision changed" - ))); - } - }; + } if current.is_none() { tx.execute( r#"INSERT INTO repository_ssh_host_trusts ( workspace_id, host_trust_id, hostname, port, key_algorithm, - host_key, fingerprint, current_revision, created_at, updated_at + host_key, fingerprint, current_operation_id, created_at, updated_at ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)"#, params![ workspace_id, @@ -895,7 +958,7 @@ impl RepositorySecretService { parsed.algorithm, parsed.canonical_key, parsed.fingerprint, - next_revision, + &operation_id, now ], )?; @@ -904,32 +967,32 @@ impl RepositorySecretService { r#"UPDATE repository_ssh_host_trusts SET hostname = ?4, port = ?5, key_algorithm = ?6, host_key = ?7, fingerprint = ?8, - current_revision = ?3, updated_at = ?9 + current_operation_id = ?3, updated_at = ?9 WHERE workspace_id = ?1 AND host_trust_id = ?2 - AND current_revision = ?10"#, + AND fingerprint = ?10"#, params![ workspace_id, host_trust_id, - next_revision, + &operation_id, hostname, request.port, parsed.algorithm, parsed.canonical_key, parsed.fingerprint, now, - request.expected_revision + request.expected_fingerprint ], )?; } tx.execute( - r#"INSERT INTO repository_ssh_host_trust_revisions ( - workspace_id, host_trust_id, revision, hostname, port, + r#"INSERT INTO repository_ssh_host_trust_keys ( + workspace_id, host_trust_id, operation_id, hostname, port, key_algorithm, host_key, fingerprint, created_at ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"#, params![ workspace_id, host_trust_id, - next_revision, + &operation_id, hostname, request.port, parsed.algorithm, @@ -938,7 +1001,7 @@ impl RepositorySecretService { now ], )?; - let event = if next_revision == 1 { + let event = if current.is_none() { "host_trust_created" } else { "host_trust_rotated" @@ -948,7 +1011,7 @@ impl RepositorySecretService { workspace_id, event, &host_trust_id, - next_revision, + &operation_id, actor_account_id, &now, )?; @@ -959,7 +1022,7 @@ impl RepositorySecretService { &fingerprint, "host_trust", &host_trust_id, - next_revision, + &operation_id, &now, )?; let record = read_host_trust(&tx, workspace_id, &host_trust_id)?.ok_or_else(|| { @@ -980,6 +1043,7 @@ impl RepositorySecretService { ) -> Result<()> { let host_trust_id = validate_identifier("host_trust_id", host_trust_id)?; let operation_id = validate_identifier("operation_id", &request.operation_id)?; + validate_key_fingerprint(&request.expected_fingerprint)?; if !host_trust_references(projection, &host_trust_id).is_empty() { return Err(Error::WorkspaceConfigConflict(format!( "host trust `{host_trust_id}` is referenced by active Workspace config" @@ -988,33 +1052,33 @@ impl RepositorySecretService { let fingerprint = simple_operation_fingerprint( "delete_host_trust", &host_trust_id, - request.expected_revision, + &request.expected_fingerprint, ); let now = now(); self.store.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; - if replay_deleted_operation(&tx, workspace_id, &operation_id, &fingerprint, "host_trust", &host_trust_id)? { + if replay_deleted_operation(&tx, workspace_id, &operation_id, &fingerprint, "host_trust", &host_trust_id, LegacySecretRequest::Delete { mutation_kind: "host_trust_deleted", expected_key: &request.expected_fingerprint })? { tx.commit()?; return Ok(()); } let current = read_host_trust(&tx, workspace_id, &host_trust_id)? .ok_or_else(|| Error::InvalidRecordId(host_trust_id.clone()))?; - if current.current_revision != request.expected_revision { + if current.fingerprint != request.expected_fingerprint { return Err(Error::WorkspaceConfigConflict(format!( - "host trust `{host_trust_id}` revision changed" + "host trust `{host_trust_id}` key fingerprint changed" ))); } - insert_audit(&tx, workspace_id, "host_trust_deleted", &host_trust_id, current.current_revision, actor_account_id, &now)?; + insert_audit(&tx, workspace_id, "host_trust_deleted", &host_trust_id, &operation_id, actor_account_id, &now)?; let deleted = tx.execute( - "DELETE FROM repository_ssh_host_trusts WHERE workspace_id = ?1 AND host_trust_id = ?2 AND current_revision = ?3", - params![workspace_id, host_trust_id, request.expected_revision], + "DELETE FROM repository_ssh_host_trusts WHERE workspace_id = ?1 AND host_trust_id = ?2 AND fingerprint = ?3", + params![workspace_id, host_trust_id, request.expected_fingerprint], )?; if deleted != 1 { return Err(Error::WorkspaceConfigConflict(format!( - "host trust `{host_trust_id}` revision changed" + "host trust `{host_trust_id}` key fingerprint changed" ))); } - insert_operation(&tx, workspace_id, &operation_id, &fingerprint, "host_trust", &host_trust_id, request.expected_revision, &now)?; + insert_operation(&tx, workspace_id, &operation_id, &fingerprint, "host_trust", &host_trust_id, &operation_id, &now)?; tx.commit()?; Ok(()) }) @@ -1028,7 +1092,7 @@ impl RepositorySecretService { self.store.with_conn(|conn| { let mut statement = conn.prepare( r#"SELECT workspace_id, host_trust_id, hostname, port, key_algorithm, - host_key, fingerprint, current_revision, created_at, updated_at + host_key, fingerprint, current_operation_id, created_at, updated_at FROM repository_ssh_host_trusts WHERE workspace_id = ?1 ORDER BY host_trust_id"#, )?; @@ -1069,7 +1133,7 @@ impl RepositorySecretService { self.store.with_conn(|conn| { let mut statement = conn.prepare( r#"SELECT workspace_id, host_trust_id, hostname, port, key_algorithm, - host_key, fingerprint, current_revision, created_at, updated_at + host_key, fingerprint, current_operation_id, created_at, updated_at FROM repository_ssh_host_trusts WHERE workspace_id = ?1 AND lower(hostname) = lower(?2) AND port = ?3 ORDER BY host_trust_id"#, @@ -1132,95 +1196,129 @@ impl RepositorySecretService { workspace_id: &str, binding: &RepositorySshAccessBinding, ) -> Result { - let credential = self - .get_credential(workspace_id, &binding.credential_id, &[])? - .ok_or_else(|| { - Error::InvalidInput(format!( - "unknown Repository SSH credential `{}`", - binding.credential_id - )) - })?; - if credential.status != "active" { - return Err(Error::InvalidInput(format!( - "Repository SSH credential `{}` is not active", - binding.credential_id - ))); - } - let host_trust = self - .get_host_trust(workspace_id, &binding.host_trust_id, &[])? - .ok_or_else(|| { - Error::InvalidInput(format!( - "unknown Repository SSH host trust `{}`", - binding.host_trust_id - )) - })?; - self.lease_ssh_materialization_access_revision( + // A live binding carries current endpoint authority. Do not resolve it by + // fingerprint alone: a host key can legitimately have historical endpoints. + let (credential_operation, host_operation) = self.store.with_conn(|conn| { + let credential: String = conn.query_row( + "SELECT current_operation_id FROM repository_ssh_credentials WHERE workspace_id=?1 AND credential_id=?2 AND status='active'", + params![workspace_id, binding.credential_id], |row| row.get(0), + ).optional()?.ok_or_else(|| Error::InvalidInput("Repository SSH credential is unavailable or inactive".into()))?; + let host: String = conn.query_row( + "SELECT current_operation_id FROM repository_ssh_host_trusts WHERE workspace_id=?1 AND host_trust_id=?2", + params![workspace_id, binding.host_trust_id], |row| row.get(0), + ).optional()?.ok_or_else(|| Error::InvalidInput("Repository SSH host trust is unavailable".into()))?; + Ok((credential, host)) + })?; + self.lease_ssh_materialization_access_operation( workspace_id, &binding.credential_id, - credential.current_revision, + &credential_operation, &binding.host_trust_id, - host_trust.current_revision, + &host_operation, ) } - pub fn lease_ssh_materialization_access_revision( + pub fn lease_ssh_materialization_access_fingerprints( &self, workspace_id: &str, credential_id: &str, - credential_revision: u64, + credential_fingerprint: &str, host_trust_id: &str, - host_trust_revision: u64, + host_trust_fingerprint: &str, ) -> Result { - let (private_key, passphrase, hostname, port, host_key) = self.store.with_conn(|conn| { + let (credential_operation, host_operation) = self.store.with_conn(|conn| { + let credential_operation: String = conn.query_row( + "SELECT k.operation_id FROM repository_ssh_credential_keys k JOIN repository_ssh_credentials c ON c.workspace_id=k.workspace_id AND c.credential_id=k.credential_id WHERE k.workspace_id=?1 AND k.credential_id=?2 AND k.public_key_fingerprint=?3 AND c.status='active' ORDER BY (k.operation_id=c.current_operation_id) DESC, k.created_at DESC, k.operation_id DESC LIMIT 1", + params![workspace_id, credential_id, credential_fingerprint], |row| row.get(0), + )?; + let mut statement = conn.prepare("SELECT operation_id, hostname, port, host_key FROM repository_ssh_host_trust_keys WHERE workspace_id=?1 AND host_trust_id=?2 AND fingerprint=?3 ORDER BY created_at DESC, operation_id DESC")?; + let matches = statement.query_map(params![workspace_id, host_trust_id, host_trust_fingerprint], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, u16>(2)?, row.get::<_, String>(3)?)))?.collect::>>()?; + let first = matches.first().ok_or_else(|| Error::RegistryInconsistency("Repository SSH host key is unavailable".into()))?; + if matches.iter().any(|entry| (&entry.1, entry.2, &entry.3) != (&first.1, first.2, &first.3)) { + return Err(Error::RegistryInconsistency("Repository SSH host key fingerprint has ambiguous endpoint evidence".into())); + } + Ok((credential_operation, first.0.clone())) + })?; + self.lease_ssh_materialization_access_operation( + workspace_id, + credential_id, + &credential_operation, + host_trust_id, + &host_operation, + ) + } + + fn lease_ssh_materialization_access_operation( + &self, + workspace_id: &str, + credential_id: &str, + credential_operation_id: &str, + host_trust_id: &str, + host_trust_operation_id: &str, + ) -> Result { + let (private_key, passphrase, credential_fingerprint, hostname, port, host_key, host_fingerprint) = self.store.with_conn(|conn| { + let active: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM repository_ssh_credentials WHERE workspace_id=?1 AND credential_id=?2 AND status='active')", + params![workspace_id, credential_id], |row| row.get(0), + )?; + if !active { return Err(Error::InvalidInput("Repository SSH credential is unavailable or inactive".into())); } + let credential_fingerprint: String = conn.query_row( + "SELECT public_key_fingerprint FROM repository_ssh_credential_keys WHERE workspace_id = ?1 AND credential_id = ?2 AND operation_id = ?3", + params![workspace_id, credential_id, credential_operation_id], + |row| row.get(0), + ).optional()?.ok_or_else(|| Error::RegistryInconsistency( + "Repository SSH credential key identity is unavailable".to_string() + ))?; let private_key = read_sealed_secret( conn, workspace_id, credential_id, - credential_revision, + credential_operation_id, "private_key", )? .ok_or_else(|| { Error::RegistryInconsistency(format!( - "Repository SSH credential `{credential_id}` revision {credential_revision} is unavailable" + "Repository SSH credential `{credential_id}` operation_id {credential_operation_id} is unavailable" )) })?; let passphrase = read_sealed_secret( conn, workspace_id, credential_id, - credential_revision, + credential_operation_id, "passphrase", )?; - let (hostname, port, host_key) = conn + let (hostname, port, host_key, host_fingerprint) = conn .query_row( - r#"SELECT h.hostname, h.port, v.host_key + r#"SELECT v.hostname, v.port, v.host_key, v.fingerprint FROM repository_ssh_host_trusts h - JOIN repository_ssh_host_trust_revisions v + JOIN repository_ssh_host_trust_keys v ON v.workspace_id = h.workspace_id AND v.host_trust_id = h.host_trust_id WHERE h.workspace_id = ?1 AND h.host_trust_id = ?2 - AND v.revision = ?3"#, - params![workspace_id, host_trust_id, host_trust_revision as i64], + AND v.operation_id = ?3"#, + params![workspace_id, host_trust_id, host_trust_operation_id], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u16, row.get::<_, String>(2)?, + row.get::<_, String>(3)?, )) }, ) .optional()? .ok_or_else(|| { Error::RegistryInconsistency(format!( - "Repository SSH host trust `{host_trust_id}` revision {host_trust_revision} is unavailable" + "Repository SSH host trust `{host_trust_id}` operation_id {host_trust_operation_id} is unavailable" )) })?; - Ok((private_key, passphrase, hostname, port, host_key)) + Ok((private_key, passphrase, credential_fingerprint, hostname, port, host_key, host_fingerprint)) })?; let private_key = self.unseal( workspace_id, credential_id, - credential_revision, + credential_operation_id, "private_key", private_key, )?; @@ -1229,7 +1327,7 @@ impl RepositorySecretService { self.unseal( workspace_id, credential_id, - credential_revision, + credential_operation_id, "passphrase", secret, ) @@ -1253,12 +1351,22 @@ impl RepositorySecretService { })?; let key = if key.is_encrypted() { key.decrypt(passphrase.as_deref().ok_or_else(|| { - Error::Store("Repository SSH passphrase revision is unavailable".to_string()) + Error::Store("Repository SSH passphrase operation_id is unavailable".to_string()) })?) .map_err(|_| Error::Store("Repository SSH private key decryption failed".to_string()))? } else { key }; + if key.public_key().fingerprint(HashAlg::Sha256).to_string() != credential_fingerprint + || parse_host_key(&host_key) + .map_err(|_| Error::Store("stored host key is invalid".to_string()))? + .fingerprint + != host_fingerprint + { + return Err(Error::RegistryInconsistency( + "Repository SSH key identity mismatch".to_string(), + )); + } let private_key = key .to_openssh(LineEnding::LF) .map_err(|_| Error::Store("Repository SSH private key encoding failed".to_string()))?; @@ -1269,9 +1377,9 @@ impl RepositorySecretService { }; Ok(LeasedRepositorySshAccess { credential_id: credential_id.to_string(), - credential_revision, + credential_fingerprint, host_trust_id: host_trust_id.to_string(), - host_trust_revision, + host_trust_fingerprint: host_fingerprint, private_key, known_hosts_entry: format!("{host} {host_key}\n"), }) @@ -1281,7 +1389,7 @@ impl RepositorySecretService { &self, workspace_id: &str, credential_id: &str, - revision: u64, + operation_id: &str, purpose: &str, secret: SealedSecret, ) -> Result> { @@ -1292,7 +1400,7 @@ impl RepositorySecretService { .map_err(|_| Error::Store("Repository secret encryption key is invalid".to_string()))?; let key = LessSafeKey::new(unbound); let mut plaintext = secret.ciphertext; - let aad = secret_aad(workspace_id, credential_id, revision, purpose); + let aad = secret_aad(workspace_id, credential_id, operation_id, purpose); let plaintext_len = key .open_in_place( Nonce::assume_unique_for_key(secret.nonce), @@ -1309,7 +1417,7 @@ impl RepositorySecretService { &self, workspace_id: &str, credential_id: &str, - revision: u64, + operation_id: &str, purpose: &str, plaintext: &[u8], ) -> Result { @@ -1329,7 +1437,7 @@ impl RepositorySecretService { .fill(&mut nonce) .map_err(|_| Error::Store("Repository secret nonce generation failed".to_string()))?; let mut ciphertext = plaintext.to_vec(); - let aad = secret_aad(workspace_id, credential_id, revision, purpose); + let aad = secret_aad(workspace_id, credential_id, operation_id, purpose); key.seal_in_place_append_tag( Nonce::assume_unique_for_key(nonce), Aad::from(aad.as_bytes()), @@ -1425,20 +1533,20 @@ fn insert_secret( tx: &rusqlite::Transaction<'_>, workspace_id: &str, credential_id: &str, - revision: u64, + operation_id: &str, purpose: &str, secret: &SealedSecret, created_at: &str, ) -> Result<()> { tx.execute( - r#"INSERT INTO server_secret_versions ( - workspace_id, secret_id, revision, purpose, encryption_algorithm, + r#"INSERT INTO server_secret_objects ( + workspace_id, secret_id, operation_id, purpose, encryption_algorithm, nonce, ciphertext, created_at ) VALUES (?1, ?2, ?3, ?4, 'aes-256-gcm-v1', ?5, ?6, ?7)"#, params![ workspace_id, credential_id, - revision, + operation_id, purpose, secret.nonce.as_slice(), secret.ciphertext, @@ -1452,16 +1560,16 @@ fn read_sealed_secret( conn: &rusqlite::Connection, workspace_id: &str, credential_id: &str, - revision: u64, + operation_id: &str, purpose: &str, ) -> Result> { let row = conn .query_row( r#"SELECT encryption_algorithm, nonce, ciphertext - FROM server_secret_versions + FROM server_secret_objects WHERE workspace_id = ?1 AND secret_id = ?2 - AND revision = ?3 AND purpose = ?4"#, - params![workspace_id, credential_id, revision, purpose], + AND operation_id = ?3 AND purpose = ?4"#, + params![workspace_id, credential_id, operation_id, purpose], |row| { Ok(( row.get::<_, String>(0)?, @@ -1487,27 +1595,166 @@ fn read_sealed_secret( })) } +// Complete caller typed input is hashed in the frozen encoding. Counters are +// evidence for an already committed receipt only, never live mutation CAS. +enum LegacySecretRequest<'a> { + Credential { + mutation_kind: &'a str, + name: &'a str, + expected_key: Option<&'a str>, + private_key: &'a str, + passphrase: Option<&'a str>, + }, + Host { + hostname: &'a str, + port: u16, + key_fingerprint: &'a str, + expected_key: Option<&'a str>, + }, + Delete { + mutation_kind: &'a str, + expected_key: &'a str, + }, +} + +fn matches_legacy_secret_replay( + conn: &rusqlite::Connection, + workspace_id: &str, + operation_id: &str, + resource_id: &str, + stored_fingerprint: &str, + request: LegacySecretRequest<'_>, +) -> Result { + let evidence: Option<(String,Option,Option,Option,Option)> = conn.query_row( + "SELECT request_fingerprint,mutation_kind,expected_revision,expected_operation_id,expected_key_fingerprint FROM repository_secret_legacy_receipts WHERE workspace_id=?1 AND operation_id=?2 AND resource_id=?3", + params![workspace_id,operation_id,resource_id], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?)), + ).optional()?; + let Some((original, Some(mutation_kind), Some(counter), prior_operation, prior_key)) = evidence + else { + return Ok(false); + }; + let Ok(counter) = u64::try_from(counter) else { + return Ok(false); + }; + if original != stored_fingerprint { + return Ok(false); + } + let (requested_kind, expected_key) = match &request { + LegacySecretRequest::Credential { + mutation_kind, + expected_key, + .. + } => (*mutation_kind, *expected_key), + LegacySecretRequest::Host { expected_key, .. } => ( + if expected_key.is_some() { + "host_trust_rotated" + } else { + "host_trust_created" + }, + *expected_key, + ), + LegacySecretRequest::Delete { + mutation_kind, + expected_key, + } => (*mutation_kind, Some(*expected_key)), + }; + if requested_kind != mutation_kind { + return Ok(false); + } + match expected_key { + None if counter == 0 + && matches!( + mutation_kind.as_str(), + "credential_created" | "host_trust_created" + ) => {} + Some(expected) + if prior_key.as_deref() == Some(expected) + && prior_operation.as_ref().is_some_and(|id| !id.is_empty()) => {} + _ => return Ok(false), + } + let mut hasher = Sha256::new(); + match request { + LegacySecretRequest::Credential { + mutation_kind, + name, + private_key, + passphrase, + .. + } => { + hasher.update(b"yoi repository credential operation v1"); + hasher.update(if mutation_kind == "credential_created" { + "create" + } else { + "rotate" + }); + hasher.update(resource_id.as_bytes()); + hasher.update(name.as_bytes()); + hasher.update(counter.to_be_bytes()); + hasher.update(Sha256::digest(private_key.as_bytes())); + if let Some(passphrase) = passphrase { + hasher.update(Sha256::digest(passphrase.as_bytes())); + } + } + LegacySecretRequest::Host { + hostname, + port, + key_fingerprint, + .. + } => { + hasher.update(b"yoi repository host trust operation v1"); + hasher.update(resource_id.as_bytes()); + hasher.update(hostname.as_bytes()); + hasher.update(port.to_be_bytes()); + hasher.update(key_fingerprint.as_bytes()); + hasher.update(counter.to_be_bytes()); + } + LegacySecretRequest::Delete { mutation_kind, .. } => { + hasher.update(b"yoi repository secret simple operation v1"); + hasher.update(if mutation_kind == "credential_deleted" { + "delete_credential" + } else { + "delete_host_trust" + }); + hasher.update(resource_id.as_bytes()); + hasher.update(counter.to_be_bytes()); + } + } + Ok(format!("sha256:{}", encode_hex(&hasher.finalize())) == original) +} + fn replay_credential_operation( tx: &rusqlite::Transaction<'_>, workspace_id: &str, operation_id: &str, fingerprint: &str, credential_id: &str, + legacy_request: LegacySecretRequest<'_>, ) -> Result> { let operation = read_operation(tx, workspace_id, operation_id)?; let Some((stored_fingerprint, kind, resource_id, _)) = operation else { return Ok(None); }; - if stored_fingerprint != fingerprint || kind != "credential" || resource_id != credential_id { + if kind != "credential" + || resource_id != credential_id + || (stored_fingerprint != fingerprint + && !matches_legacy_secret_replay( + tx, + workspace_id, + operation_id, + credential_id, + &stored_fingerprint, + legacy_request, + )?) + { return Err(Error::WorkspaceConfigConflict( - "Repository secret operation id was reused with different input".to_string(), + "Repository secret operation id was reused with different input or lacks legacy replay evidence; recover committed status with operation_status, never reexecute".to_string(), )); } read_credential(tx, workspace_id, credential_id)? .map(Some) .ok_or_else(|| { Error::RegistryInconsistency( - "Repository secret operation replay points to a missing credential".to_string(), + "Repository secret operation is committed but its credential was removed; recover operation_status, never reexecute".to_string(), ) }) } @@ -1518,21 +1765,33 @@ fn replay_host_operation( operation_id: &str, fingerprint: &str, host_trust_id: &str, + legacy_request: LegacySecretRequest<'_>, ) -> Result> { let operation = read_operation(tx, workspace_id, operation_id)?; let Some((stored_fingerprint, kind, resource_id, _)) = operation else { return Ok(None); }; - if stored_fingerprint != fingerprint || kind != "host_trust" || resource_id != host_trust_id { + if kind != "host_trust" + || resource_id != host_trust_id + || (stored_fingerprint != fingerprint + && !matches_legacy_secret_replay( + tx, + workspace_id, + operation_id, + host_trust_id, + &stored_fingerprint, + legacy_request, + )?) + { return Err(Error::WorkspaceConfigConflict( - "Repository host trust operation id was reused with different input".to_string(), + "Repository host trust operation id was reused with different input or lacks legacy replay evidence; recover committed status with operation_status, never reexecute".to_string(), )); } read_host_trust(tx, workspace_id, host_trust_id)? .map(Some) .ok_or_else(|| { Error::RegistryInconsistency( - "Repository host trust operation replay points to a missing record".to_string(), + "Repository host trust operation is committed but its resource was removed; recover operation_status, never reexecute".to_string(), ) }) } @@ -1544,15 +1803,27 @@ fn replay_deleted_operation( fingerprint: &str, kind: &str, resource_id: &str, + legacy_request: LegacySecretRequest<'_>, ) -> Result { let Some((stored_fingerprint, stored_kind, stored_resource, _)) = read_operation(tx, workspace_id, operation_id)? else { return Ok(false); }; - if stored_fingerprint != fingerprint || stored_kind != kind || stored_resource != resource_id { + if stored_kind != kind + || stored_resource != resource_id + || (stored_fingerprint != fingerprint + && !matches_legacy_secret_replay( + tx, + workspace_id, + operation_id, + resource_id, + &stored_fingerprint, + legacy_request, + )?) + { return Err(Error::WorkspaceConfigConflict( - "Repository secret operation id was reused with different input".to_string(), + "Repository secret operation id was reused with different input or lacks legacy replay evidence; recover committed status with operation_status, never reexecute".to_string(), )); } Ok(true) @@ -1562,20 +1833,13 @@ fn read_operation( conn: &rusqlite::Connection, workspace_id: &str, operation_id: &str, -) -> Result> { +) -> Result> { conn.query_row( - r#"SELECT request_fingerprint, resource_kind, resource_id, result_revision + r#"SELECT request_fingerprint, resource_kind, resource_id, result_operation_id FROM repository_secret_operations WHERE workspace_id = ?1 AND operation_id = ?2"#, params![workspace_id, operation_id], - |row| { - Ok(( - row.get(0)?, - row.get(1)?, - row.get(2)?, - row.get::<_, i64>(3)? as u64, - )) - }, + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), ) .optional() .map_err(Into::into) @@ -1588,13 +1852,13 @@ fn insert_operation( fingerprint: &str, kind: &str, resource_id: &str, - revision: u64, + result_operation_id: &str, created_at: &str, ) -> Result<()> { tx.execute( r#"INSERT INTO repository_secret_operations ( workspace_id, operation_id, request_fingerprint, resource_kind, - resource_id, result_revision, created_at + resource_id, result_operation_id, created_at ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"#, params![ workspace_id, @@ -1602,7 +1866,7 @@ fn insert_operation( fingerprint, kind, resource_id, - revision, + result_operation_id, created_at ], )?; @@ -1614,13 +1878,13 @@ fn insert_audit( workspace_id: &str, kind: &str, resource_id: &str, - revision: u64, + operation_id: &str, actor_account_id: &str, created_at: &str, ) -> Result<()> { tx.execute( r#"INSERT INTO repository_secret_audit_events ( - workspace_id, event_id, kind, resource_id, revision, + workspace_id, event_id, kind, resource_id, operation_id, actor_account_id, created_at ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"#, params![ @@ -1628,7 +1892,7 @@ fn insert_audit( format!("repo-secret-audit-{}", uuid::Uuid::now_v7()), kind, resource_id, - revision, + operation_id, actor_account_id, created_at ], @@ -1668,7 +1932,7 @@ fn read_credential( ) -> Result> { conn.query_row( r#"SELECT workspace_id, credential_id, name, public_key_algorithm, - public_key_fingerprint, current_revision, status, created_at, rotated_at + public_key_fingerprint, current_operation_id, status, created_at, rotated_at FROM repository_ssh_credentials WHERE workspace_id = ?1 AND credential_id = ?2"#, params![workspace_id, credential_id], @@ -1685,7 +1949,6 @@ fn read_credential_row(row: &rusqlite::Row<'_>) -> rusqlite::Result(5)? as u64, status: row.get(6)?, created_at: row.get(7)?, rotated_at: row.get(8)?, @@ -1700,7 +1963,7 @@ fn read_host_trust( ) -> Result> { conn.query_row( r#"SELECT workspace_id, host_trust_id, hostname, port, key_algorithm, - host_key, fingerprint, current_revision, created_at, updated_at + host_key, fingerprint, current_operation_id, created_at, updated_at FROM repository_ssh_host_trusts WHERE workspace_id = ?1 AND host_trust_id = ?2"#, params![workspace_id, host_trust_id], @@ -1719,7 +1982,6 @@ fn read_host_trust_row(row: &rusqlite::Row<'_>) -> rusqlite::Result(7)? as u64, created_at: row.get(8)?, updated_at: row.get(9)?, referenced_repositories: Vec::new(), @@ -1804,14 +2066,85 @@ fn load_or_create_master_key(path: &Path) -> Result<[u8; MASTER_KEY_BYTES]> { } } +/// Frozen bridge for the parent's one-time legacy schema migration. Ciphertext +/// cannot be copied: its authenticated context included the old numeric identity. +/// Migration requires the existing master key and never creates a replacement. +#[allow(dead_code)] +pub(crate) fn migrate_legacy_repository_secret_envelope( + database_path: &Path, + workspace_id: &str, + credential_id: &str, + legacy_revision: u64, + operation_id: &str, + purpose: &str, + nonce: &[u8], + ciphertext: &[u8], +) -> Result<(Vec, Vec)> { + let master_bytes = + zeroize::Zeroizing::new(std::fs::read(master_key_path(database_path)?).map_err(|_| { + Error::Store("Repository secret migration master key is unavailable".to_string()) + })?); + let master_key = zeroize::Zeroizing::new(master_key_from_bytes(&master_bytes)?); + let unbound = UnboundKey::new(&AES_256_GCM, master_key.as_slice()) + .map_err(|_| Error::Store("Repository secret migration key is invalid".to_string()))?; + let key = LessSafeKey::new(unbound); + let old_nonce: [u8; NONCE_BYTES] = nonce.try_into().map_err(|_| { + Error::RegistryInconsistency("Repository secret migration nonce is invalid".to_string()) + })?; + let old_aad = format!( + "yoi/repository-secret/v1/{workspace_id}/{credential_id}/{legacy_revision}/{purpose}" + ); + let mut plaintext = zeroize::Zeroizing::new(ciphertext.to_vec()); + let plaintext_len = key + .open_in_place( + Nonce::assume_unique_for_key(old_nonce), + Aad::from(old_aad.as_bytes()), + plaintext.as_mut_slice(), + ) + .map_err(|_| Error::Store("Repository secret migration decryption failed".to_string()))? + .len(); + plaintext.truncate(plaintext_len); + let mut new_nonce = [0; NONCE_BYTES]; + SystemRandom::new().fill(&mut new_nonce).map_err(|_| { + Error::Store("Repository secret migration nonce generation failed".to_string()) + })?; + let new_aad = secret_aad(workspace_id, credential_id, operation_id, purpose); + key.seal_in_place_append_tag( + Nonce::assume_unique_for_key(new_nonce), + Aad::from(new_aad.as_bytes()), + &mut *plaintext, + ) + .map_err(|_| Error::Store("Repository secret migration encryption failed".to_string()))?; + Ok((new_nonce.to_vec(), plaintext.to_vec())) +} + fn master_key_from_bytes(bytes: &[u8]) -> Result<[u8; MASTER_KEY_BYTES]> { bytes .try_into() .map_err(|_| Error::Store("Repository secret master key has an invalid length".to_string())) } -fn secret_aad(workspace_id: &str, credential_id: &str, revision: u64, purpose: &str) -> String { - format!("yoi/repository-secret/v1/{workspace_id}/{credential_id}/{revision}/{purpose}") +fn secret_aad( + workspace_id: &str, + credential_id: &str, + operation_id: &str, + purpose: &str, +) -> String { + serde_json::to_string(&( + "yoi/repository-secret/operation", + workspace_id, + credential_id, + operation_id, + purpose, + )) + .expect("string tuple is serializable") +} + +fn validate_key_fingerprint(value: &str) -> Result<()> { + value + .parse::() + .map_err(|_| Error::InvalidInput("invalid SSH key fingerprint".into()))?; + Ok(()) } fn validate_identifier(field: &str, value: &str) -> Result { @@ -1866,23 +2199,19 @@ fn credential_fingerprint( kind: &str, credential_id: &str, name: &str, - expected_revision: u64, + expected_key_fingerprint: &str, private_key: &str, passphrase: Option<&str>, ) -> String { - let mut hasher = Sha256::new(); - hasher.update(b"yoi repository credential operation v1"); - hasher.update(kind.as_bytes()); - hasher.update(credential_id.as_bytes()); - hasher.update(name.as_bytes()); - hasher.update(expected_revision.to_be_bytes()); - hasher.update(Sha256::digest(private_key.as_bytes())); - hasher.update( - passphrase - .map(|value| Sha256::digest(value.as_bytes()).to_vec()) - .unwrap_or_default(), - ); - format!("sha256:{}", encode_hex(&hasher.finalize())) + operation_fingerprint(&( + "credential", + kind, + credential_id, + name, + expected_key_fingerprint, + encode_hex(&Sha256::digest(private_key.as_bytes())), + passphrase.map(|value| encode_hex(&Sha256::digest(value.as_bytes()))), + )) } fn host_operation_fingerprint( @@ -1890,23 +2219,27 @@ fn host_operation_fingerprint( hostname: &str, key_fingerprint: &str, ) -> String { - let mut hasher = Sha256::new(); - hasher.update(b"yoi repository host trust operation v1"); - hasher.update(request.host_trust_id.as_bytes()); - hasher.update(hostname.as_bytes()); - hasher.update(request.port.to_be_bytes()); - hasher.update(key_fingerprint.as_bytes()); - hasher.update(request.expected_revision.unwrap_or(0).to_be_bytes()); - format!("sha256:{}", encode_hex(&hasher.finalize())) + operation_fingerprint(&( + "host_trust", + request.host_trust_id.trim(), + hostname, + request.port, + key_fingerprint, + request.expected_fingerprint.as_deref(), + )) } -fn simple_operation_fingerprint(kind: &str, resource_id: &str, revision: u64) -> String { - let mut hasher = Sha256::new(); - hasher.update(b"yoi repository secret simple operation v1"); - hasher.update(kind.as_bytes()); - hasher.update(resource_id.as_bytes()); - hasher.update(revision.to_be_bytes()); - format!("sha256:{}", encode_hex(&hasher.finalize())) +fn simple_operation_fingerprint( + kind: &str, + resource_id: &str, + expected_key_fingerprint: &str, +) -> String { + operation_fingerprint(&(kind, resource_id, expected_key_fingerprint)) +} + +fn operation_fingerprint(value: &impl serde::Serialize) -> String { + let bytes = serde_json::to_vec(value).expect("operation identity is serializable"); + format!("sha256:{}", encode_hex(&Sha256::digest(bytes))) } fn encode_hex(bytes: &[u8]) -> String { @@ -2043,7 +2376,6 @@ mod tests { .unwrap(); assert_eq!(created, replayed); - assert_eq!(created.current_revision, 1); assert_eq!( public_key.public_key_fingerprint, created.public_key_fingerprint @@ -2055,7 +2387,7 @@ mod tests { WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID, RotateRepositorySshCredentialRequest { operation_id: "rotate-default".to_string(), - expected_revision: 1, + expected_public_key_fingerprint: created.public_key_fingerprint.clone(), private_key: test_private_key(12).0, passphrase: None, }, @@ -2070,12 +2402,11 @@ mod tests { WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID, DeleteRepositorySshCredentialRequest { operation_id: "delete-default".to_string(), - expected_revision: 1, + expected_public_key_fingerprint: created.public_key_fingerprint.clone(), }, "owner-a", &RepositoryAccessProjection { workspace_id: "workspace-a".to_string(), - config_revision: 1, projection_digest: "sha256:empty".to_string(), bindings: Vec::new(), }, @@ -2105,7 +2436,6 @@ mod tests { .unwrap(); assert_eq!(replayed, created); - assert_eq!(public_key.current_revision, created.current_revision); assert_eq!( public_key.public_key_fingerprint, created.public_key_fingerprint @@ -2138,7 +2468,6 @@ mod tests { .create_credential("workspace-a", request, "owner-a") .unwrap(); assert_eq!(created, replayed); - assert_eq!(created.current_revision, 1); assert!( service .get_credential("workspace-b", "deploy-main", &[]) @@ -2153,7 +2482,7 @@ mod tests { store .with_conn(|conn| { let (nonce, ciphertext): (Vec, Vec) = conn.query_row( - "SELECT nonce, ciphertext FROM server_secret_versions WHERE workspace_id = 'workspace-a' AND secret_id = 'deploy-main' AND revision = 1 AND purpose = 'private_key'", + "SELECT nonce, ciphertext FROM server_secret_objects WHERE workspace_id = 'workspace-a' AND secret_id = 'deploy-main' AND operation_id = 'create-one' AND purpose = 'private_key'", [], |row| Ok((row.get(0)?, row.get(1)?)), )?; @@ -2170,14 +2499,13 @@ mod tests { "deploy-main", RotateRepositorySshCredentialRequest { operation_id: "rotate-one".to_string(), - expected_revision: 1, + expected_public_key_fingerprint: created.public_key_fingerprint.clone(), private_key: rotated_key, passphrase: None, }, "owner-a", ) .unwrap(); - assert_eq!(rotated.current_revision, 2); assert_ne!( rotated.public_key_fingerprint, created.public_key_fingerprint @@ -2185,7 +2513,7 @@ mod tests { } #[test] - fn pinned_host_trust_has_stable_fingerprint_and_revision() { + fn pinned_host_trust_has_stable_key_identity_across_operations() { let (_dir, _store, service) = test_service(); let (_, public_key) = test_private_key(9); let created = service @@ -2197,13 +2525,12 @@ mod tests { hostname: "GitHub.COM.".to_string(), port: 22, host_key: public_key.clone(), - expected_revision: None, + expected_fingerprint: None, }, "owner-a", ) .unwrap(); assert_eq!(created.hostname, "github.com"); - assert_eq!(created.current_revision, 1); let updated = service .put_host_trust( "workspace-a", @@ -2213,19 +2540,18 @@ mod tests { hostname: "github.com".to_string(), port: 22, host_key: public_key, - expected_revision: Some(1), + expected_fingerprint: Some(created.fingerprint.clone()), }, "owner-a", ) .unwrap(); - assert_eq!(updated.current_revision, 2); assert_eq!(updated.fingerprint, created.fingerprint); } fn config_state(source: &str) -> WorkspaceConfigState { let path = VirtualPath::parse("main.dcdl").unwrap(); let entry = ConfigEntry::new(path.clone(), ConfigContentType::Decodal, source).unwrap(); - let snapshot = ConfigTreeSnapshot::from_entries(1, vec![entry]).unwrap(); + let snapshot = ConfigTreeSnapshot::from_entries(vec![entry]).unwrap(); let schema_bundle = WorkspaceConfigSchemaBundle::compose(vec![ RepositoryAccessConfigSchemaProvider.contribution().unwrap(), ]) @@ -2264,7 +2590,6 @@ mod tests { provider: Some("git".to_string()), source, default_ref: Some("main".to_string()), - source_revision: 1, source_fingerprint, observed_status: RepositoryObservedStatus::Unverified, observed_at: None, @@ -2304,7 +2629,7 @@ mod tests { hostname: "example.test".to_string(), port: 22, host_key: public_key, - expected_revision: None, + expected_fingerprint: None, }, "owner-a", ) @@ -2333,8 +2658,6 @@ mod tests { let lease = service .lease_ssh_materialization_access("workspace-a", &projection.bindings[0]) .unwrap(); - assert_eq!(lease.credential_revision, 1); - assert_eq!(lease.host_trust_revision, 1); assert!(lease.private_key.contains("BEGIN OPENSSH PRIVATE KEY")); assert!( lease @@ -2342,16 +2665,16 @@ mod tests { .starts_with("example.test ssh-ed25519 ") ); let exact = service - .lease_ssh_materialization_access_revision( + .lease_ssh_materialization_access_fingerprints( "workspace-a", "deploy", - lease.credential_revision, + &lease.credential_fingerprint, "example", - lease.host_trust_revision, + &lease.host_trust_fingerprint, ) .unwrap(); - assert_eq!(exact.credential_revision, lease.credential_revision); - assert_eq!(exact.host_trust_revision, lease.host_trust_revision); + assert_eq!(exact.credential_fingerprint, lease.credential_fingerprint); + assert_eq!(exact.host_trust_fingerprint, lease.host_trust_fingerprint); assert_eq!(exact.known_hosts_entry, lease.known_hosts_entry); let unknown = config_state( @@ -2399,7 +2722,7 @@ mod tests { hostname: "example.test".to_string(), port: 22, host_key, - expected_revision: None, + expected_fingerprint: None, }, "owner-a", ) @@ -2440,7 +2763,7 @@ mod tests { hostname: "example.test".to_string(), port: 22, host_key: second_host_key, - expected_revision: None, + expected_fingerprint: None, }, "owner-a", ) @@ -2457,10 +2780,10 @@ mod tests { } #[test] - fn retryable_workdir_create_retains_candidate_revision_until_success() { + fn retryable_workdir_create_retains_candidate_key_until_success() { let (_dir, store, service) = test_service(); let (private_key, _) = test_private_key(13); - service + let credential = service .create_credential( "workspace-a", CreateRepositorySshCredentialRequest { @@ -2481,16 +2804,14 @@ mod tests { selector: Some("develop".to_string()), requested_runtime_id: Some("runtime-a".to_string()), resolved_runtime_id: "runtime-a".to_string(), - config_revision: 1, config_projection_digest: "sha256:projection".to_string(), source_kind: Some("ssh".to_string()), source_uri: Some("ssh://git@example.test/org/main.git".to_string()), - source_revision: Some(1), source_fingerprint: Some("sha256:source".to_string()), credential_id: None, - credential_revision: None, + credential_fingerprint: None, host_trust_id: None, - host_trust_revision: None, + host_trust_fingerprint: None, repository_access_mode: None, credential_candidates: Vec::new(), working_directory_id: "workdir-retained".to_string(), @@ -2503,7 +2824,7 @@ mod tests { let candidates = vec![crate::store::WorkdirCreateCredentialCandidate { role: crate::store::WorkdirCreateCredentialCandidateRole::Primary, credential_id: "retained-deploy".to_string(), - credential_revision: 1, + credential_fingerprint: credential.public_key_fingerprint.clone(), }]; store .bind_workdir_create_repository_access( @@ -2511,9 +2832,9 @@ mod tests { "create-workdir-retained", "sha256:request", "retained-deploy", - 1, + &credential.public_key_fingerprint, "host-a", - 1, + "host-created", "read_only", &candidates, "2026-08-24T00:00:01Z", @@ -2521,7 +2842,6 @@ mod tests { .unwrap(); let projection = RepositoryAccessProjection { workspace_id: "workspace-a".to_string(), - config_revision: 1, projection_digest: "sha256:empty".to_string(), bindings: Vec::new(), }; @@ -2532,7 +2852,7 @@ mod tests { "retained-deploy", DeleteRepositorySshCredentialRequest { operation_id: "delete-retained".to_string(), - expected_revision: 1, + expected_public_key_fingerprint: credential.public_key_fingerprint.clone(), }, "owner-a", &projection, @@ -2556,7 +2876,7 @@ mod tests { "retained-deploy", DeleteRepositorySshCredentialRequest { operation_id: "delete-released".to_string(), - expected_revision: 1, + expected_public_key_fingerprint: credential.public_key_fingerprint.clone(), }, "owner-a", &projection, @@ -2564,6 +2884,404 @@ mod tests { .unwrap(); } + fn seed_lease_binding( + service: &RepositorySecretService, + ) -> ( + RepositorySshCredential, + RepositorySshHostTrust, + RepositorySshAccessBinding, + ) { + let (private_key, host_key) = test_private_key(21); + let credential = service + .create_credential( + "workspace-a", + CreateRepositorySshCredentialRequest { + operation_id: "key-create".into(), + credential_id: "deploy".into(), + name: "Deploy".into(), + private_key, + passphrase: None, + }, + "owner-a", + ) + .unwrap(); + let host = service + .put_host_trust( + "workspace-a", + PutRepositorySshHostTrustRequest { + operation_id: "trust-create".into(), + host_trust_id: "host".into(), + hostname: "old.example.test".into(), + port: 22, + host_key, + expected_fingerprint: None, + }, + "owner-a", + ) + .unwrap(); + let binding = RepositorySshAccessBinding { + repository_key: "remote".into(), + credential_id: "deploy".into(), + host_trust_id: "host".into(), + access: RepositoryAccessMode::ReadOnly, + }; + (credential, host, binding) + } + + #[test] + fn same_key_reseal_keeps_key_precondition_and_replay_does_not_revert_rotation() { + let (_dir, store, service) = test_service(); + let (credential, _host, binding) = seed_lease_binding(&service); + let reseal = RotateRepositorySshCredentialRequest { + operation_id: "key-reseal".into(), + expected_public_key_fingerprint: credential.public_key_fingerprint.clone(), + private_key: test_private_key(21).0, + passphrase: None, + }; + let resealed = service + .rotate_credential("workspace-a", "deploy", reseal.clone(), "owner-a") + .unwrap(); + assert_eq!( + resealed.public_key_fingerprint, + credential.public_key_fingerprint + ); + assert_eq!( + service + .rotate_credential("workspace-a", "deploy", reseal.clone(), "owner-a") + .unwrap(), + resealed + ); + let changed = service + .rotate_credential( + "workspace-a", + "deploy", + RotateRepositorySshCredentialRequest { + operation_id: "key-change".into(), + expected_public_key_fingerprint: credential.public_key_fingerprint.clone(), + private_key: test_private_key(22).0, + passphrase: None, + }, + "owner-a", + ) + .unwrap(); + assert_ne!( + changed.public_key_fingerprint, + credential.public_key_fingerprint + ); + let replayed = service + .rotate_credential("workspace-a", "deploy", reseal.clone(), "owner-a") + .unwrap(); + assert_eq!( + replayed.public_key_fingerprint, + changed.public_key_fingerprint + ); + let stale = RotateRepositorySshCredentialRequest { + operation_id: "key-stale".into(), + ..reseal.clone() + }; + assert!(matches!( + service.rotate_credential("workspace-a", "deploy", stale, "owner-a"), + Err(Error::WorkspaceConfigConflict(_)) + )); + let reused = RotateRepositorySshCredentialRequest { + private_key: test_private_key(23).0, + ..reseal + }; + assert!(matches!( + service.rotate_credential("workspace-a", "deploy", reused, "owner-a"), + Err(Error::WorkspaceConfigConflict(_)) + )); + let historical = service + .lease_ssh_materialization_access_operation( + "workspace-a", + "deploy", + "key-create", + "host", + "trust-create", + ) + .unwrap(); + assert_eq!( + historical.credential_fingerprint, + credential.public_key_fingerprint + ); + assert_eq!( + service + .lease_ssh_materialization_access("workspace-a", &binding) + .unwrap() + .credential_fingerprint, + changed.public_key_fingerprint + ); + store.with_conn(|conn| { + assert_eq!(conn.query_row("SELECT count(*) FROM repository_ssh_credential_keys WHERE workspace_id='workspace-a' AND credential_id='deploy'", [], |r| r.get::<_, i64>(0))?, 3); + assert_eq!(conn.query_row("SELECT count(*) FROM repository_secret_operations WHERE operation_id='key-stale'", [], |r| r.get::<_, i64>(0))?, 0); + assert_eq!(conn.query_row("SELECT count(*) FROM repository_secret_audit_events WHERE kind='credential_rotated'", [], |r| r.get::<_, i64>(0))?, 2); + Ok(()) + }).unwrap(); + } + + #[test] + fn same_host_key_endpoint_change_allows_current_lease_but_rejects_ambiguous_history() { + let (_dir, _store, service) = test_service(); + let (credential, host, binding) = seed_lease_binding(&service); + let old_lease = service + .lease_ssh_materialization_access("workspace-a", &binding) + .unwrap(); + let request = PutRepositorySshHostTrustRequest { + operation_id: "trust-move".into(), + host_trust_id: "host".into(), + hostname: "new.example.test".into(), + port: 2222, + host_key: test_private_key(21).1, + expected_fingerprint: Some(host.fingerprint.clone()), + }; + let changed = service + .put_host_trust("workspace-a", request.clone(), "owner-a") + .unwrap(); + assert_eq!(changed.fingerprint, host.fingerprint); + assert_eq!( + service + .put_host_trust("workspace-a", request.clone(), "owner-a") + .unwrap(), + changed + ); + let current = service + .lease_ssh_materialization_access("workspace-a", &binding) + .unwrap(); + assert!( + current + .known_hosts_entry + .starts_with("[new.example.test]:2222 ") + ); + let historical = service.lease_ssh_materialization_access_fingerprints( + "workspace-a", + "deploy", + &credential.public_key_fingerprint, + "host", + &host.fingerprint, + ); + assert!( + matches!(historical, Err(Error::RegistryInconsistency(message)) if message.contains("ambiguous endpoint")) + ); + let exact = service + .lease_ssh_materialization_access_operation( + "workspace-a", + "deploy", + "key-create", + "host", + "trust-create", + ) + .unwrap(); + assert_eq!(exact.known_hosts_entry, old_lease.known_hosts_entry); + let changed_input = PutRepositorySshHostTrustRequest { + hostname: "other.example.test".into(), + ..request + }; + assert!(matches!( + service.put_host_trust("workspace-a", changed_input, "owner-a"), + Err(Error::WorkspaceConfigConflict(_)) + )); + assert!( + service + .lease_ssh_materialization_access("workspace-b", &binding) + .is_err() + ); + } + + #[test] + fn inactive_credential_cannot_be_leased_by_current_or_historical_identity() { + let (_dir, store, service) = test_service(); + let (credential, host, binding) = seed_lease_binding(&service); + store.with_conn(|conn| { + conn.execute("UPDATE repository_ssh_credentials SET status='revoked' WHERE workspace_id='workspace-a' AND credential_id='deploy'", [])?; + Ok(()) + }).unwrap(); + assert!( + service + .lease_ssh_materialization_access("workspace-a", &binding) + .is_err() + ); + assert!( + service + .lease_ssh_materialization_access_fingerprints( + "workspace-a", + "deploy", + &credential.public_key_fingerprint, + "host", + &host.fingerprint + ) + .is_err() + ); + assert!( + service + .lease_ssh_materialization_access_operation( + "workspace-a", + "deploy", + "key-create", + "host", + "trust-create" + ) + .is_err() + ); + } + + #[test] + fn envelopes_authenticate_operation_workspace_resource_and_purpose() { + let (_dir, _store, service) = test_service(); + for (workspace, credential, operation, purpose) in [ + ("workspace-b", "deploy", "create", "private_key"), + ("workspace-a", "other", "create", "private_key"), + ("workspace-a", "deploy", "other", "private_key"), + ("workspace-a", "deploy", "create", "passphrase"), + ] { + let secret = service + .seal("workspace-a", "deploy", "create", "private_key", b"secret") + .unwrap(); + assert!( + service + .unseal(workspace, credential, operation, purpose, secret) + .is_err() + ); + } + let secret = service + .seal("workspace-a", "deploy", "create", "private_key", b"secret") + .unwrap(); + assert_eq!( + service + .unseal("workspace-a", "deploy", "create", "private_key", secret) + .unwrap(), + b"secret" + ); + assert_ne!( + secret_aad("a/b", "c", "op", "private_key"), + secret_aad("a", "b/c", "op", "private_key") + ); + } + + #[test] + fn operation_fingerprints_preserve_field_boundaries_and_preconditions() { + assert_ne!( + credential_fingerprint("create", "ab", "c", "", "key", None), + credential_fingerprint("create", "a", "bc", "", "key", None) + ); + assert_ne!( + credential_fingerprint("rotate", "deploy", "", "first", "key", None), + credential_fingerprint("rotate", "deploy", "", "second", "key", None) + ); + assert_ne!( + simple_operation_fingerprint("delete", "ab", "c"), + simple_operation_fingerprint("delete", "a", "bc") + ); + } + + #[test] + fn frozen_legacy_envelope_migration_rebinds_authenticated_context() { + let (dir, _store, service) = test_service(); + let master = service.master_key.as_ref().unwrap(); + let key = LessSafeKey::new(UnboundKey::new(&AES_256_GCM, master.as_slice()).unwrap()); + let nonce = [42; NONCE_BYTES]; + let mut ciphertext = b"legacy-secret".to_vec(); + key.seal_in_place_append_tag( + Nonce::assume_unique_for_key(nonce), + Aad::from(b"yoi/repository-secret/v1/workspace-a/deploy/7/private_key"), + &mut ciphertext, + ) + .unwrap(); + let (new_nonce, ciphertext) = migrate_legacy_repository_secret_envelope( + &dir.path().join("server.db"), + "workspace-a", + "deploy", + 7, + "migrated-operation", + "private_key", + &nonce, + &ciphertext, + ) + .unwrap(); + let secret = SealedSecret { + nonce: new_nonce.try_into().unwrap(), + ciphertext, + }; + assert_eq!( + service + .unseal( + "workspace-a", + "deploy", + "migrated-operation", + "private_key", + secret + ) + .unwrap(), + b"legacy-secret" + ); + } + + #[test] + fn delete_operations_are_replayable_but_stale_or_reused_input_conflicts() { + let (_dir, _store, service) = test_service(); + let credential = service + .generate_credential( + "workspace-a", + GenerateRepositorySshCredentialRequest { + operation_id: "create-deleted".into(), + credential_id: "deploy".into(), + name: "Deploy".into(), + }, + "owner-a", + ) + .unwrap(); + let projection = RepositoryAccessProjection { + workspace_id: "workspace-a".into(), + projection_digest: "sha256:empty".into(), + bindings: Vec::new(), + }; + let stale = DeleteRepositorySshCredentialRequest { + operation_id: "stale-delete".into(), + expected_public_key_fingerprint: parse_private_key(&test_private_key(91).0, None) + .unwrap() + .fingerprint, + }; + assert!(matches!( + service + .delete_credential("workspace-a", "deploy", stale, "owner-a", &projection) + .unwrap_err(), + Error::WorkspaceConfigConflict(_) + )); + let request = DeleteRepositorySshCredentialRequest { + operation_id: "delete-key".into(), + expected_public_key_fingerprint: credential.public_key_fingerprint, + }; + service + .delete_credential( + "workspace-a", + "deploy", + request.clone(), + "owner-a", + &projection, + ) + .unwrap(); + service + .delete_credential( + "workspace-a", + "deploy", + request.clone(), + "owner-a", + &projection, + ) + .unwrap(); + let changed = DeleteRepositorySshCredentialRequest { + expected_public_key_fingerprint: parse_private_key(&test_private_key(92).0, None) + .unwrap() + .fingerprint, + ..request + }; + assert!(matches!( + service + .delete_credential("workspace-a", "deploy", changed, "owner-a", &projection) + .unwrap_err(), + Error::WorkspaceConfigConflict(_) + )); + } + #[test] fn referenced_resources_cannot_be_deleted() { let (_dir, _store, service) = test_service(); @@ -2590,14 +3308,13 @@ mod tests { hostname: "example.test".to_string(), port: 22, host_key: public_key, - expected_revision: None, + expected_fingerprint: None, }, "owner-a", ) .unwrap(); let projection = RepositoryAccessProjection { workspace_id: "workspace-a".to_string(), - config_revision: 3, projection_digest: "sha256:test".to_string(), bindings: vec![RepositorySshAccessBinding { repository_key: "main".to_string(), @@ -2612,7 +3329,12 @@ mod tests { "deploy", DeleteRepositorySshCredentialRequest { operation_id: "delete-ref".to_string(), - expected_revision: 1, + expected_public_key_fingerprint: parse_private_key( + &test_private_key(10).0, + None, + ) + .unwrap() + .fingerprint, }, "owner-a", &projection, diff --git a/crates/workspace-server/src/resource_broker.rs b/crates/workspace-server/src/resource_broker.rs index 9bb68fdb2..0e769b136 100644 --- a/crates/workspace-server/src/resource_broker.rs +++ b/crates/workspace-server/src/resource_broker.rs @@ -99,8 +99,6 @@ impl BackendResourceBroker { operation: BackendResourceOperation::FetchArchive, expires_at_unix_seconds: expires_at.timestamp(), nonce: nonce.clone(), - revision: archive.reference.digest.clone(), - generation: None, max_bytes: DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES, content_type: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), redaction: ResourceRedactionPolicy::RuntimeInternalOnly, @@ -126,7 +124,6 @@ impl BackendResourceBroker { workspace_id: impl Into, runtime_id: &str, resource_id: impl Into, - revision: impl Into, expires_at_unix_seconds: i64, secret: RepositorySshAccessSecret, ) -> Result { @@ -142,7 +139,6 @@ impl BackendResourceBroker { } let workspace_id = workspace_id.into(); let resource_id = resource_id.into(); - let revision = revision.into(); let nonce = Uuid::now_v7().to_string(); let handle = BackendResourceHandle { kind: BackendResourceKind::RepositorySshAccess, @@ -155,8 +151,6 @@ impl BackendResourceBroker { operation: BackendResourceOperation::FetchOnce, expires_at_unix_seconds, nonce: nonce.clone(), - revision, - generation: None, max_bytes: DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES, content_type: REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(), redaction: ResourceRedactionPolicy::RuntimeInternalOnly, @@ -404,13 +398,12 @@ mod tests { "workspace-test", "runtime-test", "repository-access-test", - "1", i64::MAX, RepositorySshAccessSecret { credential_candidates: vec![ worker_runtime::resource::RepositorySshAccessSecretCandidate { credential_id: "credential-test".to_string(), - credential_revision: 1, + public_key_fingerprint: "sha256:test-credential-key".to_string(), private_key: "private-key-bytes".to_string(), }, ], diff --git a/crates/workspace-server/src/retention.rs b/crates/workspace-server/src/retention.rs index 2dd96996a..340925a2e 100644 --- a/crates/workspace-server/src/retention.rs +++ b/crates/workspace-server/src/retention.rs @@ -38,7 +38,7 @@ pub enum ArchiveRetention { pub struct WorkerRetentionPolicy { pub workspace_id: String, pub policy_id: String, - pub revision: u64, + pub policy_digest: String, pub session_disposition: SessionDisposition, pub metadata_disposition: MetadataDisposition, pub archive_retention: ArchiveRetention, @@ -97,9 +97,9 @@ pub struct WorkerRemovalPlan { pub input_fingerprint: String, pub workspace_id: String, pub worker: RuntimeWorkerRef, - pub worker_revision: String, + pub worker_updated_at: String, pub policy_id: String, - pub policy_revision: u64, + pub policy_digest: String, pub session_disposition: SessionDisposition, pub metadata_disposition: MetadataDisposition, pub archive_retention: ArchiveRetention, @@ -132,7 +132,7 @@ pub struct WorkerTombstone { pub removed_at: String, pub archive_id: Option, pub policy_id: String, - pub policy_revision: u64, + pub policy_digest: String, pub operation_id: String, } @@ -145,7 +145,7 @@ pub struct WorkerSessionArchiveRecord { pub checksum_sha256: String, pub content_bytes: u64, pub policy_id: String, - pub policy_revision: u64, + pub policy_digest: String, pub operation_id: String, pub committed_at: String, pub expires_at: Option, @@ -168,8 +168,8 @@ pub enum WorkerRetentionError { Store(#[from] StoreError), #[error("Worker retention policy is not configured for Workspace {workspace_id}")] PolicyMissing { workspace_id: String }, - #[error("Worker retention policy revision conflict: expected {expected}, current {actual}")] - PolicyRevisionConflict { expected: u64, actual: u64 }, + #[error("Worker retention policy digest conflict: expected {expected}, current {actual}")] + PolicyDigestConflict { expected: String, actual: String }, #[error("Worker was not found in the requested Workspace")] WorkerNotFound, #[error("Worker belongs to a different Workspace")] @@ -195,21 +195,24 @@ impl SqliteWorkspaceStore { pub fn update_worker_retention_policy( &self, workspace_id: &str, - expected: u64, + expected: &str, update: &WorkerRetentionPolicyUpdate, ) -> Result { validate_policy(update)?; self.with_conn_mut(|conn| { let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?; let current=load_policy(&tx,workspace_id)?.ok_or_else(|| StoreError::InvalidInput(format!("policy-missing:{workspace_id}")))?; - if current.revision!=expected { return Err(StoreError::InvalidInput(format!("policy-conflict:{expected}:{}",current.revision))); } - let revision=current.revision+1; let now=Utc::now().to_rfc3339(); - tx.execute("INSERT INTO workspace_worker_retention_policy_revisions - (workspace_id,policy_id,revision,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,created_at) - VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",params![workspace_id,update.policy_id,revision,sess(update.session_disposition),meta(update.metadata_disposition),archive_kind(update.archive_retention),archive_seconds(update.archive_retention),diag(update.diagnostics_disposition),update.diagnostics_retention_seconds,now])?; - let changed=tx.execute("UPDATE workspace_worker_retention_policies SET policy_id=?1,revision=?2,updated_at=?3 WHERE workspace_id=?4 AND revision=?5", - params![update.policy_id,revision,now,workspace_id,expected])?; - if changed!=1 { return Err(StoreError::InvalidInput(format!("policy-conflict:{expected}:{revision}"))); } + if current.policy_digest != expected { + return Err(StoreError::InvalidInput(format!("policy-conflict:{}", serde_json::to_string(&(expected, ¤t.policy_digest)).unwrap()))); + } + let digest = worker_retention_policy_digest(update); + let now = Utc::now().to_rfc3339(); + tx.execute("INSERT OR IGNORE INTO workspace_worker_retention_policy_snapshots + (workspace_id,policy_id,policy_digest,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,created_at) + VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",params![workspace_id,update.policy_id,digest,sess(update.session_disposition),meta(update.metadata_disposition),archive_kind(update.archive_retention),archive_seconds(update.archive_retention),diag(update.diagnostics_disposition),update.diagnostics_retention_seconds,now])?; + let changed=tx.execute("UPDATE workspace_worker_retention_policies SET policy_id=?1,policy_digest=?2,updated_at=?3 WHERE workspace_id=?4 AND policy_digest=?5", + params![update.policy_id,digest,now,workspace_id,expected])?; + if changed!=1 { return Err(StoreError::InvalidInput(format!("policy-conflict:{}", serde_json::to_string(&(expected, ¤t.policy_digest)).unwrap()))); } tx.commit()?; load_policy(conn,workspace_id)?.ok_or_else(|| StoreError::InvalidInput("updated policy missing".into())) }).map_err(map_error) } @@ -243,7 +246,7 @@ impl SqliteWorkspaceStore { let plan_id=stable("wrp",&fp); let operation_id=stable("wro",&fp); let archive_id=(policy.session_disposition==SessionDisposition::Archive).then(||stable("wra",&fp)); let state=if blockers.is_empty(){WorkerRemovalPlanState::Planned}else{WorkerRemovalPlanState::Blocked}; - tx.execute("INSERT OR IGNORE INTO worker_removal_operations(operation_id,plan_id,input_fingerprint,workspace_id,runtime_id,worker_id,worker_revision,policy_id,policy_revision,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,archive_id,blockers_json,state,reason,created_at,updated_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?20)",params![operation_id,plan_id,fp,req.workspace_id,req.worker.runtime_id,req.worker.worker_id,worker.updated_at,policy.policy_id,policy.revision,sess(policy.session_disposition),meta(policy.metadata_disposition),archive_kind(policy.archive_retention),archive_seconds(policy.archive_retention),diag(policy.diagnostics_disposition),policy.diagnostics_retention_seconds,archive_id,serde_json::to_string(&blockers).map_err(|e|StoreError::InvalidInput(e.to_string()))?,state_s(state),req.reason,now])?; + tx.execute("INSERT OR IGNORE INTO worker_removal_operations(operation_id,plan_id,input_fingerprint,workspace_id,runtime_id,worker_id,worker_updated_at,policy_id,policy_digest,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,archive_id,blockers_json,state,reason,created_at,updated_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?20)",params![operation_id,plan_id,fp,req.workspace_id,req.worker.runtime_id,req.worker.worker_id,worker.updated_at,policy.policy_id,policy.policy_digest,sess(policy.session_disposition),meta(policy.metadata_disposition),archive_kind(policy.archive_retention),archive_seconds(policy.archive_retention),diag(policy.diagnostics_disposition),policy.diagnostics_retention_seconds,archive_id,serde_json::to_string(&blockers).map_err(|e|StoreError::InvalidInput(e.to_string()))?,state_s(state),req.reason,now])?; let plan=load_plan(&tx,&plan_id)?.ok_or_else(||StoreError::InvalidInput("plan missing".into()))?; if plan.input_fingerprint!=fp{return Err(StoreError::InvalidInput(format!("fingerprint:{}",plan.operation_id)));} tx.commit()?; Ok(plan) @@ -270,14 +273,14 @@ impl SqliteWorkspaceStore { } if !plan.blockers.is_empty(){return Err(StoreError::InvalidInput(format!("blocked:{}",serde_json::to_string(&plan.blockers).unwrap())));} let policy=load_policy(&tx,workspace_id)?.ok_or_else(||StoreError::InvalidInput(format!("policy-missing:{workspace_id}")))?; - if policy.policy_id!=plan.policy_id||policy.revision!=plan.policy_revision{ - mark_stale(&tx,&plan,"policy revision changed")?;tx.commit()?; - return Err(stale_error(&plan,"policy revision changed")); + if policy.policy_id!=plan.policy_id||policy.policy_digest!=plan.policy_digest{ + mark_stale(&tx,&plan,"policy content changed")?;tx.commit()?; + return Err(stale_error(&plan,"policy content changed")); } let worker=load_worker(&tx,workspace_id,&plan.worker)?.ok_or_else(||StoreError::InvalidInput(format!("stale:{plan_id}:Worker missing")))?; - if worker.updated_at!=plan.worker_revision{ - mark_stale(&tx,&plan,"Worker revision changed")?;tx.commit()?; - return Err(stale_error(&plan,"Worker revision changed")); + if worker.updated_at!=plan.worker_updated_at{ + mark_stale(&tx,&plan,"Worker updated_at changed")?;tx.commit()?; + return Err(stale_error(&plan,"Worker updated_at changed")); } if worker.retention_state=="pinned"{ mark_stale(&tx,&plan,"hold added")?;tx.commit()?; @@ -295,7 +298,7 @@ impl SqliteWorkspaceStore { } /// Revalidates Backend authority and derives the complete Runtime request - /// from the immutable plan. Callers cannot substitute generation or + /// from the immutable plan. Callers cannot substitute Worker timestamps or /// dispositions without causing a fingerprint/manifest mismatch. pub fn prepare_worker_removal_execution( &self, @@ -327,13 +330,11 @@ impl SqliteWorkspaceStore { workspace_id: plan.workspace_id.clone(), source_runtime_id: plan.worker.runtime_id.clone(), worker_id: worker_id, - expected_worker_revision: plan.worker_revision.clone(), source_created_at: worker.created_at, removed_at, effective_profile: worker.profile, retention_class: None, policy_id: plan.policy_id.clone(), - policy_revision: plan.policy_revision, session_disposition: plan.session_disposition, diagnostics_disposition: plan.diagnostics_disposition, }, @@ -354,7 +355,7 @@ impl SqliteWorkspaceStore { WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND state IN ('planned','executing','failed','succeeded') AND ( - state='succeeded' OR worker_revision=( + state='succeeded' OR worker_updated_at=( SELECT updated_at FROM worker_registry WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 ) @@ -398,7 +399,6 @@ impl SqliteWorkspaceStore { workspace_id: plan.workspace_id.clone(), source_runtime_id: plan.worker.runtime_id.clone(), worker_id: worker_id, - expected_worker_revision: plan.worker_revision.clone(), source_created_at: worker .as_ref() .map(|worker| worker.created_at.clone()) @@ -410,7 +410,6 @@ impl SqliteWorkspaceStore { .unwrap_or_else(|| Some("removed".to_string())), retention_class: None, policy_id: plan.policy_id.clone(), - policy_revision: plan.policy_revision, session_disposition: plan.session_disposition, diagnostics_disposition: plan.diagnostics_disposition, }, @@ -458,8 +457,7 @@ impl SqliteWorkspaceStore { if plan.state != WorkerRemovalPlanState::Executing { return Err(StoreError::InvalidInput(format!("stale:{}:plan state {} is not committable", plan.plan_id, state_s(plan.state)))); } - if result.expected_worker_revision != plan.worker_revision - || result.worker_id.to_string() != plan.worker.worker_id + if result.worker_id.to_string() != plan.worker.worker_id || result.session_disposition != plan.session_disposition || result.diagnostics_disposition != plan.diagnostics_disposition { @@ -467,15 +465,15 @@ impl SqliteWorkspaceStore { } if !result.source_removed{return Err(StoreError::InvalidInput("Runtime source was not removed".into()));} let worker=load_worker(&tx,workspace_id,&plan.worker)?.ok_or_else(||StoreError::InvalidInput("Worker missing before commit".into()))?; - if worker.updated_at!=plan.worker_revision{return Err(StoreError::InvalidInput(format!("stale:{}:Worker revision changed",plan.plan_id)));} + if worker.updated_at!=plan.worker_updated_at{return Err(StoreError::InvalidInput(format!("stale:{}:Worker updated_at changed",plan.plan_id)));} if worker.retention_state=="pinned" { return Err(StoreError::InvalidInput(format!("stale:{}:hold added",plan.plan_id))); } let assigned:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3 UNION ALL SELECT 1 FROM ticket_assignment_operations WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND claim_state='pending' AND action IN ('assign','reassign'))",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id],|row|row.get(0))?; if assigned { return Err(StoreError::InvalidInput(format!("stale:{}:unfinished work added",plan.plan_id))); } let now=Utc::now().to_rfc3339(); if let Some(a)=&result.archive{ - if plan.archive_id.as_deref()!=Some(&a.archive_id)||a.workspace_id!=workspace_id||a.source_runtime_id!=plan.worker.runtime_id||a.source_worker_id.to_string()!=plan.worker.worker_id||a.policy_id!=plan.policy_id||a.policy_revision!=plan.policy_revision{return Err(StoreError::InvalidInput("archive manifest mismatch".into()));} + if plan.archive_id.as_deref()!=Some(&a.archive_id)||a.workspace_id!=workspace_id||a.source_runtime_id!=plan.worker.runtime_id||a.source_worker_id.to_string()!=plan.worker.worker_id||a.policy_id!=plan.policy_id||a.operation_id!=operation_id||a.input_fingerprint!=fp{return Err(StoreError::InvalidInput("archive manifest mismatch".into()));} let expires_at=match plan.archive_retention { ArchiveRetention::Forever=>None, ArchiveRetention::ForSeconds{seconds}=>{let seconds=i64::try_from(seconds).map_err(|_|StoreError::InvalidInput("archive retention deadline overflow".into()))?;Some((Utc::now()+chrono::Duration::seconds(seconds)).to_rfc3339())} }; - tx.execute("INSERT OR IGNORE INTO worker_session_archives(archive_id,workspace_id,runtime_id,worker_id,session_id,checksum_sha256,content_bytes,policy_id,policy_revision,operation_id,committed_at,expires_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)",params![a.archive_id,workspace_id,plan.worker.runtime_id,plan.worker.worker_id,a.source_session_id,a.content_checksum_sha256,a.content_bytes,plan.policy_id,plan.policy_revision,operation_id,now,expires_at])?; + tx.execute("INSERT OR IGNORE INTO worker_session_archives(archive_id,workspace_id,runtime_id,worker_id,session_id,checksum_sha256,content_bytes,policy_id,policy_digest,operation_id,committed_at,expires_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)",params![a.archive_id,workspace_id,plan.worker.runtime_id,plan.worker.worker_id,a.source_session_id,a.content_checksum_sha256,a.content_bytes,plan.policy_id,plan.policy_digest,operation_id,now,expires_at])?; let observe_grants = { let mut statement = tx.prepare( "SELECT grant_id, controller_runtime_id, controller_worker_id, @@ -529,15 +527,15 @@ impl SqliteWorkspaceStore { let seconds=plan.diagnostics_retention_seconds.ok_or_else(||StoreError::InvalidInput("diagnostics retention deadline missing".into()))?; let seconds=i64::try_from(seconds).map_err(|_|StoreError::InvalidInput("diagnostics retention deadline overflow".into()))?; let expires_at=(Utc::now()+chrono::Duration::seconds(seconds)).to_rfc3339(); - tx.execute("INSERT OR IGNORE INTO worker_diagnostics_archives(operation_id,workspace_id,runtime_id,worker_id,policy_id,policy_revision,committed_at,expires_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",params![operation_id,workspace_id,plan.worker.runtime_id,plan.worker.worker_id,plan.policy_id,plan.policy_revision,now,expires_at])?; + tx.execute("INSERT OR IGNORE INTO worker_diagnostics_archives(operation_id,workspace_id,runtime_id,worker_id,policy_id,policy_digest,committed_at,expires_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",params![operation_id,workspace_id,plan.worker.runtime_id,plan.worker.worker_id,plan.policy_id,plan.policy_digest,now,expires_at])?; } DiagnosticsDisposition::Purge => {} } if plan.metadata_disposition==MetadataDisposition::Tombstone{ - tx.execute("INSERT OR IGNORE INTO worker_tombstones(workspace_id,runtime_id,worker_id,display_name,profile,worker_created_at,removed_at,archive_id,policy_id,policy_revision,operation_id) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id,worker.display_name,worker.profile,worker.created_at,now,plan.archive_id,plan.policy_id,plan.policy_revision,operation_id])?; + tx.execute("INSERT OR IGNORE INTO worker_tombstones(workspace_id,runtime_id,worker_id,display_name,profile,worker_created_at,removed_at,archive_id,policy_id,policy_digest,operation_id) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id,worker.display_name,worker.profile,worker.created_at,now,plan.archive_id,plan.policy_id,plan.policy_digest,operation_id])?; } tx.execute("UPDATE worker_workdir_links SET unlinked_at=?4 WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND unlinked_at IS NULL",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id,now])?; - let deleted=tx.execute("DELETE FROM worker_registry WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND updated_at=?4",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id,plan.worker_revision])?; + let deleted=tx.execute("DELETE FROM worker_registry WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND updated_at=?4",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id,plan.worker_updated_at])?; tx.execute("UPDATE worker_create_reservations SET state='removed',updated_at=?4 WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND state IN ('reserved','created')",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id,now])?; if deleted!=1{return Err(StoreError::InvalidInput(format!("stale:{}:removal fence changed",plan.plan_id)));} let catalog=commit_worker_catalog_removal(&tx,workspace_id,&plan.worker)?; @@ -657,7 +655,7 @@ impl SqliteWorkspaceStore { workspace_id: &str, worker: &RuntimeWorkerRef, ) -> crate::Result> { - self.with_conn(|conn|conn.query_row("SELECT display_name,profile,worker_created_at,removed_at,archive_id,policy_id,policy_revision,operation_id FROM worker_tombstones WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3",params![workspace_id,worker.runtime_id,worker.worker_id],|r|Ok(WorkerTombstone{workspace_id:workspace_id.into(),worker:worker.clone(),display_name:r.get(0)?,profile:r.get(1)?,created_at:r.get(2)?,removed_at:r.get(3)?,archive_id:r.get(4)?,policy_id:r.get(5)?,policy_revision:r.get::<_,i64>(6)? as u64,operation_id:r.get(7)?})).optional().map_err(StoreError::from)) + self.with_conn(|conn|conn.query_row("SELECT display_name,profile,worker_created_at,removed_at,archive_id,policy_id,policy_digest,operation_id FROM worker_tombstones WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3",params![workspace_id,worker.runtime_id,worker.worker_id],|r|Ok(WorkerTombstone{workspace_id:workspace_id.into(),worker:worker.clone(),display_name:r.get(0)?,profile:r.get(1)?,created_at:r.get(2)?,removed_at:r.get(3)?,archive_id:r.get(4)?,policy_id:r.get(5)?,policy_digest:r.get(6)?,operation_id:r.get(7)?})).optional().map_err(StoreError::from)) } pub fn worker_session_archive( @@ -670,7 +668,7 @@ impl SqliteWorkspaceStore { connection .query_row( "SELECT archive_id, checksum_sha256, content_bytes, policy_id, - policy_revision, operation_id, committed_at, expires_at + policy_digest, operation_id, committed_at, expires_at FROM worker_session_archives WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 AND session_id = ?4", @@ -689,7 +687,7 @@ impl SqliteWorkspaceStore { checksum_sha256: row.get(1)?, content_bytes: row.get::<_, i64>(2)? as u64, policy_id: row.get(3)?, - policy_revision: row.get::<_, i64>(4)? as u64, + policy_digest: row.get(4)?, operation_id: row.get(5)?, committed_at: row.get(6)?, expires_at: row.get(7)?, @@ -789,12 +787,12 @@ fn load_worker(c: &Connection, w: &str, r: &RuntimeWorkerRef) -> crate::Result crate::Result> { c.query_row( - "SELECT p.policy_id,p.revision,r.session_disposition,r.metadata_disposition, + "SELECT p.policy_id,p.policy_digest,r.session_disposition,r.metadata_disposition, r.archive_retention_kind,r.archive_retention_seconds, r.diagnostics_disposition,r.diagnostics_retention_seconds,r.created_at,p.updated_at FROM workspace_worker_retention_policies p - JOIN workspace_worker_retention_policy_revisions r - ON r.workspace_id=p.workspace_id AND r.policy_id=p.policy_id AND r.revision=p.revision + JOIN workspace_worker_retention_policy_snapshots r + ON r.workspace_id=p.workspace_id AND r.policy_id=p.policy_id AND r.policy_digest=p.policy_digest WHERE p.workspace_id=?1", params![w], |row| { @@ -806,7 +804,7 @@ fn load_policy(c: &Connection, w: &str) -> crate::Result(1)? as u64, + policy_digest: row.get(1)?, session_disposition: parse_s(&session)?, metadata_disposition: parse_m(&metadata)?, archive_retention: parse_archive(&archive_kind, archive_seconds)?, @@ -829,7 +827,7 @@ fn load_plan_op(c: &Connection, id: &str) -> crate::Result crate::Result> { let query = format!( "SELECT plan_id,operation_id,input_fingerprint,workspace_id,runtime_id,worker_id, - worker_revision,policy_id,policy_revision,session_disposition, + worker_updated_at,policy_id,policy_digest,session_disposition, metadata_disposition,archive_retention_kind,archive_retention_seconds, diagnostics_disposition,diagnostics_retention_seconds,archive_id,blockers_json, state,reason,created_at,updated_at,failure_category @@ -852,9 +850,9 @@ fn load_plan_q(c: &Connection, key: &str, id: &str) -> crate::Result(8)? as u64, + policy_digest: row.get(8)?, session_disposition: parse_s(&session)?, metadata_disposition: parse_m(&metadata)?, archive_retention: parse_archive(&archive_kind, archive_seconds)?, @@ -891,7 +889,7 @@ fn stale_error(plan: &WorkerRemovalPlan, reason: &str) -> StoreError { } fn fingerprint( r: &WorkerRemovalPlanRequest, - worker_revision: &str, + worker_updated_at: &str, i: &WorkerRetentionInventory, p: &WorkerRetentionPolicy, b: &[WorkerRemovalBlocker], @@ -900,11 +898,11 @@ fn fingerprint( r.workspace_id, r.worker.runtime_id, r.worker.worker_id, - worker_revision, + worker_updated_at, i.session_id, i.segment_ids, p.policy_id, - p.revision, + p.policy_digest, p.session_disposition, p.metadata_disposition, p.archive_retention, @@ -916,6 +914,21 @@ fn fingerprint( .map(|v| hash(&v)) .map_err(|e| StoreError::InvalidInput(e.to_string())) } +/// Content identity of the complete resolved policy, excluding observation timestamps. +/// The ordered tuple is shared with schema seeding and migration; no update counter is used. +pub fn worker_retention_policy_digest(policy: &WorkerRetentionPolicyUpdate) -> String { + let bytes = serde_json::to_vec(&serde_json::json!([ + policy.policy_id, + policy.session_disposition, + policy.metadata_disposition, + policy.archive_retention, + policy.diagnostics_disposition, + policy.diagnostics_retention_seconds, + ])) + .expect("retention policy content is serializable"); + format!("sha256:{}", hash(&bytes)) +} + fn hash(b: &[u8]) -> String { Sha256::digest(b) .iter() @@ -977,11 +990,8 @@ fn map_error(e: StoreError) -> WorkerRetentionError { }; } if let Some(x) = m.strip_prefix("policy-conflict:") { - let mut s = x.split(':'); - return WorkerRetentionError::PolicyRevisionConflict { - expected: s.next().and_then(|v| v.parse().ok()).unwrap_or(0), - actual: s.next().and_then(|v| v.parse().ok()).unwrap_or(0), - }; + let (expected, actual) = serde_json::from_str::<(String, String)>(x).unwrap_or_default(); + return WorkerRetentionError::PolicyDigestConflict { expected, actual }; } if m == "cross-workspace" { return WorkerRetentionError::CrossWorkspace; @@ -1121,7 +1131,7 @@ mod tests { c.execute( "INSERT INTO worker_registry(\ workspace_id,worker_id,runtime_id,display_name,profile,retention_state,created_at,updated_at\ - ) VALUES('w',?1,'r','one','builtin:coder','normal','created','rev1')", + ) VALUES('w',?1,'r','one','builtin:coder','normal','created','2026-08-24T00:00:00Z')", [worker_id().to_string()], )?; c.execute( @@ -1169,24 +1179,65 @@ mod tests { diagnostics_disposition: DiagnosticsDisposition::Purge, diagnostics_retention_seconds: None, }; - let updated = s.update_worker_retention_policy("w", 1, &u).unwrap(); - assert_eq!(updated.revision, 2); + let updated = s + .update_worker_retention_policy("w", &p.policy_digest, &u) + .unwrap(); + assert_eq!(updated.policy_digest, worker_retention_policy_digest(&u)); assert_eq!( updated.archive_retention, ArchiveRetention::ForSeconds { seconds: 3_600 } ); assert!(matches!( - s.update_worker_retention_policy("w", 1, &u), - Err(WorkerRetentionError::PolicyRevisionConflict { .. }) + s.update_worker_retention_policy("w", &p.policy_digest, &u), + Err(WorkerRetentionError::PolicyDigestConflict { .. }) )); } + #[test] + fn identical_policy_content_keeps_digest_and_removal_plan() { + let store = setup(); + let policy = store.worker_retention_policy("w").unwrap().unwrap(); + let update = WorkerRetentionPolicyUpdate { + policy_id: policy.policy_id.clone(), + session_disposition: policy.session_disposition, + metadata_disposition: policy.metadata_disposition, + archive_retention: policy.archive_retention, + diagnostics_disposition: policy.diagnostics_disposition, + diagnostics_retention_seconds: policy.diagnostics_retention_seconds, + }; + assert_eq!( + policy.policy_digest, + worker_retention_policy_digest(&update) + ); + let before = store.plan_worker_removal(&req(), &inv()).unwrap(); + let after = store + .update_worker_retention_policy("w", &policy.policy_digest, &update) + .unwrap(); + assert_eq!(policy.policy_digest, after.policy_digest); + assert_eq!(before, store.plan_worker_removal(&req(), &inv()).unwrap()); + store + .begin_worker_removal("w", &before.plan_id, &before.input_fingerprint) + .unwrap(); + let mut changed = update.clone(); + changed.metadata_disposition = MetadataDisposition::Purge; + assert_ne!( + worker_retention_policy_digest(&update), + worker_retention_policy_digest(&changed) + ); + changed = update.clone(); + changed.archive_retention = ArchiveRetention::ForSeconds { seconds: 3600 }; + assert_ne!( + worker_retention_policy_digest(&update), + worker_retention_policy_digest(&changed) + ); + } + #[test] fn deterministic_plan_hold_and_cross_workspace() { let s = setup(); let a = s.plan_worker_removal(&req(), &inv()).unwrap(); let b = s.plan_worker_removal(&req(), &inv()).unwrap(); assert_eq!(a.plan_id, b.plan_id); - assert_eq!(a.worker_revision, "rev1"); + assert_eq!(a.worker_updated_at, "2026-08-24T00:00:00Z"); s.with_conn(|c| { c.execute( "UPDATE worker_registry SET retention_state='pinned' WHERE workspace_id='w'", @@ -1226,7 +1277,15 @@ mod tests { diagnostics_disposition: DiagnosticsDisposition::Purge, diagnostics_retention_seconds: None, }; - s.update_worker_retention_policy("w", 1, &u).unwrap(); + s.update_worker_retention_policy( + "w", + &s.worker_retention_policy("w") + .unwrap() + .unwrap() + .policy_digest, + &u, + ) + .unwrap(); assert!(matches!( s.begin_worker_removal("w", &p.plan_id, &p.input_fingerprint), Err(WorkerRetentionError::StalePlan { .. }) @@ -1295,7 +1354,10 @@ mod tests { prepared.runtime_request.session_disposition, SessionDisposition::Archive ); - assert_eq!(prepared.runtime_request.policy_revision, 1); + assert_eq!( + prepared.runtime_request.input_fingerprint, + prepared.plan.input_fingerprint + ); assert_eq!( prepared.runtime_request.worker_id, WorkerId::from_legacy_u64(1) @@ -1337,7 +1399,7 @@ mod tests { .unwrap(); s.with_conn(|conn| { conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES('w','ticket-old','ticket-old','Old Ticket','open','task','normal','','planning',1)", [])?; - conn.execute("INSERT INTO worker_registry(workspace_id,worker_id,runtime_id,display_name,profile,retention_state,created_at,updated_at) VALUES('w','1','r','old worker','builtin:coder','normal','created','rev1')", [])?; + conn.execute("INSERT INTO worker_registry(workspace_id,worker_id,runtime_id,display_name,profile,retention_state,created_at,updated_at) VALUES('w','1','r','old worker','builtin:coder','normal','created','2026-08-24T00:00:00Z')", [])?; conn.execute("INSERT INTO worker_create_reservations(workspace_id,allocation_key,worker_id,runtime_id,create_fingerprint,state,created_at,updated_at) VALUES('w','allocation-old',?1,'r','fingerprint','created','created','created')", [worker_id().to_string()])?; conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,runtime_id,worker_id,assigned_by,assigned_at) VALUES('w','ticket-old','assignment-old','r','1','test','t')", [])?; conn.execute("DELETE FROM worker_registry WHERE workspace_id='w' AND runtime_id='r' AND worker_id='1'", [])?; @@ -1350,7 +1412,7 @@ mod tests { let result = WorkerRetentionExecutionResult { operation_id: p.operation_id.clone(), input_fingerprint: p.input_fingerprint.clone(), - expected_worker_revision: p.worker_revision.clone(), + worker_id: worker_id(), session_disposition: p.session_disposition, diagnostics_disposition: p.diagnostics_disposition, @@ -1371,7 +1433,7 @@ mod tests { content_bytes: 1, content_file_count: 1, policy_id: p.policy_id.clone(), - policy_revision: p.policy_revision, + operation_id: p.operation_id.clone(), input_fingerprint: p.input_fingerprint.clone(), }), @@ -1608,7 +1670,11 @@ mod tests { store .update_worker_retention_policy( "w", - 1, + &store + .worker_retention_policy("w") + .unwrap() + .unwrap() + .policy_digest, &WorkerRetentionPolicyUpdate { policy_id: "purge".into(), session_disposition: SessionDisposition::Purge, @@ -1630,7 +1696,6 @@ mod tests { let result = WorkerRetentionExecutionResult { operation_id: plan.operation_id.clone(), input_fingerprint: plan.input_fingerprint.clone(), - expected_worker_revision: plan.worker_revision.clone(), worker_id: worker_id(), session_disposition: plan.session_disposition, diagnostics_disposition: plan.diagnostics_disposition, @@ -1817,7 +1882,7 @@ mod tests { let result = WorkerRetentionExecutionResult { operation_id: plan.operation_id.clone(), input_fingerprint: plan.input_fingerprint.clone(), - expected_worker_revision: plan.worker_revision.clone(), + worker_id: worker_id(), session_disposition: plan.session_disposition, diagnostics_disposition: plan.diagnostics_disposition, @@ -1897,7 +1962,7 @@ mod tests { let result = WorkerRetentionExecutionResult { operation_id: plan.operation_id.clone(), input_fingerprint: plan.input_fingerprint.clone(), - expected_worker_revision: plan.worker_revision.clone(), + worker_id: worker_id(), session_disposition: plan.session_disposition, diagnostics_disposition: plan.diagnostics_disposition, @@ -1949,14 +2014,22 @@ mod tests { diagnostics_disposition: DiagnosticsDisposition::Purge, diagnostics_retention_seconds: None, }; - s.update_worker_retention_policy("w", 1, &u).unwrap(); + s.update_worker_retention_policy( + "w", + &s.worker_retention_policy("w") + .unwrap() + .unwrap() + .policy_digest, + &u, + ) + .unwrap(); let p = s.plan_worker_removal(&req(), &inv()).unwrap(); s.begin_worker_removal("w", &p.plan_id, &p.input_fingerprint) .unwrap(); let r = WorkerRetentionExecutionResult { operation_id: p.operation_id.clone(), input_fingerprint: p.input_fingerprint.clone(), - expected_worker_revision: p.worker_revision.clone(), + worker_id: worker_id(), session_disposition: SessionDisposition::Purge, diagnostics_disposition: DiagnosticsDisposition::Purge, @@ -1980,7 +2053,15 @@ mod tests { diagnostics_retention_seconds: None, }; store - .update_worker_retention_policy("w", 1, &purge) + .update_worker_retention_policy( + "w", + &store + .worker_retention_policy("w") + .unwrap() + .unwrap() + .policy_digest, + &purge, + ) .unwrap(); let plan = store.plan_worker_removal(&req(), &inv()).unwrap(); store @@ -1989,7 +2070,7 @@ mod tests { let result = WorkerRetentionExecutionResult { operation_id: plan.operation_id.clone(), input_fingerprint: plan.input_fingerprint.clone(), - expected_worker_revision: plan.worker_revision.clone(), + worker_id: worker_id(), session_disposition: SessionDisposition::Purge, diagnostics_disposition: DiagnosticsDisposition::Purge, @@ -2067,7 +2148,7 @@ mod tests { let mut result = WorkerRetentionExecutionResult { operation_id: plan.operation_id.clone(), input_fingerprint: plan.input_fingerprint.clone(), - expected_worker_revision: plan.worker_revision.clone(), + worker_id: worker_id(), session_disposition: plan.session_disposition, diagnostics_disposition: plan.diagnostics_disposition, @@ -2117,15 +2198,15 @@ mod tests { summary_ref: None, diagnostics_ref: None, created_at: "created".into(), - updated_at: "rev2".into(), + updated_at: "2026-08-24T00:00:01Z".into(), }; store.upsert_worker_registry(&stale).unwrap(); - let revision: String = store.with_conn(|conn| conn.query_row( + let updated_at: String = store.with_conn(|conn| conn.query_row( "SELECT updated_at FROM worker_registry WHERE workspace_id='w' AND runtime_id='r' AND worker_id=?1", [worker_id().to_string()], |row| row.get(0), ).map_err(StoreError::from)).unwrap(); - assert_eq!(revision, "rev1"); + assert_eq!(updated_at, "2026-08-24T00:00:00Z"); let assignment = TicketWorkerAssignmentRecord { workspace_id: "w".into(), @@ -2152,7 +2233,7 @@ mod tests { } #[test] - fn runtime_result_must_match_prepared_worker_revision() { + fn runtime_result_must_match_prepared_input_fingerprint() { let s = setup(); let plan = s.plan_worker_removal(&req(), &inv()).unwrap(); let prepared = s @@ -2161,7 +2242,7 @@ mod tests { let mut runtime_result = WorkerRetentionExecutionResult { operation_id: prepared.plan.operation_id.clone(), input_fingerprint: prepared.plan.input_fingerprint.clone(), - expected_worker_revision: prepared.plan.worker_revision.clone(), + worker_id: worker_id(), session_disposition: prepared.plan.session_disposition, diagnostics_disposition: prepared.plan.diagnostics_disposition, @@ -2169,7 +2250,7 @@ mod tests { source_removed: true, diagnostics_retained: false, }; - runtime_result.expected_worker_revision = "stale-revision".to_string(); + runtime_result.input_fingerprint = "stale-input-fingerprint".to_string(); let error = s .commit_worker_removal( "w", @@ -2180,7 +2261,7 @@ mod tests { .unwrap_err(); assert!( !error.to_string().is_empty(), - "mismatched Runtime revision must be rejected" + "mismatched Runtime input fingerprint must be rejected" ); } @@ -2237,7 +2318,7 @@ mod tests { store .with_conn(|conn| { conn.execute( - "UPDATE worker_registry SET updated_at='rev2' WHERE workspace_id='w' AND runtime_id='r' AND worker_id=?1", + "UPDATE worker_registry SET updated_at='2026-08-24T00:00:01Z' WHERE workspace_id='w' AND runtime_id='r' AND worker_id=?1", [worker_id().to_string()], )?; Ok(()) @@ -2251,7 +2332,7 @@ mod tests { .is_none() ); let replacement = store.plan_worker_removal(&request, &inv()).unwrap(); - assert_eq!(replacement.worker_revision, "rev2"); + assert_eq!(replacement.worker_updated_at, "2026-08-24T00:00:01Z"); assert_ne!(replacement.plan_id, plan.plan_id); } @@ -2266,7 +2347,7 @@ mod tests { let runtime_result = WorkerRetentionExecutionResult { operation_id: prepared.plan.operation_id.clone(), input_fingerprint: prepared.plan.input_fingerprint.clone(), - expected_worker_revision: prepared.plan.worker_revision.clone(), + worker_id: worker_id(), session_disposition: prepared.plan.session_disposition, diagnostics_disposition: prepared.plan.diagnostics_disposition, @@ -2287,7 +2368,7 @@ mod tests { content_bytes: 1, content_file_count: 1, policy_id: prepared.plan.policy_id.clone(), - policy_revision: prepared.plan.policy_revision, + operation_id: prepared.plan.operation_id.clone(), input_fingerprint: prepared.plan.input_fingerprint.clone(), }), @@ -2332,8 +2413,8 @@ mod tests { .unwrap(); assert_eq!(recovered.plan.state, WorkerRemovalPlanState::Succeeded); assert_eq!( - recovered.runtime_request.expected_worker_revision, - plan.worker_revision + recovered.runtime_request.input_fingerprint, + plan.input_fingerprint ); } diff --git a/crates/workspace-server/src/runtime_settings.rs b/crates/workspace-server/src/runtime_settings.rs index e7d8ac5c0..eb9652778 100644 --- a/crates/workspace-server/src/runtime_settings.rs +++ b/crates/workspace-server/src/runtime_settings.rs @@ -24,7 +24,6 @@ impl WorkspaceConfigSchemaProvider for RuntimeConfigSchemaProvider { #[derive(Debug, Clone, PartialEq, Eq)] pub struct RuntimeConfigProjection { - pub config_revision: u64, pub projection_digest: String, pub default_runtime_id: Option, } @@ -52,7 +51,6 @@ pub fn project_runtime_from_workspace_config( .any(|entry| entry.provider_id == "builtin:runtime"); if !has_runtime_schema { return Ok(RuntimeConfigProjection { - config_revision: state.snapshot.revision, projection_digest: state.projection_digest.clone(), default_runtime_id: None, }); @@ -71,7 +69,6 @@ pub fn project_runtime_from_workspace_config( .map_err(|error| Error::RegistryInconsistency(error.to_string()))?; let default_runtime_id = normalize_runtime_id(&config.runtime.default_runtime_id)?; Ok(RuntimeConfigProjection { - config_revision: state.snapshot.revision, projection_digest: evaluation.projection_digest, default_runtime_id, }) @@ -103,15 +100,12 @@ mod tests { .contribution() .unwrap()]) .unwrap(); - let snapshot = ConfigTreeSnapshot::from_entries( - 7, - [ConfigEntry::new( - VirtualPath::parse("main.dcdl").unwrap(), - ConfigContentType::Decodal, - source, - ) - .unwrap()], + let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new( + VirtualPath::parse("main.dcdl").unwrap(), + ConfigContentType::Decodal, + source, ) + .unwrap()]) .unwrap(); let contract = config_source::ToolchainContract::with_schema_bundle( config_source::DEFAULT_SCHEMA_VERSION, @@ -131,7 +125,7 @@ mod tests { } #[test] - fn runtime_projection_reads_default_and_preserves_revision_evidence() { + fn runtime_projection_reads_default_and_preserves_content_evidence() { let projection = project_runtime_from_workspace_config( "workspace", &state( @@ -140,10 +134,48 @@ mod tests { ) .unwrap(); assert_eq!(projection.default_runtime_id.as_deref(), Some("arcadia")); - assert_eq!(projection.config_revision, 7); assert!(!projection.projection_digest.is_empty()); } + #[test] + fn runtime_projection_digest_tracks_content_not_observation_order() { + let first = + state(r#"{ runtime = { default_runtime_id = "arcadia"; }; } as WorkspaceConfigSchema"#); + let same = + state(r#"{ runtime = { default_runtime_id = "arcadia"; }; } as WorkspaceConfigSchema"#); + let changed = + state(r#"{ runtime = { default_runtime_id = "other"; }; } as WorkspaceConfigSchema"#); + let first_projection = project_runtime_from_workspace_config("workspace", &first).unwrap(); + assert_eq!( + first_projection, + project_runtime_from_workspace_config("workspace", &same).unwrap() + ); + let changed_projection = + project_runtime_from_workspace_config("workspace", &changed).unwrap(); + assert_ne!( + first_projection.projection_digest, + changed_projection.projection_digest + ); + assert_eq!( + changed_projection.default_runtime_id.as_deref(), + Some("other") + ); + assert_eq!( + first_projection, + project_runtime_from_workspace_config("workspace", &first).unwrap() + ); + } + + #[test] + fn runtime_projection_rejects_mismatched_content_evidence() { + let mut active = state("{} as WorkspaceConfigSchema"); + active.projection_digest = "sha256:tampered".to_string(); + assert!(matches!( + project_runtime_from_workspace_config("workspace", &active), + Err(Error::RegistryInconsistency(_)) + )); + } + #[test] fn runtime_projection_treats_missing_default_as_unconfigured() { let projection = project_runtime_from_workspace_config( @@ -165,15 +197,12 @@ mod tests { ) .unwrap()]) .unwrap(); - let snapshot = ConfigTreeSnapshot::from_entries( - 6, - [ConfigEntry::new( - VirtualPath::parse("main.dcdl").unwrap(), - ConfigContentType::Decodal, - "{ legacy = { enabled = true; }; } as WorkspaceConfigSchema", - ) - .unwrap()], + let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new( + VirtualPath::parse("main.dcdl").unwrap(), + ConfigContentType::Decodal, + "{ legacy = { enabled = true; }; } as WorkspaceConfigSchema", ) + .unwrap()]) .unwrap(); let contract = config_source::ToolchainContract::with_schema_bundle( config_source::DEFAULT_SCHEMA_VERSION, @@ -193,7 +222,6 @@ mod tests { let projection = project_runtime_from_workspace_config("workspace", &state).unwrap(); assert_eq!(projection.default_runtime_id, None); - assert_eq!(projection.config_revision, 6); assert_eq!(projection.projection_digest, projection_digest); } @@ -204,15 +232,12 @@ mod tests { .contribution() .unwrap()]) .unwrap(); - let snapshot = ConfigTreeSnapshot::from_entries( - 1, - [ConfigEntry::new( - VirtualPath::parse("main.dcdl").unwrap(), - ConfigContentType::Decodal, - "{ runtime = { default_runtime_id = 42; }; } as WorkspaceConfigSchema", - ) - .unwrap()], + let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new( + VirtualPath::parse("main.dcdl").unwrap(), + ConfigContentType::Decodal, + "{ runtime = { default_runtime_id = 42; }; } as WorkspaceConfigSchema", ) + .unwrap()]) .unwrap(); let contract = config_source::ToolchainContract::with_schema_bundle( config_source::DEFAULT_SCHEMA_VERSION, diff --git a/crates/workspace-server/src/runtime_subscription_tests.rs b/crates/workspace-server/src/runtime_subscription_tests.rs index c07ca6450..73dbff4d3 100644 --- a/crates/workspace-server/src/runtime_subscription_tests.rs +++ b/crates/workspace-server/src/runtime_subscription_tests.rs @@ -70,7 +70,6 @@ fn create_request(name: &str) -> CreateWorkerRequest { workspace_api: None, memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "local".to_string(), - settings_revision: 1, language: "English".to_string(), }), subjektiv_attached: false, diff --git a/crates/workspace-server/src/schema_equivalence_tests.rs b/crates/workspace-server/src/schema_equivalence_tests.rs new file mode 100644 index 000000000..39a74ebed --- /dev/null +++ b/crates/workspace-server/src/schema_equivalence_tests.rs @@ -0,0 +1,102 @@ +// Included in store::tests so both paths use the real retained migration chain. +#[test] +fn fresh_and_upgraded_state_contract_schemas_enforce_the_same_constraints() { + fn signature(conn: &Connection, table: &str) -> Vec> { + let queries = [ + "SELECT name, type, CAST(\"notnull\" AS TEXT), COALESCE(dflt_value, ''), CAST(pk AS TEXT) FROM pragma_table_info(?1) ORDER BY name", + "SELECT \"table\", \"from\", \"to\", on_update, on_delete, match, CAST(seq AS TEXT) FROM pragma_foreign_key_list(?1) ORDER BY \"table\", \"from\", \"to\", seq", + "SELECT CAST(i.\"unique\" AS TEXT), CAST(i.partial AS TEXT), (SELECT group_concat(name, ',') FROM (SELECT name FROM pragma_index_info(i.name) ORDER BY seqno)) FROM pragma_index_list(?1) AS i ORDER BY 1, 2, 3", + "SELECT name, sql FROM sqlite_schema WHERE type='trigger' AND tbl_name=?1 ORDER BY name", + ]; + queries + .into_iter() + .flat_map(|sql| { + let mut statement = conn.prepare(sql).unwrap(); + let count = statement.column_count(); + statement + .query_map([table], |row| { + (0..count) + .map(|i| row.get::<_, String>(i)) + .collect::>>() + }) + .unwrap() + .map(|row| { + row.unwrap() + .into_iter() + .map(|value| { + value + .chars() + .filter(|c| !c.is_whitespace() && *c != '"') + .collect() + }) + .collect() + }) + .collect::>() + }) + .collect() + } + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("upgraded.db"); + prepare_retained_schema(&path, 85); + let upgraded = SqliteWorkspaceStore::open(&path).unwrap(); + let fresh = SqliteWorkspaceStore::in_memory().unwrap(); + let mut mismatches = Vec::new(); + for table in [ + "workspace_config_trees", + "workspace_config_tree_history", + "workspace_memory_settings", + "worker_create_reservations", + "backend_jobs", + "backend_job_attempts", + "flow_sources", + "flow_source_contents", + "repositories", + "workspace_runtime_bindings", + "workspace_runtime_verifications", + "workspace_runtime_binding_audit", + "workspace_signing_identities", + "workspace_signing_identity_provisioning_operations", + "workspace_signing_identity_audit", + "runtime_removal_operations", + "workdir_removal_operations", + "workspace_deletion_operations", + "workspace_worker_retention_policies", + "workspace_worker_retention_policy_snapshots", + "worker_removal_operations", + "worker_session_archives", + "worker_diagnostics_archives", + "worker_tombstones", + "repository_secret_audit_events", + "repository_secret_operations", + "repository_ssh_credential_keys", + "repository_ssh_credentials", + "repository_ssh_host_trust_keys", + "repository_ssh_host_trusts", + "server_secret_objects", + "workdir_create_operations", + "workdir_create_credential_candidates", + "workdir_create_credential_retentions", + "workdir_create_legacy_ssh_archives", + "repository_secret_legacy_receipts", + ] { + let expected = fresh.with_conn(|conn| Ok(signature(conn, table))).unwrap(); + let actual = upgraded + .with_conn(|conn| Ok(signature(conn, table))) + .unwrap(); + assert!(!expected.is_empty(), "missing canonical table {table}"); + if actual != expected { + mismatches.push(format!( + "{table}:\n upgraded-only: {:?}\n fresh-only: {:?}", + actual + .iter() + .filter(|row| !expected.contains(row)) + .collect::>(), + expected + .iter() + .filter(|row| !actual.contains(row)) + .collect::>() + )); + } + } + assert!(mismatches.is_empty(), "{}", mismatches.join("\n")); +} diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index c22a1b963..4f359f61c 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -397,7 +397,6 @@ impl ServerConfig { provider: repository.provider.unwrap_or(repository.kind), path: repository_local_path(&repository.source), source: repository.source, - source_revision: repository.source_revision, source_fingerprint: repository.source_fingerprint, observed_status: repository.observed_status, observed_at: repository.observed_at, @@ -2557,7 +2556,6 @@ fn workspace_repository_record(record: RepositoryRecord) -> WorkspaceRepositoryR provider: record.provider, source: record.source, default_ref: record.default_ref, - source_revision: record.source_revision, source_fingerprint: record.source_fingerprint, observed_status: record.observed_status, observed_at: record.observed_at, @@ -2572,7 +2570,6 @@ fn workspace_create_response( WorkspaceCreateResponse { workspace: workspace_summary(created.workspace), repository: created.repository.map(workspace_repository_record), - config_revision: created.config_revision, request_fingerprint: created.request_fingerprint, replayed: created.replayed, } @@ -3313,11 +3310,14 @@ impl WorkspaceApi { base_url: "in-process://embedded".to_owned(), public_key: embedded_identity.public_key.clone(), public_key_fingerprint: String::new(), - binding_revision: 1, + binding_id: "binding-test".to_string(), state: StoredRuntimeBindingState::Verified, authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer, workspace_key_id: None, - workspace_key_generation: None, + + workspace_public_key_fingerprint: None, + + workspace_trust_id: None, created_at: config.workspace_created_at.clone(), updated_at: config.workspace_created_at.clone(), revoked_at: None, @@ -3761,7 +3761,7 @@ impl WorkspaceApi { job_id: request.job_id.clone(), attempt_id: reservation.attempt.attempt_id.clone(), purpose: request.purpose.clone(), - input_revision: request.input_revision.clone(), + input_digest: request.input_digest()?, subjektiv_consolidation: request.grants.subjektiv_consolidation.is_some(), }, acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { @@ -4515,7 +4515,7 @@ impl WorkspaceApi { attempt.state, BackendJobAttemptState::Dispatched | BackendJobAttemptState::Completed ) || attempt.worker.as_ref() != Some(source_worker) - || attempt.input_revision != submission.input_revision + || attempt.input_digest != submission.input_digest || job.current_attempt != attempt.attempt { return Err(Error::InvalidInput( @@ -4555,7 +4555,7 @@ impl WorkspaceApi { .ok_or_else(|| Error::InvalidInput("unknown consolidation attempt".into()))?; if attempt.worker.as_ref() != Some(source_worker) || job.current_attempt != attempt.attempt - || attempt.input_revision != submission.input_revision + || attempt.input_digest != submission.input_digest || !matches!( attempt.state, BackendJobAttemptState::Dispatched | BackendJobAttemptState::Completed @@ -4658,8 +4658,8 @@ impl WorkspaceApi { Ok(ticket_item_checker::TicketItemCheckNotification::Stale) => { let detail = format!( "checked revision {} is no longer current ({})", - checker_input.revision, - ticket_item_checker::item_revision(¤t_ticket) + checker_input.content_digest, + ticket_item_checker::content_digest(¤t_ticket) ); let _ = self.store.finish_backend_job_delivery( &self.config.workspace_id, @@ -6063,7 +6063,6 @@ fn import_configured_repositories( provider: Some(repository.provider.clone()), source: repository.source.clone(), default_ref: repository.default_selector.clone(), - source_revision: repository.source_revision, source_fingerprint: repository.source_fingerprint.clone(), observed_status: repository.observed_status, observed_at: repository.observed_at.clone(), @@ -6094,7 +6093,6 @@ fn configured_repository_from_record(record: RepositoryRecord) -> Result Rou .merge(server_api::server_api_axum::workspace_config_tree_commit( service.clone(), )) - .merge(server_api::server_api_axum::workspace_config_revision( + .merge(server_api::server_api_axum::workspace_config_history( service.clone(), )) .merge(server_api::server_api_axum::workspace_config_entry( @@ -6406,7 +6404,7 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::subjektiv_memory_detail( service.clone(), )) - .merge(server_api::server_api_axum::subjektiv_memory_revisions( + .merge(server_api::server_api_axum::subjektiv_memory_changes( service.clone(), )) .merge(server_api::server_api_axum::subjektiv_subject_retire( @@ -7741,7 +7739,7 @@ impl server_api::ServerApi for ServerApiContractService { server_api::RepositorySshHostTrustMutationResponse, server_api::RepositoryApiError, > { - let created = request.expected_revision.is_none(); + let created = request.expected_fingerprint.is_none(); scoped_put_repository_ssh_host_trust( State(self.workspace_api()?.clone()), AxumPath(ScopedWorkspacePath { workspace_id }), @@ -7842,22 +7840,16 @@ impl server_api::ServerApi for ServerApiContractService { .map_err(ApiError::into_repository_api_error) } - async fn workspace_config_revision( + async fn workspace_config_history( &self, workspace_id: String, - revision: String, + content_digest: String, ) -> std::result::Result { - let revision = revision.parse::().map_err(|_| { - ApiError::from(Error::InvalidInput( - "config revision must be an unsigned integer".into(), - )) - .into_repository_api_error() - })?; - scoped_get_workspace_config_revision( + scoped_get_workspace_config_history( State(self.workspace_api()?.clone()), - AxumPath(WorkspaceConfigRevisionPath { + AxumPath(WorkspaceConfigHistoryPath { workspace_id, - revision, + content_digest, }), ) .await @@ -8181,17 +8173,17 @@ impl server_api::ServerApi for ServerApiContractService { .map_err(ApiError::into_repository_api_error) } - async fn subjektiv_memory_revisions( + async fn subjektiv_memory_changes( &self, workspace_id: String, subject_id: String, memory_id: String, - query: server_api::SubjektivMemoryRevisionsQuery, + query: server_api::SubjektivMemoryChangesQuery, ) -> std::result::Result< - server_api::SubjektivMemoryListRevisionsResponse, + server_api::SubjektivMemoryListChangesResponse, server_api::RepositoryApiError, > { - scoped_list_subjektiv_memory_revisions( + scoped_list_subjektiv_memory_changes( State(self.workspace_api()?.clone()), AxumPath(ScopedSubjektivMemoryPath { workspace_id, @@ -9087,10 +9079,11 @@ impl server_api::ServerApi for ServerApiContractService { "the embedded Runtime trust key is managed by the embedded Runtime authority", ))); } - if request.expected_revision == 0 { + if request.expected_binding_id.trim().is_empty() || request.expected_binding_id.len() > 256 + { return Err(map_error(settings_bad_request( - "invalid_runtime_binding_revision", - "expected_revision must be greater than zero", + "invalid_runtime_binding_id", + "expected_binding_id must contain between 1 and 256 bytes", ))); } let now = Utc::now().to_rfc3339(); @@ -9099,15 +9092,15 @@ impl server_api::ServerApi for ServerApiContractService { .revoke_workspace_runtime_binding_key( &workspace_id, &runtime_id, - request.expected_revision, + &request.expected_binding_id, &actor.account_id, &now, ) .await { let kind = match error { - Error::RuntimeBindingRevisionConflict { .. } => { - Some(server_api::RuntimeTrustConflictKind::StaleRevision) + Error::RuntimeBindingIdConflict { .. } => { + Some(server_api::RuntimeTrustConflictKind::StaleBinding) } Error::RuntimeBindingFingerprintConflict { .. } => { Some(server_api::RuntimeTrustConflictKind::FingerprintInUse) @@ -9124,14 +9117,14 @@ impl server_api::ServerApi for ServerApiContractService { server_api::RuntimeTrustConflictResponse { error: kind, message: match kind { - server_api::RuntimeTrustConflictKind::StaleRevision => { + server_api::RuntimeTrustConflictKind::StaleBinding => { "the Runtime trust binding changed; reload before retrying".to_string() } server_api::RuntimeTrustConflictKind::FingerprintInUse => { "the public key is already bound to another Runtime in this Workspace".to_string() } }, - current_revision: current.as_ref().map(|binding| binding.binding_revision), + current_binding_id: current.as_ref().map(|binding| binding.binding_id.clone()), current_fingerprint: current .as_ref() .map(|binding| binding.public_key_fingerprint.clone()), @@ -12146,7 +12139,6 @@ async fn scoped_resolve_flow_source( selector: request.selector.clone(), workspace_id: path.workspace_id, flow_id: format!("builtin:{slug}"), - revision: builtin.revision, content_digest: definition.content_digest.clone(), definition, } @@ -12156,22 +12148,25 @@ async fn scoped_resolve_flow_source( .store .get_flow_source_by_name(&path.workspace_id, FlowSourceKind::Workspace, slug)? .ok_or_else(|| Error::InvalidRecordId(request.selector.to_string()))?; - let revision = api + let content = api .store - .get_flow_source_revision(&path.workspace_id, &source.flow_id, source.revision)? + .get_flow_source_content( + &path.workspace_id, + &source.flow_id, + &source.content_digest, + )? .ok_or_else(|| { Error::Store(format!( - "resolved Flow revision {}@{} is missing", - source.flow_id, source.revision + "resolved Flow content {}@{} is missing", + source.flow_id, source.content_digest )) })?; ResolvedFlowSource { selector: request.selector.clone(), workspace_id: path.workspace_id, flow_id: source.flow_id, - revision: source.revision, - content_digest: revision.content_digest, - definition: revision.definition, + content_digest: content.content_digest, + definition: content.definition, } } }; @@ -12218,10 +12213,10 @@ async fn scoped_update_workspace_settings( .get_workspace(&path.workspace_id) .await? .ok_or_else(|| Error::InvalidRecordId(path.workspace_id.clone()))?; - if request.revision != current.updated_at { + if request.expected_updated_at != current.updated_at { return Err(Error::RuntimeOperationFailed { runtime_id: "workspace-backend".to_string(), - code: "workspace_metadata_revision_conflict".to_string(), + code: "workspace_metadata_updated_at_conflict".to_string(), message: "Workspace metadata changed before this update was applied".to_string(), } .into()); @@ -12232,7 +12227,7 @@ async fn scoped_update_workspace_settings( .await? .ok_or_else(|| Error::RuntimeOperationFailed { runtime_id: "workspace-backend".to_string(), - code: "workspace_metadata_revision_conflict".to_string(), + code: "workspace_metadata_updated_at_conflict".to_string(), message: "Workspace metadata changed before this update was applied".to_string(), })?; let workspace = crate::profile_settings::workspace_metadata_settings(&workspace); @@ -12326,7 +12321,6 @@ fn project_workspace_signing_identity( algorithm: identity.algorithm.clone(), public_key, public_key_fingerprint, - revision: identity.revision, }) } else { None @@ -12338,7 +12332,6 @@ fn project_workspace_signing_identity( algorithm: identity.algorithm, public_key: identity.public_key, public_key_fingerprint: identity.public_key_fingerprint, - revision: identity.revision, state, created_at: identity.created_at, provisioned_at: identity.provisioned_at, @@ -12348,20 +12341,20 @@ fn project_workspace_signing_identity( } #[derive(Debug, Deserialize)] -struct WorkspaceConfigRevisionPath { +struct WorkspaceConfigHistoryPath { workspace_id: String, - revision: u64, + content_digest: String, } -async fn scoped_get_workspace_config_revision( +async fn scoped_get_workspace_config_history( State(api): State, - AxumPath(path): AxumPath, + AxumPath(path): AxumPath, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; let snapshot = api .config_store - .load_workspace_config_revision(&path.workspace_id, path.revision)? - .ok_or_else(|| ApiError::from(Error::InvalidRecordId(path.revision.to_string())))?; + .load_workspace_config_history(&path.workspace_id, &path.content_digest)? + .ok_or_else(|| ApiError::from(Error::InvalidRecordId(path.content_digest.clone())))?; Ok(Json(config_tree_snapshot_to_api(snapshot))) } @@ -12401,7 +12394,6 @@ async fn scoped_get_workspace_memory_settings( .map_err(ApiError::from)?; Ok(Json(server_api::WorkspaceMemorySettings { workspace_id: settings.workspace_id, - settings_revision: settings.settings_revision, language: settings.language, })) } @@ -12416,13 +12408,12 @@ async fn scoped_update_workspace_memory_settings( .config_store .update_workspace_memory_settings( &workspace_id, - request.expected_revision, + &request.expected_language, &request.language, ) .map_err(ApiError::from)?; Ok(Json(server_api::WorkspaceMemorySettings { workspace_id: settings.workspace_id, - settings_revision: settings.settings_revision, language: settings.language, })) } @@ -12641,7 +12632,7 @@ async fn scoped_put_repository_ssh_host_trust( Json(request): Json, ) -> ApiResult<(StatusCode, Json)> { require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?; - let status = if request.expected_revision.is_some() { + let status = if request.expected_fingerprint.is_some() { StatusCode::OK } else { StatusCode::CREATED @@ -12692,7 +12683,6 @@ fn config_tree_snapshot_to_api( snapshot: config_source::ConfigTreeSnapshot, ) -> server_api::ConfigTreeSnapshot { server_api::ConfigTreeSnapshot { - revision: snapshot.revision, digest: snapshot.digest, entries: snapshot .entries @@ -12833,7 +12823,6 @@ fn config_commit_request_from_api( .map(config_virtual_path) .collect::>>()?; Ok(ConfigCommitRequest { - base_revision: request.base_revision, base_digest: request.base_digest, changes, entrypoints, @@ -12846,14 +12835,12 @@ fn prompt_projection_to_api( let catalog = projection.catalog; server_api::WorkspacePromptProjection { workspace_id: projection.workspace_id, - config_revision: projection.config_revision, source_digest: projection.source_digest, projection_digest: projection.projection_digest, schema_fingerprint: projection.schema_fingerprint, toolchain_fingerprint: projection.toolchain_fingerprint, catalog: server_api::EffectivePromptCatalog { templates: catalog.templates, - config_revision: catalog.config_revision, source_digest: catalog.source_digest, schema_fingerprint: catalog.schema_fingerprint, toolchain_fingerprint: catalog.toolchain_fingerprint, @@ -12874,7 +12861,6 @@ fn flow_source_record_to_api(source: FlowSourceRecord) -> server_api::FlowSource path: source.path, content: source.content, content_digest: source.content_digest, - revision: source.revision, created_at: source.created_at, updated_at: source.updated_at, } @@ -12931,7 +12917,6 @@ fn resolved_flow_source_to_api(source: ResolvedFlowSource) -> server_api::Resolv selector: source.selector.to_string(), workspace_id: source.workspace_id, flow_id: source.flow_id, - revision: source.revision, content_digest: source.content_digest, definition: compiled_flow_definition_to_api(source.definition), } @@ -13350,11 +13335,11 @@ async fn start_manual_ticket_worker_assignment( let recovery = api .config_store .get_ticket_claim_recovery(&api.config.workspace_id, operation_id)?; - let item_revision = match &recovery { - Some(saved) => saved.item_revision.clone(), - None => api.authority.ticket(&record.ticket_id)?.item_revision, + let content_digest = match &recovery { + Some(saved) => saved.content_digest.clone(), + None => api.authority.ticket(&record.ticket_id)?.content_digest, }; - let binding_fingerprint = serde_json::to_string(&(&selected, &item_revision)) + let binding_fingerprint = serde_json::to_string(&(&selected, &content_digest)) .map_err(|error| Error::Store(error.to_string()))?; let fingerprint = crate::store::manual_ticket_assignment_fingerprint(record, Some(&binding_fingerprint))?; @@ -13395,7 +13380,7 @@ async fn start_manual_ticket_worker_assignment( record, operation_id, &binding_fingerprint, - &item_revision, + &content_digest, &worker, &binding, ) @@ -13470,7 +13455,7 @@ async fn start_manual_ticket_worker_assignment( return Err(Error::TicketAssignmentConflict("manual Worker claim is already admitted; reread its durable result instead of redispatching".into()).into()); } let saved = crate::store::TicketWorkerClaimRecovery { - item_revision: item_revision.clone(), + content_digest: content_digest.clone(), selected: selected.clone(), original_links: binding.original_links.clone(), effective_links: binding.effective_links.clone(), @@ -13657,7 +13642,7 @@ async fn start_manual_ticket_worker_assignment( record, operation_id, &binding_fingerprint, - &item_revision, + &content_digest, &worker, &binding, ) @@ -13669,11 +13654,11 @@ async fn finalize_manual_ticket_worker_claim( record: &TicketRoleAssignmentRecord, operation_id: &str, binding_fingerprint: &str, - item_revision: &str, + content_digest: &str, worker: &RuntimeWorkerRef, binding: &ManualTicketWorkerWorkdirBinding, ) -> ApiResult { - if api.authority.ticket(&record.ticket_id)?.item_revision != item_revision { + if api.authority.ticket(&record.ticket_id)?.content_digest != content_digest { let diagnostics = compensate_manual_ticket_worker_binding(api, &worker, &binding, operation_id, true) .await; @@ -14370,7 +14355,7 @@ async fn scoped_transition_ticket_state( Json(ticket::TicketStateUpdate { state, operation_key: request.operation_key, - expected_item_revision: request.expected_item_revision, + expected_content_digest: request.expected_content_digest, expected_state: browser_ticket_workflow_state(request.expected_state), reason, references: Vec::new(), @@ -14457,7 +14442,7 @@ async fn scoped_close_ticket( Json(ticket::TicketStateUpdate { state: TicketWorkflowState::Closed, operation_key: request.operation_key, - expected_item_revision: request.expected_item_revision, + expected_content_digest: request.expected_content_digest, expected_state: browser_ticket_workflow_state(request.expected_state), reason: request.resolution, references: Vec::new(), @@ -14598,7 +14583,7 @@ fn schedule_ticket_item_check( Err(error) => { tracing::warn!( ticket_id = %ticket.meta.id, - revision = %request.input_revision, + input_digest = ?request.input_digest(), %error, "Ticket item was saved but its asynchronous checker could not be reserved" ); @@ -14610,7 +14595,7 @@ fn schedule_ticket_item_check( if let Err(error) = api.dispatch_backend_job_reservation(reservation) { tracing::warn!( job_id = %request.job_id, - ticket_revision = %request.input_revision, + ticket_input_digest = ?request.input_digest(), %error, "asynchronous Ticket item checker dispatch failed" ); @@ -14754,7 +14739,7 @@ async fn execute_ticket_rest_operation( &before.meta.id, ticket::TicketStateUpdate { operation_key: new_id("tdecision"), - expected_item_revision: ticket::ticket_item_revision(before), + expected_content_digest: ticket::ticket_content_digest(before), expected_state, state, reason, @@ -15232,7 +15217,7 @@ impl TicketMergeRevisionSource for RuntimeTicketMergeRevisionSource { repository_id, selector, ) - .map(|observation| observation.revision_ref) + .map(|observation| observation.resolved_ref) .map_err(merge_ref_diagnostic) } } @@ -15388,7 +15373,7 @@ fn require_assigned_workdir_source( assignment: &crate::store::TicketWorkerAssignmentRecord, repository_id: &str, selector: &str, - revision_ref: &str, + resolved_ref: &str, ) -> ApiResult<()> { let worker = api .runtime @@ -15417,7 +15402,7 @@ fn require_assigned_workdir_source( else { continue; }; - match validate_assigned_workdir_source(&workdir, repository_id, selector, revision_ref) { + match validate_assigned_workdir_source(&workdir, repository_id, selector, resolved_ref) { Ok(()) => return Ok(()), Err(diagnostic) => last_diagnostic = Some(diagnostic), } @@ -15440,7 +15425,7 @@ fn validate_assigned_workdir_source( workdir: &worker_runtime::catalog::WorkingDirectorySummary, repository_id: &str, selector: &str, - revision_ref: &str, + resolved_ref: &str, ) -> std::result::Result<(), worker_runtime::working_directory::WorkingDirectoryDiagnostic> { if workdir.repository_id != repository_id { return Err( @@ -15469,7 +15454,7 @@ fn validate_assigned_workdir_source( crate::repositories::normalize_target_branch_selector(repository_id, selector) .is_ok_and(|selector| selector == workdir_selector) }); - if !workdir_selector_matches || workdir.current_ref.as_deref() != Some(revision_ref) { + if !workdir_selector_matches || workdir.current_ref.as_deref() != Some(resolved_ref) { return Err( worker_runtime::working_directory::WorkingDirectoryDiagnostic { code: "source_ref_revision_mismatch".to_string(), @@ -15666,7 +15651,7 @@ fn public_review_event(event: merge_request::ReviewEvent) -> server_api::ReviewE sequence: event.sequence, request_event_id: event.request_event_id, subject_ref: event.subject_ref, - ticket_item_revision: event.ticket_item_revision, + ticket_content_digest: event.ticket_content_digest, ticket_merge_request_subjects: event .ticket_merge_request_subjects .into_iter() @@ -15764,7 +15749,7 @@ fn public_merge_request_thread_event( event_id: event.event_id, sequence: event.sequence, subject_ref: event.subject_ref, - ticket_item_revision: event.ticket_item_revision, + ticket_content_digest: event.ticket_content_digest, ticket_merge_request_subjects: event .ticket_merge_request_subjects .into_iter() @@ -15906,7 +15891,7 @@ async fn scoped_list_merge_requests( &merge_request.repository_id, selector, ) { - Ok(observation) => (Some(observation.revision_ref), Vec::new()), + Ok(observation) => (Some(observation.resolved_ref), Vec::new()), Err(error) => (None, vec![merge_ref_diagnostic(error)]), }, None => ( @@ -15973,7 +15958,7 @@ fn merge_ref_diagnostic(error: ApiError) -> MergeRequestRefDiagnostic { fn unknown_merge_ref(code: &str, message: &str) -> server_api::MergeRequestRefResponse { server_api::MergeRequestRefResponse { status: "unknown".to_string(), - revision_ref: None, + resolved_ref: None, observed_at: Utc::now().to_rfc3339(), diagnostic: Some(MergeRequestRefDiagnostic { code: code.to_string(), @@ -15985,7 +15970,7 @@ fn unknown_merge_ref(code: &str, message: &str) -> server_api::MergeRequestRefRe fn unknown_merge_ref_response(error: ApiError) -> server_api::MergeRequestRefResponse { server_api::MergeRequestRefResponse { status: "unknown".to_string(), - revision_ref: None, + resolved_ref: None, observed_at: Utc::now().to_rfc3339(), diagnostic: Some(merge_ref_diagnostic(error)), } @@ -16000,7 +15985,7 @@ fn merge_ref_response( .to_rfc3339(); server_api::MergeRequestRefResponse { status: "known".into(), - revision_ref: Some(observation.revision_ref), + resolved_ref: Some(observation.resolved_ref), observed_at, diagnostic: None, } @@ -16031,7 +16016,7 @@ fn integrated_merge_refs( "unknown" } .into(), - revision_ref: result.map(|merge| merge.approved_source_ref.clone()), + resolved_ref: result.map(|merge| merge.approved_source_ref.clone()), observed_at: result.map_or_else( || mr.updated_at.to_rfc3339(), |merge| merge.created_at.to_rfc3339(), @@ -16040,7 +16025,7 @@ fn integrated_merge_refs( }; let target = server_api::MergeRequestRefResponse { status: if result.is_some() { "known" } else { "unknown" }.into(), - revision_ref: result.map(|merge| merge.target_ref_after.clone()), + resolved_ref: result.map(|merge| merge.target_ref_after.clone()), observed_at: source.observed_at.clone(), diagnostic: result.is_none().then(|| MergeRequestRefDiagnostic { code: "merge_result_missing".into(), @@ -16096,7 +16081,7 @@ async fn scoped_show_merge_request( ), (None, _) => server_api::MergeRequestRefResponse { status: "requires_repair".into(), - revision_ref: None, + resolved_ref: None, observed_at: Utc::now().to_rfc3339(), diagnostic: None, }, @@ -16170,7 +16155,7 @@ async fn scoped_merge_request_readiness( &mr.repository_id, selector, ) { - Ok(observation) => (Some(observation.revision_ref), None), + Ok(observation) => (Some(observation.resolved_ref), None), Err(error) => { let diagnostic = merge_ref_diagnostic(error); let blocker = source_ref_readiness_blocker(&diagnostic.code); @@ -16249,7 +16234,7 @@ async fn scoped_open_merge_request( &assignment, &repository_id, &input.selector_from, - &source_observation.revision_ref, + &source_observation.resolved_ref, )?; observe_published_merge_ref( &api, @@ -16332,7 +16317,7 @@ async fn scoped_repair_merge_request_selector( &mr.repository_id, &input.selector_from, )? - .revision_ref; + .resolved_ref; let repaired = store.repair_selector_from(merge_request::RepairSelectorFrom { workspace_id: workspace_id.clone(), merge_request_id, @@ -16424,9 +16409,9 @@ async fn scoped_register_merge_request_review_capability( &assignment, &mr.repository_id, selector, - &source_observation.revision_ref, + &source_observation.resolved_ref, )?; - source_observation.revision_ref + source_observation.resolved_ref } merge_request::MergeRequestState::Closed => { return Err( @@ -16452,18 +16437,18 @@ async fn scoped_register_merge_request_review_capability( &linked.repository_id, linked_selector, )? - .revision_ref + .resolved_ref }; ticket_merge_request_subjects.push(merge_request::MergeRequestReviewSubject { merge_request_id: linked.merge_request_id, subject_ref: linked_subject_ref, }); } - let ticket_item_revision = api.authority.ticket(&ticket_id)?.item_revision; + let ticket_content_digest = api.authority.ticket(&ticket_id)?.content_digest; store.request_review(merge_request::RequestMergeRequestReview { merge_request_id, ticket_id, - ticket_item_revision, + ticket_content_digest, ticket_merge_request_subjects, subject_ref, child_session_id: input.child_session_id, @@ -16521,7 +16506,7 @@ async fn scoped_submit_merge_request_review( &mr.repository_id, selector, )? - .revision_ref + .resolved_ref } merge_request::MergeRequestState::Closed => { return Err( @@ -16650,7 +16635,7 @@ async fn scoped_complete_merge_request( &mr.repository_id, selector, )? - .revision_ref; + .resolved_ref; let observed_target = observe_published_merge_ref( &api, &workspace_id, @@ -16659,7 +16644,7 @@ async fn scoped_complete_merge_request( &mr.selector_to, )?; require_completed_target_observation( - &observed_target.revision_ref, + &observed_target.resolved_ref, &input.target_ref_before, &input.target_ref_after, )?; @@ -16702,7 +16687,7 @@ async fn scoped_complete_ticket( AxumPath((workspace_id, ticket_id)), Json(ticket::TicketStateUpdate { operation_key: input.operation_key, - expected_item_revision: input.expected_item_revision, + expected_content_digest: input.expected_content_digest, expected_state: input.expected_state, state: TicketWorkflowState::Done, reason: input.reason, @@ -16785,7 +16770,7 @@ async fn scoped_close_ticket_record( Json(ticket::TicketStateUpdate { state: TicketWorkflowState::Closed, operation_key: request.operation_key, - expected_item_revision: request.expected_item_revision, + expected_content_digest: request.expected_content_digest, expected_state: request.expected_state, reason: request.reason, references: request.references, @@ -17769,7 +17754,7 @@ async fn scoped_submit_backend_job_result( &BackendJobResultSubmission { job_id: binding.job_id, attempt_id: binding.attempt_id, - input_revision: attempt.input_revision, + input_digest: attempt.input_digest, result: request.result, }, )?; @@ -18032,7 +18017,7 @@ fn list_current_worker_workdir_catalog_for_worker( workspace_id: api.config.workspace_id.clone(), items, next_cursor: page.next_cursor, - revision: page.revision, + digest: page.revision, }) } @@ -18048,7 +18033,7 @@ fn list_current_worker_workdir_attachments( Error::InvalidInput("attachment page limit must be 1..=100".to_string()).into(), ); } - let (mut links, revision) = api.store.list_worker_workdir_links_page_with_revision( + let (mut links, digest) = api.store.list_worker_workdir_links_page_with_revision( &api.config.workspace_id, worker, limit + 1, @@ -18076,7 +18061,7 @@ fn list_current_worker_workdir_attachments( }) .collect(), next_offset, - revision, + digest, }) } @@ -19143,7 +19128,10 @@ fn require_existing_subjektiv_session_attribution( fn subjektiv_subject_response( api: &WorkspaceApi, subject: crate::subjektiv::SubjectRecord, -) -> server_api::SubjektivSubjectResponse { +) -> ApiResult { + let memory_fingerprint = open_subjektiv_store(api)? + .memory_fingerprint(&subject.id) + .map_err(subjektiv_store_error)?; let current_worker = crate::subjektiv::subject_worker_singleton_key(&subject.id) .ok() .and_then(|key| { @@ -19154,20 +19142,19 @@ fn subjektiv_subject_response( }) .and_then(|lease| api.runtime.worker(&lease.worker).ok()) .map(worker_launch_worker_summary); - server_api::SubjektivSubjectResponse { + Ok(server_api::SubjektivSubjectResponse { id: subject.id, role: subject.role.as_str().to_string(), behavior_md: subject.behavior_md, - behavior_revision: subject.behavior_revision, + memory_fingerprint, state: match subject.state { crate::subjektiv::SubjectState::Active => server_api::SubjektivSubjectState::Active, crate::subjektiv::SubjectState::Retired => server_api::SubjektivSubjectState::Retired, }, - store_revision: subject.store_revision, created_at: subject.created_at, updated_at: subject.updated_at, current_worker, - } + }) } async fn scoped_create_subjektiv_subject( @@ -19185,7 +19172,7 @@ async fn scoped_create_subjektiv_subject( .map_err(subjektiv_store_error)?; Ok(( StatusCode::CREATED, - Json(subjektiv_subject_response(&api, subject)), + Json(subjektiv_subject_response(&api, subject)?), )) } @@ -19256,7 +19243,7 @@ async fn scoped_list_subjektiv_subjects( .items .into_iter() .map(|subject| subjektiv_subject_response(&api, subject)) - .collect(), + .collect::>>()?, next_cursor, has_more: page.has_more, })) @@ -19271,7 +19258,7 @@ async fn scoped_get_subjektiv_subject( .subject(&path.subject_id) .map_err(|error| Error::Store(error.to_string()))? .ok_or_else(|| Error::SubjektivSubjectNotFound(path.subject_id.clone()))?; - Ok(Json(subjektiv_subject_response(&api, subject))) + Ok(Json(subjektiv_subject_response(&api, subject)?)) } async fn scoped_update_subjektiv_subject_behavior( @@ -19285,11 +19272,11 @@ async fn scoped_update_subjektiv_subject_behavior( let subject = open_subjektiv_store(&api)? .update_subject_behavior( &path.subject_id, - request.expected_behavior_revision, + &request.expected_behavior_md, request.behavior_md, ) .map_err(subjektiv_store_error)?; - Ok(Json(subjektiv_subject_response(&api, subject))) + Ok(Json(subjektiv_subject_response(&api, subject)?)) } fn require_subjektiv_subject( @@ -19338,12 +19325,12 @@ async fn scoped_get_subjektiv_resident_surface( memory_refs: snapshot .memory_refs .into_iter() - .map(|reference| server_api::SubjektivMemoryRevisionRef { + .map(|reference| server_api::SubjektivMemoryChangeRef { memory_id: reference.memory_id, - revision: reference.revision, + change_id: reference.change_id, }) .collect(), - built_from_store_revision: snapshot.built_from_store_revision, + built_from_memory_fingerprint: snapshot.built_from_memory_fingerprint, created_at: snapshot.created_at, }); Ok(Json(server_api::SubjektivResidentSurfaceResponse { @@ -19381,7 +19368,7 @@ async fn scoped_get_subjektiv_memory( &path.subject_id, server_api::SubjektivMemoryReadRequest { memory_id: path.memory_id, - revision: query.revision, + change_id: query.change_id, offset: query.offset, byte_offset: query.byte_offset, limit: query.limit, @@ -19390,18 +19377,18 @@ async fn scoped_get_subjektiv_memory( )?)) } -async fn scoped_list_subjektiv_memory_revisions( +async fn scoped_list_subjektiv_memory_changes( State(api): State, AxumPath(path): AxumPath, - Query(query): Query, -) -> ApiResult> { + Query(query): Query, +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; let store = open_subjektiv_store(&api)?; require_subjektiv_subject(&store, &path.subject_id)?; - Ok(Json(subjektiv_memory_list_revisions( + Ok(Json(subjektiv_memory_list_changes( &store, &path.subject_id, - server_api::SubjektivMemoryListRevisionsRequest { + server_api::SubjektivMemoryListChangesRequest { memory_id: path.memory_id, limit: query.limit, cursor: query.cursor, @@ -19419,7 +19406,7 @@ async fn scoped_retire_subjektiv_subject( .map_err(subjektiv_store_error)?; // Retirement deliberately does not stop or remove the current Worker. The // Worker lease and subject lifecycle are independent authorities. - Ok(Json(subjektiv_subject_response(&api, subject))) + Ok(Json(subjektiv_subject_response(&api, subject)?)) } async fn scoped_stage_subjektiv_candidate( @@ -20693,7 +20680,6 @@ fn subjektiv_session_archive_manifest_matches( && actual.content_checksum_sha256 == expected.checksum_sha256 && actual.content_bytes == expected.content_bytes && actual.policy_id == expected.policy_id - && actual.policy_revision == expected.policy_revision && actual.operation_id == expected.operation_id } _ => false, @@ -20887,7 +20873,7 @@ async fn scoped_subjektiv_memory_backend( Some(SubjektivWorkerAuthority::Consolidation), "surface generation failure", ), - Op::Query(_) | Op::Read(_) | Op::ListRevisions(_) => (None, "recall"), + Op::Query(_) | Op::Read(_) | Op::ListChanges(_) => (None, "recall"), }; if let Some(expected) = expected { require_subjektiv_worker_authority(authority, expected, label)?; @@ -20951,10 +20937,10 @@ fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error { crate::subjektiv::SubjektivError::SubjectScopeMismatch { .. } => { Error::WorkspacePermissionDenied(format!("subject_scope_mismatch: {error}")) } - crate::subjektiv::SubjektivError::RevisionConflict { .. } + crate::subjektiv::SubjektivError::MemoryChangeConflict { .. } | crate::subjektiv::SubjektivError::SubjectBehaviorConflict { .. } | crate::subjektiv::SubjektivError::SurfaceGenerationConflict(_) => { - Error::RepositoryConflict(format!("revision_conflict: {error}")) + Error::RepositoryConflict(format!("change_conflict: {error}")) } crate::subjektiv::SubjektivError::CandidateResolved(_) | crate::subjektiv::SubjektivError::DecisionRequestConflict(_) => { @@ -21531,7 +21517,6 @@ async fn scoped_create_repository( source_fingerprint: repository_source_fingerprint(&source), source, default_ref, - source_revision: 1, observed_status: server_api::RepositoryObservedStatus::Unverified, observed_at: None, created_at: now.clone(), @@ -21583,7 +21568,6 @@ fn repository_create_intent_matches( && existing.provider == requested.provider && existing.source == requested.source && existing.default_ref == requested.default_ref - && existing.source_revision == requested.source_revision && existing.source_fingerprint == requested.source_fingerprint } @@ -21625,7 +21609,7 @@ async fn scoped_confirm_repository_ssh_host_trust( "Selected SSH host key was not presented by the target Runtime", ) })?; - if request.expected_host_trust_revision != probe.expected_host_trust_revision { + if request.expected_host_key_fingerprint != probe.expected_host_key_fingerprint { return Err(ApiError::from(Error::WorkspaceConfigConflict( "SSH host trust revision changed after the connection test".to_string(), ))); @@ -21638,7 +21622,7 @@ async fn scoped_confirm_repository_ssh_host_trust( hostname: probe.hostname, port: probe.port, host_key: candidate.host_key.clone(), - expected_revision: probe.expected_host_trust_revision, + expected_fingerprint: probe.expected_host_key_fingerprint, }, &actor.account_id, )?; @@ -21732,7 +21716,7 @@ fn probe_repository_ssh_connection( }, |host_trust| host_trust.host_trust_id.clone(), ), - expected_host_trust_revision: existing.map(|host_trust| host_trust.current_revision), + expected_host_key_fingerprint: existing.map(|host_trust| host_trust.fingerprint.clone()), candidates, }) } @@ -21926,7 +21910,7 @@ fn worker_retention_error_response( "worker_not_found", "Worker was not found in this Workspace", ), - crate::retention::WorkerRetentionError::PolicyRevisionConflict { .. } + crate::retention::WorkerRetentionError::PolicyDigestConflict { .. } | crate::retention::WorkerRetentionError::StalePlan { .. } | crate::retention::WorkerRetentionError::OperationFingerprintConflict { .. } => { worker_remove_error_response( @@ -24202,10 +24186,10 @@ async fn create_workspace_working_directory( ) .into()); } - if let Some(existing) = api + let existing = api .config_store - .load_workdir_create_operation(workspace_id, &operation_id)? - { + .load_workdir_create_operation(workspace_id, &operation_id)?; + if let Some(existing) = existing.as_ref() { working_directory_request.repository.selector = crate::workdir_create_operations::selector_for_retry( request.selector.as_deref(), @@ -24213,10 +24197,9 @@ async fn create_workspace_working_directory( working_directory_request.repository.selector.as_deref(), ) .map(RuntimeRepositorySelector::from); - if let (Some(kind), Some(uri), Some(revision), Some(fingerprint)) = ( + if let (Some(kind), Some(uri), Some(fingerprint)) = ( existing.source_kind.as_deref(), existing.source_uri.clone(), - existing.source_revision, existing.source_fingerprint.clone(), ) { let kind = match kind { @@ -24234,7 +24217,6 @@ async fn create_workspace_working_directory( }; working_directory_request.repository.source = server_api::RepositorySource { kind, uri }; - working_directory_request.repository.source_revision = revision; working_directory_request.repository.source_fingerprint = fingerprint; } } @@ -24243,24 +24225,15 @@ async fn create_workspace_working_directory( .selector .as_ref() .map(|selector| selector.as_ref().to_string()); - let request_fingerprint = crate::workdir_create_operations::request_fingerprint( + let request_fingerprint = crate::workdir_create_operations::request_fingerprint_for_replay( &request.repository_key, selector.as_deref(), requested_runtime_id.as_deref(), working_directory_request.display_name.as_deref(), &working_directory_request.repository.source_fingerprint, - working_directory_request.repository.source_revision, - ); - let reserved = if let Some(existing) = api - .config_store - .load_workdir_create_operation(workspace_id, &operation_id)? - { - if existing.request_fingerprint != request_fingerprint { - return Err(Error::InvalidInput(format!( - "Workdir create operation `{operation_id}` was reused with different input" - )) - .into()); - } + existing.as_ref().map(|r| r.request_fingerprint.as_str()), + )?; + let reserved = if let Some(existing) = existing { existing } else { let config_state = api @@ -24301,7 +24274,6 @@ async fn create_workspace_working_directory( selector, requested_runtime_id, resolved_runtime_id, - config_revision: runtime_projection.config_revision, config_projection_digest: runtime_projection.projection_digest, source_kind: Some( working_directory_request @@ -24312,7 +24284,6 @@ async fn create_workspace_working_directory( .to_string(), ), source_uri: Some(working_directory_request.repository.source.uri.clone()), - source_revision: Some(working_directory_request.repository.source_revision), source_fingerprint: Some( working_directory_request .repository @@ -24320,9 +24291,9 @@ async fn create_workspace_working_directory( .clone(), ), credential_id: None, - credential_revision: None, + credential_fingerprint: None, host_trust_id: None, - host_trust_revision: None, + host_trust_fingerprint: None, repository_access_mode: None, credential_candidates: Vec::new(), working_directory_id: next_backend_workdir_id(&request.repository_key), @@ -24736,7 +24707,7 @@ fn current_workdir_removal_attempt_owner() -> Result fn workdir_removal_attempt_is_orphaned(operation: &WorkdirRemovalOperation) -> Result { let Some(owner) = operation.attempt_owner else { - return Ok(operation.attempt_count == 0); + return Ok(operation.attempt_id.is_none()); }; match observe_workdir_removal_owner(owner.process_id) { WorkdirRemovalOwnerObservation::Running { @@ -24861,7 +24832,7 @@ fn execute_reserved_workdir_removal_with_provider( &operation.request_fingerprint, api.workdir_remove_attempt_owner, operation.attempt_owner, - operation.attempt_count, + operation.attempt_id.as_deref(), )? } else { api.config_store.begin_workdir_removal_attempt( @@ -25329,7 +25300,6 @@ fn build_runtime_cleanup_plan( workspace_id: api.config.workspace_id.clone(), runtime_id: runtime_id.to_string(), generated_at, - revision: digest.clone(), digest, workers: worker_candidates, workdirs: workdir_candidates, @@ -25383,13 +25353,13 @@ async fn execute_runtime_cleanup_with_context( context.authorize_workspace(api).await?; let plan = build_runtime_cleanup_plan(api, runtime_id)?; - if request.expected_plan_revision != plan.revision - || request.expected_plan_digest != plan.digest - { + // Rebuild the actual candidate set immediately before executing. The digest + // binds targets, dependencies and blockers; per-target authority is checked again below. + if request.expected_plan_digest != plan.digest { return Err(cleanup_api_error( runtime_id, "workspace_cleanup_plan_stale", - "cleanup plan revision/digest is stale; refresh the preview before executing", + "cleanup candidates or blockers changed; refresh the preview before executing", )); } let worker_targets: HashSet<_> = request.worker_target_ids.iter().cloned().collect(); @@ -25704,7 +25674,12 @@ async fn scoped_test_runtime_connection( }, display_name: Some(binding.display_name.clone()), endpoint: binding.base_url.clone(), - expected_revision: Some(binding.binding_revision), + workspace_trust_id: binding.workspace_trust_id.clone().ok_or_else(|| { + Error::RuntimeBindingConflict( + "Runtime binding is missing its Workspace trust ID".to_string(), + ) + })?, + expected_binding_id: Some(binding.binding_id.clone()), }) .await?; } @@ -27088,18 +27063,25 @@ async fn create_remote_runtime( base_url: endpoint.to_string().trim_end_matches('/').to_string(), public_key: request.public_bundle.public_key.trim().to_string(), public_key_fingerprint: String::new(), - binding_revision: request.expected_revision.unwrap_or(0), + binding_id: Uuid::now_v7().to_string(), state: StoredRuntimeBindingState::Configured, authentication_mode: StoredRuntimeAuthenticationMode::WorkspaceIdentity, workspace_key_id: Some(identity.key_id.clone()), - workspace_key_generation: Some(identity.revision), + + workspace_public_key_fingerprint: identity.public_key_fingerprint.clone(), + + workspace_trust_id: Some(request.workspace_trust_id.clone()), created_at: now.clone(), updated_at: now, revoked_at: None, }; let (mutation, binding) = api .store - .put_workspace_runtime_binding_key(record, request.expected_revision, &actor.account_id) + .put_workspace_runtime_binding_key( + record, + request.expected_binding_id.as_deref(), + &actor.account_id, + ) .await?; api.runtime_binding_expectations .write() @@ -27150,10 +27132,10 @@ fn runtime_removal_response(operation: RuntimeRemovalOperation) -> RuntimeRemova fn runtime_removal_fingerprint( workspace_id: &str, runtime_id: &str, - expected_binding_revision: u64, + expected_binding_id: &str, ) -> String { let digest = Sha256::digest(format!( - "runtime-removal-v1\0{workspace_id}\0{runtime_id}\0{expected_binding_revision}" + "runtime-removal-v2\0{workspace_id}\0{runtime_id}\0{expected_binding_id}" )); let digest = digest .iter() @@ -27175,9 +27157,9 @@ fn runtime_removal_config_guard( operation.workspace_id )) })?; - if config_state.snapshot.revision != operation.config_revision { + if config_state.snapshot.digest != operation.config_digest { return Err(Error::RuntimeBindingConflict( - "runtime_removal_config_revision_changed".to_string(), + "runtime_removal_config_digest_changed".to_string(), )); } let projection = crate::runtime_settings::project_runtime_from_workspace_config( @@ -27365,13 +27347,13 @@ async fn remove_remote_runtime( let request_fingerprint = runtime_removal_fingerprint( &api.config.workspace_id, &runtime_id, - request.expected_binding_revision, + &request.expected_binding_id, ); if let Some(existing) = api.store.get_runtime_removal(&request.operation_id).await? { if existing.workspace_id != api.config.workspace_id || existing.runtime_id != runtime_id || existing.request_fingerprint != request_fingerprint - || existing.expected_binding_revision != request.expected_binding_revision + || existing.expected_binding_id != request.expected_binding_id { return Err(Error::RuntimeBindingConflict( "runtime_removal_operation_id_reused".to_string(), @@ -27385,8 +27367,8 @@ async fn remove_remote_runtime( &existing.runtime_id, &existing.operation_id, &existing.request_fingerprint, - existing.expected_binding_revision, - existing.config_revision, + &existing.expected_binding_id, + &existing.config_digest, ) .await? .operation @@ -27423,8 +27405,8 @@ async fn remove_remote_runtime( &runtime_id, &request.operation_id, &request_fingerprint, - request.expected_binding_revision, - config_state.snapshot.revision, + &request.expected_binding_id, + &config_state.snapshot.digest, ) .await?; let operation = execute_runtime_removal(&api, reservation.operation).await?; @@ -27458,10 +27440,19 @@ async fn perform_workspace_runtime_verification( .workspace_key_id .as_deref() .ok_or_else(|| "Runtime binding is missing the Workspace key identity".to_string())?; - let workspace_trust_generation = binding - .workspace_key_generation - .ok_or_else(|| "Runtime binding is missing the Workspace trust generation".to_string())?; - if identity.key_id != workspace_key_id || identity.revision != workspace_trust_generation { + let workspace_trust_id = binding + .workspace_trust_id + .as_deref() + .ok_or_else(|| "Runtime binding is missing the Workspace trust ID".to_string())?; + let workspace_public_key_fingerprint = binding + .workspace_public_key_fingerprint + .as_deref() + .ok_or_else(|| { + "Runtime binding is missing the Workspace public key fingerprint".to_string() + })?; + if identity.key_id != workspace_key_id + || identity.public_key_fingerprint.as_deref() != Some(workspace_public_key_fingerprint) + { return Err( "Runtime binding no longer matches the active Workspace or Runtime identity" .to_string(), @@ -27474,12 +27465,11 @@ async fn perform_workspace_runtime_verification( challenge_id: Uuid::now_v7().to_string(), workspace_id: binding.workspace_id.clone(), runtime_id: binding.runtime_id.clone(), - binding_revision: binding.binding_revision, + binding_id: binding.binding_id.clone(), workspace_key_id: workspace_key_id.to_string(), - workspace_identity_revision: identity.revision, - workspace_trust_generation, + workspace_public_key_fingerprint: workspace_public_key_fingerprint.to_string(), + workspace_trust_id: workspace_trust_id.to_string(), runtime_public_key_fingerprint: binding.public_key_fingerprint.clone(), - runtime_identity_revision: 1, workspace_nonce: Uuid::now_v7().to_string(), expires_at, }; @@ -27487,12 +27477,11 @@ async fn perform_workspace_runtime_verification( let pending = crate::store::WorkspaceRuntimeVerificationEvidence { workspace_id: binding.workspace_id.clone(), runtime_id: binding.runtime_id.clone(), - binding_revision: binding.binding_revision, + binding_id: binding.binding_id.clone(), workspace_key_id: workspace_key_id.to_string(), - workspace_identity_revision: identity.revision, - workspace_trust_generation, + workspace_public_key_fingerprint: workspace_public_key_fingerprint.to_string(), + workspace_trust_id: workspace_trust_id.to_string(), runtime_public_key_fingerprint: binding.public_key_fingerprint.clone(), - runtime_identity_revision: 1, challenge_id: challenge.challenge_id.clone(), state: "pending".to_string(), last_outcome: "challenge_issued".to_string(), @@ -27510,9 +27499,9 @@ async fn perform_workspace_runtime_verification( issuer: backend_url.to_string(), issuer_workspace_id: binding.workspace_id.clone(), issuer_key_id: identity.key_id.clone(), - issuer_identity_revision: identity.revision, - trust_generation: workspace_trust_generation, - binding_revision: binding.binding_revision, + issuer_public_key_fingerprint: workspace_public_key_fingerprint.to_string(), + trust_id: workspace_trust_id.to_string(), + binding_id: binding.binding_id.clone(), runtime_id: binding.runtime_id.clone(), worker_id: None, operation: WORKSPACE_VERIFICATION_OPERATION.to_string(), @@ -27553,12 +27542,11 @@ async fn perform_workspace_runtime_verification( challenge_id: response.challenge_id.clone(), workspace_id: response.workspace_id.clone(), runtime_id: response.runtime_id.clone(), - binding_revision: response.binding_revision, + binding_id: response.binding_id.clone(), workspace_key_id: response.workspace_key_id.clone(), - workspace_identity_revision: response.workspace_identity_revision, - workspace_trust_generation: response.workspace_trust_generation, + workspace_public_key_fingerprint: response.workspace_public_key_fingerprint.clone(), + workspace_trust_id: response.workspace_trust_id.clone(), runtime_public_key_fingerprint: response.runtime_public_key_fingerprint.clone(), - runtime_identity_revision: response.runtime_identity_revision, workspace_nonce: response.workspace_nonce.clone(), runtime_nonce: response.runtime_nonce.clone(), response_digest: workspace_request_body_digest(&response_bytes), @@ -27571,9 +27559,9 @@ async fn perform_workspace_runtime_verification( issuer: backend_url.to_string(), issuer_workspace_id: binding.workspace_id.clone(), issuer_key_id: identity.key_id.clone(), - issuer_identity_revision: identity.revision, - trust_generation: workspace_trust_generation, - binding_revision: binding.binding_revision, + issuer_public_key_fingerprint: workspace_public_key_fingerprint.to_string(), + trust_id: workspace_trust_id.to_string(), + binding_id: binding.binding_id.clone(), runtime_id: binding.runtime_id.clone(), worker_id: None, operation: WORKSPACE_VERIFICATION_OPERATION.to_string(), @@ -27604,7 +27592,7 @@ async fn perform_workspace_runtime_verification( if receipt.challenge_id != challenge.challenge_id || receipt.workspace_id != binding.workspace_id || receipt.runtime_id != binding.runtime_id - || receipt.binding_revision != binding.binding_revision + || receipt.binding_id != binding.binding_id { return Err("Runtime verification acknowledgement receipt mismatched".to_string()); } @@ -27688,7 +27676,7 @@ async fn test_runtime_connection( message, ), ); - result.binding_revision = binding.binding_revision; + result.binding_id = binding.binding_id.clone(); result.connection_state = RuntimeConnectionDisplayState::Unavailable; return Ok(Json(result)); } @@ -27735,7 +27723,7 @@ async fn test_runtime_connection( let summary = runtime_binding_summary(¤t_binding, verification.as_ref()); let mut result = runtime_connection_test_response(api.workspace_id(), &runtime_id, checked_at, ping); - result.binding_revision = current_binding.binding_revision; + result.binding_id = current_binding.binding_id.clone(); result.connection_state = if result.status == RuntimeConnectionTestStatus::Compatible { summary.connection_state } else if summary.connection_state == RuntimeConnectionDisplayState::Revoked { @@ -27762,7 +27750,6 @@ fn working_directory_request_from_repository( id: repository.id.clone(), provider: repository.provider.clone(), source: repository.source.clone(), - source_revision: repository.source_revision, source_fingerprint: repository.source_fingerprint.clone(), selector: selector .map(|selector| RuntimeRepositorySelector::from(selector.to_string())) @@ -28394,8 +28381,6 @@ fn backend_resource_request_from_api( }, expires_at_unix_seconds: handle.expires_at_unix_seconds, nonce: handle.nonce, - revision: handle.revision, - generation: handle.generation, max_bytes: handle.max_bytes, content_type: handle.content_type, redaction: match handle.redaction { @@ -30293,10 +30278,14 @@ fn runtime_binding_summary( verification: Option<&crate::store::WorkspaceRuntimeVerificationEvidence>, ) -> WorkspaceRuntimeBindingSummary { let current_verification = verification.filter(|verification| { - verification.binding_revision == binding.binding_revision + verification.binding_id == binding.binding_id && verification.runtime_public_key_fingerprint == binding.public_key_fingerprint && verification.workspace_key_id == binding.workspace_key_id.as_deref().unwrap_or_default() + && Some(verification.workspace_public_key_fingerprint.as_str()) + == binding.workspace_public_key_fingerprint.as_deref() + && Some(verification.workspace_trust_id.as_str()) + == binding.workspace_trust_id.as_deref() }); let valid_verification = current_verification.filter(|verification| { verification.state == "verified" && verification.last_outcome == "verified" @@ -30324,9 +30313,9 @@ fn runtime_binding_summary( StoredRuntimeBindingState::Revoked => WorkspaceRuntimeBindingState::Revoked, }, connection_state, - revision: binding.binding_revision, + binding_id: binding.binding_id.clone(), workspace_key_id: binding.workspace_key_id.clone(), - workspace_key_generation: binding.workspace_key_generation, + workspace_trust_id: binding.workspace_trust_id.clone(), verification: current_verification.and_then(runtime_verification_summary), } } @@ -30345,12 +30334,11 @@ fn runtime_verification_summary( verified_at: verification.verified_at.clone(), last_checked_at: verification.checked_at.clone(), last_outcome, - binding_revision: verification.binding_revision, + binding_id: verification.binding_id.clone(), workspace_key_id: verification.workspace_key_id.clone(), - workspace_identity_revision: verification.workspace_identity_revision, - workspace_trust_generation: verification.workspace_trust_generation, + workspace_public_key_fingerprint: verification.workspace_public_key_fingerprint.clone(), + workspace_trust_id: verification.workspace_trust_id.clone(), runtime_public_key_fingerprint: verification.runtime_public_key_fingerprint.clone(), - runtime_identity_revision: verification.runtime_identity_revision, }) } @@ -30422,7 +30410,7 @@ async fn workspace_runtime_detail( RuntimeTrustKeyState { status: RuntimeTrustKeyStatus::Unconfigured, fingerprint: None, - revision: None, + binding_id: None, created_at: None, updated_at: None, revoked_at: None, @@ -30434,7 +30422,7 @@ async fn workspace_runtime_detail( RuntimeTrustKeyStatus::Active }, fingerprint: Some(binding.public_key_fingerprint.clone()), - revision: Some(binding.binding_revision), + binding_id: Some(binding.binding_id.clone()), created_at: Some(binding.created_at.clone()), updated_at: Some(binding.updated_at.clone()), revoked_at: binding.revoked_at.clone(), @@ -30475,7 +30463,7 @@ fn project_runtime_trust_audit( actor_account_id: record.actor_account_id, old_fingerprint: record.old_fingerprint, new_fingerprint: record.new_fingerprint, - revision: record.binding_revision, + binding_id: record.binding_id, at: record.at, }) } @@ -30490,6 +30478,28 @@ async fn validate_runtime_connection_request( "Runtime public bundle must contain a public key", )); } + if request.workspace_trust_id.trim().is_empty() + || request.workspace_trust_id.len() > 256 + || request + .workspace_trust_id + .chars() + .any(|ch| ch.is_control() || ch.is_whitespace()) + { + return Err(settings_bad_request( + "invalid_workspace_trust_id", + "workspace_trust_id must be a bounded Runtime-issued trust identity", + )); + } + if request + .expected_binding_id + .as_deref() + .is_some_and(|id| id.trim().is_empty() || id.len() > 256) + { + return Err(settings_bad_request( + "invalid_runtime_binding_id", + "expected_binding_id must be a bounded binding identity", + )); + } validate_runtime_metadata(request.display_name.as_deref(), &request.endpoint).await } @@ -30650,7 +30660,7 @@ fn runtime_connection_test_response( Ok(ping) => RuntimeConnectionTestResponse { workspace_id: workspace_id.to_string(), runtime_id: runtime_id.to_string(), - binding_revision: 0, + binding_id: String::new(), connection_state: RuntimeConnectionDisplayState::Configured, verification: None, checked_at, @@ -30702,7 +30712,7 @@ fn runtime_connection_test_failure( RuntimeConnectionTestResponse { workspace_id: workspace_id.to_string(), runtime_id: runtime_id.to_string(), - binding_revision: 0, + binding_id: String::new(), connection_state: RuntimeConnectionDisplayState::Unavailable, verification: None, checked_at, @@ -31759,12 +31769,12 @@ fn repository_ssh_lease_candidates( } let workspace_default = api .repository_secrets - .lease_ssh_materialization_access_revision( + .lease_ssh_materialization_access_fingerprints( &api.config.workspace_id, crate::repository_access::WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID, - workspace_default.current_revision, + &workspace_default.public_key_fingerprint, &primary.host_trust_id, - primary.host_trust_revision, + &primary.host_trust_fingerprint, )?; Ok(vec![primary, workspace_default]) } @@ -31778,15 +31788,15 @@ fn authorize_repository_materialization_operation( let context = if request.repository.source.kind == server_api::RepositorySourceKind::Ssh { if let ( Some(_credential_id), - Some(_credential_revision), + Some(_credential_fingerprint), Some(_host_trust_id), - Some(_host_trust_revision), + Some(_host_trust_fingerprint), Some(access_mode), ) = ( operation.credential_id.as_deref(), - operation.credential_revision, + operation.credential_fingerprint.as_deref(), operation.host_trust_id.as_deref(), - operation.host_trust_revision, + operation.host_trust_fingerprint.as_deref(), operation.repository_access_mode.as_deref(), ) { let leases = repository_ssh_lease_candidates_from_operation(api, operation, request)?; @@ -31797,7 +31807,7 @@ fn authorize_repository_materialization_operation( ) })?; let primary_host_trust_id = primary_lease.host_trust_id.clone(); - let primary_host_trust_revision = primary_lease.host_trust_revision; + let primary_host_trust_fingerprint = primary_lease.host_trust_fingerprint.clone(); let known_hosts_entry = primary_lease.known_hosts_entry.clone(); let access = match access_mode { "read_only" => server_api::RepositoryAccessMode::ReadOnly, @@ -31816,18 +31826,6 @@ fn authorize_repository_materialization_operation( &api.config.workspace_id, &operation.resolved_runtime_id, format!("repository-ssh-access:{}", operation.operation_id), - format!( - "credentials:{}:host-trust:{}", - leases - .iter() - .map(|lease| format!( - "{}:{}", - lease.credential_id, lease.credential_revision - )) - .collect::>() - .join(","), - primary_host_trust_revision - ), i64::try_from(expires_at_epoch_seconds).unwrap_or(i64::MAX), worker_runtime::resource::RepositorySshAccessSecret { credential_candidates: leases @@ -31835,7 +31833,7 @@ fn authorize_repository_materialization_operation( .map(|lease| { worker_runtime::resource::RepositorySshAccessSecretCandidate { credential_id: lease.credential_id.clone(), - credential_revision: lease.credential_revision, + public_key_fingerprint: lease.credential_fingerprint.clone(), private_key: lease.private_key.as_str().to_string(), } }) @@ -31853,19 +31851,18 @@ fn authorize_repository_materialization_operation( workspace_id: api.config.workspace_id.clone(), runtime_id: operation.resolved_runtime_id.clone(), operation_id: operation.operation_id.clone(), - config_revision: operation.config_revision, config_projection_digest: operation.config_projection_digest.clone(), ssh: Some(RepositorySshMaterializationAccess { credential_candidates: leases .into_iter() .map(|lease| RepositorySshCredentialCandidate { credential_id: lease.credential_id, - credential_revision: lease.credential_revision, + public_key_fingerprint: lease.credential_fingerprint.clone(), private_key: SensitiveString::default(), }) .collect(), host_trust_id: primary_host_trust_id, - host_trust_revision: primary_host_trust_revision, + host_key_fingerprint: primary_host_trust_fingerprint, access, expires_at_epoch_seconds, repository_id: request.repository.id.clone(), @@ -31877,12 +31874,10 @@ fn authorize_repository_materialization_operation( } } else { let projection = active_repository_access_projection(api, &api.config.workspace_id)?; - if projection.config_revision != operation.config_revision - || projection.projection_digest != operation.config_projection_digest - { + if projection.projection_digest != operation.config_projection_digest { return Err(settings_bad_request( - "working_directory_repository_access_revision_changed", - "Workspace Repository access revision changed before operation reservation was bound", + "working_directory_repository_access_content_changed", + "Workspace Repository access content changed before operation reservation was bound", )); } authorize_repository_materialization( @@ -31921,7 +31916,7 @@ fn authorize_repository_materialization_operation( WorkdirCreateCredentialCandidateRole::WorkspaceDefaultFallback }, credential_id: candidate.credential_id.clone(), - credential_revision: candidate.credential_revision, + credential_fingerprint: candidate.public_key_fingerprint.clone(), }) .collect::>(); api.config_store.bind_workdir_create_repository_access( @@ -31929,9 +31924,9 @@ fn authorize_repository_materialization_operation( &operation.operation_id, request_fingerprint, &primary_credential.credential_id, - primary_credential.credential_revision, + &primary_credential.public_key_fingerprint, &ssh.host_trust_id, - ssh.host_trust_revision, + &ssh.host_key_fingerprint, match ssh.access { server_api::RepositoryAccessMode::ReadOnly => "read_only", server_api::RepositoryAccessMode::ReadWrite => "read_write", @@ -31946,7 +31941,6 @@ fn authorize_repository_materialization_operation( workspace_id: api.config.workspace_id.clone(), runtime_id: operation.resolved_runtime_id.clone(), operation_id: operation.operation_id.clone(), - config_revision: operation.config_revision, config_projection_digest: operation.config_projection_digest.clone(), ssh: None, } @@ -31981,7 +31975,6 @@ fn repository_ssh_lease_candidates_from_operation( if operation.repository_id != request.repository.id || operation.source_kind.as_deref() != Some(request.repository.source.kind.as_str()) || operation.source_uri.as_deref() != Some(request.repository.source.uri.as_str()) - || operation.source_revision != Some(request.repository.source_revision) || operation.source_fingerprint.as_deref() != Some(request.repository.source_fingerprint.as_str()) { @@ -31996,7 +31989,7 @@ fn repository_ssh_lease_candidates_from_operation( "persisted Workdir Repository access snapshot is incomplete", ) })?; - let host_trust_revision = operation.host_trust_revision.ok_or_else(|| { + let host_trust_fingerprint = operation.host_trust_fingerprint.as_deref().ok_or_else(|| { settings_bad_request( "working_directory_repository_access_snapshot_missing", "persisted Workdir Repository access snapshot is incomplete", @@ -32014,15 +32007,16 @@ fn repository_ssh_lease_candidates_from_operation( "persisted Workdir primary credential evidence is unavailable", ) })?; - let primary_credential_revision = operation.credential_revision.ok_or_else(|| { - settings_bad_request( - "working_directory_repository_access_snapshot_missing", - "persisted Workdir primary credential evidence is unavailable", - ) - })?; + let primary_credential_fingerprint = + operation.credential_fingerprint.as_deref().ok_or_else(|| { + settings_bad_request( + "working_directory_repository_access_snapshot_missing", + "persisted Workdir primary credential evidence is unavailable", + ) + })?; crate::workdir_create_operations::validate_workdir_create_credential_candidates( primary_credential_id, - primary_credential_revision, + primary_credential_fingerprint, &operation.credential_candidates, ) .map_err(|_error| { @@ -32054,20 +32048,21 @@ fn repository_ssh_lease_candidates_from_operation( for candidate in &operation.credential_candidates { let lease = api .repository_secrets - .lease_ssh_materialization_access_revision( + .lease_ssh_materialization_access_fingerprints( &api.config.workspace_id, &candidate.credential_id, - candidate.credential_revision, + &candidate.credential_fingerprint, host_trust_id, - host_trust_revision, + host_trust_fingerprint, ) .map_err(|_| { settings_bad_request( "working_directory_repository_access_snapshot_unavailable", - "persisted Workdir credential or host-trust revision is unavailable", + "persisted Workdir credential or host-key identity is unavailable", ) })?; - if lease.host_trust_id != host_trust_id || lease.host_trust_revision != host_trust_revision + if lease.host_trust_id != host_trust_id + || lease.host_trust_fingerprint != host_trust_fingerprint { return Err(settings_bad_request( "working_directory_repository_access_snapshot_mismatch", @@ -32126,7 +32121,7 @@ fn authorize_repository_materialization( ) })?; let primary_host_trust_id = primary_lease.host_trust_id.clone(); - let primary_host_trust_revision = primary_lease.host_trust_revision; + let primary_host_trust_fingerprint = primary_lease.host_trust_fingerprint.clone(); let known_hosts_entry = primary_lease.known_hosts_entry.clone(); let expires_at_epoch_seconds = repository_access_expiry(); let secret_resource = api @@ -32135,18 +32130,6 @@ fn authorize_repository_materialization( &api.config.workspace_id, runtime_id, format!("repository-ssh-access:{operation_id}"), - format!( - "credentials:{}:host-trust:{}", - leases - .iter() - .map(|lease| format!( - "{}:{}", - lease.credential_id, lease.credential_revision - )) - .collect::>() - .join(","), - primary_host_trust_revision - ), i64::try_from(expires_at_epoch_seconds).unwrap_or(i64::MAX), worker_runtime::resource::RepositorySshAccessSecret { credential_candidates: leases @@ -32154,7 +32137,7 @@ fn authorize_repository_materialization( .map( |lease| worker_runtime::resource::RepositorySshAccessSecretCandidate { credential_id: lease.credential_id.clone(), - credential_revision: lease.credential_revision, + public_key_fingerprint: lease.credential_fingerprint.clone(), private_key: lease.private_key.as_str().to_string(), }, ) @@ -32173,12 +32156,12 @@ fn authorize_repository_materialization( .into_iter() .map(|lease| RepositorySshCredentialCandidate { credential_id: lease.credential_id, - credential_revision: lease.credential_revision, + public_key_fingerprint: lease.credential_fingerprint.clone(), private_key: SensitiveString::default(), }) .collect(), host_trust_id: primary_host_trust_id, - host_trust_revision: primary_host_trust_revision, + host_key_fingerprint: primary_host_trust_fingerprint, access: binding.access, expires_at_epoch_seconds, repository_id: request.repository.id.clone(), @@ -32194,7 +32177,6 @@ fn authorize_repository_materialization( workspace_id: api.config.workspace_id.clone(), runtime_id: runtime_id.to_string(), operation_id: operation_id.to_string(), - config_revision: projection.config_revision, config_projection_digest: projection.projection_digest.clone(), ssh, }); @@ -32231,12 +32213,12 @@ fn complete_restore_repository_access( for candidate in &ssh.credential_candidates { leases.push( api.repository_secrets - .lease_ssh_materialization_access_revision( + .lease_ssh_materialization_access_fingerprints( api.workspace_id(), &candidate.credential_id, - candidate.credential_revision, + &candidate.public_key_fingerprint, &ssh.host_trust_id, - ssh.host_trust_revision, + &ssh.host_key_fingerprint, )?, ); } @@ -32255,7 +32237,6 @@ fn complete_restore_repository_access( "repository-ssh-access:{}", access.materialization.operation_id ), - ssh.secret_resource.revision.clone(), i64::try_from(ssh.expires_at_epoch_seconds).unwrap_or(i64::MAX), worker_runtime::resource::RepositorySshAccessSecret { credential_candidates: leases @@ -32263,7 +32244,7 @@ fn complete_restore_repository_access( .map( |lease| worker_runtime::resource::RepositorySshAccessSecretCandidate { credential_id: lease.credential_id, - credential_revision: lease.credential_revision, + public_key_fingerprint: lease.credential_fingerprint.clone(), private_key: lease.private_key.as_str().to_owned(), }, ) @@ -32354,7 +32335,6 @@ fn working_directory_request_for_browser( id: repository.id.clone(), provider: "git".to_string(), source: repository.source.clone(), - source_revision: repository.source_revision, source_fingerprint: repository.source_fingerprint.clone(), selector: selector.map(RuntimeRepositorySelector), }, @@ -32764,7 +32744,7 @@ impl From for ApiError { ticket::TicketError::Locked { .. } => "ticket_locked", ticket::TicketError::Conflict(_) | ticket::TicketError::StaleWorkflowState { .. } - | ticket::TicketError::StaleItemRevision { .. } + | ticket::TicketError::StaleContent { .. } | ticket::TicketError::InvalidWorkflowTransition { .. } | ticket::TicketError::BlockingRelations(_) | ticket::TicketError::OperationFingerprintMismatch { .. } => "ticket_conflict", @@ -32818,7 +32798,7 @@ impl From for ApiError { fn subjektiv_diagnostic_code(message: &str) -> Option<&'static str> { [ - "revision_conflict", + "change_conflict", "stale_cursor", "candidate_decision_conflict", "subject_scope_mismatch", @@ -32857,14 +32837,14 @@ fn api_error_status(error: &Error) -> StatusCode { | Error::WorkdirAttachmentConflict(_) | Error::WorkspaceConfigConflict(_) | Error::RuntimeBindingConflict(_) - | Error::RuntimeBindingRevisionConflict { .. } + | Error::RuntimeBindingIdConflict { .. } | Error::RuntimeBindingFingerprintConflict { .. } | Error::RepositoryConflict(_) | Error::RestoreObservationConflict => StatusCode::CONFLICT, Error::WorkerSourceIdentity(_) => StatusCode::BAD_REQUEST, Error::InvalidInput(message) if message.starts_with("memory_not_found:") - || message.starts_with("memory_revision_not_found:") => + || message.starts_with("memory_change_not_found:") => { StatusCode::NOT_FOUND } @@ -32882,7 +32862,7 @@ fn api_error_status(error: &Error) -> StatusCode { ticket::TicketError::Ambiguous { .. } | ticket::TicketError::Locked { .. } | ticket::TicketError::Conflict(_) - | ticket::TicketError::StaleItemRevision { .. } + | ticket::TicketError::StaleContent { .. } | ticket::TicketError::StaleWorkflowState { .. } | ticket::TicketError::OperationFingerprintMismatch { .. }, ) => StatusCode::CONFLICT, @@ -32921,7 +32901,7 @@ fn api_error_status(error: &Error) -> StatusCode { Error::RuntimeOperationFailed { code, .. } if code == "profile_registry_revision_conflict" || code == "profile_source_revision_conflict" - || code == "workspace_metadata_revision_conflict" + || code == "workspace_metadata_updated_at_conflict" || code == "workspace_cleanup_plan_stale" || code == "workspace_cleanup_worker_blocked" || code == "workspace_cleanup_workdir_blocked" @@ -33102,7 +33082,7 @@ mod tests { subject_id: "subject-a".into(), body_md: String::new(), memory_refs: Vec::new(), - built_from_store_revision: 7, + built_from_memory_fingerprint: "memory-empty".into(), created_at: "2026-10-02T00:00:00Z".into(), }), }); @@ -36485,11 +36465,14 @@ mod tests { base_url: "https://runtime.test".to_owned(), public_key: identity.public_key.clone(), public_key_fingerprint: String::new(), - binding_revision: 1, + binding_id: "binding-test".to_string(), state: StoredRuntimeBindingState::Verified, authentication_mode: StoredRuntimeAuthenticationMode::WorkspaceIdentity, workspace_key_id: Some("WK-test".to_owned()), - workspace_key_generation: Some(1), + + workspace_public_key_fingerprint: Some("sha256:workspace-test".to_string()), + + workspace_trust_id: Some("trust-test".to_string()), created_at: "2026-01-01T00:00:00Z".to_owned(), updated_at: "2026-01-01T00:00:00Z".to_owned(), revoked_at: None, @@ -36664,7 +36647,7 @@ mod tests { fn test_worker_memory_settings() -> manifest::WorkspaceMemorySettingsSnapshot { manifest::WorkspaceMemorySettingsSnapshot { workspace_id: TEST_WORKSPACE_ID.to_string(), - settings_revision: 1, + language: "English".to_string(), } } @@ -37066,7 +37049,6 @@ mod tests { .await .unwrap(); assert_eq!(resolved.flow_id, stored.flow_id); - assert_eq!(resolved.revision, stored.revision); assert_eq!(resolved.content_digest, stored.content_digest); assert_eq!(resolved.definition.name, "browser-flow"); @@ -37085,10 +37067,12 @@ mod tests { assert_eq!(builtin.selector.to_string(), "builtin:coder-review"); assert_eq!(builtin.flow_id, "builtin:coder-review"); assert_eq!( - builtin.revision, + builtin.content_digest, flow::builtin_flow_source(flow::CODER_REVIEW_FLOW_SLUG) .unwrap() - .revision + .compile() + .unwrap() + .content_digest ); assert_eq!( api.store @@ -37197,7 +37181,7 @@ mod tests { hostname: "example.test".to_string(), port: 22, host_key: host_public_key, - expected_revision: None, + expected_fingerprint: None, }, "owner-account", ) @@ -37240,7 +37224,6 @@ mod tests { id: "repository-a".to_string(), provider: "git".to_string(), source: source.clone(), - source_revision: 1, source_fingerprint: source_fingerprint.clone(), selector: None, }, @@ -37257,16 +37240,14 @@ mod tests { selector: None, requested_runtime_id: Some("runtime-1".to_string()), resolved_runtime_id: "runtime-1".to_string(), - config_revision: 1, config_projection_digest: "sha256:projection".to_string(), source_kind: Some(source.kind.as_str().to_string()), source_uri: Some(source.uri.clone()), - source_revision: Some(1), source_fingerprint: Some(source_fingerprint), credential_id: Some(candidates[0].credential_id.clone()), - credential_revision: Some(candidates[0].credential_revision), + credential_fingerprint: Some(candidates[0].credential_fingerprint.clone()), host_trust_id: Some(candidates[0].host_trust_id.clone()), - host_trust_revision: Some(candidates[0].host_trust_revision), + host_trust_fingerprint: Some(candidates[0].host_trust_fingerprint.clone()), repository_access_mode: Some("read_only".to_string()), credential_candidates: candidates .iter() @@ -37278,7 +37259,7 @@ mod tests { WorkdirCreateCredentialCandidateRole::WorkspaceDefaultFallback }, credential_id: candidate.credential_id.clone(), - credential_revision: candidate.credential_revision, + credential_fingerprint: candidate.credential_fingerprint.clone(), }) .collect(), working_directory_id: "workdir-a".to_string(), @@ -37295,7 +37276,7 @@ mod tests { .iter() .map(|candidate| ( candidate.credential_id.as_str(), - candidate.credential_revision + candidate.credential_fingerprint.as_str() )) .collect::>(), operation @@ -37303,7 +37284,7 @@ mod tests { .iter() .map(|candidate| ( candidate.credential_id.as_str(), - candidate.credential_revision + candidate.credential_fingerprint.as_str() )) .collect::>() ); @@ -37352,7 +37333,6 @@ mod tests { uri: dir.path().join("foreign").display().to_string(), }, default_ref: Some("HEAD".to_string()), - source_revision: 1, source_fingerprint: "sha256:test".to_string(), observed_status: server_api::RepositoryObservedStatus::Unverified, observed_at: None, @@ -37438,13 +37418,12 @@ mod tests { &api.config.workspace_id, "runtime-1", "repository-access-test", - "1", i64::MAX, worker_runtime::resource::RepositorySshAccessSecret { credential_candidates: vec![ worker_runtime::resource::RepositorySshAccessSecretCandidate { credential_id: "credential-1".to_string(), - credential_revision: 1, + public_key_fingerprint: "SHA256:credential-test".to_string(), private_key: "private-key-bytes".to_string(), }, ], @@ -37463,19 +37442,18 @@ mod tests { workspace_id: api.config.workspace_id.clone(), runtime_id: "runtime-1".to_string(), operation_id: "operation-1".to_string(), - config_revision: 1, config_projection_digest: "sha256:projection".to_string(), ssh: Some( worker_runtime::catalog::RepositorySshMaterializationAccess { credential_candidates: vec![ worker_runtime::catalog::RepositorySshCredentialCandidate { credential_id: "credential-1".to_string(), - credential_revision: 1, + public_key_fingerprint: "SHA256:credential-test".to_string(), private_key: worker_runtime::catalog::SensitiveString::default(), }, ], host_trust_id: "host-trust-1".to_string(), - host_trust_revision: 1, + host_key_fingerprint: "SHA256:host-test".to_string(), access: server_api::RepositoryAccessMode::ReadOnly, expires_at_epoch_seconds: u64::MAX, repository_id: working_directory.repository.id.clone(), @@ -39913,8 +39891,8 @@ mod tests { .memory(&subject.id, &memory.id) .unwrap() .unwrap() - .revision, - 1 + .change_id, + memory.change_id ); let Json(repeated) = scoped_reset_legacy_memory( @@ -40025,14 +40003,17 @@ mod tests { created["behavior_md"], "Prefer explicit evidence.\nAsk when uncertain." ); - assert_eq!(created["behavior_revision"], 0); + assert!(created.get("behavior_revision").is_none()); assert_eq!(created["state"], "active"); - assert_eq!(created["store_revision"], 0); + assert!(created.get("store_revision").is_none()); assert!(created.get("current_worker").is_none()); let created_id = created["id"].as_str().unwrap(); let persisted = store.subject(created_id).unwrap().unwrap(); assert_eq!(persisted.role.as_str(), " Browser author "); - assert_eq!(persisted.behavior_revision, 0); + assert_eq!( + persisted.behavior_md, + created["behavior_md"].as_str().unwrap() + ); let behavior_uri = format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{created_id}/behavior"); request_json( @@ -40040,7 +40021,7 @@ mod tests { "PATCH", &behavior_uri, Some(serde_json::json!({ - "expected_behavior_revision": 0, + "expected_behavior_md": "Prefer explicit evidence.\nAsk when uncertain.", "behavior_md": "Updated without rewriting history." })), StatusCode::UNAUTHORIZED, @@ -40051,7 +40032,7 @@ mod tests { "PATCH", &behavior_uri, Some(serde_json::json!({ - "expected_behavior_revision": 0, + "expected_behavior_md": "Prefer explicit evidence.\nAsk when uncertain.", "behavior_md": "Updated without rewriting history." })), &owner_token, @@ -40059,13 +40040,13 @@ mod tests { ) .await; assert_eq!(updated["behavior_md"], "Updated without rewriting history."); - assert_eq!(updated["behavior_revision"], 1); + assert!(updated.get("behavior_revision").is_none()); request_json_authenticated( app.clone(), "PATCH", &behavior_uri, Some(serde_json::json!({ - "expected_behavior_revision": 0, + "expected_behavior_md": "Prefer explicit evidence.\nAsk when uncertain.", "behavior_md": "stale write" })), &owner_token, @@ -40077,7 +40058,7 @@ mod tests { "PATCH", &behavior_uri, Some(serde_json::json!({ - "expected_behavior_revision": 1, + "expected_behavior_md": "Updated without rewriting history.", "behavior_md": "" })), &owner_token, @@ -40085,7 +40066,7 @@ mod tests { ) .await; assert_eq!(cleared["behavior_md"], ""); - assert_eq!(cleared["behavior_revision"], 2); + assert!(cleared.get("behavior_revision").is_none()); assert!( api.store .current_worker_singleton_owner( @@ -40127,7 +40108,7 @@ mod tests { format!("{subject_root}/surface"), format!("{subject_root}/memories?limit=1"), memory_root.clone(), - format!("{memory_root}/revisions?limit=1"), + format!("{memory_root}/changes?limit=1"), ] { request_json_authenticated( app.clone(), @@ -40181,8 +40162,8 @@ mod tests { store .scoped_memory(&subject.id, &memory.id) .unwrap() - .revision, - 1 + .change_id, + memory.change_id ); } @@ -40205,7 +40186,7 @@ mod tests { .await .unwrap(); assert_eq!(status, StatusCode::CREATED); - assert_eq!(created.behavior_revision, 0); + assert_eq!(created.behavior_md, "Prefer explicit evidence."); let Json(updated) = scoped_update_subjektiv_subject_behavior( @@ -40215,13 +40196,13 @@ mod tests { subject_id: created.id.clone(), }), Json(server_api::SubjektivSubjectBehaviorUpdateRequest { - expected_behavior_revision: 0, + expected_behavior_md: created.behavior_md.clone(), behavior_md: "Ask when uncertain.".to_string(), }), ) .await .unwrap(); - assert_eq!(updated.behavior_revision, 1); + assert_eq!(updated.behavior_md, "Ask when uncertain."); let conflict = scoped_update_subjektiv_subject_behavior( @@ -40231,7 +40212,7 @@ mod tests { subject_id: created.id.clone(), }), Json(server_api::SubjektivSubjectBehaviorUpdateRequest { - expected_behavior_revision: 0, + expected_behavior_md: created.behavior_md.clone(), behavior_md: "Stale edit".to_string(), }), ) @@ -40246,13 +40227,13 @@ mod tests { subject_id: created.id, }), Json(server_api::SubjektivSubjectBehaviorUpdateRequest { - expected_behavior_revision: 1, + expected_behavior_md: updated.behavior_md.clone(), behavior_md: String::new(), }), ) .await .unwrap(); - assert_eq!(cleared.behavior_revision, 2); + assert!(cleared.behavior_md.is_empty()); let oversized = scoped_create_subjektiv_subject( @@ -40271,7 +40252,7 @@ mod tests { } #[tokio::test] - async fn subjektiv_browser_reads_are_bounded_scoped_and_revision_exact() { + async fn subjektiv_browser_reads_are_bounded_scoped_and_change_id_exact() { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); let api = test_api(workspace.path()).await; @@ -40373,7 +40354,7 @@ mod tests { &first_subject.id, crate::subjektiv::MemoryDraft::active( memory::extract::CandidateKind::Lesson, - "Keep exact revisions", + "Keep exact changes", "first body", "Supports historical reads", "initial", @@ -40384,10 +40365,10 @@ mod tests { .revise_memory( &first_subject.id, &memory.id, - 1, + memory.change_id.clone(), crate::subjektiv::MemoryDraft::active( memory::extract::CandidateKind::Lesson, - "Keep exact revisions", + "Keep exact changes", "second body", "Supports historical reads", "correction", @@ -40425,7 +40406,7 @@ mod tests { .unwrap(); assert_eq!(memories.items.len(), 1); assert_eq!(memories.items[0].id, memory.id); - assert_eq!(memories.items[0].revision, 2); + assert_eq!(memories.items[0].change_id, revised.change_id); let missing_memory = scoped_get_subjektiv_memory( State(api.clone()), @@ -40443,7 +40424,7 @@ mod tests { StatusCode::NOT_FOUND ); - let missing_revision = scoped_get_subjektiv_memory( + let missing_change_id = scoped_get_subjektiv_memory( State(api.clone()), AxumPath(ScopedSubjektivMemoryPath { workspace_id: TEST_WORKSPACE_ID.to_string(), @@ -40451,17 +40432,36 @@ mod tests { memory_id: memory.id.clone(), }), Query(server_api::SubjektivMemoryDetailQuery { - revision: Some(99), + change_id: Some("missing-change".into()), ..Default::default() }), ) .await .unwrap_err(); assert_eq!( - missing_revision.into_response().status(), + missing_change_id.into_response().status(), StatusCode::NOT_FOUND ); + let invalid_change_id = scoped_get_subjektiv_memory( + State(api.clone()), + AxumPath(ScopedSubjektivMemoryPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: first_subject.id.clone(), + memory_id: memory.id.clone(), + }), + Query(server_api::SubjektivMemoryDetailQuery { + change_id: Some(String::new()), + ..Default::default() + }), + ) + .await + .unwrap_err(); + assert_eq!( + invalid_change_id.into_response().status(), + StatusCode::BAD_REQUEST + ); + let cross_subject = scoped_get_subjektiv_memory( State(api.clone()), AxumPath(ScopedSubjektivMemoryPath { @@ -40486,45 +40486,45 @@ mod tests { memory_id: memory.id.clone(), }), Query(server_api::SubjektivMemoryDetailQuery { - revision: Some(1), + change_id: Some(memory.change_id.clone()), ..Default::default() }), ) .await .unwrap(); - assert_eq!(historical.revision, 1); - assert_eq!(historical.current_revision, 2); + assert_eq!(historical.change_id, memory.change_id); + assert_eq!(historical.current_change_id, revised.change_id); assert_eq!(historical.body_md, "first body"); - let Json(revisions) = scoped_list_subjektiv_memory_revisions( + let Json(changes) = scoped_list_subjektiv_memory_changes( State(api.clone()), AxumPath(ScopedSubjektivMemoryPath { workspace_id: TEST_WORKSPACE_ID.to_string(), subject_id: first_subject.id.clone(), memory_id: memory.id.clone(), }), - Query(server_api::SubjektivMemoryRevisionsQuery { + Query(server_api::SubjektivMemoryChangesQuery { limit: Some(1), cursor: None, }), ) .await .unwrap(); - assert_eq!(revisions.current_revision, 2); - assert_eq!(revisions.items[0].revision, 2); - assert!(revisions.has_more); + assert_eq!(changes.current_change_id, revised.change_id); + assert_eq!(changes.items[0].change_id, revised.change_id); + assert!(changes.has_more); let generation = store.prepare_surface_generation(&first_subject.id).unwrap(); - let expected_ref = crate::subjektiv::MemoryRevisionRef { + let expected_ref = crate::subjektiv::MemoryChangeRef { memory_id: revised.id.clone(), - revision: revised.revision, + change_id: revised.change_id.clone(), }; store .publish_surface_generation( &first_subject.id, &generation.id, vec![crate::subjektiv::SurfacePoint { - body_md: "- Keep exact revisions".to_string(), + body_md: "- Keep exact changes".to_string(), memory_refs: vec![expected_ref.clone()], }], ) @@ -40545,7 +40545,7 @@ mod tests { let snapshot = surface.snapshot.unwrap(); assert_eq!(snapshot.memory_refs.len(), 1); assert_eq!(snapshot.memory_refs[0].memory_id, expected_ref.memory_id); - assert_eq!(snapshot.memory_refs[0].revision, expected_ref.revision); + assert_eq!(snapshot.memory_refs[0].change_id, expected_ref.change_id); } #[tokio::test] @@ -40833,7 +40833,6 @@ mod tests { server_api::SubjektivMemoryBackendResponse::ResidentContext( server_api::SubjektivResidentContextOutput { behavior_md, - behavior_revision: 0, memory_surface: memory::backend::MemoryResidentSummaryOutput { availability: memory::backend::MemoryResidentSummaryAvailability::Ungenerated, content: None, @@ -40917,7 +40916,7 @@ mod tests { worker_id: None, flow_selector: None, flow_definition_id: None, - flow_definition_revision: None, + flow_definition_fingerprint: None, }), excerpt: Some("Keep subject scope host-derived".to_string()), summary: Some("Scope decision".to_string()), @@ -41091,7 +41090,7 @@ mod tests { worker_id: None, flow_selector: None, flow_definition_id: None, - flow_definition_revision: None, + flow_definition_fingerprint: None, }), excerpt: Some("Correct the first rule".to_string()), summary: Some("Human correction".to_string()), @@ -41152,10 +41151,10 @@ mod tests { evidence_id: Some("E00000000".to_string()), ..Default::default() }], - proposal: Some(server_api::SubjektivMemoryRevisionProposal { + proposal: Some(server_api::SubjektivMemoryChangeProposal { memory_id: target.id.clone(), - expected_revision: 1, - intent: server_api::SubjektivMemoryRevisionIntent::Revise, + expected_change_id: target.change_id.clone(), + intent: server_api::SubjektivMemoryChangeIntent::Revise, change_reason: "The human corrected the rule".to_string(), }), }, @@ -41201,20 +41200,20 @@ mod tests { .staging_candidate(&subject.id, &staged.candidate_id) .unwrap() .unwrap(); - assert_eq!(candidate.revision_proposal.unwrap().memory_id, target.id); + assert_eq!(candidate.change_proposal.unwrap().memory_id, target.id); assert_eq!( store .memory(&subject.id, &target.id) .unwrap() .unwrap() - .revision, - 1, - "staging a proposal must not create a confirmed revision" + .change_id, + target.change_id, + "staging a proposal must not create a confirmed change" ); } #[tokio::test] - async fn subjektiv_memory_tools_filter_page_and_read_fixed_revisions() { + async fn subjektiv_memory_tools_filter_page_and_read_fixed_changes() { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); let api = test_api(workspace.path()).await; @@ -41254,21 +41253,25 @@ mod tests { "resolved after verification", ); resolved.state = crate::subjektiv::MemoryState::Resolved; - store - .revise_memory(&subject.id, &second.id, 1, resolved) + let revised_second = store + .revise_memory(&subject.id, &second.id, second.change_id.clone(), resolved) .unwrap(); - let revision_conflict = subjektiv_memory_validate_proposal( + let change_id_conflict = subjektiv_memory_validate_proposal( &store, &subject.id, server_api::SubjektivMemoryValidateProposalRequest { memory_id: second.id.clone(), - expected_revision: 1, - intent: server_api::SubjektivMemoryRevisionIntent::Revise, + expected_change_id: second.change_id.clone(), + intent: server_api::SubjektivMemoryChangeIntent::Revise, }, ) .unwrap_err(); - let revision_api = ApiError::from(revision_conflict).into_repository_api_error(); - assert_eq!(revision_api.diagnostics[0].code, "revision_conflict"); + let change_id_api = ApiError::from(change_id_conflict).into_repository_api_error(); + assert_eq!( + api_macros::HttpError::status_code(&change_id_api), + StatusCode::CONFLICT.as_u16() + ); + assert_eq!(change_id_api.diagnostics[0].code, "change_conflict"); let default_page = subjektiv_memory_query( &store, @@ -41356,7 +41359,7 @@ mod tests { &subject.id, server_api::SubjektivMemoryReadRequest { memory_id: first.id.clone(), - revision: None, + change_id: None, offset: None, byte_offset: None, limit: Some(1), @@ -41364,13 +41367,13 @@ mod tests { }, ) .unwrap(); - assert_eq!(initial_read.revision, 1); + assert_eq!(initial_read.change_id, first.change_id); assert_eq!(initial_read.body_next_offset, Some(1)); - store + let revised_first = store .revise_memory( &subject.id, &first.id, - 1, + first.change_id.clone(), crate::subjektiv::MemoryDraft::active( memory::extract::CandidateKind::Decision, "Choose stable cursors", @@ -41385,7 +41388,7 @@ mod tests { &subject.id, server_api::SubjektivMemoryReadRequest { memory_id: first.id.clone(), - revision: None, + change_id: None, offset: initial_read.body_next_offset, byte_offset: initial_read.body_next_byte_offset, limit: Some(1), @@ -41403,7 +41406,7 @@ mod tests { &subject.id, server_api::SubjektivMemoryReadRequest { memory_id: first.id.clone(), - revision: Some(1), + change_id: Some(first.change_id.clone()), offset: Some(1), byte_offset: None, limit: Some(1), @@ -41414,8 +41417,8 @@ mod tests { assert_eq!(read.body_md, "line two\n"); assert_eq!(read.body_next_offset, Some(2)); assert!(read.body_truncated); - assert_eq!(read.revision, 1); - assert_eq!(read.current_revision, 2); + assert_eq!(read.change_id, first.change_id); + assert_eq!(read.current_change_id, revised_first.change_id); let oversized_body = "界".repeat(7_000); let oversized_memory = store @@ -41435,7 +41438,7 @@ mod tests { &subject.id, server_api::SubjektivMemoryReadRequest { memory_id: oversized_memory.id.clone(), - revision: None, + change_id: None, offset: None, byte_offset: None, limit: Some(1), @@ -41451,7 +41454,7 @@ mod tests { &subject.id, server_api::SubjektivMemoryReadRequest { memory_id: oversized_memory.id, - revision: Some(oversized_first.revision), + change_id: Some(oversized_first.change_id), offset: oversized_first.body_next_offset, byte_offset: oversized_first.body_next_byte_offset, limit: Some(1), @@ -41482,7 +41485,7 @@ mod tests { worker_id: Some(format!("worker-{index}-{}", "w".repeat(4_000))), flow_selector: Some(format!("flow-{index}-{}", "f".repeat(4_000))), flow_definition_id: Some(format!("definition-{index}-{}", "d".repeat(4_000))), - flow_definition_revision: None, + flow_definition_fingerprint: None, }), excerpt: Some(format!("evidence-{index}-{}", "x".repeat(4_000))), summary: Some(format!("evidence-summary-{index}-{}", "q".repeat(4_000))), @@ -41511,7 +41514,7 @@ mod tests { "source-definition-{index}-{}", "d".repeat(4_000) )), - flow_definition_revision: Some(index as u64), + flow_definition_fingerprint: Some(format!("flow-content-{index}")), }), ..Default::default() }) @@ -41581,7 +41584,7 @@ mod tests { .apply_candidate( &subject.id, &nested_candidate.id, - crate::subjektiv::MemoryRevisionTarget::Create, + crate::subjektiv::MemoryChangeTarget::Create, crate::subjektiv::MemoryDraft::active( memory::extract::CandidateKind::Lesson, "Keep nested provenance bounded", @@ -41597,7 +41600,7 @@ mod tests { &subject.id, server_api::SubjektivMemoryReadRequest { memory_id: nested_memory.id.clone(), - revision: Some(1), + change_id: Some(nested_memory.change_id.clone()), offset: None, byte_offset: None, limit: None, @@ -41667,7 +41670,7 @@ mod tests { &subject.id, server_api::SubjektivMemoryReadRequest { memory_id: nested_memory.id.clone(), - revision: Some(1), + change_id: Some(nested_memory.change_id.clone()), offset: None, byte_offset: None, limit: None, @@ -41697,7 +41700,7 @@ mod tests { &subject.id, server_api::SubjektivMemoryReadRequest { memory_id: nested_memory.id.clone(), - revision: Some(1), + change_id: Some(nested_memory.change_id.clone()), offset: Some(offset), byte_offset: next_byte, limit: None, @@ -41733,7 +41736,7 @@ mod tests { worker_id: None, flow_selector: None, flow_definition_id: None, - flow_definition_revision: None, + flow_definition_fingerprint: None, }), excerpt: Some(format!("legacy excerpt {index}")), summary: None, @@ -41754,7 +41757,7 @@ mod tests { worker_id: None, flow_selector: None, flow_definition_id: None, - flow_definition_revision: None, + flow_definition_fingerprint: None, }), ..Default::default() }) @@ -41792,7 +41795,7 @@ mod tests { .apply_candidate( &subject.id, &legacy_candidate.id, - crate::subjektiv::MemoryRevisionTarget::Create, + crate::subjektiv::MemoryChangeTarget::Create, crate::subjektiv::MemoryDraft::active( memory::extract::CandidateKind::Lesson, "Preserve compatible historical provenance", @@ -41812,7 +41815,7 @@ mod tests { &subject.id, server_api::SubjektivMemoryReadRequest { memory_id: legacy_memory.id.clone(), - revision: Some(1), + change_id: Some(legacy_memory.change_id.clone()), offset: None, byte_offset: None, limit: None, @@ -41840,29 +41843,29 @@ mod tests { assert!(retrieved_sessions.contains(&format!("legacy-session-{index}"))); } - let revisions = subjektiv_memory_list_revisions( + let changes = subjektiv_memory_list_changes( &store, &subject.id, - server_api::SubjektivMemoryListRevisionsRequest { + server_api::SubjektivMemoryListChangesRequest { memory_id: second.id.clone(), limit: Some(1), cursor: None, }, ) .unwrap(); - assert_eq!(revisions.items[0].revision, 2); - assert!(revisions.has_more); - let older = subjektiv_memory_list_revisions( + assert_eq!(changes.items[0].change_id, revised_second.change_id); + assert!(changes.has_more); + let older = subjektiv_memory_list_changes( &store, &subject.id, - server_api::SubjektivMemoryListRevisionsRequest { + server_api::SubjektivMemoryListChangesRequest { memory_id: second.id, limit: Some(1), - cursor: revisions.next_cursor, + cursor: changes.next_cursor, }, ) .unwrap(); - assert_eq!(older.items[0].revision, 1); + assert_eq!(older.items[0].change_id, second.change_id); assert_eq!(older.items[0].claim, "Keep immutable history"); assert!(!older.has_more); } @@ -42566,11 +42569,14 @@ mod tests { base_url: "https://8.8.8.8".to_string(), public_key: runtime_identity.public_key, public_key_fingerprint: String::new(), - binding_revision: 1, + binding_id: "binding-test".to_string(), state: StoredRuntimeBindingState::Configured, authentication_mode: StoredRuntimeAuthenticationMode::WorkspaceIdentity, workspace_key_id: Some(identity.key_id.clone()), - workspace_key_generation: Some(identity.revision), + + workspace_public_key_fingerprint: identity.public_key_fingerprint.clone(), + + workspace_trust_id: Some("trust-test".to_string()), created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -42588,12 +42594,11 @@ mod tests { let evidence = crate::store::WorkspaceRuntimeVerificationEvidence { workspace_id: configured.workspace_id.clone(), runtime_id: configured.runtime_id.clone(), - binding_revision: configured.binding_revision, + binding_id: configured.binding_id.clone(), workspace_key_id: identity.key_id, - workspace_identity_revision: identity.revision, - workspace_trust_generation: identity.revision, + workspace_public_key_fingerprint: identity.public_key_fingerprint.clone().unwrap(), + workspace_trust_id: "trust-test".to_string(), runtime_public_key_fingerprint: configured.public_key_fingerprint.clone(), - runtime_identity_revision: 1, challenge_id: "restart-challenge".to_string(), state: "verified".to_string(), last_outcome: "verified".to_string(), @@ -42635,12 +42640,12 @@ mod tests { restored.config.workspace_id.clone(), "restored-runtime".to_string(), )), - "restored Runtime must retain its revision-fenced binding expectation" + "restored Runtime must retain its binding-ID-fenced expectation" ); } #[tokio::test] - async fn verified_remote_runtime_metadata_update_preserves_public_key_and_verification() { + async fn remote_runtime_endpoint_update_preserves_public_key_but_requires_reverification() { let dir = tempfile::tempdir().unwrap(); let api = test_api(dir.path()).await; let actor = test_owner_actor(); @@ -42658,11 +42663,14 @@ mod tests { base_url: "https://8.8.8.8".to_string(), public_key: runtime_identity.public_key.clone(), public_key_fingerprint: String::new(), - binding_revision: 1, + binding_id: "binding-test".to_string(), state: StoredRuntimeBindingState::Configured, authentication_mode: StoredRuntimeAuthenticationMode::WorkspaceIdentity, workspace_key_id: Some(workspace_identity.key_id.clone()), - workspace_key_generation: Some(workspace_identity.revision), + workspace_public_key_fingerprint: workspace_identity + .public_key_fingerprint + .clone(), + workspace_trust_id: Some("trust-test".to_string()), created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -42680,12 +42688,14 @@ mod tests { let evidence = crate::store::WorkspaceRuntimeVerificationEvidence { workspace_id: configured.workspace_id.clone(), runtime_id: configured.runtime_id.clone(), - binding_revision: configured.binding_revision, + binding_id: configured.binding_id.clone(), workspace_key_id: workspace_identity.key_id, - workspace_identity_revision: workspace_identity.revision, - workspace_trust_generation: workspace_identity.revision, + workspace_public_key_fingerprint: workspace_identity + .public_key_fingerprint + .clone() + .unwrap(), + workspace_trust_id: "trust-test".to_string(), runtime_public_key_fingerprint: configured.public_key_fingerprint.clone(), - runtime_identity_revision: 1, challenge_id: "verified-metadata-update".to_string(), state: "verified".to_string(), last_outcome: "verified".to_string(), @@ -42719,9 +42729,9 @@ mod tests { assert_eq!(updated.runtime.runtime.label, "New label"); assert_eq!(updated.endpoint.as_deref(), Some("https://8.8.4.4")); let binding = updated.runtime.management.binding.unwrap(); - assert_eq!(binding.state, WorkspaceRuntimeBindingState::Verified); - assert_eq!(binding.revision, 1); - assert_eq!(binding.verification.unwrap().binding_revision, 1); + assert_eq!(binding.state, WorkspaceRuntimeBindingState::Configured); + assert_ne!(binding.binding_id, configured.binding_id); + assert_eq!(binding.verification, None); assert_eq!( updated.trust_key.fingerprint.as_deref(), Some(configured.public_key_fingerprint.as_str()) @@ -42749,7 +42759,8 @@ mod tests { }, display_name: Some("New label".to_string()), endpoint: "https://8.8.4.4".to_string(), - expected_revision: Some(1), + workspace_trust_id: "trust-test".to_string(), + expected_binding_id: Some(configured.binding_id.clone()), }), ) .await @@ -42758,7 +42769,7 @@ mod tests { } #[tokio::test] - async fn remote_runtime_registration_is_workspace_scoped_revisioned_and_configured() { + async fn remote_runtime_registration_is_workspace_scoped_binding_fenced_and_configured() { let dir = tempfile::tempdir().unwrap(); let api = test_api(dir.path()).await; let actor = test_owner_actor(); @@ -42786,7 +42797,8 @@ mod tests { }, display_name: Some("Configured Runtime".to_string()), endpoint: "https://8.8.8.8".to_string(), - expected_revision: None, + workspace_trust_id: "trust-test".to_string(), + expected_binding_id: None, }; let mut non_owner = actor.clone(); non_owner.user_id = "other-user".to_string(); @@ -42811,9 +42823,9 @@ mod tests { assert_eq!(status, StatusCode::CREATED); let binding = created.management.binding.as_ref().unwrap(); assert_eq!(binding.state, WorkspaceRuntimeBindingState::Configured); - assert_eq!(binding.revision, 1); + assert!(!binding.binding_id.is_empty()); assert!(binding.workspace_key_id.is_some()); - assert_eq!(binding.workspace_key_generation, Some(1)); + assert_eq!(binding.workspace_trust_id.as_deref(), Some("trust-test")); assert!(!created.runtime.worker_creation_available); assert!( api.runtime @@ -42831,7 +42843,7 @@ mod tests { api.config.workspace_id.clone(), "configured-runtime".to_string(), )), - "configured binding must remain fenced by its current binding revision" + "configured binding must remain fenced by its current binding ID" ); let Json(configured_test) = scoped_test_runtime_connection( State(api.clone()), @@ -42857,7 +42869,10 @@ mod tests { .await .unwrap(); assert_eq!(status, StatusCode::OK); - assert_eq!(replayed.management.binding.unwrap().revision, 1); + assert_eq!( + replayed.management.binding.unwrap().binding_id, + binding.binding_id + ); let mut mismatched = request.clone(); mismatched.display_name = Some("Different Runtime".to_string()); @@ -42870,13 +42885,16 @@ mod tests { .unwrap_err(); assert_eq!(error.into_response().status(), StatusCode::CONFLICT); - mismatched.expected_revision = Some(1); + mismatched.expected_binding_id = Some(binding.binding_id.clone()); let (status, Json(replaced)) = create_remote_runtime(State(api.clone()), Extension(actor), Json(mismatched)) .await .unwrap(); assert_eq!(status, StatusCode::OK); - assert_eq!(replaced.management.binding.unwrap().revision, 2); + assert_ne!( + replaced.management.binding.unwrap().binding_id, + binding.binding_id + ); let unknown_identity = RuntimeIdentityMaterial::generate("unknown-runtime").unwrap(); let unknown_revision = create_remote_runtime( @@ -42889,7 +42907,8 @@ mod tests { }, display_name: None, endpoint: "https://8.8.4.4".to_string(), - expected_revision: Some(9), + workspace_trust_id: "trust-test".to_string(), + expected_binding_id: Some("binding-missing".to_string()), }), ) .await @@ -42915,6 +42934,94 @@ mod tests { ); } + #[tokio::test] + async fn runtime_trust_reregistration_changes_binding_and_rejects_old_revoke_id() { + let temp = tempfile::tempdir().unwrap(); + let api = test_api(temp.path()).await; + let actor = test_owner_actor(); + api.signing_identities + .provision_existing(api.workspace_id(), &actor.account_id) + .unwrap(); + let runtime_identity = + RuntimeIdentityMaterial::generate("trust-reaccepted-runtime").unwrap(); + let request = CreateRemoteRuntimeRequest { + public_bundle: server_api::RuntimePublicIdentityBundle { + identity_id: "trust-reaccepted-runtime".to_string(), + public_key: runtime_identity.public_key, + }, + display_name: None, + endpoint: "http://127.0.0.1:1".to_string(), + workspace_trust_id: "trust-first-acceptance".to_string(), + expected_binding_id: None, + }; + let (_, Json(created)) = create_remote_runtime( + State(api.clone()), + Extension(actor.clone()), + Json(request.clone()), + ) + .await + .unwrap(); + let original = created.management.binding.unwrap(); + let service = ServerApiContractService::Workspace(api.clone()); + let revoked = + ::runtime_trust_key_revoke( + &service, + actor.clone(), + api.workspace_id().to_string(), + "trust-reaccepted-runtime".to_string(), + server_api::RevokeRuntimeTrustKeyRequest { + expected_binding_id: original.binding_id.clone(), + }, + ) + .await + .unwrap(); + let revoked_id = revoked.trust_key.binding_id.unwrap(); + assert_ne!(revoked_id, original.binding_id); + let mut reaccepted = request; + reaccepted.workspace_trust_id = "trust-second-acceptance".to_string(); + reaccepted.expected_binding_id = Some(revoked_id); + let (_, Json(created)) = create_remote_runtime( + State(api.clone()), + Extension(actor.clone()), + Json(reaccepted), + ) + .await + .unwrap(); + let current = created.management.binding.unwrap(); + assert_ne!(current.binding_id, original.binding_id); + assert_eq!( + current.workspace_trust_id.as_deref(), + Some("trust-second-acceptance") + ); + assert_eq!( + current.connection_state, + RuntimeConnectionDisplayState::Configured + ); + let stale = ::runtime_trust_key_revoke( + &service, + actor, + api.workspace_id().to_string(), + "trust-reaccepted-runtime".to_string(), + server_api::RevokeRuntimeTrustKeyRequest { + expected_binding_id: original.binding_id, + }, + ) + .await + .unwrap_err(); + assert_eq!(api_macros::HttpError::status_code(&stale), 409); + let body = serde_json::to_value(stale).unwrap(); + assert_eq!(body["error"], "stale_binding"); + assert_eq!(body["current_binding_id"], current.binding_id); + let stored = api + .store + .get_workspace_runtime_binding(api.workspace_id(), "trust-reaccepted-runtime") + .await + .unwrap() + .unwrap(); + assert!(stored.revoked_at.is_none()); + assert_eq!(stored.binding_id, current.binding_id); + } + #[tokio::test] async fn runtime_connection_request_validation_bounds_browser_input() { let identity = RuntimeIdentityMaterial::generate("team-runtime_1").unwrap(); @@ -42925,7 +43032,8 @@ mod tests { }, display_name: Some("Team Runtime".to_string()), endpoint: "https://8.8.8.8".to_string(), - expected_revision: None, + workspace_trust_id: "trust-test".to_string(), + expected_binding_id: None, }; assert!(validate_runtime_connection_request(&ok).await.is_ok()); @@ -42954,6 +43062,59 @@ mod tests { } } + #[tokio::test] + async fn runtime_registration_rejects_blank_and_malformed_workspace_trust_ids() { + let temp = tempfile::tempdir().unwrap(); + let api = test_api(temp.path()).await; + let actor = test_owner_actor(); + let identity = RuntimeIdentityMaterial::generate("invalid-trust-runtime").unwrap(); + for trust_id in [ + String::new(), + " ".to_string(), + " trust-valid".to_string(), + "trust-valid ".to_string(), + "trust invalid".to_string(), + "trust\ninvalid".to_string(), + "x".repeat(257), + ] { + let error = create_remote_runtime( + State(api.clone()), + Extension(actor.clone()), + Json(CreateRemoteRuntimeRequest { + public_bundle: server_api::RuntimePublicIdentityBundle { + identity_id: identity.identity_id.clone(), + public_key: identity.public_key.clone(), + }, + display_name: None, + endpoint: "http://127.0.0.1:1".to_string(), + workspace_trust_id: trust_id.clone(), + expected_binding_id: None, + }), + ) + .await + .unwrap_err(); + let response = error.into_response(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{trust_id:?}"); + let body: serde_json::Value = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert!( + body["message"] + .as_str() + .unwrap() + .contains("invalid_workspace_trust_id"), + "{body}" + ); + assert!( + api.store + .get_workspace_runtime_binding(api.workspace_id(), &identity.identity_id) + .await + .unwrap() + .is_none() + ); + } + } + #[test] fn backend_errors_preserve_operation_details() { let sanitized = sanitize_backend_error("failed to open server database"); @@ -43594,7 +43755,6 @@ mod tests { source_fingerprint: crate::repository_source::repository_source_fingerprint(&source), source, default_ref: Some("main".to_string()), - source_revision: 1, observed_status: server_api::RepositoryObservedStatus::Unverified, observed_at: None, created_at: "1".to_string(), @@ -43648,7 +43808,6 @@ mod tests { source_fingerprint: crate::repository_source::repository_source_fingerprint(&source), source, default_ref: Some("main".to_string()), - source_revision: 1, observed_status: server_api::RepositoryObservedStatus::Unverified, observed_at: None, created_at: "1".to_string(), @@ -43683,7 +43842,6 @@ mod tests { provider: "git".to_string(), source_fingerprint: crate::repository_source::repository_source_fingerprint(&source), source, - source_revision: 1, observed_status: server_api::RepositoryObservedStatus::Unverified, observed_at: None, path: Some(workspace_root), @@ -43757,6 +43915,43 @@ mod tests { headers } + fn ticket_check_jobs_for_snapshot( + api: &WorkspaceApi, + ticket: &ticket::Ticket, + ) -> Vec { + let digest = ticket_item_checker::content_digest(ticket); + let requests = api + .config_store + .with_conn(|conn| { + let mut query = + conn.prepare("SELECT request_json FROM backend_jobs WHERE workspace_id = ?1")?; + let rows = query.query_map([TEST_WORKSPACE_ID], |row| row.get::<_, String>(0))?; + rows.collect::, _>>() + .map_err(Error::from) + }) + .unwrap(); + requests + .into_iter() + .filter_map(|json| { + let request: crate::backend_job::BackendJobRequest = + serde_json::from_str(&json).unwrap(); + if request.purpose != ticket_item_checker::PURPOSE { + return None; + } + let input = ticket_item_checker::parse_input(&request.input).unwrap(); + if input.ticket_id != ticket.meta.id || input.content_digest != digest { + return None; + } + Some( + api.store + .get_backend_job(TEST_WORKSPACE_ID, &request.job_id) + .unwrap() + .unwrap(), + ) + }) + .collect() + } + fn ticket_check_job( api: &WorkspaceApi, ticket_id: &str, @@ -43765,14 +43960,13 @@ mod tests { .unwrap() .show(ticket_id.to_string().into()) .unwrap(); - let revision = ticket_item_checker::item_revision(&ticket); - api.store - .get_backend_job( - TEST_WORKSPACE_ID, - &format!("ticket-item-check:{ticket_id}:{revision}"), - ) - .unwrap() - .expect("Ticket item checker Job was reserved after save") + let mut jobs = ticket_check_jobs_for_snapshot(api, &ticket); + assert_eq!( + jobs.len(), + 1, + "fixture must have exactly one checker intent for its current content" + ); + jobs.pop().unwrap() } async fn wait_for_ticket_check_attempt( @@ -43868,7 +44062,10 @@ mod tests { checker_input.body, "This turn only creates the Ticket; I will not implement it. Production deployment requires approval." ); - assert_eq!(checker_input.revision, job.request.input_revision); + assert_eq!( + checker_input.content_digest, + api.authority.ticket(&ticket_ref.id).unwrap().content_digest + ); assert_eq!(job.request.source_worker.as_ref(), Some(&source)); assert_eq!(job.request.notification_target.as_ref(), Some(&source)); assert_eq!(job.request.limits.max_attempts, 1); @@ -43876,7 +44073,11 @@ mod tests { assert!(job.request.instruction.contains("Do not infer user intent")); let dispatched_inputs = execution.take_inputs(); assert_eq!(dispatched_inputs.len(), 1); - assert!(dispatched_inputs[0].1.contains(&checker_input.revision)); + assert!( + dispatched_inputs[0] + .1 + .contains(&checker_input.content_digest) + ); release_tx.send(()).unwrap(); let attempt = wait_for_ticket_check_attempt(&api, &job).await; @@ -43885,7 +44086,7 @@ mod tests { let submission = BackendJobResultSubmission { job_id: job.request.job_id.clone(), attempt_id: attempt.attempt_id.clone(), - input_revision: job.request.input_revision.clone(), + input_digest: job.request.input_digest().unwrap(), result: serde_json::json!({"findings": [{ "category": "writer_scope", "quote": "I will not implement it", @@ -43903,7 +44104,7 @@ mod tests { .1 .contains("[Ticket item checker advisory]") ); - assert!(notifications[0].1.contains(&checker_input.revision)); + assert!(notifications[0].1.contains(&checker_input.content_digest)); assert!(notifications[0].1.contains("I will not implement it")); assert!(notifications[0].1.contains("not a new user request")); assert!( @@ -44027,7 +44228,7 @@ mod tests { &BackendJobResultSubmission { job_id: job.request.job_id.clone(), attempt_id: attempt.attempt_id, - input_revision: job.request.input_revision.clone(), + input_digest: job.request.input_digest().unwrap(), result: FakeTicketCheckerModel::respond(&job.request), }, ) @@ -44139,7 +44340,7 @@ mod tests { &BackendJobResultSubmission { job_id: job.request.job_id.clone(), attempt_id: attempt.attempt_id, - input_revision: job.request.input_revision.clone(), + input_digest: job.request.input_digest().unwrap(), result: serde_json::json!({"findings": []}), }, ) @@ -44220,17 +44421,16 @@ mod tests { ]; let mut checked_inputs = Vec::new(); for (ticket, source, expected_body) in snapshots { - let revision = ticket_item_checker::item_revision(&ticket); - let job = api - .store - .get_backend_job( - TEST_WORKSPACE_ID, - &format!("ticket-item-check:{}:{revision}", ticket.meta.id), - ) - .unwrap() - .expect("each successful edit has its own checker Job"); + let revision = ticket_item_checker::content_digest(&ticket); + let mut jobs = ticket_check_jobs_for_snapshot(&api, &ticket); + assert_eq!( + jobs.len(), + 1, + "each successful edit has its own checker Job" + ); + let job = jobs.pop().unwrap(); let input = ticket_item_checker::parse_input(&job.request.input).unwrap(); - assert_eq!(input.revision, revision); + assert_eq!(input.content_digest, revision); assert_eq!(input.body, expected_body); assert_eq!(job.request.source_worker.as_ref(), Some(&source)); assert_eq!(job.request.notification_target.as_ref(), Some(&source)); @@ -44250,7 +44450,7 @@ mod tests { .expect("one edit must atomically observe the initial preimage"); let second = checked_inputs .iter() - .find(|input| input.revision != first.revision) + .find(|input| input.content_digest != first.content_digest) .unwrap(); assert!(matches!( &second.edit, @@ -44332,7 +44532,7 @@ mod tests { &BackendJobResultSubmission { job_id: stale_job.request.job_id.clone(), attempt_id: stale_attempt.attempt_id.clone(), - input_revision: stale_job.request.input_revision.clone(), + input_digest: stale_job.request.input_digest().unwrap(), result: serde_json::json!({"findings": [{ "category": "writer_scope", "quote": "I will not implement this Ticket", @@ -44371,7 +44571,7 @@ mod tests { &BackendJobResultSubmission { job_id: current_job.request.job_id.clone(), attempt_id: current_attempt.attempt_id.clone(), - input_revision: current_job.request.input_revision.clone(), + input_digest: current_job.request.input_digest().unwrap(), result: serde_json::json!({"findings": []}), }, ) @@ -44415,7 +44615,7 @@ mod tests { &BackendJobResultSubmission { job_id: invalid_job.request.job_id.clone(), attempt_id: invalid_attempt.attempt_id.clone(), - input_revision: invalid_job.request.input_revision.clone(), + input_digest: invalid_job.request.input_digest().unwrap(), result: serde_json::json!({"findings": [{ "category": "internal_inconsistency", "quote": "invented quote", @@ -44476,15 +44676,8 @@ mod tests { .unwrap() .show(ticket_ref.id.clone().into()) .unwrap(); - let browser_revision = ticket_item_checker::item_revision(&browser_ticket); assert!( - api.store - .get_backend_job( - TEST_WORKSPACE_ID, - &format!("ticket-item-check:{}:{browser_revision}", ticket_ref.id), - ) - .unwrap() - .is_none(), + ticket_check_jobs_for_snapshot(&api, &browser_ticket).is_empty(), "direct browser edits are explicitly outside the Worker advisory path" ); @@ -44515,7 +44708,6 @@ mod tests { let request = BackendJobRequest { job_id: "runner-check-1".to_string(), purpose: "runner_contract_check".to_string(), - input_revision: "revision-1".to_string(), input_ref: "test://runner/input/1".to_string(), input: serde_json::json!({"value": "immutable"}), instruction: "Return a structured check result.".to_string(), @@ -44602,7 +44794,7 @@ mod tests { &BackendJobResultSubmission { job_id: request.job_id.clone(), attempt_id: dispatched.attempt.attempt_id, - input_revision: request.input_revision, + input_digest: request.input_digest().unwrap(), result: serde_json::json!({"valid": true}), }, ) @@ -44618,7 +44810,6 @@ mod tests { let request = BackendJobRequest { job_id: "runner-cleanup-success-1".to_string(), purpose: "cleanup_contract_check".to_string(), - input_revision: "revision-1".to_string(), input_ref: "test://runner/cleanup/1".to_string(), input: serde_json::json!({"value": "immutable"}), instruction: "Return a structured check result.".to_string(), @@ -44639,7 +44830,7 @@ mod tests { let submission = BackendJobResultSubmission { job_id: request.job_id.clone(), attempt_id: dispatched.attempt.attempt_id.clone(), - input_revision: request.input_revision.clone(), + input_digest: request.input_digest().unwrap(), result: serde_json::json!({"valid": true}), }; @@ -44726,7 +44917,6 @@ mod tests { let request = BackendJobRequest { job_id: "runner-cleanup-retry-1".to_string(), purpose: "cleanup_retry_contract_check".to_string(), - input_revision: "revision-1".to_string(), input_ref: "test://runner/cleanup-retry/1".to_string(), input: serde_json::json!({"value": "immutable"}), instruction: "Return a structured check result.".to_string(), @@ -44801,7 +44991,7 @@ mod tests { &BackendJobResultSubmission { job_id: request.job_id.clone(), attempt_id: second.attempt.attempt_id.clone(), - input_revision: request.input_revision.clone(), + input_digest: request.input_digest().unwrap(), result: serde_json::json!({"retried": true}), }, ) @@ -44835,7 +45025,6 @@ mod tests { let request = BackendJobRequest { job_id: "runner-cleanup-pre-marker-restart-1".to_string(), purpose: "cleanup_pre_marker_restart_contract_check".to_string(), - input_revision: "revision-1".to_string(), input_ref: "test://runner/cleanup-pre-marker-restart/1".to_string(), input: serde_json::json!({"value": "immutable"}), instruction: "Return a structured check result.".to_string(), @@ -44960,7 +45149,7 @@ mod tests { &BackendJobResultSubmission { job_id: request.job_id.clone(), attempt_id: reservation.attempt.attempt_id.clone(), - input_revision: request.input_revision.clone(), + input_digest: request.input_digest().unwrap(), result: serde_json::json!({"persisted": true}), }, &now, @@ -45015,7 +45204,6 @@ mod tests { let request = BackendJobRequest { job_id: "r".repeat(256), purpose: "fast_result_contract_check".to_string(), - input_revision: "revision-1".to_string(), input_ref: "test://runner/fast-result/1".to_string(), input: serde_json::json!({"value": "immutable"}), instruction: "Return a structured check result.".to_string(), @@ -45041,7 +45229,7 @@ mod tests { &BackendJobResultSubmission { job_id: hook_request.job_id.clone(), attempt_id: crate::backend_job::attempt_id(&hook_request.job_id, 1), - input_revision: hook_request.input_revision.clone(), + input_digest: hook_request.input_digest().unwrap(), result: serde_json::json!({"valid": true}), }, ) @@ -45079,7 +45267,6 @@ mod tests { let request = BackendJobRequest { job_id: "runner-result-response-fence-1".to_string(), purpose: "result_response_fence_check".to_string(), - input_revision: "revision-1".to_string(), input_ref: "test://runner/result-response-fence/1".to_string(), input: serde_json::json!({"value": "immutable"}), instruction: "Return a structured check result.".to_string(), @@ -45214,7 +45401,6 @@ mod tests { let request = BackendJobRequest { job_id: "n".repeat(256), purpose: "delivery_recovery_contract_check".to_string(), - input_revision: "revision-1".to_string(), input_ref: "test://runner/delivery-recovery/1".to_string(), input: serde_json::json!({"value": "immutable"}), instruction: "Return a structured check result.".to_string(), @@ -45238,7 +45424,7 @@ mod tests { &BackendJobResultSubmission { job_id: request.job_id.clone(), attempt_id: dispatched.attempt.attempt_id.clone(), - input_revision: request.input_revision.clone(), + input_digest: request.input_digest().unwrap(), result: serde_json::json!({"valid": true}), }, &Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), @@ -45298,7 +45484,6 @@ mod tests { let request = BackendJobRequest { job_id: "runner-spawn-error-1".to_string(), purpose: "spawn_error_contract_check".to_string(), - input_revision: "revision-1".to_string(), input_ref: "test://runner/spawn-error/1".to_string(), input: serde_json::json!({"value": "immutable"}), instruction: "Return a structured check result.".to_string(), @@ -45338,7 +45523,6 @@ mod tests { let request = BackendJobRequest { job_id: "runner-input-failure-1".to_string(), purpose: "failure_contract_check".to_string(), - input_revision: "revision-1".to_string(), input_ref: "test://runner/failure/1".to_string(), input: serde_json::json!({"value": "immutable"}), instruction: "Return a structured check result.".to_string(), @@ -45405,7 +45589,6 @@ mod tests { let request = BackendJobRequest { job_id: format!("restart-page-{index:03}"), purpose: "restart_page_contract_check".to_string(), - input_revision: "1".to_string(), input_ref: format!("fixture://restart-page/{index}"), input: serde_json::json!({"index": index}), instruction: "Return a structured fixture result.".to_string(), @@ -45452,7 +45635,6 @@ mod tests { let request = BackendJobRequest { job_id: "runner-restart-1".to_string(), purpose: "restart_contract_check".to_string(), - input_revision: "revision-1".to_string(), input_ref: "test://runner/restart/1".to_string(), input: serde_json::json!({"value": "immutable"}), instruction: "Return a structured check result.".to_string(), @@ -45497,7 +45679,6 @@ mod tests { let request = BackendJobRequest { job_id: "runner-unknown-1".to_string(), purpose: "unknown_contract_check".to_string(), - input_revision: "revision-1".to_string(), input_ref: "test://runner/unknown/1".to_string(), input: serde_json::json!({"value": "immutable"}), instruction: "Return a structured check result.".to_string(), @@ -45588,7 +45769,6 @@ mod tests { let request = BackendJobRequest { job_id: "runner-timeout-1".to_string(), purpose: "timeout_contract_check".to_string(), - input_revision: "revision-1".to_string(), input_ref: "test://runner/timeout/1".to_string(), input: serde_json::json!({"value": "immutable"}), instruction: "Wait for a structured result.".to_string(), @@ -45633,9 +45813,9 @@ mod tests { api.accept_backend_job_result( &worker, &BackendJobResultSubmission { - job_id: request.job_id, + job_id: request.job_id.clone(), attempt_id: dispatched.attempt.attempt_id, - input_revision: request.input_revision, + input_digest: request.input_digest().unwrap(), result: serde_json::json!({"late": true}), }, ) @@ -45714,7 +45894,7 @@ mod tests { } #[test] - fn runtime_binding_summary_omits_stale_verification_revision() { + fn runtime_binding_summary_omits_stale_verification_binding() { let binding = WorkspaceRuntimeBinding { workspace_id: "workspace-a".to_string(), runtime_id: "runtime-a".to_string(), @@ -45722,12 +45902,15 @@ mod tests { base_url: "https://runtime.example.test".to_string(), public_key: "runtime-public-key".to_string(), public_key_fingerprint: "sha256:runtime".to_string(), - binding_revision: 2, + binding_id: "binding-test".to_string(), state: crate::store::WorkspaceRuntimeBindingState::Revoked, authentication_mode: crate::store::WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity, workspace_key_id: Some("WK-a".to_string()), - workspace_key_generation: Some(1), + + workspace_public_key_fingerprint: Some("sha256:workspace-test".to_string()), + + workspace_trust_id: Some("trust-test".to_string()), created_at: "1".to_string(), updated_at: "2".to_string(), revoked_at: Some("2".to_string()), @@ -45735,12 +45918,11 @@ mod tests { let stale = crate::store::WorkspaceRuntimeVerificationEvidence { workspace_id: "workspace-a".to_string(), runtime_id: "runtime-a".to_string(), - binding_revision: 1, + binding_id: "binding-retired".to_string(), workspace_key_id: "WK-a".to_string(), - workspace_identity_revision: 1, - workspace_trust_generation: 1, + workspace_public_key_fingerprint: "sha256:workspace-test".to_string(), + workspace_trust_id: "trust-test".to_string(), runtime_public_key_fingerprint: "sha256:runtime".to_string(), - runtime_identity_revision: 1, challenge_id: "challenge-a".to_string(), state: "failed".to_string(), last_outcome: "connectivity_failed".to_string(), @@ -46137,7 +46319,7 @@ mod tests { .await; assert_eq!(preflight["can_delete"], true, "{preflight}"); assert_eq!(preflight["resources"]["repositories"], 0); - let deletion_request = json!({"operation_id": "delete-second-empty", "expected_revision": preflight["expected_revision"], "confirmation": "Second Empty"}); + let deletion_request = json!({"operation_id": "delete-second-empty", "expected_workspace_updated_at": preflight["expected_workspace_updated_at"], "confirmation": "Second Empty"}); request_json_authenticated( app.clone(), "POST", @@ -46989,7 +47171,7 @@ mod tests { &workspace.workspace.workspace_id, &server_api::WorkspaceDeletionRequest { operation_id: "repository-conflict-test".to_owned(), - expected_revision: preflight.expected_revision, + expected_workspace_updated_at: preflight.expected_workspace_updated_at, confirmation: preflight.display_name, }, ) @@ -48295,7 +48477,7 @@ mod tests { let before = backend.show(ticket.id.clone().into()).unwrap(); let request = server_api::CompleteTicketRequest { operation_key: "forged".into(), - expected_item_revision: ticket::ticket_item_revision(&before), + expected_content_digest: ticket::ticket_content_digest(&before), expected_state: TicketWorkflowState::InProgress, reason: "Not my task".into(), references: Vec::new(), @@ -48321,7 +48503,7 @@ mod tests { state: server_api::BrowserTicketWorkflowState::Done, expected_state: server_api::BrowserTicketWorkflowState::Inprogress, operation_key: "browser-forged".into(), - expected_item_revision: ticket::ticket_item_revision(&before), + expected_content_digest: ticket::ticket_content_digest(&before), reason: "Not human authority".into(), body: None, }), @@ -48345,7 +48527,7 @@ mod tests { let before = backend.show(reference.id.clone().into()).unwrap(); let request = server_api::CompleteTicketRequest { operation_key: "research-done".into(), - expected_item_revision: ticket::ticket_item_revision(&before), + expected_content_digest: ticket::ticket_content_digest(&before), expected_state: TicketWorkflowState::InProgress, reason: "## 完了理由\n\n日本語の長い検証結果を省略せず記録する。\n\n" .repeat(1024), @@ -48381,7 +48563,7 @@ mod tests { let mut changed = request.clone(); match field { "reason" => changed.reason = "different judgment".into(), - "revision" => changed.expected_item_revision = "stale".into(), + "revision" => changed.expected_content_digest = "stale".into(), "state" => changed.expected_state = TicketWorkflowState::Planning, "references" => changed.references.push(ticket::TicketReference { kind: "document".into(), @@ -48445,7 +48627,7 @@ mod tests { HeaderMap::new(), Json(ticket::TicketCompletion { operation_key: "normal-close".into(), - expected_item_revision: api.authority.ticket(&ticket.id).unwrap().item_revision, + expected_content_digest: api.authority.ticket(&ticket.id).unwrap().content_digest, expected_state: TicketWorkflowState::InProgress, reason: "Normal close ends implementation work".into(), references: Vec::new(), @@ -48991,7 +49173,6 @@ mod tests { .to_string(), }, default_ref: Some("develop".to_string()), - source_revision: 1, source_fingerprint: "sha256:undeclared".to_string(), observed_status: server_api::RepositoryObservedStatus::Unverified, observed_at: None, @@ -49773,7 +49954,7 @@ mod tests { let request = server_api::BrowserTransitionTicketStateRequest { state: state.clone(), operation_key: format!("decision-{index}"), - expected_item_revision: ticket::ticket_item_revision(&before), + expected_content_digest: ticket::ticket_content_digest(&before), expected_state, reason: "Human progress judgment".into(), body: None, @@ -49808,7 +49989,7 @@ mod tests { Json(server_api::BrowserTransitionTicketStateRequest { state: server_api::BrowserTicketWorkflowState::Done, operation_key: "stale".into(), - expected_item_revision: "stale-revision".into(), + expected_content_digest: "stale-revision".into(), expected_state: server_api::BrowserTicketWorkflowState::Planning, reason: "Stale decision".into(), body: None, @@ -51536,7 +51717,7 @@ mod tests { AxumPath(path()), Json(server_api::BrowserCloseTicketRequest { operation_key: "browser-close".into(), - expected_item_revision: queued.item_revision.clone(), + expected_content_digest: queued.content_digest.clone(), expected_state: server_api::BrowserTicketWorkflowState::Queued, resolution: "Closed through the Browser API.".to_string(), }), @@ -51608,7 +51789,7 @@ mod tests { skills::SKILL_DOCUMENT_SCHEMA_SOURCE ); let request = crate::config_source::ConfigCommitRequest { - base_revision: current.snapshot.revision, + base_digest: current.snapshot.digest.clone(), changes: vec![ config_source::ConfigTreeChange::Update { @@ -51657,7 +51838,7 @@ mod tests { entry.provenance.virtual_path.as_deref(), Some("skills/triage-errors/SKILL.md") ); - assert!(entry.provenance.revision.is_some()); + assert!(entry.provenance.tree_digest.is_some()); assert!(entry.provenance.source_digest.is_some()); assert_ne!(entry.description, "stale filesystem authority"); assert!( @@ -51710,11 +51891,14 @@ mod tests { base_url: "https://runtime.example".to_owned(), public_key: identity.public_key, public_key_fingerprint: String::new(), - binding_revision: 1, + binding_id: "binding-test".to_string(), state: StoredRuntimeBindingState::Verified, authentication_mode: StoredRuntimeAuthenticationMode::WorkspaceIdentity, workspace_key_id: Some("WK-test".to_owned()), - workspace_key_generation: Some(1), + + workspace_public_key_fingerprint: Some("sha256:workspace-test".to_string()), + + workspace_trust_id: Some("trust-test".to_string()), created_at: now.clone(), updated_at: now, revoked_at: None, @@ -51748,6 +51932,13 @@ mod tests { .unwrap_err(); assert_eq!(denied.into_response().status(), StatusCode::FORBIDDEN); + let original_binding_id = api + .store + .get_workspace_runtime_binding(TEST_WORKSPACE_ID, "runtime-a") + .await + .unwrap() + .unwrap() + .binding_id; let contract_service = ServerApiContractService::Workspace(api.clone()); let denied = ::runtime_trust_key_revoke( &contract_service, @@ -51755,7 +51946,7 @@ mod tests { TEST_WORKSPACE_ID.to_owned(), "runtime-a".to_owned(), server_api::RevokeRuntimeTrustKeyRequest { - expected_revision: 1, + expected_binding_id: original_binding_id.clone(), }, ) .await @@ -51767,7 +51958,7 @@ mod tests { TEST_WORKSPACE_ID.to_owned(), "runtime-a".to_owned(), server_api::RevokeRuntimeTrustKeyRequest { - expected_revision: 1, + expected_binding_id: original_binding_id.clone(), }, ) .await @@ -51778,10 +51969,72 @@ mod tests { .await .unwrap() .unwrap(); - assert_eq!(binding.binding_revision, 2); + assert_ne!(binding.binding_id, original_binding_id); assert!(binding.revoked_at.is_some()); } + #[tokio::test] + async fn ssh_rotation_http_fences_key_identity_and_replays_exact_operations() { + let temp = tempfile::tempdir().unwrap(); + let api = test_api(temp.path()).await; + let app = build_inner_router(api).layer(Extension(test_owner_actor())); + let base = format!("/api/w/{TEST_WORKSPACE_ID}/settings/repository-access/credentials"); + let private_key = |seed| { + ssh_key::PrivateKey::from(ssh_key::private::Ed25519Keypair::from_seed(&[seed; 32])) + .to_openssh(ssh_key::LineEnding::LF) + .unwrap() + .to_string() + }; + let created = request_json( + app.clone(), + "POST", + &base, + Some(json!({ + "operation_id": "http-key-create", "credential_id": "http-key", "name": "HTTP key", + "private_key": private_key(11), + })), + StatusCode::CREATED, + ) + .await; + let original_fingerprint = created["public_key_fingerprint"].clone(); + let rotate_path = format!("{base}/http-key/rotate"); + let reseal = json!({"operation_id":"http-key-reseal", "expected_public_key_fingerprint":original_fingerprint, "private_key":private_key(11)}); + let resealed = request_json( + app.clone(), + "POST", + &rotate_path, + Some(reseal.clone()), + StatusCode::OK, + ) + .await; + assert_eq!(resealed["public_key_fingerprint"], original_fingerprint); + let replayed = request_json( + app.clone(), + "POST", + &rotate_path, + Some(reseal), + StatusCode::OK, + ) + .await; + assert_eq!(replayed, resealed); + request_json(app.clone(), "POST", &rotate_path, Some(json!({ + "operation_id":"http-key-reseal", "expected_public_key_fingerprint":original_fingerprint, "private_key":private_key(12), + })), StatusCode::CONFLICT).await; + let rotated = request_json(app.clone(), "POST", &rotate_path, Some(json!({ + "operation_id":"http-key-rotate", "expected_public_key_fingerprint":original_fingerprint, "private_key":private_key(12), + })), StatusCode::OK).await; + assert_ne!(rotated["public_key_fingerprint"], original_fingerprint); + request_json(app.clone(), "POST", &rotate_path, Some(json!({ + "operation_id":"http-key-stale", "expected_public_key_fingerprint":original_fingerprint, "private_key":private_key(13), + })), StatusCode::CONFLICT).await; + let current = get_json(app, &format!("{base}/http-key")).await; + assert_eq!( + current["public_key_fingerprint"], + rotated["public_key_fingerprint"] + ); + assert!(current.get("private_key").is_none()); + } + #[tokio::test] async fn repository_secret_management_is_owner_only() { let temp = tempfile::tempdir().unwrap(); @@ -51837,7 +52090,7 @@ mod tests { AxumPath(path), Json(UpdateWorkspaceMetadataRequest { display_name: " Renamed Workspace ".to_string(), - revision: current.revision.clone(), + expected_updated_at: current.updated_at.clone(), }), ) .await @@ -51845,7 +52098,7 @@ mod tests { .0 .workspace; assert_eq!(updated.display_name, "Renamed Workspace"); - assert_ne!(updated.revision, current.revision); + assert_ne!(updated.updated_at, current.updated_at); assert_eq!( fs::read_to_string(&local_identity_path).unwrap(), "not valid toml = [" @@ -51867,7 +52120,7 @@ mod tests { }), Json(UpdateWorkspaceMetadataRequest { display_name: "Stale Workspace".to_string(), - revision: current.revision, + expected_updated_at: current.updated_at, }), ) .await @@ -51996,7 +52249,6 @@ mod tests { .unwrap(); let main_path = config_source::VirtualPath::parse("main.dcdl").unwrap(); let request = crate::config_source::ConfigCommitRequest { - base_revision: current.snapshot.revision, base_digest: current.snapshot.digest.clone(), changes: vec![config_source::ConfigTreeChange::Update { path: main_path.clone(), @@ -52020,7 +52272,10 @@ mod tests { .unwrap(); } - async fn register_test_runtime(api: &WorkspaceApi, runtime_id: &str) { + async fn register_test_runtime( + api: &WorkspaceApi, + runtime_id: &str, + ) -> WorkspaceRuntimeBinding { let identity = RuntimeIdentityMaterial::generate(runtime_id).unwrap(); let binding = WorkspaceRuntimeBinding { workspace_id: TEST_WORKSPACE_ID.to_string(), @@ -52029,11 +52284,14 @@ mod tests { base_url: "https://runtime.example.invalid".to_string(), public_key: identity.public_key, public_key_fingerprint: String::new(), - binding_revision: 1, + binding_id: "binding-test".to_string(), state: StoredRuntimeBindingState::Verified, authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer, workspace_key_id: None, - workspace_key_generation: None, + + workspace_public_key_fingerprint: None, + + workspace_trust_id: None, created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -52042,6 +52300,12 @@ mod tests { .upsert_workspace_runtime_binding_record(binding.clone(), false) .await .unwrap(); + let binding = api + .store + .get_workspace_runtime_binding(TEST_WORKSPACE_ID, runtime_id) + .await + .unwrap() + .unwrap(); api.runtime.register_or_replace( RemoteWorkerRuntime::new( remote_runtime_config_from_binding(&binding).unwrap(), @@ -52050,6 +52314,7 @@ mod tests { ) .unwrap(), ); + binding } fn assign_test_orchestrator(api: &WorkspaceApi, ticket_id: &str) { @@ -52112,7 +52377,7 @@ mod tests { State(api), AxumPath("workspace-foreign".to_string()), Json(server_api::UpdateWorkspaceMemorySettingsRequest { - expected_revision: 1, + expected_language: "English".to_string(), language: "English".to_string(), }), ) @@ -52636,11 +52901,14 @@ mod tests { base_url: "https://runtime.invalid".to_string(), public_key: identity.public_key.clone(), public_key_fingerprint: String::new(), - binding_revision: 1, + binding_id: "binding-test".to_string(), state: StoredRuntimeBindingState::Verified, authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer, workspace_key_id: None, - workspace_key_generation: None, + + workspace_public_key_fingerprint: None, + + workspace_trust_id: None, created_at: "2026-08-11T00:00:00Z".to_string(), updated_at: "2026-08-11T00:00:00Z".to_string(), revoked_at: None, @@ -53063,7 +53331,6 @@ mod tests { uri: api.config.workspace_execution_root.display().to_string(), }, default_ref: Some("HEAD".to_string()), - source_revision: 1, source_fingerprint: "sha256:test".to_string(), observed_status: server_api::RepositoryObservedStatus::Unverified, observed_at: None, @@ -53330,7 +53597,6 @@ mod tests { id: "repo-test".to_string(), provider: "git".to_string(), source: repository_source.clone(), - source_revision: 1, source_fingerprint: source_fingerprint.clone(), selector: Some(RuntimeRepositorySelector("HEAD".to_string())), }, @@ -53369,11 +53635,14 @@ mod tests { base_url: provider_base_url.clone(), public_key: provider_identity.public_key, public_key_fingerprint: String::new(), - binding_revision: 1, + binding_id: "binding-test".to_string(), state: StoredRuntimeBindingState::Verified, authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer, workspace_key_id: None, - workspace_key_generation: None, + + workspace_public_key_fingerprint: None, + + workspace_trust_id: None, created_at: now.clone(), updated_at: now, revoked_at: None, @@ -53725,7 +53994,7 @@ mod tests { let page: server_api::CurrentWorkerWorkdirCatalogResponse = serde_json::from_slice(&bytes).unwrap(); assert!(page.items.len() <= 37); - assert_eq!(page.revision, empty.revision); + assert_eq!(page.digest, empty.digest); seen.extend(page.items.into_iter().map(|item| item.working_directory_id)); pages += 1; if let Some(next) = page.next_cursor { @@ -53802,7 +54071,7 @@ mod tests { ) .unwrap(); assert_eq!(before.items, changed.items); - assert_ne!(before.revision, changed.revision); + assert_ne!(before.digest, changed.digest); let template_link = api .store .list_worker_workdir_links(TEST_WORKSPACE_ID, &fixture.worker) @@ -53825,7 +54094,7 @@ mod tests { ) .unwrap(); assert_eq!(changed.items, occupied.items); - assert_ne!(changed.revision, occupied.revision); + assert_ne!(changed.digest, occupied.digest); api.store .detach_worker_workdir_connection( TEST_WORKSPACE_ID, @@ -53836,14 +54105,14 @@ mod tests { ) .unwrap(); assert_eq!( - changed.revision, + changed.digest, list_current_worker_workdir_catalog(api, Default::default()) .unwrap() - .revision + .digest ); let restarted = SqliteWorkspaceStore::open(&api.config.database_path).unwrap(); assert_eq!( - changed.revision, + changed.digest, restarted .workdir_catalog_page(TEST_WORKSPACE_ID, 100, None) .unwrap() @@ -54289,7 +54558,7 @@ mod tests { assert_eq!(page.items[0].connection_id, target.connection_id); assert_eq!(page.next_offset, None); assert_eq!( - page.revision, first.revision, + page.digest, first.digest, "lookup does not narrow the collection revision" ); @@ -54320,7 +54589,7 @@ mod tests { .unwrap(); assert!(page.items.is_empty()); assert_eq!(page.next_offset, None); - assert_eq!(page.revision, first.revision); + assert_eq!(page.digest, first.digest); } let mut forged = request(&lookup_path); forged @@ -54371,7 +54640,7 @@ mod tests { assert_eq!(reattached.items, first.items); assert_eq!(reattached.next_offset, first.next_offset); assert_ne!( - reattached.revision, first.revision, + reattached.digest, first.digest, "same alias and Workdir, new lifetime beyond page zero" ); let downgraded = api @@ -54386,7 +54655,7 @@ mod tests { list_current_worker_workdir_attachments(api, &fixture.worker, Default::default()) .unwrap(); assert_eq!(capability_changed.items, reattached.items); - assert_ne!(capability_changed.revision, reattached.revision); + assert_ne!(capability_changed.digest, reattached.digest); let restarted = SqliteWorkspaceStore::open(&api.config.database_path).unwrap(); let (restarted_page, restarted_revision) = restarted .list_worker_workdir_links_page_with_revision( @@ -54398,7 +54667,7 @@ mod tests { ) .unwrap(); assert_eq!(restarted_page.len(), 51); - assert_eq!(restarted_revision, capability_changed.revision); + assert_eq!(restarted_revision, capability_changed.digest); // A SQLite backup restores the same authoritative content/revision, without a sync store. let source = rusqlite::Connection::open(&api.config.database_path).unwrap(); let mut snapshot = rusqlite::Connection::open_in_memory().unwrap(); @@ -54450,7 +54719,7 @@ mod tests { Some(&replacement.connection_id), ) .unwrap(); - assert_ne!(read_write_revision, capability_changed.revision); + assert_ne!(read_write_revision, capability_changed.digest); let reader = SqliteWorkspaceStore::open(&api.config.database_path).unwrap(); let barrier = Arc::new(std::sync::Barrier::new(2)); std::thread::scope(|scope| { @@ -54490,7 +54759,7 @@ mod tests { workdir::WorkdirSessionCapabilities::READ_WRITE ); } else { - assert_eq!(revision, capability_changed.revision); + assert_eq!(revision, capability_changed.digest); assert_eq!( page[0].capabilities, workdir::WorkdirSessionCapabilities::READ_ONLY @@ -54829,6 +55098,7 @@ mod tests { ), confirmed_workdir_cleanup_result(), ); + let dirty_attempt_id = dirty.attempt_id.clone(); let removed_after_retry = execute_reserved_workdir_removal_with_provider( &api, dirty, @@ -54840,7 +55110,7 @@ mod tests { removed_after_retry.disposition, Some(WorkdirRemovalDisposition::Removed) ); - assert_eq!(removed_after_retry.attempt_count, 2); + assert_ne!(removed_after_retry.attempt_id, dirty_attempt_id); assert_eq!(clean_retry_provider.cleanup_calls(), 1); let (corrupted_operation, mut corrupted_summary) = @@ -55057,7 +55327,7 @@ mod tests { ) .unwrap(); assert_eq!(removed.operation_id, occupied.operation_id); - assert_eq!(removed.attempt_count, 4); + assert_ne!(removed.attempt_id, occupied.attempt_id); assert_eq!( removed.disposition, Some(WorkdirRemovalDisposition::Removed) @@ -55320,6 +55590,17 @@ mod tests { .is_some() ); + let first_attempt_id = api + .config_store + .find_workdir_removal_operation_by_intent( + &api.config.workspace_id, + "missing-clean-workdir", + "account:owner", + "remove stale clean Workdir", + ) + .unwrap() + .unwrap() + .attempt_id; let retry = execute_workdir_removal( &api, "missing-clean-workdir", @@ -55338,7 +55619,7 @@ mod tests { ) .unwrap() .unwrap(); - assert_eq!(operation.attempt_count, 2); + assert_ne!(operation.attempt_id, first_attempt_id); } #[tokio::test] @@ -55394,7 +55675,8 @@ mod tests { .config_store .reserve_workdir_removal_operation(&intent) .unwrap(); - api.config_store + let started = api + .config_store .begin_workdir_removal_attempt( &reserved.workspace_id, &reserved.operation_id, @@ -55411,7 +55693,7 @@ mod tests { .unwrap() .unwrap(); assert_eq!(operation.state, WorkdirRemovalOperationState::Pending); - assert_eq!(operation.attempt_count, 1); + assert_eq!(operation.attempt_id, started.attempt_id); assert_eq!( operation.attempt_owner, Some(api.workdir_remove_attempt_owner) @@ -55447,7 +55729,7 @@ mod tests { }, ) .unwrap(); - assert_eq!(interrupted_attempt.attempt_count, 1); + assert!(interrupted_attempt.attempt_id.is_some()); recover_workdir_removals(&api).unwrap(); @@ -55457,7 +55739,7 @@ mod tests { .unwrap() .unwrap(); assert_eq!(operation.state, WorkdirRemovalOperationState::Failed); - assert_eq!(operation.attempt_count, 2); + assert_ne!(operation.attempt_id, interrupted_attempt.attempt_id); assert!(operation.retryable); assert_eq!( operation.failure_category.as_deref(), @@ -55554,29 +55836,36 @@ mod tests { .unwrap() .target_id .clone(); + // Change the candidate set after preview, without changing the requested target. + seed_cleanup_worker(&api, 2, "normal"); let stale = ExecuteRuntimeCleanupRequest { - expected_plan_revision: "stale".to_string(), expected_plan_digest: plan.digest.clone(), worker_target_ids: vec![target.clone()], workdir_target_ids: Vec::new(), confirm_dirty_discard_target_ids: Vec::new(), }; + let error = execute_runtime_cleanup(&api, "runtime-test", stale) + .await + .err() + .expect("changed candidates must reject the old preview"); assert!( - execute_runtime_cleanup(&api, "runtime-test", stale) - .await - .is_err() + matches!(error.error, Error::RuntimeOperationFailed { code, .. } if code == "workspace_cleanup_plan_stale") ); + + let refreshed = build_runtime_cleanup_plan(&api, "runtime-test").unwrap(); + assert_ne!(plan.digest, refreshed.digest); let pinned = ExecuteRuntimeCleanupRequest { - expected_plan_revision: plan.revision, - expected_plan_digest: plan.digest, + expected_plan_digest: refreshed.digest, worker_target_ids: vec![target], workdir_target_ids: Vec::new(), confirm_dirty_discard_target_ids: Vec::new(), }; + let error = execute_runtime_cleanup(&api, "runtime-test", pinned) + .await + .err() + .expect("current preview must still enforce target protection"); assert!( - execute_runtime_cleanup(&api, "runtime-test", pinned) - .await - .is_err() + matches!(error.error, Error::RuntimeOperationFailed { code, .. } if code == "workspace_cleanup_worker_blocked") ); } @@ -55768,7 +56057,6 @@ mod tests { .find(|candidate| candidate.worker_id == worker_id) .unwrap(); let request = ExecuteRuntimeCleanupRequest { - expected_plan_revision: plan.revision, expected_plan_digest: plan.digest, worker_target_ids: vec![candidate.target_id.clone()], workdir_target_ids: Vec::new(), @@ -55891,7 +56179,6 @@ mod tests { Some("worker has unfinished work for Ticket `ticket-assigned` (`worker`)") ); let request = ExecuteRuntimeCleanupRequest { - expected_plan_revision: plan.revision.clone(), expected_plan_digest: plan.digest.clone(), worker_target_ids: vec![candidate.target_id.clone()], workdir_target_ids: Vec::new(), @@ -56032,7 +56319,6 @@ mod tests { .target_id .clone(); let missing_confirmation = ExecuteRuntimeCleanupRequest { - expected_plan_revision: plan.revision.clone(), expected_plan_digest: plan.digest.clone(), worker_target_ids: Vec::new(), workdir_target_ids: vec![dirty_target], @@ -56049,7 +56335,6 @@ mod tests { .is_some() ); let delete_removed = ExecuteRuntimeCleanupRequest { - expected_plan_revision: plan.revision, expected_plan_digest: plan.digest, worker_target_ids: Vec::new(), workdir_target_ids: vec![removed_target], @@ -56136,7 +56421,7 @@ mod tests { workspace_root: impl Into, runtime_id: &str, endpoint: String, - ) -> Router { + ) -> (Router, WorkspaceRuntimeBinding) { let api = test_api(workspace_root).await; api.store .upsert_workspace_runtime_binding_record( @@ -56149,11 +56434,14 @@ mod tests { .unwrap() .public_key, public_key_fingerprint: String::new(), - binding_revision: 1, + binding_id: "binding-test".to_string(), state: StoredRuntimeBindingState::Verified, authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer, workspace_key_id: None, - workspace_key_generation: None, + + workspace_public_key_fingerprint: None, + + workspace_trust_id: None, created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -56162,6 +56450,12 @@ mod tests { ) .await .unwrap(); + let binding = api + .store + .get_workspace_runtime_binding(TEST_WORKSPACE_ID, runtime_id) + .await + .unwrap() + .unwrap(); api.runtime.register_or_replace( RemoteWorkerRuntime::new( RemoteRuntimeConfig { @@ -56183,7 +56477,7 @@ mod tests { ) .unwrap(), ); - build_inner_router(api) + (build_inner_router(api), binding) } async fn test_app(workspace_root: impl Into) -> Router { @@ -56225,8 +56519,6 @@ mod tests { operation: worker_runtime::resource::BackendResourceOperation::FetchArchive, expires_at_unix_seconds: 4_102_444_800, nonce: "missing-nonce".to_string(), - revision: "missing-revision".to_string(), - generation: None, max_bytes: worker_runtime::resource::DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES, content_type: worker_runtime::resource::PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), redaction: worker_runtime::resource::ResourceRedactionPolicy::RuntimeInternalOnly, @@ -56841,7 +57133,6 @@ mod tests { metadata: worker_runtime::config_bundle::ConfigBundleMetadata { id: "server-test-bundle".to_string(), digest: String::new(), - revision: "test".to_string(), workspace_id: "test".to_string(), created_at: "test".to_string(), provenance: worker_runtime::config_bundle::ConfigBundleProvenance { @@ -56964,7 +57255,6 @@ mod tests { Some(EMBEDDED_WORKER_RUNTIME_ID), None, &repository.source_fingerprint, - repository.source_revision, ); api.config_store .reserve_workdir_create_operation(&WorkdirCreateOperationRecord { @@ -56975,16 +57265,14 @@ mod tests { selector: Some("HEAD".to_string()), requested_runtime_id: Some(EMBEDDED_WORKER_RUNTIME_ID.to_string()), resolved_runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), - config_revision: 1, config_projection_digest: "sha256:test".to_string(), source_kind: Some("local_path".to_string()), source_uri: Some("/tmp/repo".to_string()), - source_revision: Some(1), source_fingerprint: Some("sha256:test".to_string()), credential_id: None, - credential_revision: None, + credential_fingerprint: None, host_trust_id: None, - host_trust_revision: None, + host_trust_fingerprint: None, repository_access_mode: None, credential_candidates: Vec::new(), working_directory_id: "workdir-provider-rejection".to_string(), @@ -57118,6 +57406,127 @@ mod tests { ); } + #[tokio::test] + async fn browser_workdir_create_replays_migrated_identity_and_rejects_changed_input() { + let dir = tempfile::tempdir().unwrap(); + init_clean_git_workspace(dir.path()); + let api = test_api(dir.path()).await; + let token = seed_test_api_token(api.store.as_ref(), "migrated-workdir-create-replay"); + let operation_id = "migrated-workdir-create"; + // Schema-86 hash of (test-repository, frozen-selector, None, + // Original Name, sha256:frozen-source), followed by source ordinal 9. + // Migration retains this evidence; display_name was never stored. + let legacy_identity = "workdir-create-v86:9:sha256:3b675cec7c31a4ce75c1d56a0c2e3b3b975567d459e9930db6aad78feab63ce8"; + let now = now_registry_timestamp(); + api.config_store + .reserve_workdir_create_operation(&WorkdirCreateOperationRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + operation_id: operation_id.to_string(), + request_fingerprint: legacy_identity.to_string(), + repository_id: test_repository_id(&api), + selector: Some("frozen-selector".to_string()), + requested_runtime_id: None, + resolved_runtime_id: "frozen-missing-runtime".to_string(), + config_projection_digest: "sha256:frozen-projection".to_string(), + source_kind: Some("local_path".to_string()), + source_uri: Some("/frozen/repository/source".to_string()), + source_fingerprint: Some("sha256:frozen-source".to_string()), + credential_id: None, + credential_fingerprint: None, + host_trust_id: None, + host_trust_fingerprint: None, + repository_access_mode: None, + credential_candidates: Vec::new(), + working_directory_id: "workdir-migrated-replay".to_string(), + state: "failed".to_string(), + failure: Some("legacy_failure".to_string()), + created_at: now.clone(), + updated_at: now, + }) + .unwrap(); + set_test_default_runtime(&api, EMBEDDED_WORKER_RUNTIME_ID); + let app = build_router(api.clone()); + let path = format!("/api/w/{TEST_WORKSPACE_ID}/working-directories"); + let original = serde_json::json!({ + "repository_key": "test-repository", + "operation_id": operation_id, + "display_name": "Original Name", + }); + // The omitted selector and changed current source/default Runtime must + // resolve from the persisted operation before verifying its identity. + for _ in 0..2 { + let response = request_json_authenticated( + app.clone(), + "POST", + &path, + Some(original.clone()), + &token, + StatusCode::NOT_FOUND, + ) + .await; + assert_eq!(response["diagnostics"][0]["code"], "runtime_unavailable"); + let operation = api + .config_store + .load_workdir_create_operation(TEST_WORKSPACE_ID, operation_id) + .unwrap() + .unwrap(); + assert_eq!(operation.request_fingerprint, legacy_identity); + assert_eq!(operation.state, "failed"); + assert_eq!(operation.failure.as_deref(), Some("runtime_unavailable")); + assert_eq!(operation.resolved_runtime_id, "frozen-missing-runtime"); + assert_eq!(operation.selector.as_deref(), Some("frozen-selector")); + assert_eq!( + operation.source_fingerprint.as_deref(), + Some("sha256:frozen-source") + ); + } + let before = api + .config_store + .load_workdir_create_operation(TEST_WORKSPACE_ID, operation_id) + .unwrap() + .unwrap(); + for (field, value) in [ + ("display_name", serde_json::Value::Null), + ("display_name", serde_json::json!("Changed Name")), + ("selector", serde_json::json!("changed-selector")), + ("runtime_id", serde_json::json!(EMBEDDED_WORKER_RUNTIME_ID)), + ] { + let mut changed = original.clone(); + changed[field] = value; + let response = request_json_authenticated( + app.clone(), + "POST", + &path, + Some(changed), + &token, + StatusCode::BAD_REQUEST, + ) + .await; + assert!( + response["message"] + .as_str() + .unwrap_or_default() + .contains("reused with different input"), + "{response}" + ); + let after = api + .config_store + .load_workdir_create_operation(TEST_WORKSPACE_ID, operation_id) + .unwrap() + .unwrap(); + assert_eq!(after.request_fingerprint, legacy_identity); + assert_eq!(after.state, before.state); + assert_eq!(after.failure, before.failure); + assert_eq!(after.updated_at, before.updated_at); + } + assert!( + api.store + .get_workdir_registry(TEST_WORKSPACE_ID, "workdir-migrated-replay") + .unwrap() + .is_none() + ); + } + #[tokio::test] async fn browser_workspace_workdir_create_rejects_stale_json_before_side_effects() { let dir = tempfile::tempdir().unwrap(); @@ -57196,6 +57605,12 @@ mod tests { let api = test_api(dir.path()).await; let token = seed_test_api_token(api.store.as_ref(), "failed-default-resolution"); set_test_default_runtime(&api, EMBEDDED_WORKER_RUNTIME_ID); + let original_projection_digest = api + .config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap() + .projection_digest; let app = build_router(api.clone()); let operation_id = "workdir-create-default-runtime"; @@ -57243,7 +57658,18 @@ mod tests { operation.failure.as_deref(), Some("embedded_worker_workdir_unsupported") ); - assert_eq!(operation.config_revision, 2); + assert_eq!( + operation.config_projection_digest, + original_projection_digest + ); + assert_ne!( + operation.config_projection_digest, + api.config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap() + .projection_digest + ); assert!(!operation.config_projection_digest.is_empty()); assert!( api.store @@ -57310,11 +57736,14 @@ mod tests { base_url: "https://runtime.example.invalid".to_string(), public_key: identity.public_key, public_key_fingerprint: String::new(), - binding_revision: 1, + binding_id: "binding-test".to_string(), state: StoredRuntimeBindingState::Verified, authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer, workspace_key_id: None, - workspace_key_generation: None, + + workspace_public_key_fingerprint: None, + + workspace_trust_id: None, created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -57323,6 +57752,12 @@ mod tests { .upsert_workspace_runtime_binding_record(binding.clone(), false) .await .unwrap(); + let binding = api + .store + .get_workspace_runtime_binding(TEST_WORKSPACE_ID, "team-runtime") + .await + .unwrap() + .unwrap(); let remote = remote_runtime_config_from_binding(&binding).unwrap(); api.runtime.register_or_replace( RemoteWorkerRuntime::new( @@ -57350,7 +57785,7 @@ mod tests { &format!("{runtimes_uri}/{EMBEDDED_WORKER_RUNTIME_ID}"), Some(serde_json::json!({ "operation_id": "remove-embedded", - "expected_binding_revision": 1 + "expected_binding_id": "binding-test" })), StatusCode::BAD_REQUEST, ) @@ -57374,7 +57809,8 @@ mod tests { "public_key": "" }, "display_name": "Keyless Runtime", - "endpoint": "https://8.8.8.8" + "endpoint": "https://8.8.8.8", + "workspace_trust_id": "trust-keyless-test" })), StatusCode::BAD_REQUEST, ) @@ -57421,7 +57857,7 @@ mod tests { let removal_request = serde_json::json!({ "operation_id": "remove-team-runtime", - "expected_binding_revision": 1 + "expected_binding_id": binding.binding_id }); let deleted = request_json( app.clone(), @@ -57462,7 +57898,7 @@ mod tests { async fn runtime_removal_config_and_revision_guards_preserve_active_trust() { let root = tempfile::tempdir().unwrap(); let api = test_api(root.path()).await; - register_test_runtime(&api, "guarded-runtime").await; + let original = register_test_runtime(&api, "guarded-runtime").await; let app = build_inner_router(api.clone()).layer(Extension(test_owner_actor())); let runtimes_uri = format!("/api/w/{TEST_WORKSPACE_ID}/runtimes"); @@ -57472,14 +57908,14 @@ mod tests { &format!("{runtimes_uri}/guarded-runtime"), Some(serde_json::json!({ "operation_id": "remove-guarded-stale", - "expected_binding_revision": 2 + "expected_binding_id": "binding-stale" })), StatusCode::CONFLICT, ) .await; assert!( - stale.to_string().contains("revision conflict"), - "unexpected stale-revision response: {stale}" + stale.to_string().contains("identity conflict"), + "unexpected stale-binding response: {stale}" ); let binding = api .store @@ -57487,7 +57923,7 @@ mod tests { .await .unwrap() .expect("stale removal must preserve binding"); - assert_eq!(binding.binding_revision, 1); + assert_eq!(binding.binding_id, original.binding_id); assert!(binding.revoked_at.is_none()); set_test_default_runtime(&api, "guarded-runtime"); @@ -57497,7 +57933,7 @@ mod tests { &format!("{runtimes_uri}/guarded-runtime"), Some(serde_json::json!({ "operation_id": "remove-guarded-referenced", - "expected_binding_revision": 1 + "expected_binding_id": original.binding_id })), StatusCode::CONFLICT, ) @@ -57514,30 +57950,97 @@ mod tests { .await .unwrap() .expect("config-blocked removal must preserve binding"); - assert_eq!(binding.binding_revision, 1); + assert_eq!(binding.binding_id, original.binding_id); assert!(binding.revoked_at.is_none()); } + #[tokio::test] + async fn runtime_removal_rejects_source_changes_even_when_projection_is_unchanged() { + let temp = tempfile::tempdir().unwrap(); + let api = test_api(temp.path()).await; + register_test_runtime(&api, "source-fenced-runtime").await; + let state = api + .config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(); + let binding = api + .store + .get_workspace_runtime_binding(TEST_WORKSPACE_ID, "source-fenced-runtime") + .await + .unwrap() + .unwrap(); + let operation = api + .store + .reserve_runtime_removal( + TEST_WORKSPACE_ID, + "source-fenced-runtime", + "remove-source-fenced-runtime", + &runtime_removal_fingerprint( + TEST_WORKSPACE_ID, + "source-fenced-runtime", + &binding.binding_id, + ), + &binding.binding_id, + &state.snapshot.digest, + ) + .await + .unwrap() + .operation; + runtime_removal_config_guard(&api, &operation).unwrap(); + let changed = commit_workspace_config_tree( + &api, + TEST_WORKSPACE_ID, + &crate::config_source::ConfigCommitRequest { + base_digest: state.snapshot.digest.clone(), + entrypoints: state.contract.entrypoints.clone(), + changes: vec![config_source::ConfigTreeChange::Create { + path: config_source::VirtualPath::parse("notes/removal-fence.txt").unwrap(), + content_type: config_source::ConfigContentType::Text, + content: "new source content, same runtime projection".to_string(), + }], + }, + ) + .unwrap(); + assert_ne!(changed.snapshot.digest, state.snapshot.digest); + assert_eq!(changed.projection_digest, state.projection_digest); + assert!( + matches!(runtime_removal_config_guard(&api, &operation), Err(Error::RuntimeBindingConflict(message)) if message == "runtime_removal_config_digest_changed") + ); + let retained = api + .store + .get_workspace_runtime_binding(TEST_WORKSPACE_ID, "source-fenced-runtime") + .await + .unwrap() + .unwrap(); + assert_eq!(retained.binding_id, binding.binding_id); + assert!(retained.revoked_at.is_none()); + } + #[tokio::test] async fn startup_recovers_runtime_removal_after_binding_checkpoint() { let root = tempfile::tempdir().unwrap(); let api = test_api(root.path()).await; - register_test_runtime(&api, "recover-runtime").await; - let config_revision = api + let binding = register_test_runtime(&api, "recover-runtime").await; + let config_digest = api .config_store .load_workspace_config(TEST_WORKSPACE_ID) .unwrap() .unwrap() .snapshot - .revision; + .digest; api.store .reserve_runtime_removal( TEST_WORKSPACE_ID, "recover-runtime", "remove-recover-runtime", - &runtime_removal_fingerprint(TEST_WORKSPACE_ID, "recover-runtime", 1), - 1, - config_revision, + &runtime_removal_fingerprint( + TEST_WORKSPACE_ID, + "recover-runtime", + &binding.binding_id, + ), + &binding.binding_id, + &config_digest, ) .await .unwrap(); @@ -57599,11 +58102,14 @@ mod tests { .unwrap() .public_key, public_key_fingerprint: String::new(), - binding_revision: 1, + binding_id: "binding-test".to_string(), state: StoredRuntimeBindingState::Verified, authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer, workspace_key_id: None, - workspace_key_generation: None, + + workspace_public_key_fingerprint: None, + + workspace_trust_id: None, created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -57612,6 +58118,12 @@ mod tests { .upsert_workspace_runtime_binding_record(binding.clone(), false) .await .unwrap(); + let binding = api + .store + .get_workspace_runtime_binding(TEST_WORKSPACE_ID, "busy-runtime") + .await + .unwrap() + .unwrap(); api.runtime.register_or_replace( RemoteWorkerRuntime::new( remote_runtime_config_from_binding(&binding).unwrap(), @@ -57633,7 +58145,7 @@ mod tests { &format!("/api/w/{TEST_WORKSPACE_ID}/runtimes/busy-runtime"), Some(serde_json::json!({ "operation_id": "remove-busy-runtime", - "expected_binding_revision": 1 + "expected_binding_id": binding.binding_id })), StatusCode::CONFLICT, ) @@ -57642,7 +58154,8 @@ mod tests { response["message"] .as_str() .unwrap() - .contains("runtime_removal_active_worker_blocked") + .contains("runtime_removal_active_worker_blocked"), + "unexpected active-worker removal response: {response}" ); let persisted = api .store @@ -57650,7 +58163,7 @@ mod tests { .await .unwrap() .expect("busy Runtime binding must remain after rejected removal"); - assert_eq!(persisted.binding_revision, 1); + assert_eq!(persisted.binding_id, binding.binding_id); assert!(persisted.revoked_at.is_none()); } @@ -57660,15 +58173,17 @@ mod tests { ) -> serde_json::Value { let (endpoint, _server) = runtime_ping_stub(status, body).await; let dir = tempfile::tempdir().unwrap(); - let app = test_app_with_remote_runtime(dir.path(), "probe-runtime", endpoint) - .await - .layer(Extension(test_owner_actor())); - post_json( + let (app, binding) = + test_app_with_remote_runtime(dir.path(), "probe-runtime", endpoint).await; + let app = app.layer(Extension(test_owner_actor())); + let response = post_json( app, &format!("/api/w/{TEST_WORKSPACE_ID}/runtimes/probe-runtime/connection-tests"), serde_json::json!({}), ) - .await + .await; + assert_eq!(response["binding_id"], binding.binding_id); + response } #[tokio::test] @@ -57702,7 +58217,6 @@ mod tests { .await; assert_eq!(response["status"], "compatible"); - assert_eq!(response["binding_revision"], 1); assert_eq!(response["connection_state"], "verified"); assert_eq!(response["verification"], serde_json::Value::Null); assert_eq!(response["failure_kind"], serde_json::Value::Null); @@ -58248,7 +58762,6 @@ mod tests { provider: Some(configured_repository.provider), source: configured_repository.source, default_ref: configured_repository.default_selector, - source_revision: configured_repository.source_revision, source_fingerprint: configured_repository.source_fingerprint, observed_status: configured_repository.observed_status, observed_at: configured_repository.observed_at, @@ -58452,10 +58965,7 @@ mod tests { .await; assert_eq!(scoped_objective["id"], "00000000001J3"); assert_eq!(scoped_objective["record_source"], "workspace-sqlite"); - assert_eq!( - scoped_objective["revision"].as_str().unwrap().is_empty(), - false - ); + assert_eq!(scoped_objective["body"], "Objective body.\n"); assert_eq!( scoped_objective["resources"][0]["path"], "memory-architecture-overview.md" @@ -58485,7 +58995,7 @@ mod tests { ) .await; assert!(shown_ticket["evidence"]["missing"].is_array()); - assert!(shown_ticket["item_revision"].as_str().is_some()); + assert!(shown_ticket["content_digest"].as_str().is_some()); let queried_objectives = request_json( app.clone(), "POST", @@ -59167,7 +59677,6 @@ mod tests { kind: server_api::RepositorySourceKind::LocalPath, uri: root.path().display().to_string(), }, - source_revision: 1, source_fingerprint: "sha256:test".to_string(), observed_status: server_api::RepositoryObservedStatus::Unverified, observed_at: None, @@ -60920,7 +61429,6 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); let app = test_app(dir.path()).await; let source_tree_path = format!("/api/w/{TEST_WORKSPACE_ID}/config/source-tree"); let initial = get_json(app.clone(), &source_tree_path).await; - let revision = initial["snapshot"]["revision"].as_u64().unwrap(); let digest = initial["snapshot"]["digest"].as_str().unwrap(); let committed = request_json( @@ -60928,7 +61436,6 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); "POST", &format!("{source_tree_path}/commit"), Some(json!({ - "base_revision": revision, "base_digest": digest, "changes": [{ "kind": "create", @@ -60941,14 +61448,14 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); StatusCode::CREATED, ) .await; - let committed_revision = committed["snapshot"]["revision"].as_u64().unwrap(); + let committed_digest = committed["snapshot"]["digest"].as_str().unwrap(); let revision_snapshot = get_json( app.clone(), - &format!("{source_tree_path}/revisions/{committed_revision}"), + &format!("{source_tree_path}/history/{committed_digest}"), ) .await; - assert_eq!(revision_snapshot["revision"], committed_revision); + assert_eq!(revision_snapshot["digest"], committed_digest); assert_eq!( revision_snapshot["entries"]["notes/readme.txt"]["content"], "nested entry" @@ -60977,7 +61484,7 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); let path = format!("/api/w/{TEST_WORKSPACE_ID}/settings/profiles"); let settings = get_json(app.clone(), &path).await; assert_eq!(settings["default_profile"], "builtin:companion"); - assert_eq!(settings["config_revision"], 1); + assert!(settings.get("config_revision").is_none()); assert!(settings["tree_digest"].as_str().is_some()); assert!(settings["projection_digest"].as_str().is_some()); assert!( @@ -61250,6 +61757,20 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); #[test] fn subjektiv_http_errors_emit_typed_retry_diagnostics() { for (error, code, status) in [ + ( + Error::from(subjektiv::api::OperationError::Conflict( + "change_conflict: Memory change was superseded".to_string(), + )), + "change_conflict", + StatusCode::CONFLICT, + ), + ( + subjektiv_store_error(crate::subjektiv::SubjektivError::SubjectBehaviorConflict { + subject_id: "subject-test".to_string(), + }), + "change_conflict", + StatusCode::CONFLICT, + ), ( Error::RepositoryConflict( "candidate_decision_conflict: candidate already resolved".to_string(), diff --git a/crates/workspace-server/src/server/subjektiv_job.rs b/crates/workspace-server/src/server/subjektiv_job.rs index f2a93075a..7be86dc41 100644 --- a/crates/workspace-server/src/server/subjektiv_job.rs +++ b/crates/workspace-server/src/server/subjektiv_job.rs @@ -31,7 +31,7 @@ pub(super) fn active_grant( || attempt.state != BackendJobAttemptState::Dispatched || attempt.worker.as_ref() != Some(worker) || job.current_attempt != attempt.attempt - || attempt.input_revision != job.request.input_revision + || attempt.input_digest != job.request.input_digest()? || chrono::DateTime::parse_from_rfc3339(&attempt.deadline_at) .map_err(|e| Error::Store(format!("invalid Job deadline: {e}")))? <= Utc::now() @@ -161,7 +161,7 @@ pub(super) fn dispatch( total_bytes: u64, ) -> ApiResult { let store = open_subjektiv_store(api)?; - let subject = store + store .subject(subject_id) .map_err(subjektiv_store_error)? .ok_or_else(|| Error::SubjektivSubjectNotFound(subject_id.into()))?; @@ -177,7 +177,6 @@ pub(super) fn dispatch( let request = BackendJobRequest { job_id: format!("subjektiv-consolidation:{}", Uuid::new_v4()), purpose: PURPOSE.into(), - input_revision: subject.store_revision.to_string(), input_ref: format!("subjektiv://{subject_id}/consolidation"), input: serde_json::json!({"subject_id": subject_id, "candidate_ids": candidate_ids}), instruction: "Resolve ONLY the immutable candidate_ids batch using MemoryStagingList/Read, confirmed Memory reads and MemoryApplyCandidate. Already resolved candidates are retained receipts, not work to repeat. Leave newly arriving candidates to a subsequent Job. When every batch candidate has a durable disposition, call SubmitBackendJobResult with {subject_id, candidate_ids}; its Host capability awaits bounded clean-context surface generation and adds the actual surface outcome before submission. Final prose is not completion.".into(), diff --git a/crates/workspace-server/src/server/tests/drive_api_tests.rs b/crates/workspace-server/src/server/tests/drive_api_tests.rs index 7849d5760..777219c7d 100644 --- a/crates/workspace-server/src/server/tests/drive_api_tests.rs +++ b/crates/workspace-server/src/server/tests/drive_api_tests.rs @@ -106,7 +106,7 @@ async fn drive_http_receipts_cas_stable_url_and_workspace_refs_follow_storage_au let create = json!({"operation":"create_text","parent":reference(&root),"name":"日本語.md","text":"# Shared\n","content_type":"text/markdown"}); let first = mutate(&api, &token, "create", create.clone(), StatusCode::OK).await; let id = first["entry"]["entry"]["node_id"].as_str().unwrap(); - let rev = first["entry"]["revision"].as_str().unwrap(); + let rev = first["entry"]["last_mutation_id"].as_str().unwrap(); assert_eq!( first, mutate(&api, &token, "create", create.clone(), StatusCode::OK).await, @@ -137,7 +137,7 @@ async fn drive_http_receipts_cas_stable_url_and_workspace_refs_follow_storage_au ) .await; assert_eq!(text["text"], "# Shared\n"); - let wrong=mutate(&api,&token,"foreign",json!({"operation":"delete","id":{"workspace_id":"foreign","node_id":id},"expected_revision":rev}),StatusCode::FORBIDDEN).await; + let wrong=mutate(&api,&token,"foreign",json!({"operation":"delete","id":{"workspace_id":"foreign","node_id":id},"expected_mutation_id":rev}),StatusCode::FORBIDDEN).await; assert_eq!(wrong["code"], "denied"); user_call( &api, @@ -156,19 +156,19 @@ async fn drive_http_receipts_cas_stable_url_and_workspace_refs_follow_storage_au StatusCode::BAD_REQUEST, ) .await; - let updated=mutate(&api,&token,"update",json!({"operation":"update_text","id":reference(id),"expected_revision":rev,"text":"new","content_type":"text/plain"}),StatusCode::OK).await; + let updated=mutate(&api,&token,"update",json!({"operation":"update_text","id":reference(id),"expected_mutation_id":rev,"text":"new","content_type":"text/plain"}),StatusCode::OK).await; for operation in [ - json!({"operation":"update_text","id":reference(id),"expected_revision":rev,"text":"lost","content_type":"text/plain"}), - json!({"operation":"delete","id":reference(id),"expected_revision":rev}), - json!({"operation":"relocate","id":reference(id),"expected_revision":rev,"parent":reference(&root),"name":"lost"}), + json!({"operation":"update_text","id":reference(id),"expected_mutation_id":rev,"text":"lost","content_type":"text/plain"}), + json!({"operation":"delete","id":reference(id),"expected_mutation_id":rev}), + json!({"operation":"relocate","id":reference(id),"expected_mutation_id":rev,"parent":reference(&root),"name":"lost"}), ] { mutate(&api, &token, "stale", operation, StatusCode::CONFLICT).await; } - user_call(&api,&token,"GET",&format!("{}/read-chunk?entry_workspace_id={TEST_WORKSPACE_ID}&id={id}&expected_revision={rev}&offset=0&length=1",base()),None,StatusCode::CONFLICT).await; - let renamed=mutate(&api,&token,"rename",json!({"operation":"relocate","id":reference(id),"expected_revision":updated["entry"]["revision"],"parent":reference(&root),"name":"renamed.md"}),StatusCode::OK).await; + user_call(&api,&token,"GET",&format!("{}/read-chunk?entry_workspace_id={TEST_WORKSPACE_ID}&id={id}&expected_mutation_id={rev}&offset=0&length=1",base()),None,StatusCode::CONFLICT).await; + let renamed=mutate(&api,&token,"rename",json!({"operation":"relocate","id":reference(id),"expected_mutation_id":updated["entry"]["last_mutation_id"],"parent":reference(&root),"name":"renamed.md"}),StatusCode::OK).await; // URL is independent of name/parent; metadata lookup still resolves the same ID. assert_eq!(renamed["entry"]["entry"], first["entry"]["entry"]); - let deleted=mutate(&api,&token,"delete",json!({"operation":"delete","id":reference(id),"expected_revision":renamed["entry"]["revision"]}),StatusCode::OK).await; + let deleted=mutate(&api,&token,"delete",json!({"operation":"delete","id":reference(id),"expected_mutation_id":renamed["entry"]["last_mutation_id"]}),StatusCode::OK).await; assert!(deleted["entry"].is_null()); user_call( &api, @@ -536,7 +536,7 @@ async fn drive_generated_client_roundtrips_binary_and_declared_headers_over_real parent_id: Some(root.entry.node_id.clone()), name: Some(request.into()), id: None, - expected_revision: None, + expected_mutation_id: None, content_type: media.into(), size: bytes.len() as u32, sha256: digest(bytes), @@ -552,7 +552,7 @@ async fn drive_generated_client_roundtrips_binary_and_declared_headers_over_real DriveDownloadQuery { entry_workspace_id: workspace.clone(), id: saved.entry.node_id.clone(), - expected_revision: Some(saved.revision.clone()), + expected_mutation_id: Some(saved.last_mutation_id.clone()), }, ) .await @@ -574,7 +574,7 @@ async fn drive_generated_client_roundtrips_binary_and_declared_headers_over_real DriveReadChunkQuery { entry_workspace_id: workspace.clone(), id: saved.entry.node_id, - expected_revision: saved.revision, + expected_mutation_id: saved.last_mutation_id, offset: 0, length: 65536, }, @@ -770,7 +770,7 @@ async fn drive_read_entrypoints_use_current_grants_and_cannot_fallback_to_browse let root = root_id(&api, &owner).await; let saved=mutate(&api,&owner,"matrix-file",json!({"operation":"create_text","parent":reference(&root),"name":"matrix","text":"bytes","content_type":"text/plain"}),StatusCode::OK).await; let id = saved["entry"]["entry"]["node_id"].as_str().unwrap(); - let rev = saved["entry"]["revision"].as_str().unwrap(); + let rev = saved["entry"]["last_mutation_id"].as_str().unwrap(); let (_runtime, workers) = workers(&api).await; let identity = RuntimeIdentityMaterial::generate(&workers[0].runtime_id).unwrap(); configure_runtime_request_auth(&mut api, &identity, &workers[0].runtime_id); @@ -790,7 +790,7 @@ async fn drive_read_entrypoints_use_current_grants_and_cannot_fallback_to_browse base() ), format!( - "{}/read-chunk?entry_workspace_id={TEST_WORKSPACE_ID}&id={id}&expected_revision={rev}&offset=0&length=4", + "{}/read-chunk?entry_workspace_id={TEST_WORKSPACE_ID}&id={id}&expected_mutation_id={rev}&offset=0&length=4", base() ), format!( @@ -885,9 +885,9 @@ async fn drive_concurrent_http_cas_has_one_winner_and_name_search_pages_are_scop let root = root_id(&api, &token).await; let saved=mutate(&api,&token,"concurrent-file",json!({"operation":"create_text","parent":reference(&root),"name":"candidate","text":"find me","content_type":"text/plain"}),StatusCode::OK).await; let id = &saved["entry"]["entry"]["node_id"]; - let rev = &saved["entry"]["revision"]; + let rev = &saved["entry"]["last_mutation_id"]; let request = |identity: &str, text: &str| { - Request::builder().method("POST").uri(format!("{}/mutate",base())).header("authorization",format!("Bearer {token}")).header(CONTENT_TYPE,"application/json").body(Body::from(json!({"request_id":identity,"mutation":{"operation":"update_text","id":reference(id.as_str().unwrap()),"expected_revision":rev,"text":text,"content_type":"text/plain"}}).to_string())).unwrap() + Request::builder().method("POST").uri(format!("{}/mutate",base())).header("authorization",format!("Bearer {token}")).header(CONTENT_TYPE,"application/json").body(Body::from(json!({"request_id":identity,"mutation":{"operation":"update_text","id":reference(id.as_str().unwrap()),"expected_mutation_id":rev,"text":text,"content_type":"text/plain"}}).to_string())).unwrap() }; let app = build_router(api.clone()); let (a, b) = tokio::join!( @@ -955,7 +955,7 @@ async fn drive_concurrent_http_cas_has_one_winner_and_name_search_pages_are_scop } #[tokio::test] -async fn drive_download_stream_expires_instead_of_mixing_revisions() { +async fn drive_download_stream_expires_instead_of_mixing_last_mutation_ids() { let temp = tempfile::tempdir().unwrap(); let api = test_api(temp.path()).await; let token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); @@ -981,7 +981,7 @@ async fn drive_download_stream_expires_instead_of_mixing_revisions() { .unwrap(); let saved = response_json(response, StatusCode::OK).await; let id = saved["entry"]["entry"]["node_id"].as_str().unwrap(); - let rev = saved["entry"]["revision"].as_str().unwrap(); + let rev = saved["entry"]["last_mutation_id"].as_str().unwrap(); let response = build_router(api.clone()) .oneshot( Request::builder() @@ -996,7 +996,7 @@ async fn drive_download_stream_expires_instead_of_mixing_revisions() { .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); - mutate(&api,&token,"stream-update",json!({"operation":"update_text","id":reference(id),"expected_revision":rev,"text":"generation b","content_type":"text/plain"}),StatusCode::OK).await; + mutate(&api,&token,"stream-update",json!({"operation":"update_text","id":reference(id),"expected_mutation_id":rev,"text":"generation b","content_type":"text/plain"}),StatusCode::OK).await; let mut stream = response.into_body().into_data_stream(); let first = stream.next().await.unwrap().unwrap(); assert_eq!(first.as_ref(), vec![b'a'; 64 * 1024]); diff --git a/crates/workspace-server/src/server/tests/drive_tests.rs b/crates/workspace-server/src/server/tests/drive_tests.rs index 921a752f3..1d05ff7db 100644 --- a/crates/workspace-server/src/server/tests/drive_tests.rs +++ b/crates/workspace-server/src/server/tests/drive_tests.rs @@ -57,7 +57,7 @@ async fn cached_drive_handle_rejects_mutation_after_deletion_reservation() { &config.workspace_id, &WorkspaceDeletionRequest { operation_id: "delete-cached-drive".into(), - expected_revision: preflight.expected_revision, + expected_workspace_updated_at: preflight.expected_workspace_updated_at, confirmation: preflight.display_name, }, ) @@ -137,7 +137,7 @@ async fn deleting_workspace_reopens_fenced_drive_and_purges_blobs_before_feature &config.workspace_id, &WorkspaceDeletionRequest { operation_id: "delete-drive-workspace".into(), - expected_revision: preflight.expected_revision, + expected_workspace_updated_at: preflight.expected_workspace_updated_at, confirmation: preflight.display_name, }, ) diff --git a/crates/workspace-server/src/server/tests/drive_web_roundtrip_tests.rs b/crates/workspace-server/src/server/tests/drive_web_roundtrip_tests.rs index 2a97dc16c..e267a77ca 100644 --- a/crates/workspace-server/src/server/tests/drive_web_roundtrip_tests.rs +++ b/crates/workspace-server/src/server/tests/drive_web_roundtrip_tests.rs @@ -110,11 +110,12 @@ impl RealDriveFixture { base_url: "https://runtime.example.invalid".into(), public_key: identity.public_key.clone(), public_key_fingerprint: String::new(), - binding_revision: 1, + binding_id: "binding-drive-test".to_string(), state: StoredRuntimeBindingState::Verified, authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer, workspace_key_id: None, - workspace_key_generation: None, + workspace_public_key_fingerprint: None, + workspace_trust_id: None, created_at: "1".into(), updated_at: "1".into(), revoked_at: None, 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 6905bfe58..adc0d79ab 100644 --- a/crates/workspace-server/src/server/tests/subjektiv_jobs_tests.rs +++ b/crates/workspace-server/src/server/tests/subjektiv_jobs_tests.rs @@ -1,6 +1,6 @@ use super::*; use crate::subjektiv::{ - CandidateDecision, CandidateDecisionRequest, MemoryDraft, MemoryRevisionTarget, SubjectRole, + CandidateDecision, CandidateDecisionRequest, MemoryChangeTarget, MemoryDraft, SubjectRole, SubjectStagingRecord, SurfaceAvailability, }; use memory::extract::{CandidateKind, ExtractedCandidate, StagingEvidence, StagingRecord}; @@ -16,7 +16,7 @@ fn stage(store: &crate::subjektiv::SubjektivStore, subject: &str, id: &str) { worker_id: None, flow_selector: None, flow_definition_id: None, - flow_definition_revision: None, + flow_definition_fingerprint: None, }; let record = StagingRecord::from_candidate( id, @@ -173,7 +173,7 @@ async fn subjektiv_job_bounds_batch_auth_partial_receipts_surface_and_cleanup() candidate_id: "candidate-first".into(), reason: "retain the learned constraint".into(), decision: CandidateDecision::Apply { - target: MemoryRevisionTarget::Create, + target: MemoryChangeTarget::Create, draft: MemoryDraft::active( CandidateKind::Constraint, "Keep committed memory", @@ -191,7 +191,7 @@ async fn subjektiv_job_bounds_batch_auth_partial_receipts_surface_and_cleanup() receipt.memory.as_ref().unwrap().id, replay.memory.as_ref().unwrap().id ); - assert_eq!(receipt.store_revision, replay.store_revision); + assert_eq!(receipt.memory_fingerprint, replay.memory_fingerprint); let generation = store .prepare_job_surface_generation( &subject.id, @@ -211,13 +211,13 @@ async fn subjektiv_job_bounds_batch_auth_partial_receipts_surface_and_cleanup() .await .unwrap(); assert!( - matches!(failed, server_api::SubjektivMemoryBackendResponse::SurfaceFailed(output) if output.status == "failed_confirmed" && output.store_revision == generation.store_revision) + matches!(failed, server_api::SubjektivMemoryBackendResponse::SurfaceFailed(output) if output.status == "failed_confirmed" && output.memory_fingerprint == generation.memory_fingerprint) ); - let result = serde_json::json!({"subject_id": subject.id, "candidate_ids": ["candidate-first"], "surface": {"availability":"failed", "generation_id":generation.id, "store_revision":generation.store_revision,"reason_code":"editor_failed"}}); + let result = serde_json::json!({"subject_id": subject.id, "candidate_ids": ["candidate-first"], "surface": {"availability":"failed", "generation_id":generation.id, "memory_fingerprint":generation.memory_fingerprint,"reason_code":"editor_failed"}}); let submission = BackendJobResultSubmission { job_id: job.request.job_id.clone(), attempt_id: attempt.attempt_id.clone(), - input_revision: job.request.input_revision.clone(), + input_digest: job.request.input_digest().unwrap(), result, }; assert!( @@ -359,8 +359,8 @@ async fn subjektiv_job_surface_only_requires_attempt_owned_generation_and_real_o let submission = BackendJobResultSubmission { job_id: job.request.job_id.clone(), attempt_id: attempt.attempt_id.clone(), - input_revision: job.request.input_revision.clone(), - result: serde_json::json!({"subject_id":subject.id,"candidate_ids":[],"surface":{"availability":"ready","generation_id":generation.id,"store_revision":generation.store_revision,"snapshot_id":snapshot.id}}), + input_digest: job.request.input_digest().unwrap(), + result: serde_json::json!({"subject_id":subject.id,"candidate_ids":[],"surface":{"availability":"ready","generation_id":generation.id,"memory_fingerprint":generation.memory_fingerprint,"snapshot_id":snapshot.id}}), }; api.accept_backend_job_result(worker, &submission).unwrap(); wait_for_backend_job_worker_cleanup( @@ -395,7 +395,6 @@ async fn backend_job_profile_resolution_rejects_ungranted_requirements_without_f let mut request = BackendJobRequest { job_id: "profile-resolution".into(), purpose: "any-purpose".into(), - input_revision: "1".into(), input_ref: "test://profiles".into(), input: serde_json::json!({}), instruction: "return structured result".into(), @@ -450,7 +449,7 @@ async fn backend_job_project_recipe_is_resolved_and_delivered_without_system_ove let entrypoint = state.contract.entrypoints.first().unwrap(); let root = state.snapshot.entries.get(entrypoint).unwrap(); let config = config_commit_request_from_api(server_api::ConfigCommitRequest { - base_revision: state.snapshot.revision, base_digest: state.snapshot.digest.clone(), + base_digest: state.snapshot.digest.clone(), entrypoints: state.contract.entrypoints.iter().map(|p| p.as_str().into()).collect(), changes: vec![server_api::ConfigTreeChange::Update { path: entrypoint.as_str().into(), expected_digest: root.content_digest.clone(), @@ -464,7 +463,6 @@ async fn backend_job_project_recipe_is_resolved_and_delivered_without_system_ove let request = BackendJobRequest { job_id: "project-recipe-job".into(), purpose: "no-profile-switch".into(), - input_revision: "r1".into(), input_ref: "test://recipe".into(), input: serde_json::json!({}), instruction: "Submit the result".into(), @@ -671,8 +669,8 @@ async fn subjektiv_job_long_receipts_and_escaped_ids_fit_result_budget_without_l let submission = BackendJobResultSubmission { job_id: job.request.job_id.clone(), attempt_id: attempt.attempt_id.clone(), - input_revision: job.request.input_revision.clone(), - result: serde_json::json!({"subject_id":subject.id,"candidate_ids":ids,"surface":{"availability":"ready","generation_id":generation.id,"store_revision":generation.store_revision,"snapshot_id":snapshot.id}}), + input_digest: job.request.input_digest().unwrap(), + result: serde_json::json!({"subject_id":subject.id,"candidate_ids":ids,"surface":{"availability":"ready","generation_id":generation.id,"memory_fingerprint":generation.memory_fingerprint,"snapshot_id":snapshot.id}}), }; seed_worker_session_for_cleanup(dir.path(), worker); let accepted = api.accept_backend_job_result(worker, &submission).unwrap(); diff --git a/crates/workspace-server/src/server/tests/ticket_evidence_tests.rs b/crates/workspace-server/src/server/tests/ticket_evidence_tests.rs index cc34cb599..0993681e0 100644 --- a/crates/workspace-server/src/server/tests/ticket_evidence_tests.rs +++ b/crates/workspace-server/src/server/tests/ticket_evidence_tests.rs @@ -122,7 +122,7 @@ impl EvidenceApiFixture { } fn revision(&self) -> String { - ticket_item_checker::item_revision( + ticket_item_checker::content_digest( &self.backend.show(self.ticket_id.clone().into()).unwrap(), ) } @@ -150,7 +150,7 @@ impl EvidenceApiFixture { .request_review(RequestMergeRequestReview { merge_request_id: EVIDENCE_MR.into(), ticket_id: self.ticket_id.clone(), - ticket_item_revision: self.revision(), + ticket_content_digest: self.revision(), ticket_merge_request_subjects: self.subjects(), subject_ref: EVIDENCE_SOURCE.into(), child_session_id, @@ -194,7 +194,7 @@ impl EvidenceApiFixture { fn completion(&self, _approval: &ReviewEvent) -> ticket::TicketCompletion { ticket::TicketCompletion { operation_key: "evidence-ticket-completion".into(), - expected_item_revision: self.revision(), + expected_content_digest: self.revision(), expected_state: TicketWorkflowState::InProgress, reason: "Implementation judged complete independently of MR attestation".into(), references: Vec::new(), @@ -247,7 +247,7 @@ impl EvidenceApiFixture { assert_eq!(ids, expected, "{filter}"); if matches { let shown = self.show().await; - assert_eq!(result.items[0].item_revision, shown.item_revision); + assert_eq!(result.items[0].content_digest, shown.content_digest); assert_eq!(result.items[0].evidence, shown.evidence); assert_eq!(result.items[0].merge_requests, shown.merge_requests); } @@ -306,7 +306,7 @@ async fn done_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_ .backend .complete(&fixture.ticket_id, fixture.completion(&approval)) .unwrap(); - assert_eq!(ticket::ticket_item_revision(&completion), revision); + assert_eq!(ticket::ticket_content_digest(&completion), revision); assert!( fixture .api @@ -326,7 +326,7 @@ async fn done_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_ let shown = fixture.show().await; assert_eq!(shown.state, "done"); - assert_eq!(shown.item_revision, revision); + assert_eq!(shown.content_digest, revision); assert!( shown.evidence.complete_for_integration, "{:?}", @@ -366,9 +366,9 @@ async fn done_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_ assert!(empty.merge_request.thread.is_empty()); for detail in [first, empty] { assert_eq!(detail.source.status, "known"); - assert_eq!(detail.source.revision_ref.as_deref(), Some(EVIDENCE_SOURCE)); + assert_eq!(detail.source.resolved_ref.as_deref(), Some(EVIDENCE_SOURCE)); assert_eq!(detail.target.status, "known"); - assert_eq!(detail.target.revision_ref.as_deref(), Some(EVIDENCE_TARGET)); + assert_eq!(detail.target.resolved_ref.as_deref(), Some(EVIDENCE_TARGET)); assert_eq!(detail.source.observed_at, merge.created_at.to_rfc3339()); assert_eq!(detail.source.diagnostic, None); assert_eq!(detail.target.diagnostic, None); @@ -382,7 +382,7 @@ async fn latest_postmerge_approval_requires_exact_revision_and_stored_source_res let merge = fixture.integrate(&integration); fixture.rescope(); let revision = fixture.revision(); - assert_ne!(integration.ticket_item_revision, revision); + assert_ne!(integration.ticket_content_digest, revision); let stale = fixture.show().await; assert!(stale.evidence.approved_current_subject); // integration proof survives @@ -396,7 +396,7 @@ async fn latest_postmerge_approval_requires_exact_revision_and_stored_source_res ); fixture.assert_filters(false, true, true).await; let mut outdated_completion = fixture.completion(&integration); - outdated_completion.expected_item_revision = integration.ticket_item_revision.clone(); + outdated_completion.expected_content_digest = integration.ticket_content_digest.clone(); assert!( fixture .backend @@ -407,7 +407,7 @@ async fn latest_postmerge_approval_requires_exact_revision_and_stored_source_res let latest = fixture.approve("latest-revision"); assert_eq!(latest.created_at, integration.created_at); - assert_eq!(latest.ticket_item_revision, revision); + assert_eq!(latest.ticket_content_digest, revision); assert_eq!(latest.ticket_merge_request_subjects, fixture.subjects()); let fresh = fixture.show().await; assert!( @@ -484,13 +484,13 @@ async fn latest_postmerge_approval_requires_exact_revision_and_stored_source_res assert_eq!(stored.merged_result().unwrap(), &merge); let detail = fixture.detail(repaired.sequence - 1, 1).await; assert_eq!(detail.merge_request.thread.len(), 1); - assert_eq!(detail.source.revision_ref.as_deref(), Some(EVIDENCE_SOURCE)); - assert_eq!(detail.target.revision_ref.as_deref(), Some(EVIDENCE_TARGET)); + assert_eq!(detail.source.resolved_ref.as_deref(), Some(EVIDENCE_SOURCE)); + assert_eq!(detail.target.resolved_ref.as_deref(), Some(EVIDENCE_TARGET)); let completion = fixture .backend .complete(&fixture.ticket_id, fixture.completion(&repaired)) .unwrap(); - assert_eq!(ticket::ticket_item_revision(&completion), revision); + assert_eq!(ticket::ticket_content_digest(&completion), revision); assert_eq!(fixture.show().await.state, "done"); fixture.assert_filters(true, false, false).await; } @@ -560,7 +560,7 @@ async fn open_source_without_runtime_is_typed_unavailable_not_stale_after_rescop let detail = fixture.detail(approval.sequence, 1).await; assert!(detail.merge_request.thread.is_empty()); assert_eq!(detail.source.status, "unknown"); - assert_eq!(detail.source.revision_ref, None); + assert_eq!(detail.source.resolved_ref, None); assert_eq!( detail.source.diagnostic.unwrap().code, "source_ref_runtime_unavailable" @@ -649,7 +649,7 @@ async fn closing_ticket_with_open_unreviewed_mr_does_not_change_its_proof_or_sta &fixture.ticket_id, ticket::TicketStateUpdate { operation_key: "close-with-open-mr".into(), - expected_item_revision: fixture.revision(), + expected_content_digest: fixture.revision(), expected_state: TicketWorkflowState::InProgress, state: TicketWorkflowState::Closed, reason: "The request was withdrawn; no integration or approval is asserted".into(), @@ -686,7 +686,7 @@ async fn mrless_completion_is_a_ticket_judgment_not_approved_or_missing_merge_ev &reference.id, ticket::TicketCompletion { operation_key: "research-result".into(), - expected_item_revision: ticket::ticket_item_revision(&ticket), + expected_content_digest: ticket::ticket_content_digest(&ticket), expected_state: TicketWorkflowState::Planning, reason: "Answer recorded in the Ticket thread".into(), references: Vec::new(), @@ -760,7 +760,7 @@ async fn all_public_state_and_close_routes_require_explicit_cas_and_operation_re ) .await; let close = json!({ - "operation_key":"guarded-close","expected_item_revision":ticket::ticket_item_revision(&before), + "operation_key":"guarded-close","expected_content_digest":ticket::ticket_content_digest(&before), "expected_state":"planning","reason":"No further work required","author":"spoofed-client-author" }); let response = app @@ -786,7 +786,7 @@ async fn all_public_state_and_close_routes_require_explicit_cas_and_operation_re Some(&"guarded-close".to_string()) ); let reopen = json!({ - "operation_key":"reopen","expected_item_revision":ticket::ticket_item_revision(&closed), + "operation_key":"reopen","expected_content_digest":ticket::ticket_content_digest(&closed), "expected_state":"closed","state":"planning","reason":"A new question arrived" }); let response = app diff --git a/crates/workspace-server/src/server/tests/ticket_worker_claim_tests.rs b/crates/workspace-server/src/server/tests/ticket_worker_claim_tests.rs index fdd75f486..26bff24e5 100644 --- a/crates/workspace-server/src/server/tests/ticket_worker_claim_tests.rs +++ b/crates/workspace-server/src/server/tests/ticket_worker_claim_tests.rs @@ -201,13 +201,13 @@ async fn ambiguous_existing_worker_restore_keeps_claim_pending_until_accepted_re .unwrap() .unwrap(); assert_eq!( - recovery.item_revision, + recovery.content_digest, fixture .api .authority .ticket(&fixture.ticket_id) .unwrap() - .item_revision + .content_digest ); assert_eq!(recovery.selected.len(), request.workdir_bindings.len()); for selected in &request.workdir_bindings { @@ -458,7 +458,7 @@ async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits ), ) .unwrap(); - let admitted_revision = api.authority.ticket(&ticket_id).unwrap().item_revision; + let admitted_revision = api.authority.ticket(&ticket_id).unwrap().content_digest; let request = server_api::SetTicketRoleAssignmentRequest { operation_id: "claim-runtime-reconciliation".into(), principal: server_api::TicketAssignmentPrincipal::Worker { @@ -515,7 +515,7 @@ async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits .get_ticket_claim_recovery(TEST_WORKSPACE_ID, &request.operation_id) .unwrap() .unwrap(); - assert_eq!(recovery.item_revision, admitted_revision); + assert_eq!(recovery.content_digest, admitted_revision); assert!(recovery.selected.is_empty()); assert!(recovery.original_links.is_empty()); assert!(recovery.effective_links.is_empty()); @@ -571,7 +571,7 @@ async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits pending.request_fingerprint ); assert_eq!( - api.authority.ticket(&ticket_id).unwrap().item_revision, + api.authority.ticket(&ticket_id).unwrap().content_digest, admitted_revision ); assert_eq!( diff --git a/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs b/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs index 548c12632..0a267d1b7 100644 --- a/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs +++ b/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs @@ -183,8 +183,7 @@ impl LaunchFixture { .public_key_fingerprint .clone() .unwrap(), - identity_revision: workspace_identity.revision, - trust_generation: workspace_identity.revision, + trust_id: "trust-launch-test".to_string(), state: WorkspaceIssuerTrustState::Active, registered_at_unix: 1, updated_at_unix: 1, @@ -206,11 +205,12 @@ impl LaunchFixture { base_url: endpoint.clone(), public_key: runtime_identity.public_key.clone(), public_key_fingerprint: signer.public_key_fingerprint().to_string(), - binding_revision: 1, + binding_id: "binding-launch-test".to_string(), state: StoredRuntimeBindingState::Configured, authentication_mode: StoredRuntimeAuthenticationMode::WorkspaceIdentity, workspace_key_id: Some(workspace_identity.key_id), - workspace_key_generation: Some(workspace_identity.revision), + workspace_public_key_fingerprint: workspace_identity.public_key_fingerprint.clone(), + workspace_trust_id: Some("trust-launch-test".to_string()), created_at: TEST_CREATED_AT.into(), updated_at: TEST_CREATED_AT.into(), revoked_at: None, @@ -221,6 +221,13 @@ impl LaunchFixture { .upsert_workspace_runtime_binding_record(binding.clone(), false) .await .unwrap(); + let binding = fixture + .api + .store + .get_workspace_runtime_binding(TEST_WORKSPACE_ID, &fixture.runtime_id) + .await + .unwrap() + .unwrap(); fixture.runtime_server = Some(tokio::spawn(async move { worker_runtime::http_server::serve_runtime_http_with_workspace_auth( runtime, @@ -471,7 +478,6 @@ async fn worker_launch_uses_published_project_profile_and_workspace_default_on_r .unwrap(); let main = config_source::VirtualPath::parse("main.dcdl").unwrap(); let request = crate::config_source::ConfigCommitRequest { - base_revision: state.snapshot.revision, base_digest: state.snapshot.digest.clone(), entrypoints: state.contract.entrypoints.clone(), changes: vec![config_source::ConfigTreeChange::Update { @@ -1025,7 +1031,7 @@ async fn choosing_reviewer_profile_does_not_register_a_reviewer_child_or_grant_m .request_review(merge_request::RequestMergeRequestReview { merge_request_id: mr_id.into(), ticket_id: ticket_id.clone(), - ticket_item_revision: ticket_item_checker::item_revision( + ticket_content_digest: ticket_item_checker::content_digest( &browser_ticket_backend(&fixture.api) .unwrap() .show(ticket_id.clone().into()) diff --git a/crates/workspace-server/src/server/tests/value_profiles_tests.rs b/crates/workspace-server/src/server/tests/value_profiles_tests.rs index 8a4a7d307..df5489aba 100644 --- a/crates/workspace-server/src/server/tests/value_profiles_tests.rs +++ b/crates/workspace-server/src/server/tests/value_profiles_tests.rs @@ -31,7 +31,6 @@ fn request( }]; changes.extend(extra); ConfigCommitRequest { - base_revision: state.snapshot.revision, base_digest: state.snapshot.digest, entrypoints: state.contract.entrypoints, changes, @@ -75,10 +74,7 @@ async fn value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_ba StatusCode::CREATED, ) .await; - assert_eq!( - saved["snapshot"]["revision"].as_u64().unwrap(), - initial["snapshot"]["revision"].as_u64().unwrap() + 1 - ); + assert_ne!(saved["snapshot"]["digest"], initial["snapshot"]["digest"]); let listed = get_json_authenticated(app.clone(), &tree, &token).await; assert_eq!(listed, saved); let entries = listed["snapshot"]["entries"].as_object().unwrap(); @@ -108,7 +104,6 @@ async fn value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_ba .unwrap(); let main_digest = entries["main.dcdl"]["content_digest"].as_str().unwrap(); let base = json!({ - "base_revision": listed["snapshot"]["revision"], "base_digest": listed["snapshot"]["digest"], "entrypoints": listed["contract"]["entrypoints"], }); @@ -326,7 +321,7 @@ async fn value_profiles_builtin_companion_composition_seals_observed_sources() { } #[tokio::test] -async fn value_profiles_save_project_and_runtime_consume_the_same_revision() { +async fn value_profiles_save_project_and_runtime_consume_the_same_content() { let dir = tempfile::tempdir().unwrap(); let api = test_api(dir.path()).await; let forms = [ @@ -390,8 +385,8 @@ async fn value_profiles_save_project_and_runtime_consume_the_same_revision() { Some("project:alpha") ); assert_eq!( - projection.settings.config_revision, - Some(state.snapshot.revision) + projection.settings.tree_digest.as_deref(), + Some(state.snapshot.digest.as_str()) ); let selected = projection .settings @@ -413,10 +408,7 @@ async fn value_profiles_save_project_and_runtime_consume_the_same_revision() { .unwrap() .unwrap(); assert_eq!(bundle.metadata.digest, bundle.computed_digest()); - assert_eq!( - bundle.metadata.revision, - state.snapshot.revision.to_string() - ); + let archive = bundle.profile_source_archive.as_ref().unwrap(); let verified = archive.verify().unwrap(); // This is the Runtime's archive consumer. It needs neither the Workspace tree nor imports. @@ -454,7 +446,7 @@ async fn value_profiles_save_project_and_runtime_consume_the_same_revision() { } if index == 2 { let mut other_state = state.clone(); - other_state.snapshot.revision += 1; + other_state.snapshot.digest = "sha256:different-tree".to_string(); assert!( build_virtual_profile_config_bundle( &projection, diff --git a/crates/workspace-server/src/server/tests/worker_drive_tests.rs b/crates/workspace-server/src/server/tests/worker_drive_tests.rs index 4bcd6af78..2b24e45a5 100644 --- a/crates/workspace-server/src/server/tests/worker_drive_tests.rs +++ b/crates/workspace-server/src/server/tests/worker_drive_tests.rs @@ -393,8 +393,8 @@ async fn workdirless_workers_share_markdown_through_current_read_and_write_grant .unwrap(), ); assert_ne!( - updated["entry"]["metadata"]["revision"], - created["entry"]["metadata"]["revision"] + updated["entry"]["metadata"]["last_mutation_id"], + created["entry"]["metadata"]["last_mutation_id"] ); assert_eq!( value( @@ -416,7 +416,8 @@ async fn workdirless_workers_share_markdown_through_current_read_and_write_grant } #[tokio::test] -async fn simultaneous_worker_edits_publish_exactly_one_revision_and_preserve_the_loser_conflict() { +async fn simultaneous_worker_edits_publish_exactly_one_last_mutation_id_and_preserve_the_loser_conflict() + { let f = Fixture::new(["read_write", "read_write"]).await; let id = entry(&f.create("simultaneous.md", "original").await); for feature in &f.features { @@ -456,7 +457,7 @@ async fn simultaneous_worker_edits_publish_exactly_one_revision_and_preserve_the } #[tokio::test] -async fn stale_worker_observations_cannot_write_edit_relocate_or_delete_newer_revision() { +async fn stale_worker_observations_cannot_write_edit_relocate_or_delete_newer_last_mutation_id() { let f = Fixture::new(["read_write", "read_write"]).await; let id = entry(&f.create("cas.md", "old").await); let root = f.root(0).await; @@ -914,8 +915,8 @@ async fn lost_mutation_response_is_resolved_by_receipt_without_resending_effect( let listed = value(tool(&f.features[1], "DriveList", json!({})).await.unwrap()); assert_eq!(listed["entries"].as_array().unwrap().len(), 1); assert_eq!( - listed["entries"][0]["metadata"]["revision"], - created["entry"]["metadata"]["revision"] + listed["entries"][0]["metadata"]["last_mutation_id"], + created["entry"]["metadata"]["last_mutation_id"] ); } @@ -954,8 +955,8 @@ async fn scoped_workdir_import_publishes_document_and_image_bytes_with_bounded_r .append_pair("entry_workspace_id", TEST_WORKSPACE_ID) .append_pair("id", id["node_id"].as_str().unwrap()) .append_pair( - "expected_revision", - metadata["metadata"]["revision"].as_str().unwrap(), + "expected_mutation_id", + metadata["metadata"]["last_mutation_id"].as_str().unwrap(), ) .append_pair("offset", "0") .append_pair("length", "17") @@ -1271,7 +1272,7 @@ async fn native_tree_does_not_index_entries_but_direct_inspect_and_invoke_use_cu } #[tokio::test] -async fn native_create_and_write_use_client_managed_revisions_and_reject_stale_worker() { +async fn native_create_and_write_use_client_managed_committed_requests_and_reject_stale_worker() { let f = Fixture::new(["read_write", "read_write"]).await; let first = native_tools(&f.features[0]); let second = native_tools(&f.features[1]); @@ -1294,8 +1295,8 @@ async fn native_create_and_write_use_client_managed_revisions_and_reject_stale_w ); let updated = value(native_tool(&first, "Invoke", json!({"path":path,"interface":observed_first["interfaces"][0]["reference"],"operation":"write","arguments":{"content":"committed"}})).await.unwrap()); assert_ne!( - updated["entry"]["metadata"]["revision"], - created["entry"]["metadata"]["revision"] + updated["entry"]["metadata"]["last_mutation_id"], + created["entry"]["metadata"]["last_mutation_id"] ); let error = native_tool(&second, "Invoke", json!({"path":path,"interface":observed_second["interfaces"][0]["reference"],"operation":"write","arguments":{"content":"lost"}})).await.unwrap_err(); assert!( @@ -1333,7 +1334,7 @@ async fn image_attachment_retains_original_bytes_after_drive_update_and_revocati request: &str, bytes: &[u8], id: Option<&Value>, - revision: Option<&str>| { + last_mutation_id: Option<&str>| { let mut query = url::form_urlencoded::Serializer::new(String::new()); query .append_pair("operation", operation) @@ -1345,7 +1346,7 @@ async fn image_attachment_retains_original_bytes_after_drive_update_and_revocati if let Some(id) = id { query .append_pair("id", id["node_id"].as_str().unwrap()) - .append_pair("expected_revision", revision.unwrap()); + .append_pair("expected_mutation_id", last_mutation_id.unwrap()); } else { query .append_pair("parent_id", root["node_id"].as_str().unwrap()) @@ -1400,7 +1401,7 @@ async fn image_attachment_retains_original_bytes_after_drive_update_and_revocati "image-update", replacement, Some(&id), - saved["entry"]["revision"].as_str(), + saved["entry"]["last_mutation_id"].as_str(), ), replacement, ) diff --git a/crates/workspace-server/src/server/tests/worker_operations_tests.rs b/crates/workspace-server/src/server/tests/worker_operations_tests.rs index ebcddc3ab..80bfe89bc 100644 --- a/crates/workspace-server/src/server/tests/worker_operations_tests.rs +++ b/crates/workspace-server/src/server/tests/worker_operations_tests.rs @@ -184,7 +184,6 @@ async fn handle_resolution_rejects_unknown_foreign_and_internal_only_workers() { &source.worker_ref, protocol::Event::InternalWorker { worker: internal.clone(), - revision: 1, event: Box::new(protocol::Event::WorkerState { snapshot: protocol::WorkerStateSnapshot::initial(), }), @@ -522,7 +521,6 @@ async fn browser_protocol_sender_rejects_job_input_and_all_live_methods() { let request = BackendJobRequest { job_id: "worker-operation-read-only-job".into(), purpose: "operation_boundary_test".into(), - input_revision: "revision-1".into(), input_ref: "test://worker-operation/input".into(), input: serde_json::json!({"immutable": true}), instruction: "Return a structured result.".into(), @@ -755,7 +753,6 @@ async fn generic_management_aliases_cannot_bypass_worker_grants_or_target_bound_ TEST_WORKSPACE_ID.into(), "offline-runtime".into(), ExecuteRuntimeCleanupRequest { - expected_plan_revision: "irrelevant".into(), expected_plan_digest: "irrelevant".into(), worker_target_ids: Vec::new(), workdir_target_ids: Vec::new(), @@ -875,7 +872,6 @@ async fn browser_protocol_connection_and_cleanup_reject_cookie_origin_mismatch() &api, EMBEDDED_WORKER_RUNTIME_ID, ExecuteRuntimeCleanupRequest { - expected_plan_revision: "irrelevant".into(), expected_plan_digest: "irrelevant".into(), worker_target_ids: Vec::new(), workdir_target_ids: Vec::new(), @@ -1112,7 +1108,7 @@ async fn restore_guard_stale_ssh_intent_does_not_issue_workspace_resource() { hostname: "example.invalid".into(), port: 22, host_key: public_key, - expected_revision: None, + expected_fingerprint: None, }, "owner-account", ) @@ -1642,7 +1638,7 @@ async fn restore_guard_cross_runtime_ssh_completion_retries_pinned_snapshot_at_w hostname: "example.invalid".into(), port: 22, host_key: public_key, - expected_revision: None, + expected_fingerprint: None, }, "owner-account", ) @@ -1678,7 +1674,7 @@ async fn restore_guard_cross_runtime_ssh_completion_retries_pinned_snapshot_at_w ); assert_ne!(first.materialization.runtime_id, identity.runtime_id); // Rotate current host trust while the accepted operation is uncertain. Its - // original credential/trust revisions, source and binding must remain pinned. + // original credential/trust fingerprints, source and binding must remain pinned. let alternate = api .repository_secrets .generate_credential( @@ -1697,12 +1693,13 @@ async fn restore_guard_cross_runtime_ssh_completion_retries_pinned_snapshot_at_w .unwrap() .unwrap() .public_key; - let original_trust_revision = first + let original_trust_fingerprint = first .materialization .ssh .as_ref() .unwrap() - .host_trust_revision; + .host_key_fingerprint + .clone(); api.repository_secrets .put_host_trust( api.workspace_id(), @@ -1712,7 +1709,7 @@ async fn restore_guard_cross_runtime_ssh_completion_retries_pinned_snapshot_at_w hostname: "example.invalid".into(), port: 22, host_key: alternate_public, - expected_revision: Some(original_trust_revision), + expected_fingerprint: Some(original_trust_fingerprint), }, "owner-account", ) 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 e5c745503..d90918a36 100644 --- a/crates/workspace-server/src/server/tests/workspace_admission_tests.rs +++ b/crates/workspace-server/src/server/tests/workspace_admission_tests.rs @@ -598,7 +598,6 @@ async fn runtime_callbacks_during_deletion_drain_still_require_bound_proofs() { TEST_WORKSPACE_ID, WorkdirlessFixtureRuntime::RUNTIME_ID, "fixture-resource", - "1", Utc::now().timestamp() + 60, worker_runtime::resource::RepositorySshAccessSecret { credential_candidates: Vec::new(), @@ -846,7 +845,7 @@ async fn create_and_restore_session_callbacks_finish_while_deletion_start_waits( &format!("/api/workspaces/{TEST_WORKSPACE_ID}/deletion"), json!({ "operation_id": if restore {"delete-during-restore"} else {"delete-during-create"}, - "expected_revision": "fixture-revision", "confirmation":"fixture", + "expected_workspace_updated_at": "fixture-updated-at", "confirmation":"fixture", }), ); let mut deletion = Box::pin(f.app.clone().oneshot(request)); diff --git a/crates/workspace-server/src/server_drive.rs b/crates/workspace-server/src/server_drive.rs index 0f8dc7c85..57c79ef7f 100644 --- a/crates/workspace-server/src/server_drive.rs +++ b/crates/workspace-server/src/server_drive.rs @@ -4,7 +4,7 @@ use super::*; use futures::StreamExt; use server_api::*; use sha2::{Digest, Sha256}; -use workspace_drive::{Drive, Mutation, NodeId, Revision}; +use workspace_drive::{Drive, Mutation, NodeId}; fn mutation_actor(context: &ServerRequestContext) -> DriveResult { if let Some(source) = &context.runtime_source { @@ -65,8 +65,11 @@ async fn blocking( fn node_id(value: &str) -> DriveResult { value.to_owned().try_into().map_err(|_| failure(400)) } -fn revision(value: &str) -> DriveResult { - value.to_owned().try_into().map_err(|_| failure(400)) +fn last_mutation_id(value: &str) -> DriveResult { + if value.is_empty() || value.len() > 128 || value.chars().any(char::is_control) { + return Err(failure(400)); + } + Ok(value.to_owned()) } fn entry(workspace: &str, reference: &DriveEntryRef) -> DriveResult { if reference.workspace_id != workspace { @@ -188,7 +191,7 @@ fn public_node(node: workspace_drive::Node) -> DriveEntry { workspace_drive::Kind::File => DriveEntryKind::File, workspace_drive::Kind::Directory => DriveEntryKind::Folder, }, - revision: node.revision.get().to_string(), + last_mutation_id: node.last_mutation_id.clone(), size: (node.kind == workspace_drive::Kind::File).then_some(node.size as u32), content_type: node.content_type, updated_by: node.updated_by, @@ -292,7 +295,7 @@ pub(super) async fn read_text( } let (node, chunk) = blocking(false, move || { let node = drive.metadata(id)?; - let chunk = drive.read(id, node.revision, 0, max)?; + let chunk = drive.read(id, &node.last_mutation_id, 0, max)?; Ok((node, chunk)) }) .await?; @@ -317,12 +320,17 @@ pub(super) async fn read_chunk( ) -> DriveResult { let drive = drive_grants::authorized_drive(api, context, workspace, false).await?; let id = scoped_id(workspace, &query.entry_workspace_id, &query.id)?; - let revision = revision(&query.expected_revision)?; + let last_mutation_id = last_mutation_id(&query.expected_mutation_id)?; if query.length == 0 || query.length > DRIVE_CHUNK_MAX_BYTES { return Err(failure(413)); } let chunk = blocking(false, move || { - drive.read(id, revision, query.offset.into(), query.length as usize) + drive.read( + id, + &last_mutation_id, + query.offset.into(), + query.length as usize, + ) }) .await?; Ok(chunk.bytes.into()) @@ -352,32 +360,32 @@ fn mutation(workspace: &str, mutation: DriveMutation) -> DriveResult { }, DriveMutation::UpdateText { id, - expected_revision, + expected_mutation_id, text, content_type, } => Mutation::Update { id: entry(workspace, &id)?, - expected_revision: revision(&expected_revision)?, + expected_mutation_id: last_mutation_id(&expected_mutation_id)?, content_type: safe_content_type(&content_type)?, bytes: text_bytes(text)?, }, DriveMutation::Relocate { id, - expected_revision, + expected_mutation_id, parent, name, } => Mutation::Relocate { id: entry(workspace, &id)?, - expected_revision: revision(&expected_revision)?, + expected_mutation_id: last_mutation_id(&expected_mutation_id)?, parent: entry(workspace, &parent)?, name, }, DriveMutation::Delete { id, - expected_revision, + expected_mutation_id, } => Mutation::Delete { id: entry(workspace, &id)?, - expected_revision: revision(&expected_revision)?, + expected_mutation_id: last_mutation_id(&expected_mutation_id)?, }, }) } @@ -413,7 +421,9 @@ fn upload_mutation( } let content_type = safe_content_type(&query.content_type)?; match query.operation { - DriveUploadOperation::Create if query.id.is_none() && query.expected_revision.is_none() => { + DriveUploadOperation::Create + if query.id.is_none() && query.expected_mutation_id.is_none() => + { Ok(Mutation::CreateFile { parent: node_id(query.parent_id.as_deref().ok_or_else(|| failure(400))?)?, name: query.name.clone().ok_or_else(|| failure(400))?, @@ -424,9 +434,9 @@ fn upload_mutation( DriveUploadOperation::Update if query.parent_id.is_none() && query.name.is_none() => { Ok(Mutation::Update { id: node_id(query.id.as_deref().ok_or_else(|| failure(400))?)?, - expected_revision: revision( + expected_mutation_id: last_mutation_id( query - .expected_revision + .expected_mutation_id .as_deref() .ok_or_else(|| failure(400))?, )?, @@ -508,9 +518,9 @@ async fn select_download( return Err(failure(400)); } if query - .expected_revision + .expected_mutation_id .as_deref() - .is_some_and(|v| v != node.revision.get().to_string()) + .is_some_and(|v| v != node.last_mutation_id.clone()) { return Err(failure(409)); } @@ -528,8 +538,9 @@ pub(super) async fn download( let mut offset = 0; loop { let copy = drive.clone(); + let node = node.clone(); let chunk = blocking(false, move || { - copy.read(node.id, node.revision, offset, TRANSFER_CHUNK) + copy.read(node.id, &node.last_mutation_id, offset, TRANSFER_CHUNK) }) .await?; offset += chunk.bytes.len() as u64; @@ -548,12 +559,18 @@ pub(super) async fn download( }) } fn validator(node: &workspace_drive::Node) -> String { - format!( - "\"drive-{}-{}-{}\"", - node.workspace_id, - node.id.get(), - node.revision.get() - ) + // Request IDs are caller-chosen UTF-8, never interpolate them into HTTP headers. + let mut digest = Sha256::new(); + digest.update(node.workspace_id.as_bytes()); + digest.update([0]); + digest.update(node.id.get().to_be_bytes()); + digest.update(node.last_mutation_id.as_bytes()); + let hex: String = digest + .finalize() + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + format!("\"drive-{hex}\"") } pub(super) fn router(service: Arc) -> Router { @@ -664,16 +681,17 @@ async fn streaming_download( let (drive, node) = select_download(api, &context, &workspace, query).await?; // Validate the referenced blob before HTTP success, including a zero-byte file. let first_drive = drive.clone(); + let selected = node.clone(); let first = blocking(false, move || { - first_drive.read(node.id, node.revision, 0, TRANSFER_CHUNK) + first_drive.read(selected.id, &selected.last_mutation_id, 0, TRANSFER_CHUNK) }) .await?; let id = node.id; - let revision = node.revision; - let state = (drive, id, revision, Some(first), 0_u64, false); + let last_mutation_id = node.last_mutation_id.clone(); + let state = (drive, id, last_mutation_id, Some(first), 0_u64, false); let stream = futures::stream::try_unfold( state, - |(drive, id, revision, first, offset, done)| async move { + |(drive, id, last_mutation_id, first, offset, done)| async move { if done { return Ok(None); } @@ -681,8 +699,9 @@ async fn streaming_download( Some(chunk) => chunk, None => { let copy = drive.clone(); + let expected = last_mutation_id.clone(); blocking(false, move || { - copy.read(id, revision, offset, TRANSFER_CHUNK) + copy.read(id, &expected, offset, TRANSFER_CHUNK) }) .await .map_err(|_| stream_error())? @@ -692,7 +711,7 @@ async fn streaming_download( let eof = chunk.eof; Ok::<_, std::io::Error>(Some(( axum::body::Bytes::from(chunk.bytes), - (drive, id, revision, None, next, eof), + (drive, id, last_mutation_id, None, next, eof), ))) }, ); @@ -734,3 +753,30 @@ async fn streaming_download( Err(error) => error_response(error), } } + +#[cfg(test)] +mod validator_tests { + use super::*; + + #[test] + fn request_identity_is_hashed_before_becoming_an_http_validator() { + let mut node = workspace_drive::Node { + id: "2".to_string().try_into().unwrap(), + workspace_id: "workspace".into(), + parent_id: Some("1".to_string().try_into().unwrap()), + name: "document".into(), + kind: workspace_drive::Kind::File, + last_mutation_id: "write/資料 ?\"#&=+".into(), + size: 0, + content_type: Some("text/plain".into()), + updated_by: "actor".into(), + updated_at_ms: 0, + }; + let first = validator(&node); + assert!(first.parse::().is_ok()); + assert!(!first.contains(&node.last_mutation_id)); + assert_eq!(first, validator(&node)); + node.last_mutation_id = "another-request".into(); + assert_ne!(first, validator(&node)); + } +} diff --git a/crates/workspace-server/src/server_workspace_config.rs b/crates/workspace-server/src/server_workspace_config.rs index eb5935d75..0e0a54959 100644 --- a/crates/workspace-server/src/server_workspace_config.rs +++ b/crates/workspace-server/src/server_workspace_config.rs @@ -268,7 +268,8 @@ fn tree(api: &WorkspaceApi) -> ConfigResult String { let bound = serde_json::to_vec(&( @@ -280,8 +281,10 @@ fn validator( &link.connection_id, effective_access(grant, link), path, - snapshot.revision, - &snapshot.digest, + &state.snapshot.digest, + &state.contract.fingerprint, + &state.projection_digest, + applied_schema_fingerprint, )) .expect("bounded configuration validator tuple"); format!( @@ -313,6 +316,11 @@ pub(super) async fn observe( let _guard = lock(api, worker).await?; let (grant, link) = resolve(api, worker, &request.connection_id)?; let state = tree(api)?; + let schema_fingerprint = api + .config_schema_registry + .compose() + .map_err(pre_error)? + .fingerprint; let snapshot = &state.snapshot; let writable = effective_access(&grant, &link) == Access::ReadWrite; // Build only bounded metadata (the canonical tree itself is bounded by config-source). @@ -386,7 +394,7 @@ pub(super) async fn observe( } } WorkspaceConfigNode { - validator: validator(&grant, &link, snapshot, &path), + validator: validator(&grant, &link, &state, &schema_fingerprint, &path), path, kind, digest: entry.map(|entry| entry.content_digest.clone()), @@ -402,8 +410,7 @@ pub(super) async fn observe( .collect(); Ok(WorkspaceConfigObserveResponse { connection_id: link.connection_id.clone(), - validator: validator(&grant, &link, snapshot, ""), - revision: snapshot.revision, + validator: validator(&grant, &link, &state, &schema_fingerprint, ""), digest: snapshot.digest.clone(), entrypoints: state .contract @@ -423,7 +430,12 @@ pub(super) async fn read( let _guard = lock(api, worker).await?; let (grant, link) = resolve(api, worker, &request.connection_id)?; let state = tree(api)?; - let expected = validator(&grant, &link, &state.snapshot, &request.path); + let schema_fingerprint = api + .config_schema_registry + .compose() + .map_err(pre_error)? + .fingerprint; + let expected = validator(&grant, &link, &state, &schema_fingerprint, &request.path); check_validator(&request.validator, &expected)?; let path = config_source::VirtualPath::parse(&request.path).map_err(|_| fail(400, "invalid_path"))?; @@ -476,9 +488,14 @@ pub(super) async fn commit( return Err(fail(403, "access_denied")); } let state = tree(api)?; + let schema_fingerprint = api + .config_schema_registry + .compose() + .map_err(pre_error)? + .fingerprint; check_validator( &request.validator, - &validator(&grant, &link, &state.snapshot, ""), + &validator(&grant, &link, &state, &schema_fingerprint, ""), )?; let canonical = config_commit_request_from_api(request.request).map_err(pre_error)?; // Evaluation/semantic validation are pure preparation. Bound this stage @@ -496,6 +513,13 @@ pub(super) async fn commit( .map_err(|_| fail(503, "evaluation_timeout"))? .map_err(|_| fail(500, "evaluation_unavailable"))? .map_err(pre_error)?; + if candidate.base_digest != state.snapshot.digest + || candidate.base_toolchain_fingerprint != state.contract.fingerprint + || candidate.base_projection_digest != state.projection_digest + || candidate.contract.schema_bundle.fingerprint != schema_fingerprint + { + return Err(fail(409, "stale_validator")); + } // Recheck durable authority before the first auxiliary/canonical effects. let (current_grant, current_link) = resolve(api, worker, &request.connection_id)?; if current_grant != grant || current_link.capabilities != link.capabilities { @@ -506,8 +530,7 @@ pub(super) async fn commit( let state = persist_workspace_config_candidate(api, &api.config.workspace_id, &candidate) .map_err(|_| unknown())?; Ok(WorkspaceConfigCommitResponse { - validator: validator(&grant, &link, &state.snapshot, ""), - revision: state.snapshot.revision, + validator: validator(&grant, &link, &state, &schema_fingerprint, ""), digest: state.snapshot.digest, }) } diff --git a/crates/workspace-server/src/server_workspace_config_tests.rs b/crates/workspace-server/src/server_workspace_config_tests.rs index 5a1775278..ac11e7766 100644 --- a/crates/workspace-server/src/server_workspace_config_tests.rs +++ b/crates/workspace-server/src/server_workspace_config_tests.rs @@ -68,7 +68,6 @@ mod workspace_config_integration { connection_id: connection.into(), validator: snapshot.validator.clone(), request: server_api::ConfigCommitRequest { - base_revision: snapshot.revision, base_digest: snapshot.digest.clone(), changes, entrypoints: snapshot.entrypoints.clone(), @@ -127,97 +126,335 @@ mod workspace_config_integration { ) } + #[tokio::test] + async fn workspace_config_schema_refresh_invalidates_read_and_commit_validators_without_source_change() + { + struct AddedSchema; + impl crate::config_source::WorkspaceConfigSchemaProvider for AddedSchema { + fn contribution(&self) -> Result { + Ok(config_source::ConfigSchemaContribution::new( + "builtin:validator-test", + "validator_test", + "1", + "{ validator_test = Bool default true; }", + ) + .unwrap()) + } + } + let mut fixture = manual_worker_assignment_fixture().await; + grant(&fixture.api, &fixture.worker, Access::ReadWrite).await; + let attached = attach(&fixture.api, &fixture.worker).await; + let prior = observe( + &fixture.api, + &fixture.worker, + &attached.connection_id, + &[""], + 1, + ) + .await; + let file = prior + .nodes + .iter() + .find(|node| node.path == "main.dcdl") + .unwrap(); + // Also bind the schema that will be applied, even before canonical refresh. + fixture.api.config_schema_registry = fixture + .api + .config_schema_registry + .clone() + .with_provider(Arc::new(AddedSchema)); + let applied = observe( + &fixture.api, + &fixture.worker, + &attached.connection_id, + &[""], + 1, + ) + .await; + assert_eq!(applied.digest, prior.digest); + assert_ne!(applied.validator, prior.validator); + fixture + .api + .config_store + .ensure_workspace_config_materialized_with_schema( + TEST_WORKSPACE_ID, + "2026-08-14T00:00:00Z", + fixture.api.config_schema_registry.compose().unwrap(), + ) + .unwrap(); + let refreshed = observe( + &fixture.api, + &fixture.worker, + &attached.connection_id, + &[""], + 1, + ) + .await; + assert_eq!(refreshed.digest, prior.digest); + assert_ne!(refreshed.validator, applied.validator); + let rejected = workspace_config::read( + &fixture.api, + &fixture.worker, + Read { + connection_id: attached.connection_id.clone(), + path: file.path.clone(), + validator: file.validator.clone(), + }, + ) + .await + .unwrap_err(); + assert_eq!( + (rejected.status, rejected.code.as_str()), + (409, "stale_validator") + ); + let rejected = workspace_config::commit( + &fixture.api, + &fixture.worker, + commit_request( + &attached.connection_id, + &prior, + vec![create("stale.txt", "must not commit")], + ), + ) + .await + .unwrap_err(); + assert_eq!( + (rejected.status, rejected.code.as_str()), + (409, "stale_validator") + ); + assert_eq!(rejected.classification, Classification::NotCommitted); + assert!( + !fixture + .api + .config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap() + .snapshot + .entries + .contains_key(&config_source::VirtualPath::parse("stale.txt").unwrap()) + ); + } + #[tokio::test] async fn workspace_config_signed_editor_routes_cannot_bypass_wip_authority() { let mut fixture = manual_worker_assignment_fixture().await; let identity = RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id).unwrap(); configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id); let api = &fixture.api; - let initial = api.config_store.load_workspace_config(TEST_WORKSPACE_ID).unwrap().unwrap(); + let initial = api + .config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(); let tree = format!("/api/w/{TEST_WORKSPACE_ID}/config/source-tree"); let change = server_api::ConfigCommitRequest { - base_revision: initial.snapshot.revision, base_digest: initial.snapshot.digest.clone(), - changes: vec![create("notes/editor-only.txt", "must not persist from Worker")], + changes: vec![create( + "notes/editor-only.txt", + "must not persist from Worker", + )], entrypoints: vec!["main.dcdl".into()], }; let routes = [ ("GET", tree.clone()), ("GET", format!("{tree}/entries/main.dcdl")), - ("GET", format!("{tree}/revisions/{}", initial.snapshot.revision)), + ("GET", format!("{tree}/history/{}", initial.snapshot.digest)), ("POST", format!("{tree}/commit")), - ("GET", format!("/api/w/{TEST_WORKSPACE_ID}/settings/profiles")), + ( + "GET", + format!("/api/w/{TEST_WORKSPACE_ID}/settings/profiles"), + ), ]; - for state in ["ungranted", "read_only", "revoked", "detached", "read_write"] { + for state in [ + "ungranted", + "read_only", + "revoked", + "detached", + "read_write", + ] { match state { "read_only" => { grant(api, &fixture.worker, Access::ReadOnly).await; attach(api, &fixture.worker).await; } "revoked" => { - let current = api.store.current_workspace_config_grant(TEST_WORKSPACE_ID, &fixture.worker).unwrap().unwrap(); - workspace_config::revoke_grant(api, &test_owner_actor(), TEST_WORKSPACE_ID, - ¤t.grant_id).await.unwrap(); + let current = api + .store + .current_workspace_config_grant(TEST_WORKSPACE_ID, &fixture.worker) + .unwrap() + .unwrap(); + workspace_config::revoke_grant( + api, + &test_owner_actor(), + TEST_WORKSPACE_ID, + ¤t.grant_id, + ) + .await + .unwrap(); } "detached" => { grant(api, &fixture.worker, Access::ReadWrite).await; let attached = attach(api, &fixture.worker).await; - detach_current_worker_workdir(api, &fixture.worker, "workspace-config", - Some(&attached.connection_id)).await.unwrap(); + detach_current_worker_workdir( + api, + &fixture.worker, + "workspace-config", + Some(&attached.connection_id), + ) + .await + .unwrap(); + } + "read_write" => { + attach(api, &fixture.worker).await; } - "read_write" => { attach(api, &fixture.worker).await; } _ => {} } for (method, path) in &routes { - let body = if *method == "POST" { serde_json::to_vec(&change).unwrap() } else { vec![] }; + let body = if *method == "POST" { + serde_json::to_vec(&change).unwrap() + } else { + vec![] + }; for scoped in [false, true] { let app = if scoped { - workspace_server_router(WorkspaceServerApi::new(api.config.clone(), api.store.clone())) + workspace_server_router(WorkspaceServerApi::new( + api.config.clone(), + api.store.clone(), + )) } else { build_router(api.clone()) }; - let response = app.oneshot(runtime_source_request(&identity, - Some(&fixture.worker.worker_id), method, path, body.clone())).await.unwrap(); - assert_eq!(response.status(), StatusCode::FORBIDDEN, - "{state}: {method} {path}, scoped={scoped}"); + let response = app + .oneshot(runtime_source_request( + &identity, + Some(&fixture.worker.worker_id), + method, + path, + body.clone(), + )) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "{state}: {method} {path}, scoped={scoped}" + ); let bytes = to_bytes(response.into_body(), 4096).await.unwrap(); assert!(!String::from_utf8_lossy(&bytes).contains("must not persist")); } } - let after = api.config_store.load_workspace_config(TEST_WORKSPACE_ID).unwrap().unwrap(); - assert_eq!(after.snapshot, initial.snapshot, "{state}: editor rejection must have no effect"); + let after = api + .config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(); + assert_eq!( + after.snapshot, initial.snapshot, + "{state}: editor rejection must have no effect" + ); } // A Runtime-only proof is not a user editor identity either. - assert_eq!(signed_call(api, &identity, None, "GET", &tree, Value::Null).await.0, - StatusCode::FORBIDDEN); + assert_eq!( + signed_call(api, &identity, None, "GET", &tree, Value::Null) + .await + .0, + StatusCode::FORBIDDEN + ); let attached = attach(api, &fixture.worker).await; - let observed = observe(api, &fixture.worker, &attached.connection_id, &["main.dcdl"], 0).await; - let node = observed.nodes.iter().find(|n| n.path == "main.dcdl").unwrap(); - assert!(workspace_config::read(api, &fixture.worker, Read { - connection_id: attached.connection_id, - path: "main.dcdl".into(), validator: node.validator.clone(), - }).await.is_ok(), "the granted WIP path remains available"); + let observed = observe( + api, + &fixture.worker, + &attached.connection_id, + &["main.dcdl"], + 0, + ) + .await; + let node = observed + .nodes + .iter() + .find(|n| n.path == "main.dcdl") + .unwrap(); + assert!( + workspace_config::read( + api, + &fixture.worker, + Read { + connection_id: attached.connection_id, + path: "main.dcdl".into(), + validator: node.validator.clone(), + } + ) + .await + .is_ok(), + "the granted WIP path remains available" + ); // Real API-token authentication still permits the normal UI editor. let token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); for (method, path) in &routes { if *method == "GET" { - request_json_authenticated(build_router(api.clone()), method, path, None, - &token, StatusCode::OK).await; + request_json_authenticated( + build_router(api.clone()), + method, + path, + None, + &token, + StatusCode::OK, + ) + .await; } } - request_json_authenticated(build_router(api.clone()), "POST", &format!("{tree}/commit"), - Some(serde_json::to_value(change).unwrap()), &token, StatusCode::CREATED).await; - let saved = api.config_store.load_workspace_config(TEST_WORKSPACE_ID).unwrap().unwrap(); - assert_eq!(saved.snapshot.revision, initial.snapshot.revision + 1); - assert!(saved.snapshot.entries.keys().any(|p| p.as_str() == "notes/editor-only.txt")); + request_json_authenticated( + build_router(api.clone()), + "POST", + &format!("{tree}/commit"), + Some(serde_json::to_value(change).unwrap()), + &token, + StatusCode::CREATED, + ) + .await; + let saved = api + .config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(); + assert_ne!(saved.snapshot.digest, initial.snapshot.digest); + assert!( + saved + .snapshot + .entries + .keys() + .any(|p| p.as_str() == "notes/editor-only.txt") + ); // Prompt and Skill runtime consumption are separate read-only projections, // not authored-tree editor APIs; bootstrap must remain usable. - assert_eq!(signed_call(api, &identity, Some(&fixture.worker.worker_id), "GET", - &format!("/api/w/{TEST_WORKSPACE_ID}/config/projections/prompts"), Value::Null).await.0, - StatusCode::OK); - assert_eq!(signed_call(api, &identity, Some(&fixture.worker.worker_id), "GET", - &format!("/api/w/{TEST_WORKSPACE_ID}/skills"), Value::Null).await.0, StatusCode::OK); + assert_eq!( + signed_call( + api, + &identity, + Some(&fixture.worker.worker_id), + "GET", + &format!("/api/w/{TEST_WORKSPACE_ID}/config/projections/prompts"), + Value::Null + ) + .await + .0, + StatusCode::OK + ); + assert_eq!( + signed_call( + api, + &identity, + Some(&fixture.worker.worker_id), + "GET", + &format!("/api/w/{TEST_WORKSPACE_ID}/skills"), + Value::Null + ) + .await + .0, + StatusCode::OK + ); } #[tokio::test] @@ -836,7 +1073,6 @@ mod workspace_config_integration { let b = attach(api, &second).await; let first = observe(api, &fixture.worker, &a.connection_id, &[""], 0).await; let other = observe(api, &second, &b.connection_id, &[""], 0).await; - assert_eq!(first.revision, other.revision); assert_eq!(first.digest, other.digest); assert_ne!(first.validator, other.validator); workspace_config::commit( @@ -871,7 +1107,6 @@ mod workspace_config_integration { validator: file.validator.clone(), }; let canonical = config_commit_request_from_api(server_api::ConfigCommitRequest { - base_revision: fresh.revision, base_digest: fresh.digest.clone(), entrypoints: fresh.entrypoints.clone(), changes: vec![server_api::ConfigTreeChange::Update { @@ -881,34 +1116,12 @@ mod workspace_config_integration { }], }) .unwrap(); - // Same bytes, new virtual identity/revision via the ORIGINAL UI commit boundary. + // Identical bytes retain content identity through the original UI commit boundary. commit_workspace_config_tree(api, TEST_WORKSPACE_ID, &canonical).unwrap(); let after = observe(api, &second, &b.connection_id, &["notes/concurrent.md"], 0).await; assert_eq!(after.digest, fresh.digest); - assert!(after.revision > fresh.revision); - assert_ne!(after.validator, fresh.validator); - assert_eq!( - workspace_config::read(api, &second, old_read) - .await - .unwrap_err() - .code, - "stale_validator" - ); - assert_eq!( - workspace_config::commit( - api, - &second, - commit_request( - &b.connection_id, - &fresh, - vec![create("notes/stale.md", "stale")] - ) - ) - .await - .unwrap_err() - .code, - "stale_validator" - ); + assert_eq!(after.validator, fresh.validator); + assert!(workspace_config::read(api, &second, old_read).await.is_ok()); let delete = vec![server_api::ConfigTreeChange::Delete { path: file.path.clone(), expected_digest: file.digest.clone().unwrap(), diff --git a/crates/workspace-server/src/server_workspace_config_wip_tests.rs b/crates/workspace-server/src/server_workspace_config_wip_tests.rs index 2948e3bf8..b5a229f55 100644 --- a/crates/workspace-server/src/server_workspace_config_wip_tests.rs +++ b/crates/workspace-server/src/server_workspace_config_wip_tests.rs @@ -475,7 +475,7 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im .load_workspace_config(TEST_WORKSPACE_ID) .unwrap() .unwrap(); - assert_eq!(after.snapshot.revision, before.snapshot.revision + 1); + assert_ne!(after.snapshot.digest, before.snapshot.digest); assert!( after .snapshot diff --git a/crates/workspace-server/src/skills.rs b/crates/workspace-server/src/skills.rs index fd3262816..ebfe890b6 100644 --- a/crates/workspace-server/src/skills.rs +++ b/crates/workspace-server/src/skills.rs @@ -71,7 +71,7 @@ pub enum SkillError { NotFound(String), #[error("Skill `{0}` has blocking diagnostics")] InvalidSkill(String), - #[error("failed to evaluate the active Workspace config revision: {0}")] + #[error("failed to evaluate the active Workspace config content: {0}")] Evaluation(String), #[error("the active Workspace config projection is missing its root value")] MissingProjection, @@ -120,7 +120,6 @@ pub fn lint(state: &WorkspaceConfigState) -> Result SkillProjectionIdentity { SkillProjectionIdentity { - config_revision: state.snapshot.revision, tree_digest: state.snapshot.digest.clone(), } } @@ -183,7 +182,7 @@ fn merged_skills( kind: SkillSourceKind::Builtin, id: format!("builtin:{BUILTIN_SKILL_ID}"), virtual_path: Some(BUILTIN_SKILL_VIRTUAL_PATH.to_string()), - revision: None, + source_digest: Some(config_source::digest_bytes(BUILTIN_SKILL_SOURCE.as_bytes())), tree_digest: None, }, @@ -249,7 +248,7 @@ fn merged_skills( kind: SkillSourceKind::Workspace, id: format!("workspace:{name}"), virtual_path: Some(canonical_path), - revision: Some(state.snapshot.revision), + source_digest: Some(source_digest), tree_digest: Some(state.snapshot.digest.clone()), }, @@ -479,29 +478,26 @@ mod tests { fn state(main: &str, markdown: &str, name: &str) -> WorkspaceConfigState { let skill_path = format!("skills/{name}/SKILL.md"); let reference_path = format!("skills/{name}/references/checklist.md"); - let tree = ConfigTreeSnapshot::from_entries( - 9, - [ - ConfigEntry::new( - VirtualPath::parse("main.dcdl").unwrap(), - ConfigContentType::Decodal, - main, - ) - .unwrap(), - ConfigEntry::new( - VirtualPath::parse(&skill_path).unwrap(), - ConfigContentType::Text, - markdown, - ) - .unwrap(), - ConfigEntry::new( - VirtualPath::parse(&reference_path).unwrap(), - ConfigContentType::Text, - "checklist", - ) - .unwrap(), - ], - ) + let tree = ConfigTreeSnapshot::from_entries([ + ConfigEntry::new( + VirtualPath::parse("main.dcdl").unwrap(), + ConfigContentType::Decodal, + main, + ) + .unwrap(), + ConfigEntry::new( + VirtualPath::parse(&skill_path).unwrap(), + ConfigContentType::Text, + markdown, + ) + .unwrap(), + ConfigEntry::new( + VirtualPath::parse(&reference_path).unwrap(), + ConfigContentType::Text, + "checklist", + ) + .unwrap(), + ]) .unwrap(); let bundle = WorkspaceConfigSchemaBundle::compose([SkillConfigSchemaProvider .contribution() @@ -553,8 +549,10 @@ mod tests { .find(|item| item.name == "debug-rust") .unwrap(); assert_eq!(item.provenance.kind, SkillSourceKind::Workspace); - assert_eq!(item.provenance.revision, Some(9)); - assert_eq!(catalog.projection.config_revision, 9); + assert_eq!( + item.provenance.tree_digest.as_deref(), + Some(state.snapshot.digest.as_str()) + ); assert_eq!(catalog.projection.tree_digest, state.snapshot.digest); assert_eq!(item.activation_status, SkillActivationStatus::Active); assert_eq!(item.projection_status, SkillProjectionStatus::Valid); @@ -565,7 +563,7 @@ mod tests { ); let detail = detail(&state, "debug-rust").unwrap(); assert_eq!(detail.authority, SKILL_CATALOG_AUTHORITY); - assert_eq!(detail.projection.config_revision, 9); + assert_eq!(detail.projection.tree_digest, state.snapshot.digest); assert_eq!(detail.activation_status, SkillActivationStatus::Active); assert_eq!(detail.projection_status, SkillProjectionStatus::Valid); assert_eq!(detail.body, "# Debug Rust\n"); diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 2388611c3..6c9460485 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -28,7 +28,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore; use crate::{Error, Result}; const OLDEST_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 84; +const LATEST_SCHEMA_VERSION: i64 = 88; const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants"; const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs"; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; @@ -84,49 +84,6 @@ const WORKER_RESTORE_INTENTS_MIGRATION_NAME: &str = "durable internal Worker Restore request identity"; const TICKET_SCHEMA_VERSION_WITH_TARGETS: i64 = 7; const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline"; -const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#" -CREATE TABLE IF NOT EXISTS worker_registry_observations ( - workspace_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - worker_id TEXT NOT NULL, - availability TEXT NOT NULL CHECK (availability IN ('observed', 'unavailable')), - worker_json TEXT, - connection_generation INTEGER NOT NULL, - subject_revision INTEGER NOT NULL, - snapshot_revision INTEGER NOT NULL, - projection_revision INTEGER NOT NULL, - observed_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, runtime_id, worker_id), - FOREIGN KEY (workspace_id, worker_id) - REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE -); -CREATE TABLE IF NOT EXISTS worker_registry_projection_cursors ( - workspace_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - connection_generation INTEGER NOT NULL, - snapshot_revision INTEGER NOT NULL, - PRIMARY KEY (workspace_id, runtime_id) -); -CREATE TABLE IF NOT EXISTS worker_registry_projection_revisions ( - workspace_id TEXT PRIMARY KEY, - revision INTEGER NOT NULL -); -CREATE TABLE IF NOT EXISTS worker_registry_projection_removals ( - workspace_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - worker_id TEXT NOT NULL, - projection_revision INTEGER NOT NULL, - PRIMARY KEY (workspace_id, runtime_id, worker_id) -); -CREATE TABLE IF NOT EXISTS worker_registry_projection_diagnostics ( - diagnostic_id INTEGER PRIMARY KEY AUTOINCREMENT, - workspace_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - worker_id TEXT NOT NULL, - reason TEXT NOT NULL, - observed_at TEXT NOT NULL -); -"#; const MIGRATIONS: &[Migration] = &[ Migration { @@ -299,6 +256,26 @@ const MIGRATIONS: &[Migration] = &[ name: WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME, apply: migrate_workspace_drive_grants_v83_to_v84, }, + Migration { + version: 85, + name: "content-addressed Flow sources", + apply: migrate_flow_content_v84_to_v85, + }, + Migration { + version: 86, + name: "content-fenced config, Memory settings and Jobs", + apply: migrate_content_state_v85_to_v86, + }, + Migration { + version: 87, + name: "Repository SSH key identities and encrypted operation objects", + apply: migrate_repository_keys_v86_to_v87, + }, + Migration { + version: 88, + name: "Runtime binding identities and retention content contracts", + apply: migrate_runtime_bindings_v87_to_v88, + }, ]; #[derive(Clone, Copy)] @@ -342,7 +319,6 @@ pub struct WorkspaceRecord { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WorkspaceMemorySettingsRecord { pub workspace_id: String, - pub settings_revision: u64, pub language: String, pub created_at: String, pub updated_at: String, @@ -376,7 +352,6 @@ pub struct RepositoryRecord { pub provider: Option, pub source: RepositorySource, pub default_ref: Option, - pub source_revision: u64, pub source_fingerprint: String, pub observed_status: RepositoryObservedStatus, pub observed_at: Option, @@ -402,7 +377,6 @@ pub struct WorkspaceBootstrapRecord { pub struct WorkspaceBootstrapResult { pub workspace: WorkspaceRecord, pub repository: Option, - pub config_revision: u64, pub replayed: bool, } @@ -414,7 +388,6 @@ pub struct WorkspaceSigningIdentityRecord { pub public_key: Option, pub public_key_fingerprint: Option, pub private_material_ref: String, - pub revision: u64, pub state: String, pub created_at: String, pub provisioned_at: Option, @@ -431,7 +404,6 @@ impl std::fmt::Debug for WorkspaceSigningIdentityRecord { .field("public_key", &self.public_key) .field("public_key_fingerprint", &self.public_key_fingerprint) .field("private_material_ref", &"[REDACTED]") - .field("revision", &self.revision) .field("state", &self.state) .field("created_at", &self.created_at) .field("provisioned_at", &self.provisioned_at) @@ -447,7 +419,6 @@ pub struct WorkspaceSigningIdentityActivation { pub public_key: String, pub public_key_fingerprint: String, pub private_material_ref: String, - pub revision: u64, pub provisioned_at: String, } @@ -459,7 +430,6 @@ pub struct WorkspaceSigningIdentityProvisioningOperation { pub workspace_id: String, pub key_id: String, pub private_material_ref: String, - pub revision: u64, pub actor_account_id: String, pub state: String, pub created_at: String, @@ -470,12 +440,11 @@ pub struct WorkspaceSigningIdentityProvisioningOperation { pub struct WorkspaceRuntimeVerificationEvidence { pub workspace_id: String, pub runtime_id: String, - pub binding_revision: u64, + pub binding_id: String, pub workspace_key_id: String, - pub workspace_identity_revision: u64, - pub workspace_trust_generation: u64, + pub workspace_public_key_fingerprint: String, + pub workspace_trust_id: String, pub runtime_public_key_fingerprint: String, - pub runtime_identity_revision: u64, pub challenge_id: String, pub state: String, pub last_outcome: String, @@ -491,11 +460,12 @@ pub struct WorkspaceRuntimeBinding { pub base_url: String, pub public_key: String, pub public_key_fingerprint: String, - pub binding_revision: u64, + pub binding_id: String, pub state: WorkspaceRuntimeBindingState, pub authentication_mode: WorkspaceRuntimeAuthenticationMode, pub workspace_key_id: Option, - pub workspace_key_generation: Option, + pub workspace_public_key_fingerprint: Option, + pub workspace_trust_id: Option, pub created_at: String, pub updated_at: String, pub revoked_at: Option, @@ -507,8 +477,8 @@ pub struct RuntimeRemovalOperation { pub workspace_id: String, pub runtime_id: String, pub request_fingerprint: String, - pub expected_binding_revision: u64, - pub config_revision: u64, + pub expected_binding_id: String, + pub config_digest: String, pub state: RuntimeRemovalOperationState, pub failure_category: Option, pub binding_removed: bool, @@ -604,7 +574,7 @@ pub struct WorkspaceRuntimeBindingAuditRecord { pub action: String, pub old_fingerprint: Option, pub new_fingerprint: Option, - pub binding_revision: u64, + pub binding_id: String, pub at: String, } @@ -899,7 +869,7 @@ impl WorkdirCreateCredentialCandidateRole { pub struct WorkdirCreateCredentialCandidate { pub role: WorkdirCreateCredentialCandidateRole, pub credential_id: String, - pub credential_revision: u64, + pub credential_fingerprint: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -911,16 +881,14 @@ pub struct WorkdirCreateOperationRecord { pub selector: Option, pub requested_runtime_id: Option, pub resolved_runtime_id: String, - pub config_revision: u64, pub config_projection_digest: String, pub source_kind: Option, pub source_uri: Option, - pub source_revision: Option, pub source_fingerprint: Option, pub credential_id: Option, - pub credential_revision: Option, + pub credential_fingerprint: Option, pub host_trust_id: Option, - pub host_trust_revision: Option, + pub host_trust_fingerprint: Option, pub repository_access_mode: Option, #[serde(default)] pub credential_candidates: Vec, @@ -1121,16 +1089,14 @@ pub struct FlowSourceRecord { pub path: String, pub content: String, pub content_digest: String, - pub revision: u64, pub created_at: String, pub updated_at: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct FlowSourceRevisionRecord { +pub struct FlowSourceContentRecord { pub workspace_id: String, pub flow_id: String, - pub revision: u64, pub content: String, pub content_digest: String, pub definition: CompiledFlowDefinition, @@ -1220,8 +1186,8 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { fn workspace_runtime_verification_matches( &self, binding: &WorkspaceRuntimeBinding, - workspace_identity_revision: u64, - workspace_trust_generation: u64, + workspace_public_key_fingerprint: &str, + workspace_trust_id: &str, ) -> Result; async fn get_workspace_runtime_verification( &self, @@ -1269,8 +1235,8 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { runtime_id: &str, operation_id: &str, request_fingerprint: &str, - expected_binding_revision: u64, - config_revision: u64, + expected_binding_id: &str, + config_digest: &str, ) -> Result; async fn get_runtime_removal( &self, @@ -1308,7 +1274,7 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { async fn put_workspace_runtime_binding_key( &self, record: WorkspaceRuntimeBinding, - expected_revision: Option, + expected_binding_id: Option<&str>, actor_account_id: &str, ) -> Result<(WorkspaceRuntimeBindingMutation, WorkspaceRuntimeBinding)>; async fn update_workspace_runtime_binding_metadata( @@ -1323,7 +1289,7 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { &self, workspace_id: &str, runtime_id: &str, - expected_revision: u64, + expected_binding_id: &str, actor_account_id: &str, revoked_at: &str, ) -> Result<(WorkspaceRuntimeBindingMutation, WorkspaceRuntimeBinding)>; @@ -1454,12 +1420,12 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { workspace_id: &str, flow_id: &str, ) -> Result>; - fn get_flow_source_revision( + fn get_flow_source_content( &self, workspace_id: &str, flow_id: &str, - revision: u64, - ) -> Result>; + content_digest: &str, + ) -> Result>; fn upsert_objective(&self, record: &ObjectiveRecord) -> Result<()>; fn list_objectives(&self, workspace_id: &str, limit: usize) -> Result>; @@ -2221,7 +2187,7 @@ impl SqliteWorkspaceStore { Duration::from_millis(1), None, )?; - prepare_connection(&candidate)?; + prepare_connection_with_secret_source(&candidate, Some(path))?; Ok(plan) } @@ -2364,27 +2330,10 @@ impl SqliteWorkspaceStore { &self, workspace_id: &str, ) -> Result { - let record = self.with_conn(|conn| { - conn.query_row( - "SELECT workspace_id, settings_revision, language, created_at, updated_at \ - FROM workspace_memory_settings WHERE workspace_id = ?1", - params![workspace_id], - |row| { - let revision = row.get::<_, i64>(1)?; - Ok(WorkspaceMemorySettingsRecord { - workspace_id: row.get(0)?, - settings_revision: revision - .try_into() - .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(1, revision))?, - language: row.get(2)?, - created_at: row.get(3)?, - updated_at: row.get(4)?, - }) - }, - ) - .optional()? - .ok_or_else(|| Error::Store("Workspace Memory settings are missing".to_string())) - })?; + let record = self.with_conn(|conn| conn.query_row( + "SELECT workspace_id, language, created_at, updated_at FROM workspace_memory_settings WHERE workspace_id=?1", + [workspace_id], read_workspace_memory_settings, + ).optional()?.ok_or_else(|| Error::Store("Workspace Memory settings are missing".into())))?; validate_workspace_memory_settings_record(&record, workspace_id)?; Ok(record) } @@ -2392,70 +2341,23 @@ impl SqliteWorkspaceStore { pub(crate) fn update_workspace_memory_settings( &self, workspace_id: &str, - expected_revision: u64, + expected_language: &str, language: &str, ) -> Result { let language = normalize_workspace_memory_language(language)?; self.with_conn_mut(|conn| { - let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; - let current = tx - .query_row( - "SELECT workspace_id, settings_revision, language, created_at, updated_at \ - FROM workspace_memory_settings WHERE workspace_id = ?1", - params![workspace_id], - |row| { - let revision = row.get::<_, i64>(1)?; - Ok(WorkspaceMemorySettingsRecord { - workspace_id: row.get(0)?, - settings_revision: revision.try_into().map_err(|_| { - rusqlite::Error::IntegralValueOutOfRange(1, revision) - })?, - language: row.get(2)?, - created_at: row.get(3)?, - updated_at: row.get(4)?, - }) - }, - ) - .optional()? - .ok_or_else(|| Error::Store("Workspace Memory settings are missing".to_string()))?; - validate_workspace_memory_settings_record(¤t, workspace_id)?; - let current_revision = current.settings_revision; - if current_revision != expected_revision { - return Err(Error::WorkspaceConfigConflict(format!( - "Workspace Memory settings revision changed: expected {expected_revision}, current {current_revision}" - ))); + let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let current=tx.query_row("SELECT workspace_id,language,created_at,updated_at FROM workspace_memory_settings WHERE workspace_id=?1",[workspace_id],read_workspace_memory_settings) + .optional()?.ok_or_else(|| Error::Store("Workspace Memory settings are missing".into()))?; + validate_workspace_memory_settings_record(¤t,workspace_id)?; + if current.language != expected_language { + return Err(Error::WorkspaceConfigConflict("Workspace Memory language changed".into())); } - if current.language == language { - tx.commit()?; - return Ok(current); - } - let next_revision = current_revision.checked_add(1).ok_or_else(|| { - Error::InvalidInput("Workspace Memory settings revision overflow".to_string()) - })?; - let now = chrono::Utc::now().to_rfc3339(); - tx.execute( - "UPDATE workspace_memory_settings \ - SET settings_revision = ?2, language = ?3, updated_at = ?4 \ - WHERE workspace_id = ?1", - params![workspace_id, next_revision as i64, language, now], - )?; - let record = tx.query_row( - "SELECT workspace_id, settings_revision, language, created_at, updated_at \ - FROM workspace_memory_settings WHERE workspace_id = ?1", - params![workspace_id], - |row| { - let revision = row.get::<_, i64>(1)?; - Ok(WorkspaceMemorySettingsRecord { - workspace_id: row.get(0)?, - settings_revision: revision as u64, - language: row.get(2)?, - created_at: row.get(3)?, - updated_at: row.get(4)?, - }) - }, - )?; + if current.language == language { tx.commit()?; return Ok(current); } + let now=chrono::Utc::now().to_rfc3339(); + tx.execute("UPDATE workspace_memory_settings SET language=?2,updated_at=?3 WHERE workspace_id=?1",params![workspace_id,language,now])?; tx.commit()?; - Ok(record) + Ok(WorkspaceMemorySettingsRecord { language,updated_at:now,..current }) }) } @@ -2482,7 +2384,7 @@ impl SqliteWorkspaceStore { let existing = tx .query_row( "SELECT worker_id, runtime_id, request_fingerprint, create_fingerprint, \ - memory_settings_revision, memory_language, state, singleton_key, \ + memory_language, state, singleton_key, \ singleton_generation \ FROM worker_create_reservations \ WHERE workspace_id = ?1 AND allocation_key = ?2", @@ -2493,16 +2395,15 @@ impl SqliteWorkspaceStore { row.get::<_, String>(1)?, row.get::<_, Option>(2)?, row.get::<_, String>(3)?, - row.get::<_, Option>(4)?, - row.get::<_, Option>(5)?, - row.get::<_, String>(6)?, - row.get::<_, Option>(7)?, - row.get::<_, Option>(8)?, + row.get::<_, Option>(4)?, + row.get::<_, String>(5)?, + row.get::<_, Option>(6)?, + row.get::<_, Option>(7)?, )) }, ) .optional()?; - if let Some((worker_id, reserved_runtime_id, stored_request_fingerprint, create_fingerprint, revision, language, state, stored_singleton_key, singleton_generation)) = existing { + if let Some((worker_id, reserved_runtime_id, stored_request_fingerprint, create_fingerprint, language, state, stored_singleton_key, singleton_generation)) = existing { if state == "removed" { return Err(Error::InvalidInput(format!( "Worker create allocation {allocation_key} was terminally removed" @@ -2516,11 +2417,6 @@ impl SqliteWorkspaceStore { "Worker create allocation {allocation_key} was already used with different input" ))); } - let revision = revision.ok_or_else(|| { - Error::InvalidInput(format!( - "Worker create allocation {allocation_key} has no persisted Memory settings snapshot" - )) - })?; let language = language.ok_or_else(|| { Error::InvalidInput(format!( "Worker create allocation {allocation_key} has no persisted Memory language" @@ -2533,9 +2429,6 @@ impl SqliteWorkspaceStore { })?; let snapshot = manifest::WorkspaceMemorySettingsSnapshot { workspace_id: workspace_id.to_string(), - settings_revision: revision.try_into().map_err(|_| { - rusqlite::Error::IntegralValueOutOfRange(4, revision) - })?, language, }; validate_workspace_memory_settings_snapshot(&snapshot, workspace_id)?; @@ -2577,7 +2470,7 @@ impl SqliteWorkspaceStore { let stored = tx .query_row( "SELECT allocation_key, request_fingerprint, create_fingerprint, \ - memory_settings_revision, memory_language, singleton_key, \ + memory_language, singleton_key, \ singleton_generation \ FROM worker_create_reservations \ WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3", @@ -2591,15 +2484,14 @@ impl SqliteWorkspaceStore { row.get::<_, String>(0)?, row.get::<_, Option>(1)?, row.get::<_, String>(2)?, - row.get::<_, Option>(3)?, + row.get::<_, Option>(3)?, row.get::<_, Option>(4)?, - row.get::<_, Option>(5)?, - row.get::<_, Option>(6)?, + row.get::<_, Option>(5)?, )) }, ) .optional()?; - if let Some((stored_allocation_key, stored_request_fingerprint, create_fingerprint, revision, language, stored_singleton_key, stored_generation)) = stored + if let Some((stored_allocation_key, stored_request_fingerprint, create_fingerprint, language, stored_singleton_key, stored_generation)) = stored && allocation_key.starts_with("manual:") && stored_allocation_key.starts_with("manual:") && owner.worker.runtime_id == runtime_id @@ -2608,13 +2500,7 @@ impl SqliteWorkspaceStore { && stored_generation.and_then(|generation| u64::try_from(generation).ok()) == Some(owner.generation) { - let revision = revision.ok_or_else(|| { - Error::InvalidInput(format!( - "Worker singleton reservation {}:{} has no persisted Memory settings snapshot", - owner.worker.runtime_id, owner.worker.worker_id - )) - })?; - let language = language.ok_or_else(|| { + let language = language.ok_or_else(|| { Error::InvalidInput(format!( "Worker singleton reservation {}:{} has no persisted Memory language", owner.worker.runtime_id, owner.worker.worker_id @@ -2628,10 +2514,7 @@ impl SqliteWorkspaceStore { })?; let snapshot = manifest::WorkspaceMemorySettingsSnapshot { workspace_id: workspace_id.to_string(), - settings_revision: revision.try_into().map_err(|_| { - rusqlite::Error::IntegralValueOutOfRange(2, revision) - })?, - language, + language, }; validate_workspace_memory_settings_snapshot(&snapshot, workspace_id)?; return Ok(WorkerCreateReservation { @@ -2647,19 +2530,15 @@ impl SqliteWorkspaceStore { ))); } - let (authoritative_revision, authoritative_language) = tx + let authoritative_language = tx .query_row( - "SELECT settings_revision, language FROM workspace_memory_settings WHERE workspace_id = ?1", + "SELECT language FROM workspace_memory_settings WHERE workspace_id = ?1", params![workspace_id], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), + |row| row.get::<_, String>(0), ) .optional()? .ok_or_else(|| Error::Store("Workspace Memory settings are missing".to_string()))?; - let authoritative_revision: u64 = authoritative_revision.try_into().map_err(|_| { - rusqlite::Error::IntegralValueOutOfRange(0, authoritative_revision) - })?; - if authoritative_revision != current_memory_settings.settings_revision - || authoritative_language != current_memory_settings.language + if authoritative_language != current_memory_settings.language { return Err(Error::WorkspaceConfigConflict( "Workspace Memory settings changed while the Worker create reservation was being accepted" @@ -2669,7 +2548,6 @@ impl SqliteWorkspaceStore { let snapshot = manifest::WorkspaceMemorySettingsSnapshot { workspace_id: workspace_id.to_string(), - settings_revision: authoritative_revision, language: authoritative_language, }; validate_workspace_memory_settings_snapshot(&snapshot, workspace_id)?; @@ -2704,8 +2582,8 @@ impl SqliteWorkspaceStore { "INSERT INTO worker_create_reservations(\ workspace_id, allocation_key, worker_id, runtime_id, create_fingerprint,\ state, created_at, updated_at, request_fingerprint,\ - memory_settings_revision, memory_language, singleton_key, singleton_generation\ - ) VALUES (?1, ?2, ?3, ?4, ?5, 'reserved', ?6, ?6, ?7, ?8, ?9, ?10, ?11)", + memory_language, singleton_key, singleton_generation\ + ) VALUES (?1, ?2, ?3, ?4, ?5, 'reserved', ?6, ?6, ?7, ?8, ?9, ?10)", params![ workspace_id, allocation_key, @@ -2714,7 +2592,6 @@ impl SqliteWorkspaceStore { create_fingerprint, now, request_fingerprint, - snapshot.settings_revision as i64, snapshot.language, singleton_key, singleton_generation.map(|generation| generation as i64), @@ -3046,15 +2923,15 @@ impl SqliteWorkspaceStore { self.with_conn(|conn| { let sql = if include_revoked { r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, state, authentication_mode, - workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at + public_key_fingerprint, binding_id, state, authentication_mode, + workspace_key_id, workspace_trust_id, created_at, updated_at, revoked_at, workspace_public_key_fingerprint FROM workspace_runtime_bindings WHERE workspace_id = ?1 ORDER BY runtime_id ASC"# } else { r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, state, authentication_mode, - workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at + public_key_fingerprint, binding_id, state, authentication_mode, + workspace_key_id, workspace_trust_id, created_at, updated_at, revoked_at, workspace_public_key_fingerprint FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND revoked_at IS NULL ORDER BY runtime_id ASC"# @@ -3076,8 +2953,8 @@ impl SqliteWorkspaceStore { self.with_conn(|conn| { conn.query_row( r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, state, authentication_mode, - workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at + public_key_fingerprint, binding_id, state, authentication_mode, + workspace_key_id, workspace_trust_id, created_at, updated_at, revoked_at, workspace_public_key_fingerprint FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![workspace_id, runtime_id], @@ -3176,13 +3053,15 @@ impl SqliteWorkspaceStore { runtime_id: &str, operation_id: &str, request_fingerprint: &str, - expected_binding_revision: u64, - config_revision: u64, + expected_binding_id: &str, + config_digest: &str, ) -> Result { validate_identifier("workspace_id", workspace_id)?; validate_identifier("runtime_id", runtime_id)?; validate_identifier("operation_id", operation_id)?; validate_non_empty("request_fingerprint", request_fingerprint)?; + validate_identifier("expected_binding_id", expected_binding_id)?; + validate_non_empty("config_digest", config_digest)?; if operation_id.len() > 128 || request_fingerprint.len() > 128 { return Err(Error::InvalidInput( "Runtime removal operation identity is too long".to_string(), @@ -3194,8 +3073,8 @@ impl SqliteWorkspaceStore { if existing.workspace_id != workspace_id || existing.runtime_id != runtime_id || existing.request_fingerprint != request_fingerprint - || existing.expected_binding_revision != expected_binding_revision - || existing.config_revision != config_revision + || existing.expected_binding_id != expected_binding_id + || existing.config_digest != config_digest { return Err(Error::RuntimeBindingConflict( "runtime_removal_operation_id_reused".to_string(), @@ -3212,7 +3091,7 @@ impl SqliteWorkspaceStore { &tx, workspace_id, runtime_id, - expected_binding_revision, + expected_binding_id, )?; let now = chrono::Utc::now().to_rfc3339(); tx.execute( @@ -3249,13 +3128,13 @@ impl SqliteWorkspaceStore { &tx, workspace_id, runtime_id, - expected_binding_revision, + expected_binding_id, )?; let now = chrono::Utc::now().to_rfc3339(); tx.execute( "INSERT INTO runtime_removal_operations(\ operation_id, workspace_id, runtime_id, request_fingerprint, \ - expected_binding_revision, config_revision, state, failure_category, \ + expected_binding_id, config_digest, state, failure_category, \ binding_removed, runtime_registration_removed, created_at, updated_at, completed_at\ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', NULL, 0, NULL, ?7, ?7, NULL)", params![ @@ -3263,12 +3142,8 @@ impl SqliteWorkspaceStore { workspace_id, runtime_id, request_fingerprint, - i64::try_from(expected_binding_revision).map_err(|_| { - Error::InvalidInput("binding revision is too large".to_string()) - })?, - i64::try_from(config_revision).map_err(|_| { - Error::InvalidInput("config revision is too large".to_string()) - })?, + expected_binding_id, + config_digest, now, ], )?; @@ -3338,7 +3213,7 @@ impl SqliteWorkspaceStore { &tx, &operation.workspace_id, &operation.runtime_id, - operation.expected_binding_revision, + &operation.expected_binding_id, )?; tx.execute( "DELETE FROM worker_mutation_source_proof_jtis \ @@ -3352,18 +3227,16 @@ impl SqliteWorkspaceStore { )?; let removed = tx.execute( "DELETE FROM workspace_runtime_bindings \ - WHERE workspace_id = ?1 AND runtime_id = ?2 AND binding_revision = ?3", + WHERE workspace_id = ?1 AND runtime_id = ?2 AND binding_id = ?3", params![ operation.workspace_id, operation.runtime_id, - i64::try_from(operation.expected_binding_revision).map_err(|_| { - Error::InvalidInput("binding revision is too large".to_string()) - })?, + &operation.expected_binding_id, ], )?; if removed != 1 { - return Err(Error::RuntimeBindingRevisionConflict { - expected: Some(operation.expected_binding_revision), + return Err(Error::RuntimeBindingIdConflict { + expected: Some(operation.expected_binding_id.clone()), actual: None, }); } @@ -3434,8 +3307,8 @@ impl SqliteWorkspaceStore { let existing = tx .query_row( r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, state, authentication_mode, - workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at + public_key_fingerprint, binding_id, state, authentication_mode, + workspace_key_id, workspace_trust_id, created_at, updated_at, revoked_at, workspace_public_key_fingerprint FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![record.workspace_id, record.runtime_id], @@ -3452,7 +3325,8 @@ impl SqliteWorkspaceStore { && existing.state == record.state && existing.authentication_mode == record.authentication_mode && existing.workspace_key_id == record.workspace_key_id - && existing.workspace_key_generation == record.workspace_key_generation; + && existing.workspace_public_key_fingerprint == record.workspace_public_key_fingerprint + && existing.workspace_trust_id == record.workspace_trust_id; if exact_active_match { tx.commit()?; return Ok(WorkspaceRuntimeBindingUpsert::Unchanged); @@ -3466,10 +3340,10 @@ impl SqliteWorkspaceStore { tx.execute( r#"UPDATE workspace_runtime_bindings SET display_name = ?3, base_url = ?4, public_key = ?5, - public_key_fingerprint = ?6, binding_revision = binding_revision + 1, + public_key_fingerprint = ?6, binding_id = ?13, state = ?7, authentication_mode = ?8, - workspace_key_id = ?9, workspace_key_generation = ?10, - updated_at = ?11, revoked_at = ?12 + workspace_key_id = ?9, workspace_trust_id = ?10, + updated_at = ?11, revoked_at = ?12, workspace_public_key_fingerprint = ?14 WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![ record.workspace_id, @@ -3481,10 +3355,11 @@ impl SqliteWorkspaceStore { record.state.as_str(), record.authentication_mode.as_str(), record.workspace_key_id, - record.workspace_key_generation, + record.workspace_trust_id, record.updated_at, record.revoked_at, - ], + new_runtime_binding_id(), + record.workspace_public_key_fingerprint, ], ) .map_err(map_runtime_binding_write_error)?; tx.execute( @@ -3497,9 +3372,9 @@ impl SqliteWorkspaceStore { tx.execute( r#"INSERT INTO workspace_runtime_bindings ( workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, state, authentication_mode, - workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7, ?8, ?9, ?10, ?11, ?12, ?13)"#, + public_key_fingerprint, binding_id, state, authentication_mode, + workspace_key_id, workspace_trust_id, created_at, updated_at, revoked_at, workspace_public_key_fingerprint + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?14, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?15)"#, params![ record.workspace_id, record.runtime_id, @@ -3510,10 +3385,12 @@ impl SqliteWorkspaceStore { record.state.as_str(), record.authentication_mode.as_str(), record.workspace_key_id, - record.workspace_key_generation, + record.workspace_trust_id, record.created_at, record.updated_at, record.revoked_at, + new_runtime_binding_id(), + record.workspace_public_key_fingerprint, ], ) .map_err(map_runtime_binding_write_error)?; @@ -3536,9 +3413,9 @@ impl SqliteWorkspaceStore { let changed = tx.execute( r#"UPDATE workspace_runtime_bindings SET state = 'revoked', revoked_at = ?3, updated_at = ?3, - binding_revision = binding_revision + 1 + binding_id = ?4 WHERE workspace_id = ?1 AND runtime_id = ?2 AND revoked_at IS NULL"#, - params![workspace_id, runtime_id, revoked_at], + params![workspace_id, runtime_id, revoked_at, new_runtime_binding_id()], )?; if changed > 0 { tx.execute( @@ -3554,7 +3431,7 @@ impl SqliteWorkspaceStore { pub fn put_workspace_runtime_binding_key( &self, mut record: WorkspaceRuntimeBinding, - expected_revision: Option, + expected_binding_id: Option<&str>, actor_account_id: &str, ) -> Result<(WorkspaceRuntimeBindingMutation, WorkspaceRuntimeBinding)> { validate_identifier("workspace_id", &record.workspace_id)?; @@ -3569,8 +3446,8 @@ impl SqliteWorkspaceStore { let existing = tx .query_row( r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, state, authentication_mode, - workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at + public_key_fingerprint, binding_id, state, authentication_mode, + workspace_key_id, workspace_trust_id, created_at, updated_at, revoked_at, workspace_public_key_fingerprint FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![record.workspace_id, record.runtime_id], @@ -3579,6 +3456,12 @@ impl SqliteWorkspaceStore { .optional()?; if let Some(existing) = existing { + if expected_binding_id.is_some_and(|id| id != existing.binding_id) { + return Err(Error::RuntimeBindingIdConflict { + expected: expected_binding_id.map(str::to_owned), + actual: Some(existing.binding_id.clone()), + }); + } if existing.revoked_at.is_none() && existing.display_name == record.display_name && existing.base_url == record.base_url @@ -3587,15 +3470,16 @@ impl SqliteWorkspaceStore { && existing.state == record.state && existing.authentication_mode == record.authentication_mode && existing.workspace_key_id == record.workspace_key_id - && existing.workspace_key_generation == record.workspace_key_generation + && existing.workspace_public_key_fingerprint == record.workspace_public_key_fingerprint + && existing.workspace_trust_id == record.workspace_trust_id { tx.commit()?; return Ok((WorkspaceRuntimeBindingMutation::Unchanged, existing)); } - if expected_revision != Some(existing.binding_revision) { - return Err(Error::RuntimeBindingRevisionConflict { - expected: expected_revision, - actual: Some(existing.binding_revision), + if expected_binding_id != Some(existing.binding_id.as_str()) { + return Err(Error::RuntimeBindingIdConflict { + expected: expected_binding_id.map(str::to_owned), + actual: Some(existing.binding_id.clone()), }); } let fingerprint_owner = tx @@ -3626,16 +3510,14 @@ impl SqliteWorkspaceStore { WorkspaceRuntimeBindingMutation::Replaced => "replaced", _ => unreachable!("action is selected above"), }; - let next_revision = existing.binding_revision.checked_add(1).ok_or_else(|| { - Error::Store("Runtime binding revision overflow".to_string()) - })?; + let next_binding_id = new_runtime_binding_id(); tx.execute( r#"UPDATE workspace_runtime_bindings SET display_name = ?3, base_url = ?4, public_key = ?5, public_key_fingerprint = ?6, state = ?7, authentication_mode = ?8, - workspace_key_id = ?9, workspace_key_generation = ?10, - binding_revision = ?11, updated_at = ?12, revoked_at = NULL + workspace_key_id = ?9, workspace_trust_id = ?10, + binding_id = ?11, updated_at = ?12, revoked_at = NULL, workspace_public_key_fingerprint = ?13 WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![ record.workspace_id, @@ -3647,9 +3529,10 @@ impl SqliteWorkspaceStore { record.state.as_str(), record.authentication_mode.as_str(), record.workspace_key_id, - record.workspace_key_generation, - next_revision, + record.workspace_trust_id, + &next_binding_id, record.updated_at, + record.workspace_public_key_fingerprint, ], )?; tx.execute( @@ -3664,13 +3547,13 @@ impl SqliteWorkspaceStore { action_name, Some(&existing.public_key_fingerprint), Some(&record.public_key_fingerprint), - next_revision, + &next_binding_id, &record.updated_at, )?; let updated = tx.query_row( r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, state, authentication_mode, - workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at + public_key_fingerprint, binding_id, state, authentication_mode, + workspace_key_id, workspace_trust_id, created_at, updated_at, revoked_at, workspace_public_key_fingerprint FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![record.workspace_id, record.runtime_id], @@ -3680,9 +3563,9 @@ impl SqliteWorkspaceStore { return Ok((action, updated)); } - if expected_revision.is_some() { - return Err(Error::RuntimeBindingRevisionConflict { - expected: expected_revision, + if expected_binding_id.is_some() { + return Err(Error::RuntimeBindingIdConflict { + expected: expected_binding_id.map(str::to_owned), actual: None, }); } @@ -3699,14 +3582,14 @@ impl SqliteWorkspaceStore { fingerprint: record.public_key_fingerprint, }); } - record.binding_revision = 1; + record.binding_id = new_runtime_binding_id(); record.revoked_at = None; tx.execute( r#"INSERT INTO workspace_runtime_bindings ( workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, state, authentication_mode, - workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7, ?8, ?9, ?10, ?11, ?12, NULL)"#, + public_key_fingerprint, binding_id, state, authentication_mode, + workspace_key_id, workspace_trust_id, created_at, updated_at, revoked_at, workspace_public_key_fingerprint + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?13, ?7, ?8, ?9, ?10, ?11, ?12, NULL, ?14)"#, params![ record.workspace_id, record.runtime_id, @@ -3717,10 +3600,11 @@ impl SqliteWorkspaceStore { record.state.as_str(), record.authentication_mode.as_str(), record.workspace_key_id, - record.workspace_key_generation, + record.workspace_trust_id, record.created_at, record.updated_at, - ], + record.binding_id, + record.workspace_public_key_fingerprint, ], )?; insert_workspace_runtime_binding_audit( &tx, @@ -3730,7 +3614,7 @@ impl SqliteWorkspaceStore { "created", None, Some(&record.public_key_fingerprint), - 1, + &record.binding_id, &record.updated_at, )?; tx.commit()?; @@ -3756,8 +3640,8 @@ impl SqliteWorkspaceStore { let existing = tx .query_row( r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, state, authentication_mode, - workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at + public_key_fingerprint, binding_id, state, authentication_mode, + workspace_key_id, workspace_trust_id, created_at, updated_at, revoked_at, workspace_public_key_fingerprint FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![workspace_id, runtime_id], @@ -3773,14 +3657,22 @@ impl SqliteWorkspaceStore { } tx.execute( r#"UPDATE workspace_runtime_bindings - SET display_name = ?3, base_url = ?4, updated_at = ?5 + SET display_name = ?3, base_url = ?4, updated_at = ?5, + binding_id = ?6, + state = CASE WHEN base_url != ?4 AND state = 'verified' + AND authentication_mode = 'workspace_identity' + THEN 'configured' ELSE state END WHERE workspace_id = ?1 AND runtime_id = ?2"#, - params![workspace_id, runtime_id, display_name, base_url, updated_at], + params![workspace_id, runtime_id, display_name, base_url, updated_at, + if existing.base_url != base_url { new_runtime_binding_id() } else { existing.binding_id.clone() }], )?; + if existing.base_url != base_url { + tx.execute("DELETE FROM workspace_runtime_verifications WHERE workspace_id=?1 AND runtime_id=?2", params![workspace_id,runtime_id])?; + } let updated = tx.query_row( r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, state, authentication_mode, - workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at + public_key_fingerprint, binding_id, state, authentication_mode, + workspace_key_id, workspace_trust_id, created_at, updated_at, revoked_at, workspace_public_key_fingerprint FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![workspace_id, runtime_id], @@ -3795,7 +3687,7 @@ impl SqliteWorkspaceStore { &self, workspace_id: &str, runtime_id: &str, - expected_revision: u64, + expected_binding_id: &str, actor_account_id: &str, revoked_at: &str, ) -> Result<(WorkspaceRuntimeBindingMutation, WorkspaceRuntimeBinding)> { @@ -3808,8 +3700,8 @@ impl SqliteWorkspaceStore { let existing = tx .query_row( r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, state, authentication_mode, - workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at + public_key_fingerprint, binding_id, state, authentication_mode, + workspace_key_id, workspace_trust_id, created_at, updated_at, revoked_at, workspace_public_key_fingerprint FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![workspace_id, runtime_id], @@ -3819,25 +3711,22 @@ impl SqliteWorkspaceStore { .ok_or_else(|| Error::RuntimeBindingNotFound { runtime_id: runtime_id.to_string(), })?; + if expected_binding_id != existing.binding_id { + return Err(Error::RuntimeBindingIdConflict { + expected: Some(expected_binding_id.to_owned()), + actual: Some(existing.binding_id.clone()), + }); + } if existing.revoked_at.is_some() { tx.commit()?; return Ok((WorkspaceRuntimeBindingMutation::Unchanged, existing)); } - if expected_revision != existing.binding_revision { - return Err(Error::RuntimeBindingRevisionConflict { - expected: Some(expected_revision), - actual: Some(existing.binding_revision), - }); - } - let next_revision = existing - .binding_revision - .checked_add(1) - .ok_or_else(|| Error::Store("Runtime binding revision overflow".to_string()))?; + let next_binding_id = new_runtime_binding_id(); tx.execute( r#"UPDATE workspace_runtime_bindings - SET state = 'revoked', revoked_at = ?3, updated_at = ?3, binding_revision = ?4 + SET state = 'revoked', revoked_at = ?3, updated_at = ?3, binding_id = ?4 WHERE workspace_id = ?1 AND runtime_id = ?2"#, - params![workspace_id, runtime_id, revoked_at, next_revision], + params![workspace_id, runtime_id, revoked_at, next_binding_id], )?; tx.execute( "DELETE FROM workspace_runtime_verifications WHERE workspace_id = ?1 AND runtime_id = ?2", @@ -3851,13 +3740,13 @@ impl SqliteWorkspaceStore { "revoked", Some(&existing.public_key_fingerprint), None, - next_revision, + &next_binding_id, revoked_at, )?; let updated = tx.query_row( r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, state, authentication_mode, - workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at + public_key_fingerprint, binding_id, state, authentication_mode, + workspace_key_id, workspace_trust_id, created_at, updated_at, revoked_at, workspace_public_key_fingerprint FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![workspace_id, runtime_id], @@ -3871,8 +3760,8 @@ impl SqliteWorkspaceStore { pub fn workspace_runtime_verification_matches( &self, binding: &WorkspaceRuntimeBinding, - workspace_identity_revision: u64, - workspace_trust_generation: u64, + workspace_public_key_fingerprint: &str, + workspace_trust_id: &str, ) -> Result { let Some(evidence) = self.get_workspace_runtime_verification(&binding.workspace_id, &binding.runtime_id)? @@ -3882,12 +3771,15 @@ impl SqliteWorkspaceStore { Ok(evidence.state == "verified" && evidence.last_outcome == "verified" && evidence.verified_at.is_some() - && evidence.binding_revision == binding.binding_revision + && evidence.binding_id == binding.binding_id && evidence.workspace_key_id == binding.workspace_key_id.as_deref().unwrap_or_default() - && evidence.workspace_identity_revision == workspace_identity_revision - && evidence.workspace_trust_generation == workspace_trust_generation + && evidence.workspace_public_key_fingerprint == workspace_public_key_fingerprint + && binding.workspace_public_key_fingerprint.as_deref() + == Some(workspace_public_key_fingerprint) + && evidence.workspace_trust_id == workspace_trust_id && evidence.runtime_public_key_fingerprint == binding.public_key_fingerprint - && evidence.runtime_identity_revision > 0) + && binding.revoked_at.is_none() + && binding.workspace_trust_id.as_deref() == Some(workspace_trust_id)) } pub fn get_workspace_runtime_verification( @@ -3900,9 +3792,9 @@ impl SqliteWorkspaceStore { self.with_conn(|conn| { let evidence = conn .query_row( - r#"SELECT workspace_id, runtime_id, binding_revision, workspace_key_id, - workspace_identity_revision, workspace_trust_generation, - runtime_public_key_fingerprint, runtime_identity_revision, + r#"SELECT workspace_id, runtime_id, binding_id, workspace_key_id, + workspace_public_key_fingerprint, workspace_trust_id, + runtime_public_key_fingerprint, challenge_id, state, last_outcome, verified_at, checked_at FROM workspace_runtime_verifications WHERE workspace_id = ?1 AND runtime_id = ?2"#, @@ -3923,41 +3815,44 @@ impl SqliteWorkspaceStore { ) -> Result<()> { validate_workspace_runtime_verification(evidence)?; self.with_conn(|conn| { - conn.execute( + let changed = conn.execute( r#"INSERT INTO workspace_runtime_verifications ( - workspace_id, runtime_id, binding_revision, workspace_key_id, - workspace_identity_revision, workspace_trust_generation, - runtime_public_key_fingerprint, runtime_identity_revision, + workspace_id, runtime_id, binding_id, workspace_key_id, + workspace_public_key_fingerprint, workspace_trust_id, + runtime_public_key_fingerprint, challenge_id, state, last_outcome, verified_at, checked_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) + ) SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12 + WHERE EXISTS(SELECT 1 FROM workspace_runtime_bindings binding + WHERE binding.workspace_id=?1 AND binding.runtime_id=?2 + AND binding.binding_id=?3 AND binding.workspace_key_id=?4 + AND binding.workspace_public_key_fingerprint=?5 + AND binding.workspace_trust_id=?6 AND binding.public_key_fingerprint=?7 + AND binding.state IN ('configured','verified') AND binding.revoked_at IS NULL) ON CONFLICT(workspace_id, runtime_id) DO UPDATE SET - binding_revision = excluded.binding_revision, + binding_id = excluded.binding_id, workspace_key_id = excluded.workspace_key_id, - workspace_identity_revision = excluded.workspace_identity_revision, - workspace_trust_generation = excluded.workspace_trust_generation, + workspace_public_key_fingerprint = excluded.workspace_public_key_fingerprint, + workspace_trust_id = excluded.workspace_trust_id, runtime_public_key_fingerprint = excluded.runtime_public_key_fingerprint, - runtime_identity_revision = excluded.runtime_identity_revision, challenge_id = excluded.challenge_id, state = CASE WHEN workspace_runtime_verifications.state = 'verified' - AND workspace_runtime_verifications.binding_revision = excluded.binding_revision + AND workspace_runtime_verifications.binding_id = excluded.binding_id AND workspace_runtime_verifications.workspace_key_id = excluded.workspace_key_id - AND workspace_runtime_verifications.workspace_identity_revision = excluded.workspace_identity_revision - AND workspace_runtime_verifications.workspace_trust_generation = excluded.workspace_trust_generation + AND workspace_runtime_verifications.workspace_public_key_fingerprint = excluded.workspace_public_key_fingerprint + AND workspace_runtime_verifications.workspace_trust_id = excluded.workspace_trust_id AND workspace_runtime_verifications.runtime_public_key_fingerprint = excluded.runtime_public_key_fingerprint - AND workspace_runtime_verifications.runtime_identity_revision = excluded.runtime_identity_revision THEN workspace_runtime_verifications.state ELSE excluded.state END, last_outcome = excluded.last_outcome, verified_at = CASE WHEN workspace_runtime_verifications.state = 'verified' - AND workspace_runtime_verifications.binding_revision = excluded.binding_revision + AND workspace_runtime_verifications.binding_id = excluded.binding_id AND workspace_runtime_verifications.workspace_key_id = excluded.workspace_key_id - AND workspace_runtime_verifications.workspace_identity_revision = excluded.workspace_identity_revision - AND workspace_runtime_verifications.workspace_trust_generation = excluded.workspace_trust_generation + AND workspace_runtime_verifications.workspace_public_key_fingerprint = excluded.workspace_public_key_fingerprint + AND workspace_runtime_verifications.workspace_trust_id = excluded.workspace_trust_id AND workspace_runtime_verifications.runtime_public_key_fingerprint = excluded.runtime_public_key_fingerprint - AND workspace_runtime_verifications.runtime_identity_revision = excluded.runtime_identity_revision THEN workspace_runtime_verifications.verified_at ELSE excluded.verified_at END, @@ -3965,12 +3860,12 @@ impl SqliteWorkspaceStore { params![ evidence.workspace_id, evidence.runtime_id, - evidence.binding_revision, + evidence.binding_id, evidence.workspace_key_id, - evidence.workspace_identity_revision, - evidence.workspace_trust_generation, + evidence.workspace_public_key_fingerprint, + evidence.workspace_trust_id, evidence.runtime_public_key_fingerprint, - evidence.runtime_identity_revision, + evidence.challenge_id, evidence.state, evidence.last_outcome, @@ -3978,6 +3873,9 @@ impl SqliteWorkspaceStore { evidence.checked_at, ], )?; + if changed != 1 { + return Err(Error::RuntimeBindingConflict("Runtime verification attempt no longer matches binding authority".into())); + } Ok(()) }) } @@ -4011,26 +3909,24 @@ impl SqliteWorkspaceStore { self.with_conn(|conn| { let changed = conn.execute( r#"UPDATE workspace_runtime_verifications - SET state = CASE WHEN state = 'verified' THEN state ELSE ?10 END, - last_outcome = ?11, - checked_at = ?12 + SET state = CASE WHEN state = 'verified' THEN state ELSE ?9 END, + last_outcome = ?10, + checked_at = ?11 WHERE workspace_id = ?1 AND runtime_id = ?2 - AND binding_revision = ?3 + AND binding_id = ?3 AND workspace_key_id = ?4 - AND workspace_identity_revision = ?5 - AND workspace_trust_generation = ?6 + AND workspace_public_key_fingerprint = ?5 + AND workspace_trust_id = ?6 AND runtime_public_key_fingerprint = ?7 - AND runtime_identity_revision = ?8 - AND challenge_id = ?9"#, + AND challenge_id = ?8"#, params![ expected.workspace_id, expected.runtime_id, - expected.binding_revision, + expected.binding_id, expected.workspace_key_id, - expected.workspace_identity_revision, - expected.workspace_trust_generation, + expected.workspace_public_key_fingerprint, + expected.workspace_trust_id, expected.runtime_public_key_fingerprint, - expected.runtime_identity_revision, expected.challenge_id, state, outcome, @@ -4046,7 +3942,10 @@ impl SqliteWorkspaceStore { evidence: &WorkspaceRuntimeVerificationEvidence, ) -> Result { validate_workspace_runtime_verification(evidence)?; - if evidence.state != "verified" || evidence.verified_at.is_none() { + if evidence.state != "verified" + || evidence.last_outcome != "verified" + || evidence.verified_at.is_none() + { return Err(Error::Store( "completed Runtime verification evidence must be verified".to_string(), )); @@ -4057,23 +3956,22 @@ impl SqliteWorkspaceStore { r#"SELECT EXISTS( SELECT 1 FROM workspace_runtime_verifications WHERE workspace_id = ?1 AND runtime_id = ?2 - AND binding_revision = ?3 + AND binding_id = ?3 AND workspace_key_id = ?4 - AND workspace_identity_revision = ?5 - AND workspace_trust_generation = ?6 + AND workspace_public_key_fingerprint = ?5 + AND workspace_trust_id = ?6 AND runtime_public_key_fingerprint = ?7 - AND runtime_identity_revision = ?8 - AND challenge_id = ?9 + AND challenge_id = ?8 )"#, params![ evidence.workspace_id, evidence.runtime_id, - evidence.binding_revision, + evidence.binding_id, evidence.workspace_key_id, - evidence.workspace_identity_revision, - evidence.workspace_trust_generation, + evidence.workspace_public_key_fingerprint, + evidence.workspace_trust_id, evidence.runtime_public_key_fingerprint, - evidence.runtime_identity_revision, + evidence.challenge_id, ], |row| row.get::<_, bool>(0), @@ -4087,21 +3985,26 @@ impl SqliteWorkspaceStore { r#"UPDATE workspace_runtime_bindings SET state = 'verified', updated_at = ?7 WHERE workspace_id = ?1 AND runtime_id = ?2 - AND binding_revision = ?3 + AND binding_id = ?3 AND state IN ('configured', 'verified') AND authentication_mode = 'workspace_identity' AND workspace_key_id = ?4 - AND workspace_key_generation = ?5 + AND workspace_trust_id = ?5 + AND workspace_public_key_fingerprint = ?8 AND public_key_fingerprint = ?6 - AND revoked_at IS NULL"#, + AND revoked_at IS NULL + AND EXISTS(SELECT 1 FROM workspace_signing_identities identity + WHERE identity.workspace_id = ?1 AND identity.key_id = ?4 + AND identity.public_key_fingerprint = ?8 AND identity.state = 'active')"#, params![ evidence.workspace_id, evidence.runtime_id, - evidence.binding_revision, + evidence.binding_id, evidence.workspace_key_id, - evidence.workspace_trust_generation, + evidence.workspace_trust_id, evidence.runtime_public_key_fingerprint, evidence.checked_at, + evidence.workspace_public_key_fingerprint, ], )?; if changed != 1 { @@ -4112,18 +4015,17 @@ impl SqliteWorkspaceStore { } tx.execute( r#"INSERT INTO workspace_runtime_verifications ( - workspace_id, runtime_id, binding_revision, workspace_key_id, - workspace_identity_revision, workspace_trust_generation, - runtime_public_key_fingerprint, runtime_identity_revision, + workspace_id, runtime_id, binding_id, workspace_key_id, + workspace_public_key_fingerprint, workspace_trust_id, + runtime_public_key_fingerprint, challenge_id, state, last_outcome, verified_at, checked_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) ON CONFLICT(workspace_id, runtime_id) DO UPDATE SET - binding_revision = excluded.binding_revision, + binding_id = excluded.binding_id, workspace_key_id = excluded.workspace_key_id, - workspace_identity_revision = excluded.workspace_identity_revision, - workspace_trust_generation = excluded.workspace_trust_generation, + workspace_public_key_fingerprint = excluded.workspace_public_key_fingerprint, + workspace_trust_id = excluded.workspace_trust_id, runtime_public_key_fingerprint = excluded.runtime_public_key_fingerprint, - runtime_identity_revision = excluded.runtime_identity_revision, challenge_id = excluded.challenge_id, state = excluded.state, last_outcome = excluded.last_outcome, @@ -4132,12 +4034,12 @@ impl SqliteWorkspaceStore { params![ evidence.workspace_id, evidence.runtime_id, - evidence.binding_revision, + evidence.binding_id, evidence.workspace_key_id, - evidence.workspace_identity_revision, - evidence.workspace_trust_generation, + evidence.workspace_public_key_fingerprint, + evidence.workspace_trust_id, evidence.runtime_public_key_fingerprint, - evidence.runtime_identity_revision, + evidence.challenge_id, evidence.state, evidence.last_outcome, @@ -4147,8 +4049,8 @@ impl SqliteWorkspaceStore { )?; let binding = tx.query_row( r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, state, authentication_mode, - workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at + public_key_fingerprint, binding_id, state, authentication_mode, + workspace_key_id, workspace_trust_id, created_at, updated_at, revoked_at, workspace_public_key_fingerprint FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![evidence.workspace_id, evidence.runtime_id], @@ -4171,10 +4073,10 @@ impl SqliteWorkspaceStore { self.with_conn(|conn| { let mut stmt = conn.prepare( r#"SELECT workspace_id, runtime_id, actor_account_id, action, - old_fingerprint, new_fingerprint, binding_revision, at + old_fingerprint, new_fingerprint, binding_id, at FROM workspace_runtime_binding_audit WHERE workspace_id = ?1 AND runtime_id = ?2 - ORDER BY binding_revision DESC + ORDER BY at DESC, binding_id DESC LIMIT ?3"#, )?; let rows = stmt.query_map(params![workspace_id, runtime_id, limit], |row| { @@ -4185,7 +4087,7 @@ impl SqliteWorkspaceStore { action: row.get(3)?, old_fingerprint: row.get(4)?, new_fingerprint: row.get(5)?, - binding_revision: row.get(6)?, + binding_id: row.get(6)?, at: row.get(7)?, }) })?; @@ -4203,13 +4105,13 @@ fn insert_workspace_runtime_binding_audit( action: &str, old_fingerprint: Option<&str>, new_fingerprint: Option<&str>, - binding_revision: u64, + binding_id: &str, at: &str, ) -> Result<()> { tx.execute( r#"INSERT INTO workspace_runtime_binding_audit ( workspace_id, runtime_id, actor_account_id, action, - old_fingerprint, new_fingerprint, binding_revision, at + old_fingerprint, new_fingerprint, binding_id, at ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"#, params![ workspace_id, @@ -4218,7 +4120,7 @@ fn insert_workspace_runtime_binding_audit( action, old_fingerprint, new_fingerprint, - binding_revision, + binding_id, at, ], )?; @@ -4422,17 +4324,17 @@ impl ControlPlaneStore for SqliteWorkspaceStore { )?; tx.execute( r#"INSERT OR IGNORE INTO workspace_memory_settings ( - workspace_id, settings_revision, language, created_at, updated_at - ) VALUES (?1, 1, 'English', ?2, ?3)"#, + workspace_id, language, created_at, updated_at + ) VALUES (?1, 'English', ?2, ?3)"#, params![record.workspace_id, record.created_at, record.updated_at], )?; tx.execute( r#"INSERT OR IGNORE INTO workspace_signing_identities ( workspace_id, key_id, algorithm, public_key, public_key_fingerprint, - private_material_ref, revision, state, created_at, provisioned_at, updated_at + private_material_ref, state, created_at, provisioned_at, updated_at ) VALUES ( ?1, 'WK-' || lower(hex(randomblob(16))), 'ed25519', NULL, NULL, - 'workspace-signing/' || ?1 || '/ed25519-v1', 1, + 'workspace-signing/' || ?1 || '/ed25519-v1', 'pending_provisioning', ?2, NULL, ?3 )"#, params![record.workspace_id, record.created_at, record.updated_at], @@ -4572,7 +4474,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { )?; let repository = if let Some(initial) = &record.repository { tx.query_row( r#"SELECT workspace_id, repository_id, repository_key, kind, provider, - source_kind, source_uri, default_ref, source_revision, + source_kind, source_uri, default_ref, source_fingerprint, observed_status, observed_at, created_at, updated_at FROM repositories WHERE workspace_id = ?1 AND repository_key = ?2"#, params![workspace.workspace_id, initial.repository_key], @@ -4580,7 +4482,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ).optional()? } else { None }; let persisted_identity = tx.query_row( r#"SELECT workspace_id, key_id, algorithm, public_key, - public_key_fingerprint, private_material_ref, revision, state, + public_key_fingerprint, private_material_ref, state, created_at, provisioned_at, updated_at FROM workspace_signing_identities WHERE workspace_id = ?1"#, params![workspace.workspace_id], @@ -4594,7 +4496,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { != Some(signing_identity.public_key_fingerprint.as_str()) || persisted_identity.private_material_ref != signing_identity.private_material_ref - || persisted_identity.revision != signing_identity.revision + || persisted_identity.state != "active" { return Err(Error::Store( @@ -4615,15 +4517,12 @@ impl ControlPlaneStore for SqliteWorkspaceStore { .to_string(), )); } - let config_revision = crate::config_source::load_state(&tx, &workspace.workspace_id)? - .ok_or_else(|| Error::Store("Workspace config is missing".to_string()))? - .snapshot - .revision; + crate::config_source::load_state(&tx, &workspace.workspace_id)? + .ok_or_else(|| Error::Store("Workspace config is missing".to_string()))?; tx.commit()?; return Ok(WorkspaceBootstrapResult { workspace, repository, - config_revision, replayed: true, }); } @@ -4656,8 +4555,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore { )?; tx.execute( r#"INSERT INTO workspace_memory_settings ( - workspace_id, settings_revision, language, created_at, updated_at - ) VALUES (?1, 1, 'English', ?2, ?3)"#, + workspace_id, language, created_at, updated_at + ) VALUES (?1, 'English', ?2, ?3)"#, params![ record.workspace.workspace_id, record.workspace.created_at, @@ -4668,9 +4567,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore { tx.execute( r#"INSERT INTO repositories ( workspace_id, repository_id, repository_key, kind, provider, uri, - source_kind, source_uri, default_ref, source_revision, + source_kind, source_uri, default_ref, source_fingerprint, observed_status, observed_at, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)"#, + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)"#, params![ repository.workspace_id, repository.repository_id, @@ -4681,7 +4580,6 @@ impl ControlPlaneStore for SqliteWorkspaceStore { repository.source.kind.as_str(), repository.source.uri, repository.default_ref, - repository.source_revision, repository.source_fingerprint, repository.observed_status.as_str(), repository.observed_at, @@ -4708,39 +4606,37 @@ impl ControlPlaneStore for SqliteWorkspaceStore { params![record.workspace.workspace_id, resource_kind], )?; } - let config_revision = crate::config_source::load_state( + crate::config_source::load_state( &tx, &record.workspace.workspace_id, )? - .ok_or_else(|| Error::Store("Workspace config is missing".to_string()))? - .snapshot - .revision; + .ok_or_else(|| Error::Store("Workspace config is missing".to_string()))?; tx.execute( r#"INSERT INTO workspace_signing_identities ( workspace_id, key_id, algorithm, public_key, public_key_fingerprint, - private_material_ref, revision, state, created_at, provisioned_at, updated_at - ) VALUES (?1, ?2, 'ed25519', ?3, ?4, ?5, ?6, 'active', ?7, ?8, ?8)"#, + private_material_ref, state, created_at, provisioned_at, updated_at + ) VALUES (?1, ?2, 'ed25519', ?3, ?4, ?5, 'active', ?6, ?7, ?7)"#, params![ signing_identity.workspace_id, signing_identity.key_id, signing_identity.public_key, signing_identity.public_key_fingerprint, signing_identity.private_material_ref, - signing_identity.revision, + record.workspace.created_at, signing_identity.provisioned_at, ], )?; tx.execute( r#"INSERT INTO workspace_signing_identity_audit ( - event_id, workspace_id, key_id, action, revision, + event_id, workspace_id, key_id, action, public_key_fingerprint, actor_account_id, created_at - ) VALUES (?1, ?2, ?3, 'provisioned', ?4, ?5, ?6, ?7)"#, + ) VALUES (?1, ?2, ?3, 'provisioned', ?4, ?5, ?6)"#, params![ uuid::Uuid::now_v7().to_string(), signing_identity.workspace_id, signing_identity.key_id, - signing_identity.revision, + signing_identity.public_key_fingerprint, record.workspace.owner_account_id, signing_identity.provisioned_at, @@ -4751,14 +4647,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore { SET state = 'completed', completed_at = ?2 WHERE operation_key = ?1 AND state = 'pending' AND workspace_id = ?3 AND key_id = ?4 - AND private_material_ref = ?5 AND revision = ?6"#, + AND private_material_ref = ?5"#, params![ identity_provisioning_operation_key, signing_identity.provisioned_at, signing_identity.workspace_id, signing_identity.key_id, signing_identity.private_material_ref, - signing_identity.revision, + ], )?; if completed != 1 { @@ -4782,7 +4678,6 @@ impl ControlPlaneStore for SqliteWorkspaceStore { Ok(WorkspaceBootstrapResult { workspace: record.workspace.clone(), repository: record.repository.clone(), - config_revision, replayed: false, }) }) @@ -4811,8 +4706,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { if !matches!( operation.operation_kind.as_str(), "workspace_create" | "existing_workspace" - ) || operation.revision == 0 - { + ) { return Err(Error::Store( "Workspace signing identity provisioning reservation is invalid".to_string(), )); @@ -4822,7 +4716,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { if let Some(existing) = tx .query_row( r#"SELECT operation_key, request_fingerprint, operation_kind, workspace_id, - key_id, private_material_ref, revision, actor_account_id, state, + key_id, private_material_ref, actor_account_id, state, created_at, completed_at FROM workspace_signing_identity_provisioning_operations WHERE operation_key = ?1"#, @@ -4845,9 +4739,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore { tx.execute( r#"INSERT INTO workspace_signing_identity_provisioning_operations ( operation_key, request_fingerprint, operation_kind, workspace_id, - key_id, private_material_ref, revision, actor_account_id, state, + key_id, private_material_ref, actor_account_id, state, created_at, completed_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, NULL)"#, + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'pending', ?8, NULL)"#, params![ operation.operation_key, operation.request_fingerprint, @@ -4855,7 +4749,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { operation.workspace_id, operation.key_id, operation.private_material_ref, - operation.revision, + operation.actor_account_id, operation.created_at, ], @@ -4883,7 +4777,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { self.with_conn(|conn| { conn.query_row( r#"SELECT workspace_id, key_id, algorithm, public_key, - public_key_fingerprint, private_material_ref, revision, state, + public_key_fingerprint, private_material_ref, state, created_at, provisioned_at, updated_at FROM workspace_signing_identities WHERE workspace_id = ?1"#, params![workspace_id], @@ -4911,7 +4805,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { let operation = tx .query_row( r#"SELECT operation_key, request_fingerprint, operation_kind, workspace_id, - key_id, private_material_ref, revision, actor_account_id, state, + key_id, private_material_ref, actor_account_id, state, created_at, completed_at FROM workspace_signing_identity_provisioning_operations WHERE operation_key = ?1"#, @@ -4927,7 +4821,6 @@ impl ControlPlaneStore for SqliteWorkspaceStore { if operation.workspace_id != activation.workspace_id || operation.key_id != activation.key_id || operation.private_material_ref != activation.private_material_ref - || operation.revision != activation.revision || operation.actor_account_id != actor_account_id { return Err(Error::Store( @@ -4936,7 +4829,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { } let existing = tx.query_row( r#"SELECT workspace_id, key_id, algorithm, public_key, - public_key_fingerprint, private_material_ref, revision, state, + public_key_fingerprint, private_material_ref, state, created_at, provisioned_at, updated_at FROM workspace_signing_identities WHERE workspace_id = ?1"#, params![activation.workspace_id], @@ -4944,7 +4837,6 @@ impl ControlPlaneStore for SqliteWorkspaceStore { )?; if existing.key_id != activation.key_id || existing.private_material_ref != activation.private_material_ref - || existing.revision != activation.revision { return Err(Error::Store( "Workspace signing identity activation does not match persisted metadata" @@ -4992,14 +4884,13 @@ impl ControlPlaneStore for SqliteWorkspaceStore { )?; tx.execute( r#"INSERT INTO workspace_signing_identity_audit ( - event_id, workspace_id, key_id, action, revision, + event_id, workspace_id, key_id, action, public_key_fingerprint, actor_account_id, created_at - ) VALUES (?1, ?2, ?3, 'provisioned', ?4, ?5, ?6, ?7)"#, + ) VALUES (?1, ?2, ?3, 'provisioned', ?4, ?5, ?6)"#, params![ uuid::Uuid::now_v7().to_string(), activation.workspace_id, activation.key_id, - activation.revision, activation.public_key_fingerprint, actor_account_id, activation.provisioned_at, @@ -5016,7 +4907,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { )?; let activated = tx.query_row( r#"SELECT workspace_id, key_id, algorithm, public_key, - public_key_fingerprint, private_material_ref, revision, state, + public_key_fingerprint, private_material_ref, state, created_at, provisioned_at, updated_at FROM workspace_signing_identities WHERE workspace_id = ?1"#, params![activation.workspace_id], @@ -5042,14 +4933,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore { fn workspace_runtime_verification_matches( &self, binding: &WorkspaceRuntimeBinding, - workspace_identity_revision: u64, - workspace_trust_generation: u64, + workspace_public_key_fingerprint: &str, + workspace_trust_id: &str, ) -> Result { SqliteWorkspaceStore::workspace_runtime_verification_matches( self, binding, - workspace_identity_revision, - workspace_trust_generation, + workspace_public_key_fingerprint, + workspace_trust_id, ) } @@ -5129,8 +5020,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore { runtime_id: &str, operation_id: &str, request_fingerprint: &str, - expected_binding_revision: u64, - config_revision: u64, + expected_binding_id: &str, + config_digest: &str, ) -> Result { SqliteWorkspaceStore::reserve_runtime_removal( self, @@ -5138,8 +5029,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore { runtime_id, operation_id, request_fingerprint, - expected_binding_revision, - config_revision, + expected_binding_id, + config_digest, ) } @@ -5209,13 +5100,13 @@ impl ControlPlaneStore for SqliteWorkspaceStore { async fn put_workspace_runtime_binding_key( &self, record: WorkspaceRuntimeBinding, - expected_revision: Option, + expected_binding_id: Option<&str>, actor_account_id: &str, ) -> Result<(WorkspaceRuntimeBindingMutation, WorkspaceRuntimeBinding)> { SqliteWorkspaceStore::put_workspace_runtime_binding_key( self, record, - expected_revision, + expected_binding_id, actor_account_id, ) } @@ -5242,7 +5133,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { &self, workspace_id: &str, runtime_id: &str, - expected_revision: u64, + expected_binding_id: &str, actor_account_id: &str, revoked_at: &str, ) -> Result<(WorkspaceRuntimeBindingMutation, WorkspaceRuntimeBinding)> { @@ -5250,7 +5141,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { self, workspace_id, runtime_id, - expected_revision, + expected_binding_id, actor_account_id, revoked_at, ) @@ -5410,7 +5301,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { let existing = tx .query_row( r#"SELECT workspace_id, repository_id, repository_key, kind, provider, - source_kind, source_uri, default_ref, source_revision, + source_kind, source_uri, default_ref, source_fingerprint, observed_status, observed_at, created_at, updated_at FROM repositories WHERE workspace_id = ?1 AND repository_key = ?2"#, @@ -5443,9 +5334,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore { tx.execute( r#"INSERT INTO repositories ( workspace_id, repository_id, repository_key, kind, provider, uri, - source_kind, source_uri, default_ref, source_revision, + source_kind, source_uri, default_ref, source_fingerprint, observed_status, observed_at, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)"#, + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)"#, params![ record.workspace_id, record.repository_id, @@ -5456,7 +5347,6 @@ impl ControlPlaneStore for SqliteWorkspaceStore { record.source.kind.as_str(), record.source.uri, record.default_ref, - record.source_revision, record.source_fingerprint, record.observed_status.as_str(), record.observed_at, @@ -5477,7 +5367,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { let existing = transaction .query_row( r#"SELECT workspace_id, repository_id, repository_key, kind, provider, - source_kind, source_uri, default_ref, source_revision, + source_kind, source_uri, default_ref, source_fingerprint, observed_status, observed_at, created_at, updated_at FROM repositories WHERE workspace_id = ?1 AND repository_key = ?2"#, @@ -5493,9 +5383,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore { transaction.execute( r#"INSERT INTO repositories ( workspace_id, repository_id, repository_key, kind, provider, uri, - source_kind, source_uri, default_ref, source_revision, + source_kind, source_uri, default_ref, source_fingerprint, observed_status, observed_at, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)"#, + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)"#, params![ record.workspace_id, record.repository_id, @@ -5506,7 +5396,6 @@ impl ControlPlaneStore for SqliteWorkspaceStore { record.source.kind.as_str(), record.source.uri, record.default_ref, - record.source_revision, record.source_fingerprint, record.observed_status.as_str(), record.observed_at, @@ -5527,7 +5416,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { self.with_conn(|conn| { conn.query_row( r#"SELECT workspace_id, repository_id, repository_key, kind, provider, - source_kind, source_uri, default_ref, source_revision, + source_kind, source_uri, default_ref, source_fingerprint, observed_status, observed_at, created_at, updated_at FROM repositories WHERE workspace_id = ?1 AND repository_id = ?2"#, @@ -5547,7 +5436,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { self.with_conn(|conn| { conn.query_row( r#"SELECT workspace_id, repository_id, repository_key, kind, provider, - source_kind, source_uri, default_ref, source_revision, + source_kind, source_uri, default_ref, source_fingerprint, observed_status, observed_at, created_at, updated_at FROM repositories WHERE workspace_id = ?1 AND repository_key = ?2"#, @@ -5563,7 +5452,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { self.with_conn(|conn| { let mut stmt = conn.prepare( r#"SELECT workspace_id, repository_id, repository_key, kind, provider, - source_kind, source_uri, default_ref, source_revision, + source_kind, source_uri, default_ref, source_fingerprint, observed_status, observed_at, created_at, updated_at FROM repositories WHERE workspace_id = ?1 @@ -5607,7 +5496,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { let existing = tx .query_row( r#"SELECT workspace_id, flow_id, source_kind, name, path, content, - content_digest, revision, created_at, updated_at + content_digest, created_at, updated_at FROM flow_sources WHERE workspace_id = ?1 AND source_kind = ?2 AND name = ?3"#, params![workspace_id, source_kind.as_str(), name], @@ -5619,21 +5508,16 @@ impl ControlPlaneStore for SqliteWorkspaceStore { tx.commit()?; return Ok(existing); } - let revision = existing - .revision - .checked_add(1) - .ok_or_else(|| Error::Store("Flow source revision overflowed".to_string()))?; let definition_json = serde_json::to_string(&definition) .map_err(|error| Error::Store(error.to_string()))?; tx.execute( - r#"INSERT INTO flow_source_revisions ( - workspace_id, flow_id, revision, content, content_digest, + r#"INSERT OR IGNORE INTO flow_source_contents ( + workspace_id, flow_id, content, content_digest, definition_json, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"#, + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#, params![ workspace_id, existing.flow_id, - revision, content, definition.content_digest, definition_json, @@ -5643,7 +5527,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { tx.execute( r#"UPDATE flow_sources SET path = ?4, content = ?5, content_digest = ?6, - revision = ?7, updated_at = ?8 + updated_at = ?7 WHERE workspace_id = ?1 AND source_kind = ?2 AND name = ?3"#, params![ workspace_id, @@ -5652,13 +5536,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore { path, content, definition.content_digest, - revision, now ], )?; tx.commit()?; return Ok(FlowSourceRecord { - revision, path: path.to_string(), content: content.to_string(), content_digest: definition.content_digest, @@ -5673,8 +5555,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore { tx.execute( r#"INSERT INTO flow_sources ( workspace_id, flow_id, source_kind, name, path, content, - content_digest, revision, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 1, ?8, ?8)"#, + content_digest, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8)"#, params![ workspace_id, flow_id, @@ -5687,10 +5569,10 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ], )?; tx.execute( - r#"INSERT INTO flow_source_revisions ( - workspace_id, flow_id, revision, content, content_digest, + r#"INSERT OR IGNORE INTO flow_source_contents ( + workspace_id, flow_id, content, content_digest, definition_json, created_at - ) VALUES (?1, ?2, 1, ?3, ?4, ?5, ?6)"#, + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#, params![ workspace_id, flow_id, @@ -5709,7 +5591,6 @@ impl ControlPlaneStore for SqliteWorkspaceStore { path: path.to_string(), content: content.to_string(), content_digest: definition.content_digest, - revision: 1, created_at: now.to_string(), updated_at: now.to_string(), }) @@ -5725,7 +5606,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { self.with_conn(|conn| { conn.query_row( r#"SELECT workspace_id, flow_id, source_kind, name, path, content, - content_digest, revision, created_at, updated_at + content_digest, created_at, updated_at FROM flow_sources WHERE workspace_id = ?1 AND source_kind = ?2 AND name = ?3"#, params![workspace_id, source_kind.as_str(), name], @@ -5740,7 +5621,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { self.with_conn(|conn| { let mut statement = conn.prepare( r#"SELECT workspace_id, flow_id, source_kind, name, path, content, - content_digest, revision, created_at, updated_at + content_digest, created_at, updated_at FROM flow_sources WHERE workspace_id = ?1 ORDER BY source_kind ASC, name ASC"#, )?; @@ -5758,7 +5639,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { self.with_conn(|conn| { conn.query_row( r#"SELECT workspace_id, flow_id, source_kind, name, path, content, - content_digest, revision, created_at, updated_at + content_digest, created_at, updated_at FROM flow_sources WHERE workspace_id = ?1 AND flow_id = ?2"#, params![workspace_id, flow_id], read_flow_source_record, @@ -5768,20 +5649,20 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } - fn get_flow_source_revision( + fn get_flow_source_content( &self, workspace_id: &str, flow_id: &str, - revision: u64, - ) -> Result> { + content_digest: &str, + ) -> Result> { self.with_conn(|conn| { conn.query_row( - r#"SELECT workspace_id, flow_id, revision, content, content_digest, + r#"SELECT workspace_id, flow_id, content, content_digest, definition_json, created_at - FROM flow_source_revisions - WHERE workspace_id = ?1 AND flow_id = ?2 AND revision = ?3"#, - params![workspace_id, flow_id, revision], - read_flow_source_revision_record, + FROM flow_source_contents + WHERE workspace_id = ?1 AND flow_id = ?2 AND content_digest = ?3"#, + params![workspace_id, flow_id, content_digest], + read_flow_source_content_record, ) .optional() .map_err(Error::from) @@ -6649,12 +6530,12 @@ impl ControlPlaneStore for SqliteWorkspaceStore { let current_attempt = 1_u8; let attempt_id = attempt_id(&request.job_id, current_attempt); tx.execute( - "INSERT INTO backend_jobs (workspace_id, job_id, purpose, input_revision, input_ref, request_json, intent_fingerprint, state, current_attempt, created_at, updated_at, resource_key) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'pending', ?8, ?9, ?9, ?10)", - params![workspace_id, request.job_id, request.purpose, request.input_revision, request.input_ref, request_json, fingerprint, current_attempt as i64, now, request.resource_key()], + "INSERT INTO backend_jobs (workspace_id, job_id, purpose, input_digest, input_ref, request_json, intent_fingerprint, state, current_attempt, created_at, updated_at, resource_key) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'pending', ?8, ?9, ?9, ?10)", + params![workspace_id, request.job_id, request.purpose, request.input_digest()?, request.input_ref, request_json, fingerprint, current_attempt as i64, now, request.resource_key()], )?; tx.execute( - "INSERT INTO backend_job_attempts (workspace_id, job_id, attempt_id, attempt, input_revision, state, deadline_at, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, 'reserved', ?6, ?7, ?7)", - params![workspace_id, request.job_id, attempt_id, current_attempt as i64, request.input_revision, deadline, now], + "INSERT INTO backend_job_attempts (workspace_id, job_id, attempt_id, attempt, input_digest, state, deadline_at, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, 'reserved', ?6, ?7, ?7)", + params![workspace_id, request.job_id, attempt_id, current_attempt as i64, request.input_digest()?, deadline, now], )?; let job = read_backend_job(&tx, workspace_id, &request.job_id)? .ok_or_else(|| Error::Store("reserved Backend Job is missing".to_string()))?; @@ -6713,8 +6594,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore { return Err(Error::Store("Backend Job retry compare-and-set failed".to_string())); } tx.execute( - "INSERT INTO backend_job_attempts (workspace_id, job_id, attempt_id, attempt, input_revision, state, deadline_at, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, 'reserved', ?6, ?7, ?7)", - params![workspace_id, job_id, next_id, next as i64, job.request.input_revision, deadline, now], + "INSERT INTO backend_job_attempts (workspace_id, job_id, attempt_id, attempt, input_digest, state, deadline_at, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, 'reserved', ?6, ?7, ?7)", + params![workspace_id, job_id, next_id, next as i64, job.request.input_digest()?, deadline, now], )?; job = read_backend_job(&tx, workspace_id, job_id)? .ok_or_else(|| Error::Store("retried Backend Job is missing".to_string()))?; @@ -6877,7 +6758,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { .ok_or_else(|| Error::InvalidInput(format!("unknown Backend Job `{job_id}`")))?; let attempt = read_backend_job_attempt(&tx, workspace_id, job_id, attempt_id)? .ok_or_else(|| Error::InvalidInput(format!("unknown Backend Job attempt `{attempt_id}`")))?; - if attempt.attempt != job.current_attempt || attempt.input_revision != job.request.input_revision { + if attempt.attempt != job.current_attempt || attempt.input_digest != job.request.input_digest()? { return Err(Error::InvalidInput( "Backend Job attempt is stale for the current input revision".to_string(), )); @@ -6989,8 +6870,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore { let attempt = read_backend_job_attempt(&tx, workspace_id, &submission.job_id, &submission.attempt_id)? .ok_or_else(|| Error::InvalidInput(format!("unknown Backend Job attempt `{}`", submission.attempt_id)))?; let (digest, result_json) = result_digest(&submission.result, job.request.limits.max_result_bytes)?; - if submission.input_revision != job.request.input_revision - || attempt.input_revision != submission.input_revision + if submission.input_digest != job.request.input_digest()? + || attempt.input_digest != submission.input_digest || attempt.attempt != job.current_attempt { return Err(Error::InvalidInput( @@ -8680,8 +8561,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore { } } if let Some(recovery)=read_ticket_claim_recovery(&tx,&record.workspace_id,operation_id)? { - let item_edit:i64=tx.query_row("SELECT COALESCE(MAX(event_index),0) FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2 AND kind='item_edit'",params![record.workspace_id,record.ticket_id],|r|r.get(0))?; - if recovery.item_revision!=format!("{}:{item_edit}",record.ticket_id) { + let current_digest = ticket::sqlite_ticket_content_digest(&tx, &record.workspace_id, &record.ticket_id) + .map_err(|error| Error::Store(error.to_string()))?; + if Some(&recovery.content_digest) != current_digest.as_ref() { return Err(Error::TicketAssignmentConflict("Ticket changed during explicit Worker binding".into())); } for link in &recovery.effective_links { @@ -10744,7 +10626,6 @@ fn read_flow_source_record(row: &rusqlite::Row<'_>) -> rusqlite::Result(7)?; Ok(FlowSourceRecord { workspace_id: row.get(0)?, flow_id: row.get(1)?, @@ -10753,40 +10634,25 @@ fn read_flow_source_record(row: &rusqlite::Row<'_>) -> rusqlite::Result, -) -> rusqlite::Result { - let revision = row.get::<_, i64>(2)?; - let definition_json = row.get::<_, String>(5)?; +) -> rusqlite::Result { + let definition_json = row.get::<_, String>(4)?; let definition = serde_json::from_str(&definition_json).map_err(|error| { - rusqlite::Error::FromSqlConversionFailure(5, rusqlite::types::Type::Text, Box::new(error)) + rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Text, Box::new(error)) })?; - Ok(FlowSourceRevisionRecord { + Ok(FlowSourceContentRecord { workspace_id: row.get(0)?, flow_id: row.get(1)?, - revision: u64::try_from(revision).map_err(|error| { - rusqlite::Error::FromSqlConversionFailure( - 2, - rusqlite::types::Type::Integer, - Box::new(error), - ) - })?, - content: row.get(3)?, - content_digest: row.get(4)?, + content: row.get(2)?, + content_digest: row.get(3)?, definition, - created_at: row.get(6)?, + created_at: row.get(5)?, }) } @@ -10804,7 +10670,6 @@ fn read_workspace_record(row: &rusqlite::Row<'_>) -> rusqlite::Result, ) -> rusqlite::Result { - let revision: i64 = row.get(6)?; Ok(WorkspaceSigningIdentityRecord { workspace_id: row.get(0)?, key_id: row.get(1)?, @@ -10812,19 +10677,16 @@ fn read_workspace_signing_identity( public_key: row.get(3)?, public_key_fingerprint: row.get(4)?, private_material_ref: row.get(5)?, - revision: u64::try_from(revision) - .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(6, revision))?, - state: row.get(7)?, - created_at: row.get(8)?, - provisioned_at: row.get(9)?, - updated_at: row.get(10)?, + state: row.get(6)?, + created_at: row.get(7)?, + provisioned_at: row.get(8)?, + updated_at: row.get(9)?, }) } fn read_workspace_signing_identity_provisioning_operation( row: &rusqlite::Row<'_>, ) -> rusqlite::Result { - let revision: i64 = row.get(6)?; Ok(WorkspaceSigningIdentityProvisioningOperation { operation_key: row.get(0)?, request_fingerprint: row.get(1)?, @@ -10832,12 +10694,10 @@ fn read_workspace_signing_identity_provisioning_operation( workspace_id: row.get(3)?, key_id: row.get(4)?, private_material_ref: row.get(5)?, - revision: u64::try_from(revision) - .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(6, revision))?, - actor_account_id: row.get(7)?, - state: row.get(8)?, - created_at: row.get(9)?, - completed_at: row.get(10)?, + actor_account_id: row.get(6)?, + state: row.get(7)?, + created_at: row.get(8)?, + completed_at: row.get(9)?, }) } @@ -10851,7 +10711,6 @@ fn repository_registration_intent_matches( && existing.provider == requested.provider && existing.source == requested.source && existing.default_ref == requested.default_ref - && existing.source_revision == requested.source_revision && existing.source_fingerprint == requested.source_fingerprint } @@ -10865,18 +10724,15 @@ fn read_repository_record(row: &rusqlite::Row<'_>) -> rusqlite::Result(5)?; let source_kind = server_api::RepositorySourceKind::parse(&source_kind_value) .unwrap_or(server_api::RepositorySourceKind::Invalid); - let source_revision = row.get::<_, u64>(8)?; - let source_fingerprint = row.get::<_, String>(9)?; + let source_fingerprint = row.get::<_, String>(8)?; let mut source = RepositorySource { kind: source_kind, uri: row.get(6)?, }; - let observed_status_value = row.get::<_, String>(10)?; + let observed_status_value = row.get::<_, String>(9)?; let mut observed_status = RepositoryObservedStatus::parse(&observed_status_value) .unwrap_or(RepositoryObservedStatus::Invalid); - if source_revision == 0 - || crate::repository_source::repository_source_fingerprint(&source) != source_fingerprint - { + if crate::repository_source::repository_source_fingerprint(&source) != source_fingerprint { source.kind = server_api::RepositorySourceKind::Invalid; observed_status = RepositoryObservedStatus::Invalid; } @@ -10888,12 +10744,11 @@ fn read_repository_record(row: &rusqlite::Row<'_>) -> rusqlite::Result(4)?).map_err(|_| { - rusqlite::Error::FromSqlConversionFailure( - 4, - rusqlite::types::Type::Integer, - "invalid Runtime removal binding revision".into(), - ) - })?; - let config_revision = u64::try_from(row.get::<_, i64>(5)?).map_err(|_| { - rusqlite::Error::FromSqlConversionFailure( - 5, - rusqlite::types::Type::Integer, - "invalid Runtime removal config revision".into(), - ) - })?; + let expected_binding_id = row.get(4)?; + let config_digest = row.get(5)?; Ok(RuntimeRemovalOperation { operation_id: row.get(0)?, workspace_id: row.get(1)?, runtime_id: row.get(2)?, request_fingerprint: row.get(3)?, - expected_binding_revision, - config_revision, + expected_binding_id, + config_digest, state, failure_category: row.get(7)?, binding_removed: row.get::<_, i64>(8)? != 0, @@ -11003,7 +10848,7 @@ fn read_runtime_removal_operation( } const RUNTIME_REMOVAL_OPERATION_SELECT: &str = "SELECT operation_id, workspace_id, runtime_id, request_fingerprint, \ - expected_binding_revision, config_revision, state, failure_category, \ + expected_binding_id, config_digest, state, failure_category, \ binding_removed, runtime_registration_removed, created_at, updated_at, completed_at \ FROM runtime_removal_operations"; @@ -11024,27 +10869,25 @@ fn runtime_removal_preflight( conn: &Connection, workspace_id: &str, runtime_id: &str, - expected_binding_revision: u64, + expected_binding_id: &str, ) -> Result<()> { - let binding_revision = conn + let binding_id = conn .query_row( - "SELECT binding_revision FROM workspace_runtime_bindings \ + "SELECT binding_id FROM workspace_runtime_bindings \ WHERE workspace_id = ?1 AND runtime_id = ?2", params![workspace_id, runtime_id], - |row| row.get::<_, i64>(0), + |row| row.get::<_, String>(0), ) .optional()?; - let Some(binding_revision) = binding_revision else { + let Some(binding_id) = binding_id else { return Err(Error::RuntimeBindingNotFound { runtime_id: runtime_id.to_string(), }); }; - let binding_revision = u64::try_from(binding_revision) - .map_err(|_| Error::Store("Runtime binding revision is invalid".to_string()))?; - if binding_revision != expected_binding_revision { - return Err(Error::RuntimeBindingRevisionConflict { - expected: Some(expected_binding_revision), - actual: Some(binding_revision), + if binding_id != expected_binding_id { + return Err(Error::RuntimeBindingIdConflict { + expected: Some(expected_binding_id.to_owned()), + actual: Some(binding_id), }); } @@ -11142,11 +10985,12 @@ fn read_workspace_runtime_binding( base_url: row.get(3)?, public_key: row.get(4)?, public_key_fingerprint: row.get(5)?, - binding_revision: row.get(6)?, + binding_id: row.get(6)?, state, authentication_mode, workspace_key_id: row.get(9)?, - workspace_key_generation: row.get(10)?, + workspace_public_key_fingerprint: row.get(14)?, + workspace_trust_id: row.get(10)?, created_at: row.get(11)?, updated_at: row.get(12)?, revoked_at: row.get(13)?, @@ -11207,7 +11051,10 @@ fn normalize_workspace_runtime_binding_key(record: &mut WorkspaceRuntimeBinding) record.public_key_fingerprint = fingerprint; match record.authentication_mode { WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer => { - if record.workspace_key_id.is_some() || record.workspace_key_generation.is_some() { + if record.workspace_key_id.is_some() + || record.workspace_trust_id.is_some() + || record.workspace_public_key_fingerprint.is_some() + { return Err(Error::InvalidInput( "legacy Runtime bindings must not carry Workspace key metadata".into(), )); @@ -11227,12 +11074,20 @@ fn normalize_workspace_runtime_binding_key(record: &mut WorkspaceRuntimeBinding) ) })?; validate_identifier("workspace_key_id", key_id)?; + validate_non_empty( + "workspace_public_key_fingerprint", + record + .workspace_public_key_fingerprint + .as_deref() + .unwrap_or_default(), + )?; if record - .workspace_key_generation - .is_none_or(|generation| generation == 0) + .workspace_trust_id + .as_deref() + .is_none_or(str::is_empty) { return Err(Error::InvalidInput( - "Workspace identity Runtime bindings require a positive workspace_key_generation" + "Workspace identity Runtime bindings require a non-empty workspace_trust_id" .into(), )); } @@ -11630,7 +11485,7 @@ fn read_backend_job_attempt( attempt_id: &str, ) -> Result> { let row = conn.query_row( - "SELECT attempt, input_revision, state, runtime_id, worker_id, runtime_run_id, dispatched_at, deadline_at, result_json, result_digest, failure_category, failure_detail, worker_cleanup_state, worker_cleanup_failure_category, worker_cleanup_failure_detail, worker_cleanup_updated_at, worker_cleanup_completed_at, created_at, updated_at, completed_at FROM backend_job_attempts WHERE workspace_id = ?1 AND job_id = ?2 AND attempt_id = ?3", + "SELECT attempt, input_digest, state, runtime_id, worker_id, runtime_run_id, dispatched_at, deadline_at, result_json, result_digest, failure_category, failure_detail, worker_cleanup_state, worker_cleanup_failure_category, worker_cleanup_failure_detail, worker_cleanup_updated_at, worker_cleanup_completed_at, created_at, updated_at, completed_at FROM backend_job_attempts WHERE workspace_id = ?1 AND job_id = ?2 AND attempt_id = ?3", params![workspace_id, job_id, attempt_id], |row| Ok(( row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, @@ -11644,7 +11499,7 @@ fn read_backend_job_attempt( ).optional()?; let Some(( attempt, - input_revision, + input_digest, state, runtime_id, worker_id, @@ -11691,7 +11546,7 @@ fn read_backend_job_attempt( job_id: job_id.to_string(), attempt_id: attempt_id.to_string(), attempt, - input_revision, + input_digest, state: BackendJobAttemptState::parse(&state)?, worker, runtime_run_id, @@ -12517,7 +12372,7 @@ pub struct TicketAssignmentOperationRecord { /// Immutable manual claim admission data used for recovery and compensation. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub(crate) struct TicketWorkerClaimRecovery { - pub item_revision: String, + pub content_digest: String, pub selected: Vec, pub original_links: Vec, pub effective_links: Vec, @@ -12767,41 +12622,77 @@ fn read_workdir_registry_record( }) } -fn migrate_workspace_drive_grants_v83_to_v84(conn: &Connection) -> Result<()> { - if current_schema_version(conn)? != 83 { - return Err(Error::Store( - "Drive grants migration requires schema 83".into(), - )); - } - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - tx.execute_batch(include_str!("workspace_drive_grants.sql"))?; - tx.execute( - "INSERT INTO __yoi_schema_migrations(version,name) VALUES(?1,?2)", - params![84_i64, WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME], - )?; - tx.commit()?; - Ok(()) +fn new_runtime_binding_id() -> String { + format!("RB-{}", Uuid::now_v7().simple()) } -fn migrate_worker_restore_intents_v79_to_v80(conn: &Connection) -> Result<()> { - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - tx.execute_batch("CREATE TABLE worker_restore_intents ( - workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, - domain_key TEXT NOT NULL, request_id TEXT NOT NULL, expected_token TEXT NOT NULL, - settled INTEGER NOT NULL CHECK(settled IN (0,1)), - PRIMARY KEY(workspace_id,runtime_id,worker_id,request_id), - FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE - ); - CREATE UNIQUE INDEX worker_restore_intent_pending_domain ON worker_restore_intents(workspace_id,runtime_id,worker_id,domain_key) WHERE settled=0;")?; - tx.execute( - "INSERT INTO __yoi_schema_migrations(version,name) VALUES(?1,?2)", - params![80_i64, WORKER_RESTORE_INTENTS_MIGRATION_NAME], - )?; - tx.commit()?; - Ok(()) +include!("frozen_legacy_migrations.rs"); +#[allow(dead_code)] +#[derive(Clone)] +struct FrozenWorkspaceRuntimeBinding { + workspace_id: String, + runtime_id: String, + display_name: String, + base_url: String, + public_key: String, + public_key_fingerprint: String, + binding_revision: u64, + state: WorkspaceRuntimeBindingState, + authentication_mode: WorkspaceRuntimeAuthenticationMode, + workspace_key_id: Option, + workspace_key_generation: Option, + created_at: String, + updated_at: String, + revoked_at: Option, } +fn read_frozen_workspace_runtime_binding( + row: &rusqlite::Row<'_>, +) -> rusqlite::Result { + let state = match row.get::<_, String>(7)?.as_str() { + "configured" => WorkspaceRuntimeBindingState::Configured, + "verified" => WorkspaceRuntimeBindingState::Verified, + "revoked" => WorkspaceRuntimeBindingState::Revoked, + _ => return Err(rusqlite::Error::InvalidQuery), + }; + let authentication_mode = match row.get::<_, String>(8)?.as_str() { + "workspace_identity" => WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity, + "legacy_server_issuer" => WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer, + _ => return Err(rusqlite::Error::InvalidQuery), + }; + Ok(FrozenWorkspaceRuntimeBinding { + workspace_id: row.get(0)?, + runtime_id: row.get(1)?, + display_name: row.get(2)?, + base_url: row.get(3)?, + public_key: row.get(4)?, + public_key_fingerprint: row.get(5)?, + binding_revision: row.get(6)?, + state, + authentication_mode, + workspace_key_id: row.get(9)?, + workspace_key_generation: row.get(10)?, + created_at: row.get(11)?, + updated_at: row.get(12)?, + revoked_at: row.get(13)?, + }) +} + +include!("frozen_flow_content_migration.rs"); +include!("frozen_content_state_migration.rs"); +include!("frozen_repository_key_migration.rs"); +include!("frozen_runtime_binding_migration.rs"); fn prepare_connection(conn: &Connection) -> Result<()> { + prepare_connection_with_secret_source( + conn, + conn.path().filter(|path| !path.is_empty()).map(Path::new), + ) +} + +fn prepare_connection_with_secret_source( + conn: &Connection, + secret_source: Option<&Path>, +) -> Result<()> { configure_sqlite(conn)?; let workspace_schema_version = current_schema_version(conn)?; if workspace_schema_version == 0 { @@ -12810,13 +12701,13 @@ fn prepare_connection(conn: &Connection) -> Result<()> { merge_request::migrate(conn).map_err(|error| { Error::Store(format!("Merge Request schema verification failed: {error}")) })?; - apply_migrations(conn) + apply_migrations_with_secret_source(conn, secret_source) .map_err(|error| Error::Store(format!("workspace schema migration failed: {error}")))?; } else { // Workspace migrations own cross-domain table rebuilds. Apply them before // component verification so the Ticket crate sees its new canonical target // schema and migration marker rather than rejecting the legacy singular shape. - apply_migrations(conn) + apply_migrations_with_secret_source(conn, secret_source) .map_err(|error| Error::Store(format!("workspace schema migration failed: {error}")))?; ticket::migrate_sqlite_ticket_schema(conn) .map_err(|error| Error::Store(format!("Ticket schema verification failed: {error}")))?; @@ -12858,702 +12749,10 @@ struct WorkspaceRuntimeBindingV51 { revoked_at: Option, } -fn migrate_workspace_runtime_bindings_v50_to_v51(conn: &Connection) -> Result<()> { - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - let legacy_columns = table_columns(&tx, "trusted_runtime_records")? - .into_iter() - .collect::>(); - let expected_columns = [ - "runtime_id", - "display_name", - "base_url", - "public_key", - "created_at", - "updated_at", - "revoked_at", - "workspace_id", - ] - .into_iter() - .map(str::to_string) - .collect::>(); - if legacy_columns != expected_columns { - return Err(Error::Store(format!( - "schema-50 trusted_runtime_records columns are not canonical" - ))); - } - - let all_workspace_ids = { - let mut stmt = tx.prepare("SELECT workspace_id FROM workspaces ORDER BY workspace_id")?; - stmt.query_map([], |row| row.get::<_, String>(0))? - .collect::>>()? - }; - - let mut bindings = Vec::new(); - { - let mut stmt = tx.prepare( - r#"SELECT runtime_id, workspace_id, display_name, base_url, public_key, - created_at, updated_at, revoked_at - FROM trusted_runtime_records ORDER BY runtime_id"#, - )?; - let rows = stmt.query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, Option>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - row.get::<_, String>(4)?, - row.get::<_, String>(5)?, - row.get::<_, String>(6)?, - row.get::<_, Option>(7)?, - )) - })?; - for row in rows { - let ( - runtime_id, - workspace_id, - display_name, - base_url, - public_key, - created_at, - updated_at, - revoked_at, - ) = row?; - let workspace_ids = if runtime_id == crate::hosts::EMBEDDED_RUNTIME_ID { - if all_workspace_ids.is_empty() { - return Err(Error::Store( - "embedded Runtime migration requires at least one registered Workspace" - .to_string(), - )); - } - all_workspace_ids.clone() - } else if let Some(workspace_id) = workspace_id.filter(|value| !value.trim().is_empty()) - { - vec![workspace_id] - } else { - continue; - }; - let (public_key, fingerprint) = normalize_runtime_public_key(&public_key)?; - for workspace_id in workspace_ids { - let workspace_exists = tx.query_row( - "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)", - params![workspace_id], - |row| row.get::<_, i64>(0), - )? != 0; - if !workspace_exists { - return Err(Error::Store(format!( - "Runtime `{runtime_id}` references unknown Workspace `{workspace_id}`" - ))); - } - bindings.push(WorkspaceRuntimeBindingV51 { - workspace_id, - runtime_id: runtime_id.clone(), - display_name: display_name.clone(), - base_url: base_url.clone(), - public_key: Some(public_key.clone()), - public_key_fingerprint: Some(fingerprint.clone()), - created_at: created_at.clone(), - updated_at: updated_at.clone(), - revoked_at: revoked_at.clone(), - }); - } - } - } - - let mut binding_keys = HashSet::new(); - let mut trust_keys = HashSet::new(); - for binding in &bindings { - if !binding_keys.insert((binding.workspace_id.clone(), binding.runtime_id.clone())) { - return Err(Error::Store(format!( - "duplicate Runtime binding `{}/{}` in schema-50", - binding.workspace_id, binding.runtime_id - ))); - } - let fingerprint = binding - .public_key_fingerprint - .clone() - .expect("normalized key"); - if !trust_keys.insert((binding.workspace_id.clone(), fingerprint.clone())) { - return Err(Error::Store(format!( - "duplicate Runtime trust fingerprint `{fingerprint}` in Workspace `{}`", - binding.workspace_id - ))); - } - } - - let mut consumed_jtis = Vec::new(); - { - let runtime_workspaces = bindings.iter().fold( - HashMap::<&str, Vec<&str>>::new(), - |mut workspaces, binding| { - workspaces - .entry(binding.runtime_id.as_str()) - .or_default() - .push(binding.workspace_id.as_str()); - workspaces - }, - ); - let mut stmt = tx.prepare( - "SELECT runtime_id, jti, expires_at, consumed_at FROM worker_mutation_source_proof_jtis", - )?; - let rows = stmt.query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, i64>(2)?, - row.get::<_, String>(3)?, - )) - })?; - for row in rows { - let (runtime_id, jti, expires_at, consumed_at) = row?; - let Some(workspace_ids) = runtime_workspaces.get(runtime_id.as_str()) else { - continue; - }; - for workspace_id in workspace_ids { - consumed_jtis.push(( - (*workspace_id).to_string(), - runtime_id.clone(), - jti.clone(), - expires_at, - consumed_at.clone(), - )); - } - } - } - - tx.execute_batch( - r#" - ALTER TABLE worker_mutation_source_proof_jtis - RENAME TO worker_mutation_source_proof_jtis_v50; - ALTER TABLE trusted_runtime_records - RENAME TO trusted_runtime_records_v50; - - CREATE TABLE workspace_runtime_bindings ( - workspace_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - display_name TEXT NOT NULL, - base_url TEXT NOT NULL, - public_key TEXT, - public_key_fingerprint TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - revoked_at TEXT, - PRIMARY KEY (workspace_id, runtime_id), - UNIQUE (workspace_id, public_key_fingerprint), - CHECK ((public_key IS NULL) = (public_key_fingerprint IS NULL)), - FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT - ); - CREATE INDEX idx_workspace_runtime_bindings_workspace - ON workspace_runtime_bindings(workspace_id, revoked_at, runtime_id); - CREATE TABLE worker_mutation_source_proof_jtis ( - workspace_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - jti TEXT NOT NULL, - expires_at INTEGER NOT NULL, - consumed_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, runtime_id, jti) - ); - "#, - )?; - for binding in bindings { - tx.execute( - r#"INSERT INTO workspace_runtime_bindings ( - workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, created_at, updated_at, revoked_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"#, - params![ - binding.workspace_id, - binding.runtime_id, - binding.display_name, - binding.base_url, - binding.public_key, - binding.public_key_fingerprint, - binding.created_at, - binding.updated_at, - binding.revoked_at, - ], - )?; - } - for (workspace_id, runtime_id, jti, expires_at, consumed_at) in consumed_jtis { - tx.execute( - "INSERT INTO worker_mutation_source_proof_jtis ( - workspace_id, runtime_id, jti, expires_at, consumed_at - ) VALUES (?1, ?2, ?3, ?4, ?5)", - params![workspace_id, runtime_id, jti, expires_at, consumed_at], - )?; - } - tx.execute_batch( - "DROP TABLE worker_mutation_source_proof_jtis_v50; - DROP TABLE trusted_runtime_records_v50;", - )?; - let foreign_key_failures = - tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { - row.get::<_, i64>(0) - })?; - if foreign_key_failures != 0 { - return Err(Error::Store(format!( - "schema-51 migration produced {foreign_key_failures} foreign-key violation(s)" - ))); - } - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![51, WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME], - )?; - tx.commit()?; - Ok(()) -} - -fn migrate_workspace_runtime_bindings_v51_to_v52(conn: &Connection) -> Result<()> { - let current = current_schema_version(conn)?; - if current != 51 { - return Err(Error::Store(format!( - "expected schema version 51 before {RUNTIME_BINDING_AUDIT_MIGRATION_NAME} migration, found {current}" - ))); - } - - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - tx.execute_batch( - r#" - ALTER TABLE workspace_runtime_bindings - ADD COLUMN binding_revision INTEGER NOT NULL DEFAULT 1 CHECK (binding_revision > 0); - CREATE TABLE workspace_runtime_binding_audit ( - workspace_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - actor_account_id TEXT NOT NULL, - action TEXT NOT NULL CHECK (action IN ('created', 'replaced', 'reactivated', 'revoked')), - old_fingerprint TEXT, - new_fingerprint TEXT, - binding_revision INTEGER NOT NULL CHECK (binding_revision > 0), - at TEXT NOT NULL, - PRIMARY KEY (workspace_id, runtime_id, binding_revision), - FOREIGN KEY(workspace_id, runtime_id) - REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE RESTRICT, - FOREIGN KEY(actor_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT - ); - CREATE INDEX idx_workspace_runtime_binding_audit_recent - ON workspace_runtime_binding_audit(workspace_id, runtime_id, binding_revision DESC); - "#, - )?; - verify_workspace_runtime_binding_schema(&tx)?; - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![52, RUNTIME_BINDING_AUDIT_MIGRATION_NAME], - )?; - tx.commit()?; - Ok(()) -} - -fn migrate_workspace_deletion_v52_to_v53(conn: &Connection) -> Result<()> { - let current = current_schema_version(conn)?; - if current != 52 { - return Err(Error::Store(format!( - "expected schema version 52 before {WORKSPACE_DELETION_MIGRATION_NAME} migration, found {current}" - ))); - } - - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - tx.execute_batch( - r#" - CREATE TABLE worker_create_reservations_v53 ( - workspace_id TEXT NOT NULL, - allocation_key TEXT NOT NULL, - worker_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - create_fingerprint TEXT NOT NULL, - state TEXT NOT NULL CHECK (state IN ('reserved', 'created', 'removed')), - request_fingerprint TEXT, - memory_settings_revision INTEGER, - memory_language TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, allocation_key), - UNIQUE (workspace_id, worker_id), - FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - CHECK ( - (request_fingerprint IS NULL AND memory_settings_revision IS NULL AND memory_language IS NULL) - OR - (request_fingerprint IS NOT NULL AND memory_settings_revision IS NOT NULL AND memory_settings_revision > 0 AND memory_language IS NOT NULL AND length(trim(memory_language)) > 0) - ) - ); - INSERT INTO worker_create_reservations_v53 ( - workspace_id, allocation_key, worker_id, runtime_id, create_fingerprint, - state, request_fingerprint, memory_settings_revision, memory_language, - created_at, updated_at - ) - SELECT workspace_id, allocation_key, worker_id, runtime_id, create_fingerprint, - state, - CASE WHEN request_fingerprint IS NOT NULL - AND memory_settings_revision IS NOT NULL - AND memory_settings_revision > 0 - AND memory_language IS NOT NULL - AND length(trim(memory_language)) > 0 - THEN request_fingerprint ELSE NULL END, - CASE WHEN request_fingerprint IS NOT NULL - AND memory_settings_revision IS NOT NULL - AND memory_settings_revision > 0 - AND memory_language IS NOT NULL - AND length(trim(memory_language)) > 0 - THEN memory_settings_revision ELSE NULL END, - CASE WHEN request_fingerprint IS NOT NULL - AND memory_settings_revision IS NOT NULL - AND memory_settings_revision > 0 - AND memory_language IS NOT NULL - AND length(trim(memory_language)) > 0 - THEN memory_language ELSE NULL END, - created_at, updated_at - FROM worker_create_reservations; - DROP TABLE worker_create_reservations; - ALTER TABLE worker_create_reservations_v53 RENAME TO worker_create_reservations; - - CREATE TABLE workspace_deletion_operations ( - operation_id TEXT PRIMARY KEY, - request_fingerprint TEXT NOT NULL, - workspace_id TEXT NOT NULL, - workspace_display_name TEXT NOT NULL, - workspace_revision TEXT NOT NULL, - owner_account_id TEXT NOT NULL, - actor_account_id TEXT NOT NULL, - state TEXT NOT NULL CHECK(state IN ('queued', 'running', 'blocked', 'failed', 'succeeded')), - resource_counts_json TEXT NOT NULL, - child_operation_ids_json TEXT NOT NULL, - blockers_json TEXT NOT NULL, - failure_category TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - completed_at TEXT, - FOREIGN KEY(owner_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT, - FOREIGN KEY(actor_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT - ); - CREATE INDEX workspace_deletion_operations_workspace_recent - ON workspace_deletion_operations(workspace_id, created_at DESC); - "#, - )?; - verify_workspace_deletion_schema(&tx)?; - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![53_i64, WORKSPACE_DELETION_MIGRATION_NAME], - )?; - tx.commit()?; - Ok(()) -} - -fn migrate_workspace_signing_identity_v53_to_v54(conn: &Connection) -> Result<()> { - let current = current_schema_version(conn)?; - if current != 53 { - return Err(Error::Store(format!( - "expected schema version 53 before {WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME} migration, found {current}" - ))); - } - - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - tx.execute_batch( - r#" - CREATE TABLE workspace_signing_identities ( - workspace_id TEXT PRIMARY KEY, - key_id TEXT NOT NULL UNIQUE, - algorithm TEXT NOT NULL CHECK (algorithm = 'ed25519'), - public_key TEXT, - public_key_fingerprint TEXT, - private_material_ref TEXT NOT NULL UNIQUE, - revision INTEGER NOT NULL CHECK (revision >= 1), - state TEXT NOT NULL CHECK (state IN ('pending_provisioning', 'active')), - created_at TEXT NOT NULL, - provisioned_at TEXT, - updated_at TEXT NOT NULL, - CHECK ( - (state = 'pending_provisioning' AND public_key IS NULL AND public_key_fingerprint IS NULL AND provisioned_at IS NULL) - OR - (state = 'active' AND public_key IS NOT NULL AND public_key_fingerprint IS NOT NULL AND provisioned_at IS NOT NULL) - ), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE - ); - CREATE TABLE workspace_signing_identity_provisioning_operations ( - operation_key TEXT PRIMARY KEY, - request_fingerprint TEXT NOT NULL, - operation_kind TEXT NOT NULL CHECK (operation_kind IN ('workspace_create', 'existing_workspace')), - workspace_id TEXT NOT NULL UNIQUE, - key_id TEXT NOT NULL UNIQUE, - private_material_ref TEXT NOT NULL UNIQUE, - revision INTEGER NOT NULL CHECK (revision >= 1), - actor_account_id TEXT NOT NULL, - state TEXT NOT NULL CHECK (state IN ('pending', 'completed')), - created_at TEXT NOT NULL, - completed_at TEXT - ); - CREATE TABLE workspace_signing_identity_audit ( - event_id TEXT PRIMARY KEY, - workspace_id TEXT NOT NULL, - key_id TEXT NOT NULL, - action TEXT NOT NULL CHECK (action IN ('provisioned')), - revision INTEGER NOT NULL CHECK (revision >= 1), - public_key_fingerprint TEXT NOT NULL, - actor_account_id TEXT NOT NULL, - created_at TEXT NOT NULL, - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE - ); - CREATE INDEX workspace_signing_identity_audit_workspace_idx - ON workspace_signing_identity_audit(workspace_id, created_at DESC); - INSERT INTO workspace_signing_identities ( - workspace_id, key_id, algorithm, public_key, public_key_fingerprint, - private_material_ref, revision, state, created_at, provisioned_at, updated_at - ) - SELECT workspace_id, - 'WK-' || lower(hex(randomblob(16))), - 'ed25519', NULL, NULL, - 'workspace-signing/' || workspace_id || '/ed25519-v1', - 1, 'pending_provisioning', created_at, NULL, updated_at - FROM workspaces; - "#, - )?; - verify_workspace_signing_identity_schema(&tx)?; - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![54_i64, WORKSPACE_SIGNING_IDENTITY_MIGRATION_NAME], - )?; - tx.commit()?; - Ok(()) -} - -fn migrate_workspace_runtime_binding_state_v54_to_v55(conn: &Connection) -> Result<()> { - let current = current_schema_version(conn)?; - if current != 54 { - return Err(Error::Store(format!( - "expected schema version 54 before {WORKSPACE_RUNTIME_BINDING_STATE_MIGRATION_NAME} migration, found {current}" - ))); - } - - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - let columns = table_columns(&tx, "workspace_runtime_bindings")?; - let lifecycle_columns = [ - "state", - "authentication_mode", - "workspace_key_id", - "workspace_key_generation", - ]; - let present = lifecycle_columns - .iter() - .filter(|column| columns.iter().any(|existing| existing == **column)) - .count(); - if present == 0 { - tx.execute_batch( - r#" - CREATE TABLE workspace_runtime_bindings_v55 ( - workspace_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - display_name TEXT NOT NULL, - base_url TEXT NOT NULL, - public_key TEXT NOT NULL, - public_key_fingerprint TEXT NOT NULL, - binding_revision INTEGER NOT NULL DEFAULT 1 CHECK (binding_revision > 0), - state TEXT NOT NULL CHECK (state IN ('configured', 'verified', 'revoked')), - authentication_mode TEXT NOT NULL CHECK (authentication_mode IN ('legacy_server_issuer', 'workspace_identity')), - workspace_key_id TEXT, - workspace_key_generation INTEGER CHECK (workspace_key_generation > 0), - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - revoked_at TEXT, - PRIMARY KEY (workspace_id, runtime_id), - UNIQUE (workspace_id, public_key_fingerprint), - FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT, - CHECK ( - (authentication_mode = 'legacy_server_issuer' AND workspace_key_id IS NULL AND workspace_key_generation IS NULL) - OR - (authentication_mode = 'workspace_identity' AND workspace_key_id IS NOT NULL AND workspace_key_generation IS NOT NULL) - ), - CHECK ( - (state = 'revoked' AND revoked_at IS NOT NULL) - OR - (state != 'revoked' AND revoked_at IS NULL) - ) - ); - INSERT INTO workspace_runtime_bindings_v55 ( - workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, state, authentication_mode, - workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at - ) - SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, binding_revision, - CASE WHEN revoked_at IS NULL THEN 'verified' ELSE 'revoked' END, - 'legacy_server_issuer', NULL, NULL, created_at, updated_at, revoked_at - FROM workspace_runtime_bindings; - DROP TABLE workspace_runtime_bindings; - ALTER TABLE workspace_runtime_bindings_v55 RENAME TO workspace_runtime_bindings; - CREATE INDEX idx_workspace_runtime_bindings_workspace - ON workspace_runtime_bindings(workspace_id, revoked_at, runtime_id); - "#, - )?; - } else if present != lifecycle_columns.len() { - return Err(Error::Store( - "workspace_runtime_bindings has a partially applied lifecycle schema".to_string(), - )); - } - verify_workspace_runtime_binding_schema(&tx)?; - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![55_i64, WORKSPACE_RUNTIME_BINDING_STATE_MIGRATION_NAME], - )?; - tx.commit()?; - Ok(()) -} - -fn migrate_workspace_runtime_verification_v55_to_v56(conn: &Connection) -> Result<()> { - let current = current_schema_version(conn)?; - if current != 55 { - return Err(Error::Store(format!( - "expected schema version 55 before {WORKSPACE_RUNTIME_VERIFICATION_MIGRATION_NAME} migration, found {current}" - ))); - } - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - tx.execute_batch( - r#" - CREATE TABLE IF NOT EXISTS workspace_runtime_verifications ( - workspace_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - binding_revision INTEGER NOT NULL CHECK(binding_revision > 0), - workspace_key_id TEXT NOT NULL, - workspace_identity_revision INTEGER NOT NULL CHECK(workspace_identity_revision > 0), - workspace_trust_generation INTEGER NOT NULL CHECK(workspace_trust_generation > 0), - runtime_public_key_fingerprint TEXT NOT NULL, - runtime_identity_revision INTEGER NOT NULL CHECK(runtime_identity_revision > 0), - challenge_id TEXT NOT NULL, - state TEXT NOT NULL CHECK(state IN ('pending', 'verified', 'failed')), - last_outcome TEXT NOT NULL, - verified_at TEXT, - checked_at TEXT NOT NULL, - PRIMARY KEY(workspace_id, runtime_id), - FOREIGN KEY(workspace_id, runtime_id) - REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) - ON DELETE CASCADE, - CHECK((state = 'verified' AND verified_at IS NOT NULL) - OR (state != 'verified' AND verified_at IS NULL)) - ); - CREATE INDEX IF NOT EXISTS workspace_runtime_verifications_state_idx - ON workspace_runtime_verifications(workspace_id, state, checked_at DESC); - "#, - )?; - verify_workspace_runtime_verification_schema(&tx)?; - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![56_i64, WORKSPACE_RUNTIME_VERIFICATION_MIGRATION_NAME], - )?; - tx.commit()?; - Ok(()) -} - -fn migrate_legacy_external_runtime_bindings_v56_to_v57(conn: &Connection) -> Result<()> { - let current = current_schema_version(conn)?; - if current != 56 { - return Err(Error::Store(format!( - "expected schema version 56 before {LEGACY_EXTERNAL_RUNTIME_BINDING_CUTOVER_MIGRATION_NAME} migration, found {current}" - ))); - } - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - let missing_workspace_identity_count = tx.query_row( - "SELECT COUNT(*) - FROM workspace_runtime_bindings binding - LEFT JOIN workspace_signing_identities identity - ON identity.workspace_id = binding.workspace_id - WHERE binding.authentication_mode = 'legacy_server_issuer' - AND binding.runtime_id <> ?1 - AND identity.workspace_id IS NULL", - params![crate::hosts::EMBEDDED_RUNTIME_ID], - |row| row.get::<_, i64>(0), - )?; - if missing_workspace_identity_count != 0 { - return Err(Error::Store( - "legacy external Runtime binding migration requires Workspace signing identity metadata" - .to_string(), - )); - } - tx.execute( - "DELETE FROM workspace_runtime_verifications - WHERE EXISTS ( - SELECT 1 FROM workspace_runtime_bindings binding - WHERE binding.workspace_id = workspace_runtime_verifications.workspace_id - AND binding.runtime_id = workspace_runtime_verifications.runtime_id - AND binding.authentication_mode = 'legacy_server_issuer' - AND binding.runtime_id <> ?1 - )", - params![crate::hosts::EMBEDDED_RUNTIME_ID], - )?; - tx.execute( - "UPDATE workspace_runtime_bindings - SET state = CASE WHEN state = 'verified' THEN 'configured' ELSE state END, - authentication_mode = 'workspace_identity', - workspace_key_id = ( - SELECT identity.key_id FROM workspace_signing_identities identity - WHERE identity.workspace_id = workspace_runtime_bindings.workspace_id - ), - workspace_key_generation = ( - SELECT identity.revision FROM workspace_signing_identities identity - WHERE identity.workspace_id = workspace_runtime_bindings.workspace_id - ) - WHERE authentication_mode = 'legacy_server_issuer' - AND runtime_id <> ?1", - params![crate::hosts::EMBEDDED_RUNTIME_ID], - )?; - let remaining_external_legacy_bindings = tx.query_row( - "SELECT COUNT(*) FROM workspace_runtime_bindings - WHERE authentication_mode = 'legacy_server_issuer' - AND runtime_id <> ?1", - params![crate::hosts::EMBEDDED_RUNTIME_ID], - |row| row.get::<_, i64>(0), - )?; - if remaining_external_legacy_bindings != 0 { - return Err(Error::Store( - "legacy Server-issued external Runtime bindings remain after schema-57 migration" - .to_string(), - )); - } - verify_workspace_runtime_binding_schema(&tx)?; - verify_workspace_runtime_verification_schema(&tx)?; - let foreign_key_violations = - tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { - row.get::<_, i64>(0) - })?; - if foreign_key_violations != 0 { - return Err(Error::Store(format!( - "schema-57 Runtime binding migration left {foreign_key_violations} foreign-key violation(s)" - ))); - } - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![ - 57_i64, - LEGACY_EXTERNAL_RUNTIME_BINDING_CUTOVER_MIGRATION_NAME - ], - )?; - tx.commit()?; - Ok(()) -} - -fn migrate_workdir_cache_generation_v57_to_v58(conn: &Connection) -> Result<()> { - let current = current_schema_version(conn)?; - if current != 57 { - return Err(Error::Store(format!( - "expected schema version 57 before {REMOVE_WORKDIR_CACHE_GENERATION_MIGRATION_NAME} migration, found {current}" - ))); - } - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - if table_columns(&tx, "workdir_create_operations")?.contains(&"cache_generation".to_string()) { - tx.execute( - "ALTER TABLE workdir_create_operations DROP COLUMN cache_generation", - [], - )?; - } - if table_columns(&tx, "workdir_create_operations")?.contains(&"cache_generation".to_string()) { - return Err(Error::Store( - "obsolete Workdir cache generation remains after schema-58 migration".to_string(), - )); - } - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![58_i64, REMOVE_WORKDIR_CACHE_GENERATION_MIGRATION_NAME], - )?; - tx.commit()?; - Ok(()) -} - fn verify_workspace_runtime_verification_schema(conn: &Connection) -> Result<()> { + if column_exists(conn, "workspace_runtime_verifications", "binding_id")? { + return verify_runtime_binding_content_schema(conn); + } let actual = table_columns(conn, "workspace_runtime_verifications")? .into_iter() .collect::>(); @@ -13584,6 +12783,9 @@ fn verify_workspace_runtime_verification_schema(conn: &Connection) -> Result<()> } fn verify_workspace_signing_identity_schema(conn: &Connection) -> Result<()> { + if !column_exists(conn, "workspace_signing_identities", "revision")? { + return verify_runtime_binding_content_schema(conn); + } for (table, expected) in [ ( "workspace_signing_identities", @@ -13667,7 +12869,15 @@ fn verify_workspace_deletion_schema(conn: &Connection) -> Result<()> { "request_fingerprint", "workspace_id", "workspace_display_name", - "workspace_revision", + if column_exists( + conn, + "workspace_deletion_operations", + "workspace_updated_at", + )? { + "workspace_updated_at" + } else { + "workspace_revision" + }, "owner_account_id", "actor_account_id", "state", @@ -13701,6 +12911,9 @@ fn verify_workspace_deletion_schema(conn: &Connection) -> Result<()> { } fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> { + if column_exists(conn, "workspace_runtime_bindings", "binding_id")? { + return verify_runtime_binding_content_schema(conn); + } let columns = table_columns(conn, "workspace_runtime_bindings")? .into_iter() .collect::>(); @@ -13841,7 +13054,7 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> { workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at FROM workspace_runtime_bindings"#, )?; - let rows = stmt.query_map([], read_workspace_runtime_binding)?; + let rows = stmt.query_map([], read_frozen_workspace_runtime_binding)?; for row in rows { verify_canonical_workspace_runtime_binding(row?)?; } @@ -13852,7 +13065,7 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> { FROM workspace_runtime_bindings"#, )?; let rows = stmt.query_map([], |row| { - Ok(WorkspaceRuntimeBinding { + Ok(FrozenWorkspaceRuntimeBinding { workspace_id: row.get(0)?, runtime_id: row.get(1)?, display_name: row.get(2)?, @@ -13880,7 +13093,9 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> { Ok(()) } -fn verify_canonical_workspace_runtime_binding(binding: WorkspaceRuntimeBinding) -> Result<()> { +fn verify_canonical_workspace_runtime_binding( + binding: FrozenWorkspaceRuntimeBinding, +) -> Result<()> { if binding.authentication_mode == WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer { if binding.workspace_key_id.is_some() || binding.workspace_key_generation.is_some() { return Err(Error::Store(format!( @@ -13897,10 +13112,14 @@ fn verify_canonical_workspace_runtime_binding(binding: WorkspaceRuntimeBinding) } return Ok(()); } - let mut normalized = binding.clone(); - normalize_workspace_runtime_binding_key(&mut normalized)?; - if normalized.public_key != binding.public_key - || normalized.public_key_fingerprint != binding.public_key_fingerprint + let (canonical, fingerprint) = normalize_runtime_public_key(&binding.public_key)?; + if binding + .workspace_key_id + .as_deref() + .is_none_or(str::is_empty) + || binding.workspace_key_generation.is_none_or(|v| v == 0) + || canonical != binding.public_key + || fingerprint != binding.public_key_fingerprint { return Err(Error::Store(format!( "Runtime binding `{}/{}` has non-canonical trust content", @@ -13910,364 +13129,6 @@ fn verify_canonical_workspace_runtime_binding(binding: WorkspaceRuntimeBinding) Ok(()) } -fn migrate_runtime_removal_operations_v59_to_v60(conn: &Connection) -> Result<()> { - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - tx.execute_batch( - r#"CREATE TABLE runtime_removal_operations ( - operation_id TEXT PRIMARY KEY, - workspace_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - request_fingerprint TEXT NOT NULL, - expected_binding_revision INTEGER NOT NULL, - config_revision INTEGER NOT NULL, - state TEXT NOT NULL CHECK (state IN ('pending', 'cleanup_pending', 'succeeded', 'failed')), - failure_category TEXT, - binding_removed INTEGER NOT NULL CHECK (binding_removed IN (0, 1)), - runtime_registration_removed INTEGER CHECK (runtime_registration_removed IS NULL OR runtime_registration_removed IN (0, 1)), - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - completed_at TEXT, - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE - ); - CREATE UNIQUE INDEX runtime_removal_operations_one_active_runtime - ON runtime_removal_operations(runtime_id) - WHERE state IN ('pending', 'cleanup_pending'); - CREATE INDEX runtime_removal_operations_workspace_state - ON runtime_removal_operations(workspace_id, state, updated_at); - CREATE TRIGGER runtime_binding_insert_blocked_by_removal - BEFORE INSERT ON workspace_runtime_bindings - FOR EACH ROW - WHEN EXISTS ( - SELECT 1 FROM runtime_removal_operations operation - WHERE operation.runtime_id = NEW.runtime_id - AND operation.state IN ('pending', 'cleanup_pending') - ) - BEGIN - SELECT RAISE(ABORT, 'runtime_removal_in_progress'); - END; - CREATE TRIGGER runtime_binding_update_blocked_by_removal - BEFORE UPDATE ON workspace_runtime_bindings - FOR EACH ROW - WHEN EXISTS ( - SELECT 1 FROM runtime_removal_operations operation - WHERE operation.runtime_id = NEW.runtime_id - AND operation.state IN ('pending', 'cleanup_pending') - ) - BEGIN - SELECT RAISE(ABORT, 'runtime_removal_in_progress'); - END;"#, - )?; - tx.execute_batch( - r#" - CREATE TRIGGER worker_registry_insert_blocked_by_runtime_removal - BEFORE INSERT ON worker_registry FOR EACH ROW - WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending')) - BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; - CREATE TRIGGER worker_registry_update_blocked_by_runtime_removal - BEFORE UPDATE ON worker_registry FOR EACH ROW - WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending')) - BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; - CREATE TRIGGER workdir_registry_insert_blocked_by_runtime_removal - BEFORE INSERT ON workdir_registry FOR EACH ROW - WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending')) - BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; - CREATE TRIGGER workdir_registry_update_blocked_by_runtime_removal - BEFORE UPDATE ON workdir_registry FOR EACH ROW - WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending')) - BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; - CREATE TRIGGER worker_assignment_insert_blocked_by_runtime_removal - BEFORE INSERT ON ticket_current_worker_assignments FOR EACH ROW - WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending')) - BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; - CREATE TRIGGER worker_assignment_update_blocked_by_runtime_removal - BEFORE UPDATE ON ticket_current_worker_assignments FOR EACH ROW - WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending')) - BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; - CREATE TRIGGER workdir_attachment_insert_blocked_by_runtime_removal - BEFORE INSERT ON worker_workdir_links FOR EACH ROW - WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending')) - BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; - CREATE TRIGGER workdir_attachment_update_blocked_by_runtime_removal - BEFORE UPDATE ON worker_workdir_links FOR EACH ROW - WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending')) - BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; - CREATE TRIGGER worker_create_insert_blocked_by_runtime_removal - BEFORE INSERT ON worker_create_reservations FOR EACH ROW - WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending')) - BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; - CREATE TRIGGER worker_create_update_blocked_by_runtime_removal - BEFORE UPDATE ON worker_create_reservations FOR EACH ROW - WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending')) - BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; - CREATE TRIGGER workdir_create_insert_blocked_by_runtime_removal - BEFORE INSERT ON workdir_create_operations FOR EACH ROW - WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.resolved_runtime_id AND operation.state IN ('pending', 'cleanup_pending')) - BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; - CREATE TRIGGER workdir_create_update_blocked_by_runtime_removal - BEFORE UPDATE ON workdir_create_operations FOR EACH ROW - WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.resolved_runtime_id AND operation.state IN ('pending', 'cleanup_pending')) - BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; - CREATE TRIGGER worker_removal_insert_blocked_by_runtime_removal - BEFORE INSERT ON worker_removal_operations FOR EACH ROW - WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending')) - BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; - CREATE TRIGGER worker_removal_update_blocked_by_runtime_removal - BEFORE UPDATE ON worker_removal_operations FOR EACH ROW - WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending')) - BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; - CREATE TRIGGER workdir_removal_insert_blocked_by_runtime_removal - BEFORE INSERT ON workdir_removal_operations FOR EACH ROW - WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending')) - BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; - CREATE TRIGGER workdir_removal_update_blocked_by_runtime_removal - BEFORE UPDATE ON workdir_removal_operations FOR EACH ROW - WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending')) - BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; - "#, - )?; - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![60, RUNTIME_REMOVAL_OPERATION_MIGRATION_NAME], - )?; - tx.commit()?; - Ok(()) -} - -fn migrate_worker_run_generation_v60_to_v61(conn: &Connection) -> Result<()> { - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - tx.execute_batch("ALTER TABLE worker_removal_operations DROP COLUMN run_generation;")?; - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![61, REMOVE_WORKER_RUN_GENERATION_MIGRATION_NAME], - )?; - tx.commit()?; - Ok(()) -} - -fn migrate_worker_registry_projection_v61_to_v62(conn: &Connection) -> Result<()> { - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - tx.execute_batch(WORKER_REGISTRY_PROJECTION_SCHEMA)?; - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![62, WORKER_REGISTRY_PROJECTION_MIGRATION_NAME], - )?; - tx.commit()?; - Ok(()) -} - -fn migrate_multi_workdir_attachments_v62_to_v63(conn: &Connection) -> Result<()> { - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - if !column_exists(&tx, "workdir_registry", "display_name")? { - tx.execute_batch("ALTER TABLE workdir_registry ADD COLUMN display_name TEXT;")?; - } - if column_exists(&tx, "worker_workdir_links", "role")? - && !column_exists(&tx, "worker_workdir_links", "alias")? - { - tx.execute_batch("ALTER TABLE worker_workdir_links RENAME COLUMN role TO alias;")?; - } - tx.execute_batch( - "DROP INDEX IF EXISTS worker_workdir_links_active_worker_unique; - CREATE UNIQUE INDEX IF NOT EXISTS worker_workdir_links_active_alias_unique - ON worker_workdir_links(workspace_id, worker_id, alias) - WHERE unlinked_at IS NULL;", - )?; - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![63, MULTI_WORKDIR_ATTACHMENTS_MIGRATION_NAME], - )?; - tx.commit()?; - Ok(()) -} - -fn migrate_external_workdir_grants_v63_to_v64(conn: &Connection) -> Result<()> { - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - // Migration-chain tests and repaired databases may carry the canonical v64 - // shape while the durable history still stops at v63. In that case only - // advance the marker; rebuilding would discard External grant rows and - // collide with the already-created table. - if column_exists(&tx, "workdir_registry", "source_kind")? - && table_exists(&tx, "external_workdir_grants")? - { - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![64, EXTERNAL_WORKDIR_GRANTS_MIGRATION_NAME], - )?; - tx.commit()?; - return Ok(()); - } - tx.execute_batch( - r#" - PRAGMA defer_foreign_keys = ON; - DROP TRIGGER IF EXISTS workdir_registry_insert_blocked_by_runtime_removal; - DROP TRIGGER IF EXISTS workdir_registry_update_blocked_by_runtime_removal; - CREATE TEMP TABLE worker_workdir_attachment_reservations_v64 AS - SELECT workspace_id, workdir_id, reservation_id, reserved_at - FROM worker_workdir_attachment_reservations; - CREATE TEMP TABLE worker_workdir_links_v64 AS - SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at - FROM worker_workdir_links; - DROP TABLE worker_workdir_attachment_reservations; - DROP TABLE worker_workdir_links; - CREATE TABLE external_workdir_grants ( - grant_id TEXT NOT NULL, - workspace_id TEXT NOT NULL, - workdir_id TEXT NOT NULL, - provider_instance_id TEXT NOT NULL, - display_name TEXT NOT NULL, - permissions TEXT NOT NULL CHECK (permissions = 'read_only'), - created_by TEXT NOT NULL, - created_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - generation INTEGER NOT NULL CHECK (generation > 0), - status TEXT NOT NULL CHECK (status IN ('pending', 'online', 'offline', 'revoked', 'expired')), - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, grant_id), - UNIQUE (workspace_id, workdir_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - FOREIGN KEY (workspace_id, workdir_id) REFERENCES workdir_registry_v64(workspace_id, workdir_id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED - ); - CREATE TABLE workdir_registry_v64 ( - workspace_id TEXT NOT NULL, - workdir_id TEXT NOT NULL, - display_name TEXT, - source_kind TEXT NOT NULL CHECK (source_kind IN ('repository', 'external_grant')), - runtime_id TEXT, - repository_id TEXT, - external_grant_id TEXT, - creation_selector TEXT, - creation_ref TEXT, - creation_tree TEXT, - current_selector TEXT, - current_ref TEXT, - current_tree TEXT, - observed_at_epoch_seconds INTEGER, - materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'not_found', 'corrupted', 'unknown', 'failed')), - cleanliness TEXT NOT NULL CHECK (cleanliness IN ('clean', 'dirty', 'unknown')), - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, workdir_id), - CHECK ( - (source_kind = 'repository' AND runtime_id IS NOT NULL AND repository_id IS NOT NULL AND external_grant_id IS NULL) - OR (source_kind = 'external_grant' AND runtime_id IS NULL AND repository_id IS NULL AND external_grant_id IS NOT NULL) - ), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - FOREIGN KEY (workspace_id, repository_id) REFERENCES repositories(workspace_id, repository_id), - FOREIGN KEY (workspace_id, external_grant_id) REFERENCES external_workdir_grants(workspace_id, grant_id) DEFERRABLE INITIALLY DEFERRED - ); - INSERT INTO workdir_registry_v64 ( - workspace_id, workdir_id, display_name, source_kind, runtime_id, repository_id, - external_grant_id, creation_selector, creation_ref, creation_tree, current_selector, - current_ref, current_tree, observed_at_epoch_seconds, materialization_status, - cleanliness, created_at, updated_at - ) SELECT workspace_id, workdir_id, display_name, 'repository', runtime_id, repository_id, - NULL, creation_selector, creation_ref, creation_tree, current_selector, - current_ref, current_tree, observed_at_epoch_seconds, materialization_status, - cleanliness, created_at, updated_at - FROM workdir_registry; - DROP TABLE workdir_registry; - ALTER TABLE workdir_registry_v64 RENAME TO workdir_registry; - CREATE TABLE worker_workdir_attachment_reservations ( - workspace_id TEXT NOT NULL, - workdir_id TEXT NOT NULL, - reservation_id TEXT NOT NULL, - reserved_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, workdir_id), - FOREIGN KEY (workspace_id, workdir_id) - REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE - ); - INSERT INTO worker_workdir_attachment_reservations - (workspace_id, workdir_id, reservation_id, reserved_at) - SELECT workspace_id, workdir_id, reservation_id, reserved_at - FROM worker_workdir_attachment_reservations_v64; - CREATE TABLE worker_workdir_links ( - workspace_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - worker_id TEXT NOT NULL, - workdir_id TEXT NOT NULL, - alias TEXT NOT NULL, - linked_at TEXT NOT NULL, - unlinked_at TEXT, - PRIMARY KEY (workspace_id, worker_id, workdir_id, alias), - FOREIGN KEY (workspace_id, worker_id) - REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE, - FOREIGN KEY (workspace_id, workdir_id) - REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE - ); - INSERT INTO worker_workdir_links - (workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at) - SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at - FROM worker_workdir_links_v64; - DROP TABLE worker_workdir_attachment_reservations_v64; - DROP TABLE worker_workdir_links_v64; - CREATE UNIQUE INDEX worker_workdir_links_active_workdir_unique - ON worker_workdir_links(workspace_id, workdir_id) WHERE unlinked_at IS NULL; - CREATE UNIQUE INDEX worker_workdir_links_active_alias_unique - ON worker_workdir_links(workspace_id, worker_id, alias) WHERE unlinked_at IS NULL; - CREATE INDEX worker_workdir_links_workdir - ON worker_workdir_links(workspace_id, workdir_id); - CREATE INDEX idx_workdir_registry_workspace_updated - ON workdir_registry(workspace_id, updated_at DESC); - CREATE INDEX idx_external_workdir_grants_status_expiry - ON external_workdir_grants(workspace_id, status, expires_at); - CREATE TRIGGER workdir_registry_insert_blocked_by_runtime_removal - BEFORE INSERT ON workdir_registry FOR EACH ROW - WHEN NEW.runtime_id IS NOT NULL AND EXISTS ( - SELECT 1 FROM runtime_removal_operations operation - WHERE operation.runtime_id = NEW.runtime_id - AND operation.state IN ('pending', 'cleanup_pending') - ) - BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; - CREATE TRIGGER workdir_registry_update_blocked_by_runtime_removal - BEFORE UPDATE ON workdir_registry FOR EACH ROW - WHEN NEW.runtime_id IS NOT NULL AND EXISTS ( - SELECT 1 FROM runtime_removal_operations operation - WHERE operation.runtime_id = NEW.runtime_id - AND operation.state IN ('pending', 'cleanup_pending') - ) - BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; - "#, - )?; - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![64, EXTERNAL_WORKDIR_GRANTS_MIGRATION_NAME], - )?; - tx.commit()?; - Ok(()) -} - -fn migrate_external_workdir_cleanup_v64_to_v65(conn: &Connection) -> Result<()> { - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - if !column_exists(&tx, "external_workdir_grants", "cleanup_state")? { - tx.execute_batch( - "ALTER TABLE external_workdir_grants ADD COLUMN cleanup_state TEXT NOT NULL DEFAULT 'not_required' CHECK (cleanup_state IN ('not_required', 'pending', 'retry_required', 'completed'));\ - ALTER TABLE external_workdir_grants ADD COLUMN cleanup_error TEXT;\ - ALTER TABLE external_workdir_grants ADD COLUMN cleanup_updated_at TEXT;", - )?; - tx.execute( - "UPDATE external_workdir_grants - SET cleanup_state = 'pending', cleanup_updated_at = updated_at - WHERE status IN ('revoked', 'expired')", - [], - )?; - } - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![65, EXTERNAL_WORKDIR_CLEANUP_MIGRATION_NAME], - )?; - tx.commit()?; - Ok(()) -} - -fn migrate_ticket_targets_and_workdir_capabilities_v65_to_v66(conn: &Connection) -> Result<()> { - let current = current_schema_version(conn)?; - if current != 65 { - return Err(Error::Store(format!( - "expected schema version 65 before {TICKET_TARGETS_AND_WORKDIR_CAPABILITIES_MIGRATION_NAME} migration, found {current}" - ))); - } - apply_ticket_targets_and_workdir_capabilities_schema(conn) -} - fn apply_ticket_targets_and_workdir_capabilities_schema(conn: &Connection) -> Result<()> { let foreign_keys_enabled = conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))? != 0; @@ -14686,1318 +13547,6 @@ fn apply_ticket_targets_and_workdir_capabilities_schema(conn: &Connection) -> Re } } -fn migrate_external_workdir_access_and_optional_expiry_v66_to_v67(conn: &Connection) -> Result<()> { - let current = current_schema_version(conn)?; - if current != 66 { - return Err(Error::Store(format!( - "expected schema version 66 before {EXTERNAL_WORKDIR_ACCESS_AND_OPTIONAL_EXPIRY_MIGRATION_NAME} migration, found {current}" - ))); - } - let foreign_keys_enabled = - conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))? != 0; - if foreign_keys_enabled { - conn.execute_batch("PRAGMA foreign_keys = OFF;")?; - } - let result = (|| { - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - tx.execute_batch( - r#" - CREATE TABLE external_workdir_grants_v67 ( - grant_id TEXT NOT NULL, - workspace_id TEXT NOT NULL, - workdir_id TEXT NOT NULL, - provider_instance_id TEXT NOT NULL, - display_name TEXT NOT NULL, - permissions TEXT NOT NULL CHECK (permissions IN ('read_only', 'read_write')), - created_by TEXT NOT NULL, - created_at TEXT NOT NULL, - expires_at TEXT, - generation INTEGER NOT NULL CHECK (generation > 0), - status TEXT NOT NULL CHECK (status IN ('pending', 'online', 'offline', 'revoked', 'expired')), - updated_at TEXT NOT NULL, - cleanup_state TEXT NOT NULL DEFAULT 'not_required' CHECK (cleanup_state IN ('not_required', 'pending', 'retry_required', 'completed')), - cleanup_error TEXT, - cleanup_updated_at TEXT, - PRIMARY KEY (workspace_id, grant_id), - UNIQUE (workspace_id, workdir_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - FOREIGN KEY (workspace_id, workdir_id) - REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED - ); - INSERT INTO external_workdir_grants_v67 ( - grant_id, workspace_id, workdir_id, provider_instance_id, display_name, - permissions, created_by, created_at, expires_at, generation, status, updated_at, - cleanup_state, cleanup_error, cleanup_updated_at - ) SELECT grant_id, workspace_id, workdir_id, provider_instance_id, display_name, - permissions, created_by, created_at, expires_at, generation, status, updated_at, - cleanup_state, cleanup_error, cleanup_updated_at - FROM external_workdir_grants; - DROP TABLE external_workdir_grants; - ALTER TABLE external_workdir_grants_v67 RENAME TO external_workdir_grants; - CREATE INDEX idx_external_workdir_grants_status_expiry - ON external_workdir_grants(workspace_id, status, expires_at); - CREATE TABLE worker_workdir_links_v67 ( - workspace_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - worker_id TEXT NOT NULL, - workdir_id TEXT NOT NULL, - alias TEXT NOT NULL, - capabilities TEXT NOT NULL CHECK (capabilities IN ('all', 'read_only', 'read_write')), - linked_at TEXT NOT NULL, - unlinked_at TEXT, - PRIMARY KEY (workspace_id, worker_id, workdir_id, alias), - FOREIGN KEY (workspace_id, worker_id) - REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE, - FOREIGN KEY (workspace_id, workdir_id) - REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE - ); - INSERT INTO worker_workdir_links_v67 ( - workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, - linked_at, unlinked_at - ) SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, - linked_at, unlinked_at - FROM worker_workdir_links; - DROP TABLE worker_workdir_links; - ALTER TABLE worker_workdir_links_v67 RENAME TO worker_workdir_links; - CREATE UNIQUE INDEX worker_workdir_links_active_workdir_unique - ON worker_workdir_links(workspace_id, workdir_id) WHERE unlinked_at IS NULL; - CREATE UNIQUE INDEX worker_workdir_links_active_alias_unique - ON worker_workdir_links(workspace_id, worker_id, alias) WHERE unlinked_at IS NULL; - CREATE INDEX worker_workdir_links_workdir - ON worker_workdir_links(workspace_id, workdir_id); - CREATE TRIGGER workdir_attachment_insert_blocked_by_runtime_removal - BEFORE INSERT ON worker_workdir_links FOR EACH ROW - WHEN EXISTS ( - SELECT 1 FROM runtime_removal_operations operation - WHERE operation.runtime_id = NEW.runtime_id - AND operation.state IN ('pending', 'cleanup_pending') - ) - BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; - CREATE TRIGGER workdir_attachment_update_blocked_by_runtime_removal - BEFORE UPDATE ON worker_workdir_links FOR EACH ROW - WHEN EXISTS ( - SELECT 1 FROM runtime_removal_operations operation - WHERE operation.runtime_id = NEW.runtime_id - AND operation.state IN ('pending', 'cleanup_pending') - ) - BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; - "#, - )?; - let violations = - tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { - row.get::<_, i64>(0) - })?; - if violations != 0 { - return Err(Error::Store(format!( - "schema-67 migration left {violations} foreign-key violation(s)" - ))); - } - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![ - 67_i64, - EXTERNAL_WORKDIR_ACCESS_AND_OPTIONAL_EXPIRY_MIGRATION_NAME - ], - )?; - tx.commit()?; - Ok(()) - })(); - let restore = if foreign_keys_enabled { - conn.execute_batch("PRAGMA foreign_keys = ON;") - .map_err(Error::from) - } else { - Ok(()) - }; - match (result, restore) { - (Err(error), _) => Err(error), - (Ok(()), Err(error)) => Err(error), - (Ok(()), Ok(())) => Ok(()), - } -} - -fn migrate_external_workdir_command_permission_v67_to_v68(conn: &Connection) -> Result<()> { - let current = current_schema_version(conn)?; - if current != 67 { - return Err(Error::Store(format!( - "expected schema version 67 before {EXTERNAL_WORKDIR_COMMAND_PERMISSION_MIGRATION_NAME} migration, found {current}" - ))); - } - let foreign_keys_enabled = - conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))? != 0; - if foreign_keys_enabled { - conn.execute_batch("PRAGMA foreign_keys = OFF;")?; - } - let result = (|| { - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - tx.execute_batch( - r#" - CREATE TABLE external_workdir_grants_v68 ( - grant_id TEXT NOT NULL, - workspace_id TEXT NOT NULL, - workdir_id TEXT NOT NULL, - provider_instance_id TEXT NOT NULL, - display_name TEXT NOT NULL, - permissions TEXT NOT NULL CHECK (permissions IN ( - 'read_only', 'read_write', 'command_only', - 'read_command', 'read_write_command' - )), - created_by TEXT NOT NULL, - created_at TEXT NOT NULL, - expires_at TEXT, - generation INTEGER NOT NULL CHECK (generation > 0), - status TEXT NOT NULL CHECK (status IN ('pending', 'online', 'offline', 'revoked', 'expired')), - updated_at TEXT NOT NULL, - cleanup_state TEXT NOT NULL DEFAULT 'not_required' CHECK (cleanup_state IN ('not_required', 'pending', 'retry_required', 'completed')), - cleanup_error TEXT, - cleanup_updated_at TEXT, - PRIMARY KEY (workspace_id, grant_id), - UNIQUE (workspace_id, workdir_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - FOREIGN KEY (workspace_id, workdir_id) - REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED - ); - INSERT INTO external_workdir_grants_v68 ( - grant_id, workspace_id, workdir_id, provider_instance_id, display_name, - permissions, created_by, created_at, expires_at, generation, status, updated_at, - cleanup_state, cleanup_error, cleanup_updated_at - ) SELECT grant_id, workspace_id, workdir_id, provider_instance_id, display_name, - permissions, created_by, created_at, expires_at, generation, status, updated_at, - cleanup_state, cleanup_error, cleanup_updated_at - FROM external_workdir_grants; - DROP TABLE external_workdir_grants; - ALTER TABLE external_workdir_grants_v68 RENAME TO external_workdir_grants; - CREATE INDEX idx_external_workdir_grants_status_expiry - ON external_workdir_grants(workspace_id, status, expires_at); - "#, - )?; - let violations = - tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { - row.get::<_, i64>(0) - })?; - if violations != 0 { - return Err(Error::Store(format!( - "schema-68 migration left {violations} foreign-key violation(s)" - ))); - } - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![68_i64, EXTERNAL_WORKDIR_COMMAND_PERMISSION_MIGRATION_NAME], - )?; - tx.commit()?; - Ok(()) - })(); - let restore = if foreign_keys_enabled { - conn.execute_batch("PRAGMA foreign_keys = ON;") - .map_err(Error::from) - } else { - Ok(()) - }; - match (result, restore) { - (Err(error), _) => Err(error), - (Ok(()), Err(error)) => Err(error), - (Ok(()), Ok(())) => Ok(()), - } -} - -fn migrate_workdir_command_attachment_capabilities_v68_to_v69(conn: &Connection) -> Result<()> { - let current = current_schema_version(conn)?; - if current != 68 { - return Err(Error::Store(format!( - "expected schema version 68 before {WORKDIR_COMMAND_ATTACHMENT_CAPABILITIES_MIGRATION_NAME} migration, found {current}" - ))); - } - let foreign_keys_enabled = - conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))? != 0; - if foreign_keys_enabled { - conn.execute_batch("PRAGMA foreign_keys = OFF;")?; - } - let result = (|| { - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - tx.execute_batch( - r#" - CREATE TABLE worker_workdir_links_v69 ( - workspace_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - worker_id TEXT NOT NULL, - workdir_id TEXT NOT NULL, - alias TEXT NOT NULL, - capabilities TEXT NOT NULL CHECK (capabilities IN ( - 'all', 'read_only', 'read_write', 'command_only', 'read_command' - )), - linked_at TEXT NOT NULL, - unlinked_at TEXT, - PRIMARY KEY (workspace_id, worker_id, workdir_id, alias), - FOREIGN KEY (workspace_id, worker_id) - REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE, - FOREIGN KEY (workspace_id, workdir_id) - REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE - ); - INSERT INTO worker_workdir_links_v69 ( - workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, - linked_at, unlinked_at - ) SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, - linked_at, unlinked_at - FROM worker_workdir_links; - DROP TABLE worker_workdir_links; - ALTER TABLE worker_workdir_links_v69 RENAME TO worker_workdir_links; - CREATE UNIQUE INDEX worker_workdir_links_active_workdir_unique - ON worker_workdir_links(workspace_id, workdir_id) WHERE unlinked_at IS NULL; - CREATE UNIQUE INDEX worker_workdir_links_active_alias_unique - ON worker_workdir_links(workspace_id, worker_id, alias) WHERE unlinked_at IS NULL; - CREATE INDEX worker_workdir_links_workdir - ON worker_workdir_links(workspace_id, workdir_id); - CREATE TRIGGER workdir_attachment_insert_blocked_by_runtime_removal - BEFORE INSERT ON worker_workdir_links FOR EACH ROW - WHEN EXISTS ( - SELECT 1 FROM runtime_removal_operations operation - WHERE operation.runtime_id = NEW.runtime_id - AND operation.state IN ('pending', 'cleanup_pending') - ) - BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; - CREATE TRIGGER workdir_attachment_update_blocked_by_runtime_removal - BEFORE UPDATE ON worker_workdir_links FOR EACH ROW - WHEN EXISTS ( - SELECT 1 FROM runtime_removal_operations operation - WHERE operation.runtime_id = NEW.runtime_id - AND operation.state IN ('pending', 'cleanup_pending') - ) - BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; - "#, - )?; - let violations = - tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { - row.get::<_, i64>(0) - })?; - if violations != 0 { - return Err(Error::Store(format!( - "schema-69 migration left {violations} foreign-key violation(s)" - ))); - } - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![ - 69_i64, - WORKDIR_COMMAND_ATTACHMENT_CAPABILITIES_MIGRATION_NAME - ], - )?; - tx.commit()?; - Ok(()) - })(); - let restore = if foreign_keys_enabled { - conn.execute_batch("PRAGMA foreign_keys = ON;") - .map_err(Error::from) - } else { - Ok(()) - }; - match (result, restore) { - (Err(error), _) => Err(error), - (Ok(()), Err(error)) => Err(error), - (Ok(()), Ok(())) => Ok(()), - } -} - -fn migrate_backend_job_runner_v69_to_v70(conn: &Connection) -> Result<()> { - let current = current_schema_version(conn)?; - if current != 69 { - return Err(Error::Store(format!( - "expected schema version 69 before {BACKEND_JOB_RUNNER_MIGRATION_NAME} migration, found {current}" - ))); - } - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - tx.execute_batch( - r#" - CREATE TABLE backend_jobs ( - workspace_id TEXT NOT NULL, - job_id TEXT NOT NULL, - purpose TEXT NOT NULL, - input_revision TEXT NOT NULL, - input_ref TEXT NOT NULL, - request_json TEXT NOT NULL, - intent_fingerprint TEXT NOT NULL, - state TEXT NOT NULL CHECK (state IN ('pending', 'completed', 'failed', 'unknown')), - current_attempt INTEGER NOT NULL CHECK (current_attempt > 0 AND current_attempt <= 3), - result_json TEXT, - result_digest TEXT, - failure_category TEXT, - failure_detail TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - completed_at TEXT, - PRIMARY KEY (workspace_id, job_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - CHECK ((result_json IS NULL) = (result_digest IS NULL)), - CHECK ((state = 'completed') = (result_json IS NOT NULL)) - ); - CREATE INDEX backend_jobs_active - ON backend_jobs(workspace_id, state, updated_at); - CREATE TABLE backend_job_attempts ( - workspace_id TEXT NOT NULL, - job_id TEXT NOT NULL, - attempt_id TEXT NOT NULL, - attempt INTEGER NOT NULL CHECK (attempt > 0 AND attempt <= 3), - input_revision TEXT NOT NULL, - state TEXT NOT NULL CHECK (state IN ('reserved', 'dispatched', 'completed', 'failed', 'unknown')), - runtime_id TEXT, - worker_id TEXT, - runtime_run_id TEXT, - dispatched_at TEXT, - deadline_at TEXT NOT NULL, - result_json TEXT, - result_digest TEXT, - failure_category TEXT, - failure_detail TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - completed_at TEXT, - PRIMARY KEY (workspace_id, job_id, attempt_id), - UNIQUE (workspace_id, job_id, attempt), - UNIQUE (workspace_id, runtime_id, worker_id), - FOREIGN KEY (workspace_id, job_id) - REFERENCES backend_jobs(workspace_id, job_id) ON DELETE CASCADE, - CHECK ((runtime_id IS NULL) = (worker_id IS NULL)), - CHECK ((result_json IS NULL) = (result_digest IS NULL)), - CHECK ((state = 'completed') = (result_json IS NOT NULL)) - ); - CREATE INDEX backend_job_attempts_recovery - ON backend_job_attempts(workspace_id, state, deadline_at); - CREATE TABLE backend_job_deliveries ( - workspace_id TEXT NOT NULL, - delivery_id TEXT NOT NULL, - job_id TEXT NOT NULL, - attempt_id TEXT NOT NULL, - target_runtime_id TEXT NOT NULL, - target_worker_id TEXT NOT NULL, - state TEXT NOT NULL CHECK (state IN ('pending', 'completed', 'failed')), - failure_category TEXT, - failure_detail TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - delivered_at TEXT, - PRIMARY KEY (workspace_id, delivery_id), - FOREIGN KEY (workspace_id, job_id, attempt_id) - REFERENCES backend_job_attempts(workspace_id, job_id, attempt_id) ON DELETE CASCADE - ); - CREATE INDEX backend_job_deliveries_pending - ON backend_job_deliveries(workspace_id, state, updated_at); - "#, - )?; - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![70_i64, BACKEND_JOB_RUNNER_MIGRATION_NAME], - )?; - tx.commit()?; - Ok(()) -} - -fn migrate_backend_job_delivery_claims_v70_to_v71(conn: &Connection) -> Result<()> { - let current = current_schema_version(conn)?; - if current != 70 { - return Err(Error::Store(format!( - "expected schema version 70 before {BACKEND_JOB_DELIVERY_CLAIM_MIGRATION_NAME} migration, found {current}" - ))); - } - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - tx.execute_batch( - r#" - DROP INDEX backend_job_deliveries_pending; - ALTER TABLE backend_job_deliveries RENAME TO backend_job_deliveries_v70; - CREATE TABLE backend_job_deliveries ( - workspace_id TEXT NOT NULL, - delivery_id TEXT NOT NULL, - job_id TEXT NOT NULL, - attempt_id TEXT NOT NULL, - target_runtime_id TEXT NOT NULL, - target_worker_id TEXT NOT NULL, - state TEXT NOT NULL CHECK (state IN ('pending', 'sending', 'completed', 'failed', 'unknown')), - failure_category TEXT, - failure_detail TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - delivered_at TEXT, - PRIMARY KEY (workspace_id, delivery_id), - FOREIGN KEY (workspace_id, job_id, attempt_id) - REFERENCES backend_job_attempts(workspace_id, job_id, attempt_id) ON DELETE CASCADE - ); - INSERT INTO backend_job_deliveries ( - workspace_id, delivery_id, job_id, attempt_id, target_runtime_id, - target_worker_id, state, failure_category, failure_detail, - created_at, updated_at, delivered_at - ) - SELECT workspace_id, delivery_id, job_id, attempt_id, target_runtime_id, - target_worker_id, state, failure_category, failure_detail, - created_at, updated_at, delivered_at - FROM backend_job_deliveries_v70; - DROP TABLE backend_job_deliveries_v70; - CREATE INDEX backend_job_deliveries_pending - ON backend_job_deliveries(workspace_id, state, updated_at); - "#, - )?; - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![71_i64, BACKEND_JOB_DELIVERY_CLAIM_MIGRATION_NAME], - )?; - tx.commit()?; - Ok(()) -} - -fn migrate_backend_job_dispatch_queue_v71_to_v72(conn: &Connection) -> Result<()> { - let current = current_schema_version(conn)?; - if current != 71 { - return Err(Error::Store(format!( - "expected schema version 71 before {BACKEND_JOB_DISPATCH_QUEUE_MIGRATION_NAME} migration, found {current}" - ))); - } - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - tx.execute_batch( - r#" - DROP INDEX backend_job_deliveries_pending; - DROP INDEX backend_job_attempts_recovery; - ALTER TABLE backend_job_deliveries RENAME TO backend_job_deliveries_v71; - ALTER TABLE backend_job_attempts RENAME TO backend_job_attempts_v71; - CREATE TABLE backend_job_attempts ( - workspace_id TEXT NOT NULL, - job_id TEXT NOT NULL, - attempt_id TEXT NOT NULL, - attempt INTEGER NOT NULL CHECK (attempt > 0 AND attempt <= 3), - input_revision TEXT NOT NULL, - state TEXT NOT NULL CHECK (state IN ('reserved', 'dispatching', 'dispatched', 'completed', 'failed', 'unknown')), - runtime_id TEXT, - worker_id TEXT, - runtime_run_id TEXT, - dispatched_at TEXT, - deadline_at TEXT NOT NULL, - result_json TEXT, - result_digest TEXT, - failure_category TEXT, - failure_detail TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - completed_at TEXT, - PRIMARY KEY (workspace_id, job_id, attempt_id), - UNIQUE (workspace_id, job_id, attempt), - UNIQUE (workspace_id, runtime_id, worker_id), - FOREIGN KEY (workspace_id, job_id) - REFERENCES backend_jobs(workspace_id, job_id) ON DELETE CASCADE, - CHECK ((runtime_id IS NULL) = (worker_id IS NULL)), - CHECK ((result_json IS NULL) = (result_digest IS NULL)), - CHECK ((state = 'completed') = (result_json IS NOT NULL)) - ); - INSERT INTO backend_job_attempts SELECT * FROM backend_job_attempts_v71; - CREATE INDEX backend_job_attempts_recovery - ON backend_job_attempts(workspace_id, state, deadline_at); - CREATE TABLE backend_job_deliveries ( - workspace_id TEXT NOT NULL, - delivery_id TEXT NOT NULL, - job_id TEXT NOT NULL, - attempt_id TEXT NOT NULL, - target_runtime_id TEXT NOT NULL, - target_worker_id TEXT NOT NULL, - state TEXT NOT NULL CHECK (state IN ('pending', 'sending', 'completed', 'failed', 'unknown')), - failure_category TEXT, - failure_detail TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - delivered_at TEXT, - PRIMARY KEY (workspace_id, delivery_id), - FOREIGN KEY (workspace_id, job_id, attempt_id) - REFERENCES backend_job_attempts(workspace_id, job_id, attempt_id) ON DELETE CASCADE - ); - INSERT INTO backend_job_deliveries SELECT * FROM backend_job_deliveries_v71; - CREATE INDEX backend_job_deliveries_pending - ON backend_job_deliveries(workspace_id, state, updated_at); - DROP TABLE backend_job_deliveries_v71; - DROP TABLE backend_job_attempts_v71; - "#, - )?; - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![72_i64, BACKEND_JOB_DISPATCH_QUEUE_MIGRATION_NAME], - )?; - tx.commit()?; - Ok(()) -} - -fn migrate_worker_singleton_ownership_v72_to_v73(conn: &Connection) -> Result<()> { - let current = current_schema_version(conn)?; - if current != 72 { - return Err(Error::Store(format!( - "expected schema version 72 before {WORKER_SINGLETON_OWNERSHIP_MIGRATION_NAME} migration, found {current}" - ))); - } - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - if !column_exists(&tx, "worker_create_reservations", "singleton_key")? { - tx.execute( - "ALTER TABLE worker_create_reservations ADD COLUMN singleton_key TEXT", - [], - )?; - } - if !column_exists(&tx, "worker_create_reservations", "singleton_generation")? { - tx.execute( - "ALTER TABLE worker_create_reservations ADD COLUMN singleton_generation INTEGER \ - CHECK (singleton_generation IS NULL OR singleton_generation > 0)", - [], - )?; - } - tx.execute_batch( - r#" - CREATE TABLE IF NOT EXISTS worker_singleton_owners ( - workspace_id TEXT NOT NULL, - singleton_key TEXT NOT NULL, - runtime_id TEXT NOT NULL, - worker_id TEXT NOT NULL, - generation INTEGER NOT NULL CHECK (generation > 0), - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, singleton_key), - UNIQUE (workspace_id, worker_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE - ); - CREATE TRIGGER IF NOT EXISTS worker_create_reservation_singleton_lease_insert - BEFORE INSERT ON worker_create_reservations - WHEN (NEW.singleton_key IS NULL AND NEW.singleton_generation IS NOT NULL) - OR (NEW.singleton_key IS NOT NULL AND NEW.singleton_generation IS NULL) - BEGIN - SELECT RAISE(ABORT, 'worker_singleton_lease_incomplete'); - END; - CREATE TRIGGER IF NOT EXISTS worker_create_reservation_singleton_lease_update - BEFORE UPDATE OF singleton_key, singleton_generation ON worker_create_reservations - WHEN (NEW.singleton_key IS NULL AND NEW.singleton_generation IS NOT NULL) - OR (NEW.singleton_key IS NOT NULL AND NEW.singleton_generation IS NULL) - BEGIN - SELECT RAISE(ABORT, 'worker_singleton_lease_incomplete'); - END; - - -- Preserve the two historical singleton conventions as Backend ownership. If an old - -- database contains duplicates, deterministically fence every Worker except the oldest. - INSERT INTO worker_singleton_owners ( - workspace_id, singleton_key, runtime_id, worker_id, generation, created_at, updated_at - ) - SELECT worker.workspace_id, 'workspace-orchestrator', worker.runtime_id, worker.worker_id, - 1, worker.created_at, worker.updated_at - FROM worker_registry worker - WHERE worker.profile IN ('orchestrator', 'builtin:orchestrator') - AND worker.display_name = 'Workspace Orchestrator' - AND NOT EXISTS ( - SELECT 1 FROM worker_registry older - WHERE older.workspace_id = worker.workspace_id - AND older.profile IN ('orchestrator', 'builtin:orchestrator') - AND older.display_name = 'Workspace Orchestrator' - AND (older.created_at < worker.created_at - OR (older.created_at = worker.created_at AND older.worker_id < worker.worker_id)) - ); - INSERT INTO worker_singleton_owners ( - workspace_id, singleton_key, runtime_id, worker_id, generation, created_at, updated_at - ) - SELECT worker.workspace_id, 'workspace-memory-consolidation', worker.runtime_id, - worker.worker_id, 1, worker.created_at, worker.updated_at - FROM worker_registry worker - WHERE worker.profile IN ('memory-consolidation', 'builtin:memory-consolidation') - AND NOT EXISTS ( - SELECT 1 FROM worker_registry older - WHERE older.workspace_id = worker.workspace_id - AND older.profile IN ('memory-consolidation', 'builtin:memory-consolidation') - AND (older.created_at < worker.created_at - OR (older.created_at = worker.created_at AND older.worker_id < worker.worker_id)) - ); - INSERT INTO worker_create_reservations ( - workspace_id, allocation_key, worker_id, runtime_id, create_fingerprint, - state, created_at, updated_at, request_fingerprint, - memory_settings_revision, memory_language, singleton_key, singleton_generation - ) - SELECT worker.workspace_id, 'migration-v73-singleton:' || worker.worker_id, - worker.worker_id, worker.runtime_id, - 'migration-v73:' || CASE - WHEN worker.profile IN ('orchestrator', 'builtin:orchestrator') - AND worker.display_name = 'Workspace Orchestrator' - THEN 'workspace-orchestrator' - ELSE 'workspace-memory-consolidation' - END, - 'created', worker.created_at, worker.updated_at, NULL, NULL, NULL, - CASE - WHEN worker.profile IN ('orchestrator', 'builtin:orchestrator') - AND worker.display_name = 'Workspace Orchestrator' - THEN 'workspace-orchestrator' - ELSE 'workspace-memory-consolidation' - END, - 1 - FROM worker_registry worker - WHERE ( - ( - worker.profile IN ('orchestrator', 'builtin:orchestrator') - AND worker.display_name = 'Workspace Orchestrator' - ) - OR worker.profile IN ('memory-consolidation', 'builtin:memory-consolidation') - ) - AND NOT EXISTS ( - SELECT 1 FROM worker_create_reservations reservation - WHERE reservation.workspace_id = worker.workspace_id - AND reservation.worker_id = worker.worker_id - ); - UPDATE worker_create_reservations - SET singleton_key = ( - SELECT CASE - WHEN worker.profile IN ('orchestrator', 'builtin:orchestrator') - AND worker.display_name = 'Workspace Orchestrator' - THEN 'workspace-orchestrator' - ELSE 'workspace-memory-consolidation' - END - FROM worker_registry worker - WHERE worker.workspace_id = worker_create_reservations.workspace_id - AND worker.worker_id = worker_create_reservations.worker_id - AND ( - (worker.profile IN ('orchestrator', 'builtin:orchestrator') - AND worker.display_name = 'Workspace Orchestrator') - OR worker.profile IN ('memory-consolidation', 'builtin:memory-consolidation') - ) - ), - singleton_generation = 1 - WHERE EXISTS ( - SELECT 1 FROM worker_registry worker - WHERE worker.workspace_id = worker_create_reservations.workspace_id - AND worker.worker_id = worker_create_reservations.worker_id - AND ( - (worker.profile IN ('orchestrator', 'builtin:orchestrator') - AND worker.display_name = 'Workspace Orchestrator') - OR worker.profile IN ('memory-consolidation', 'builtin:memory-consolidation') - ) - ); - "#, - )?; - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![73_i64, WORKER_SINGLETON_OWNERSHIP_MIGRATION_NAME], - )?; - tx.commit()?; - Ok(()) -} - -fn migrate_ordered_worker_projection_v73_to_v74(conn: &Connection) -> Result<()> { - let current = current_schema_version(conn)?; - if current != 73 { - return Err(Error::Store(format!( - "expected schema version 73 before {ORDERED_WORKER_PROJECTION_MIGRATION_NAME} migration, found {current}" - ))); - } - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - tx.execute_batch( - r#" - ALTER TABLE worker_registry_observations RENAME TO worker_registry_observations_v73; - CREATE TABLE worker_registry_observations ( - workspace_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - worker_id TEXT NOT NULL, - availability TEXT NOT NULL CHECK (availability IN ('observed', 'unavailable')), - worker_json TEXT, - observed_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, runtime_id, worker_id), - FOREIGN KEY (workspace_id, worker_id) - REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE - ); - INSERT INTO worker_registry_observations ( - workspace_id, runtime_id, worker_id, availability, worker_json, observed_at - ) - SELECT workspace_id, runtime_id, worker_id, availability, worker_json, observed_at - FROM worker_registry_observations_v73; - DROP TABLE worker_registry_observations_v73; - - DROP TABLE IF EXISTS worker_registry_projection_cursors; - DROP TABLE IF EXISTS worker_registry_projection_revisions; - - ALTER TABLE worker_registry_projection_removals - RENAME TO worker_registry_projection_removals_v73; - CREATE TABLE worker_registry_projection_removals ( - workspace_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - worker_id TEXT NOT NULL, - PRIMARY KEY (workspace_id, runtime_id, worker_id) - ); - INSERT INTO worker_registry_projection_removals (workspace_id, runtime_id, worker_id) - SELECT workspace_id, runtime_id, worker_id - FROM worker_registry_projection_removals_v73; - DROP TABLE worker_registry_projection_removals_v73; - "#, - )?; - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![74_i64, ORDERED_WORKER_PROJECTION_MIGRATION_NAME], - )?; - tx.commit()?; - Ok(()) -} - -fn migrate_runtime_scoped_worker_identity_v74_to_v75(conn: &Connection) -> Result<()> { - let current = current_schema_version(conn)?; - if current != 74 { - return Err(Error::Store(format!( - "expected schema version 74 before {RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME} migration, found {current}" - ))); - } - let foreign_keys_enabled = - conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))? != 0; - let legacy_alter_table_enabled = - conn.query_row("PRAGMA legacy_alter_table", [], |row| row.get::<_, i64>(0))? != 0; - if foreign_keys_enabled { - conn.execute_batch("PRAGMA foreign_keys = OFF;")?; - } - if !legacy_alter_table_enabled { - conn.execute_batch("PRAGMA legacy_alter_table = ON;")?; - } - let result = (|| { - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - tx.execute_batch( - r#" - DROP INDEX worker_control_grants_controller; - DROP INDEX worker_control_grants_subject; - DROP INDEX worker_workdir_links_active_workdir_unique; - DROP INDEX worker_workdir_links_active_alias_unique; - DROP INDEX worker_workdir_links_workdir; - DROP INDEX idx_worker_registry_workspace_runtime_worker; - DROP INDEX worker_registry_runtime; - - ALTER TABLE worker_registry_observations RENAME TO worker_registry_observations_v74; - ALTER TABLE worker_control_grants RENAME TO worker_control_grants_v74; - ALTER TABLE worker_workdir_links RENAME TO worker_workdir_links_v74; - ALTER TABLE worker_registry RENAME TO worker_registry_v74; - - CREATE TABLE worker_registry ( - workspace_id TEXT NOT NULL, - worker_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - display_name TEXT NOT NULL, - profile TEXT, - retention_state TEXT NOT NULL CHECK (retention_state IN ('normal', 'pinned')), - transcript_ref TEXT, - session_ref TEXT, - summary_ref TEXT, - diagnostics_ref TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, runtime_id, worker_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE - ); - INSERT INTO worker_registry - SELECT * FROM worker_registry_v74; - - UPDATE workspace_resource_keys AS resource - SET resource_id = COALESCE( - ( - SELECT hex(worker.runtime_id) || ':' || hex(worker.worker_id) - FROM worker_registry AS worker - WHERE worker.workspace_id = resource.workspace_id - AND worker.worker_id = resource.resource_id - LIMIT 1 - ), - ( - SELECT hex(reservation.runtime_id) || ':' || hex(reservation.worker_id) - FROM worker_create_reservations AS reservation - WHERE reservation.workspace_id = resource.workspace_id - AND reservation.worker_id = resource.resource_id - LIMIT 1 - ) - ) - WHERE resource.resource_kind = 'worker' - AND ( - EXISTS ( - SELECT 1 FROM worker_registry AS worker - WHERE worker.workspace_id = resource.workspace_id - AND worker.worker_id = resource.resource_id - ) - OR EXISTS ( - SELECT 1 FROM worker_create_reservations AS reservation - WHERE reservation.workspace_id = resource.workspace_id - AND reservation.worker_id = resource.resource_id - ) - ); - - CREATE TABLE worker_registry_observations ( - workspace_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - worker_id TEXT NOT NULL, - availability TEXT NOT NULL CHECK (availability IN ('observed', 'unavailable')), - worker_json TEXT, - observed_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, runtime_id, worker_id), - FOREIGN KEY (workspace_id, runtime_id, worker_id) - REFERENCES worker_registry(workspace_id, runtime_id, worker_id) ON DELETE CASCADE - ); - INSERT INTO worker_registry_observations - SELECT * FROM worker_registry_observations_v74; - - CREATE TABLE worker_control_grants ( - grant_id TEXT NOT NULL, - workspace_id TEXT NOT NULL, - controller_runtime_id TEXT NOT NULL, - controller_worker_id TEXT NOT NULL, - subject_runtime_id TEXT NOT NULL, - subject_worker_id TEXT NOT NULL, - relation TEXT NOT NULL, - origin TEXT NOT NULL, - permissions_json TEXT NOT NULL, - operation_id TEXT NOT NULL, - created_at TEXT NOT NULL, - revoked_at TEXT, - PRIMARY KEY (workspace_id, grant_id), - UNIQUE (workspace_id, controller_runtime_id, controller_worker_id, operation_id), - FOREIGN KEY (workspace_id, controller_runtime_id, controller_worker_id) - REFERENCES worker_registry(workspace_id, runtime_id, worker_id) ON DELETE CASCADE, - FOREIGN KEY (workspace_id, subject_runtime_id, subject_worker_id) - REFERENCES worker_registry(workspace_id, runtime_id, worker_id) ON DELETE CASCADE - ); - INSERT INTO worker_control_grants - SELECT * FROM worker_control_grants_v74; - - CREATE TABLE worker_workdir_links ( - workspace_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - worker_id TEXT NOT NULL, - workdir_id TEXT NOT NULL, - alias TEXT NOT NULL, - linked_at TEXT NOT NULL, - unlinked_at TEXT, - capabilities TEXT NOT NULL CHECK (capabilities IN ( - 'all', 'read_only', 'read_write', 'command_only', 'read_command' - )), - PRIMARY KEY (workspace_id, runtime_id, worker_id, workdir_id, alias), - FOREIGN KEY (workspace_id, runtime_id, worker_id) - REFERENCES worker_registry(workspace_id, runtime_id, worker_id) ON DELETE CASCADE, - FOREIGN KEY (workspace_id, workdir_id) - REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE - ); - INSERT INTO worker_workdir_links ( - workspace_id, runtime_id, worker_id, workdir_id, alias, - linked_at, unlinked_at, capabilities - ) - SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, - linked_at, unlinked_at, capabilities - FROM worker_workdir_links_v74; - - DROP TABLE worker_registry_observations_v74; - DROP TABLE worker_control_grants_v74; - DROP TABLE worker_workdir_links_v74; - DROP TABLE worker_registry_v74; - - CREATE INDEX worker_control_grants_controller ON worker_control_grants( - workspace_id, controller_runtime_id, controller_worker_id, revoked_at - ); - CREATE INDEX worker_control_grants_subject ON worker_control_grants( - workspace_id, subject_runtime_id, subject_worker_id, revoked_at - ); - CREATE UNIQUE INDEX worker_workdir_links_active_workdir_unique - ON worker_workdir_links(workspace_id, workdir_id) WHERE unlinked_at IS NULL; - CREATE UNIQUE INDEX worker_workdir_links_active_alias_unique - ON worker_workdir_links(workspace_id, runtime_id, worker_id, alias) - WHERE unlinked_at IS NULL; - CREATE INDEX worker_workdir_links_workdir - ON worker_workdir_links(workspace_id, workdir_id); - CREATE INDEX worker_registry_runtime - ON worker_registry(workspace_id, runtime_id, worker_id); - - CREATE TRIGGER worker_registry_insert_blocked_by_runtime_removal - BEFORE INSERT ON worker_registry FOR EACH ROW - WHEN EXISTS ( - SELECT 1 FROM runtime_removal_operations operation - WHERE operation.runtime_id = NEW.runtime_id - AND operation.state IN ('pending', 'cleanup_pending') - ) - BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; - - CREATE TRIGGER worker_registry_update_blocked_by_runtime_removal - BEFORE UPDATE ON worker_registry FOR EACH ROW - WHEN EXISTS ( - SELECT 1 FROM runtime_removal_operations operation - WHERE operation.runtime_id = NEW.runtime_id - AND operation.state IN ('pending', 'cleanup_pending') - ) - BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; - - CREATE TRIGGER workdir_attachment_insert_blocked_by_runtime_removal - BEFORE INSERT ON worker_workdir_links FOR EACH ROW - WHEN EXISTS ( - SELECT 1 FROM runtime_removal_operations operation - WHERE operation.runtime_id = NEW.runtime_id - AND operation.state IN ('pending', 'cleanup_pending') - ) - BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; - - CREATE TRIGGER workdir_attachment_update_blocked_by_runtime_removal - BEFORE UPDATE ON worker_workdir_links FOR EACH ROW - WHEN EXISTS ( - SELECT 1 FROM runtime_removal_operations operation - WHERE operation.runtime_id = NEW.runtime_id - AND operation.state IN ('pending', 'cleanup_pending') - ) - BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; - - CREATE TRIGGER ticket_assignment_worker_parent_tombstone_delete - BEFORE DELETE ON worker_registry - WHEN EXISTS ( - SELECT 1 FROM ticket_worker_assignments AS assignment - WHERE assignment.workspace_id = OLD.workspace_id - AND assignment.principal_kind = 'worker' - AND assignment.runtime_id = OLD.runtime_id - AND assignment.worker_id = OLD.worker_id - ) - BEGIN - INSERT OR IGNORE INTO ticket_assignment_worker_tombstones ( - workspace_id, runtime_id, worker_id, deleted_at - ) VALUES (OLD.workspace_id, OLD.runtime_id, OLD.worker_id, CURRENT_TIMESTAMP); - END; - - CREATE TRIGGER ticket_assignment_worker_parent_tombstone_move - BEFORE UPDATE OF runtime_id ON worker_registry - WHEN OLD.runtime_id != NEW.runtime_id - AND EXISTS ( - SELECT 1 FROM ticket_worker_assignments AS assignment - WHERE assignment.workspace_id = OLD.workspace_id - AND assignment.principal_kind = 'worker' - AND assignment.runtime_id = OLD.runtime_id - AND assignment.worker_id = OLD.worker_id - ) - BEGIN - INSERT OR IGNORE INTO ticket_assignment_worker_tombstones ( - workspace_id, runtime_id, worker_id, deleted_at - ) VALUES (OLD.workspace_id, OLD.runtime_id, OLD.worker_id, CURRENT_TIMESTAMP); - END; - "#, - )?; - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![75_i64, RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME], - )?; - tx.commit()?; - Ok(()) - })(); - let restore_result = match (legacy_alter_table_enabled, foreign_keys_enabled) { - (false, true) => { - conn.execute_batch("PRAGMA legacy_alter_table = OFF; PRAGMA foreign_keys = ON;") - } - (false, false) => conn.execute_batch("PRAGMA legacy_alter_table = OFF;"), - (true, true) => conn.execute_batch("PRAGMA foreign_keys = ON;"), - (true, false) => Ok(()), - } - .map_err(Error::from); - match (result, restore_result) { - (Err(error), _) => Err(error), - (Ok(()), Err(error)) => Err(error), - (Ok(()), Ok(())) => { - let violations = conn - .prepare("PRAGMA foreign_key_check")? - .query_map([], |_| Ok(()))? - .count(); - if violations != 0 { - return Err(Error::Store(format!( - "Runtime-scoped Worker identity migration left {violations} foreign key violation(s)" - ))); - } - Ok(()) - } - } -} - -fn migrate_archive_observe_grants_v75_to_v76(conn: &Connection) -> Result<()> { - let current = current_schema_version(conn)?; - if current != 75 { - return Err(Error::Store(format!( - "expected schema version 75 before {ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME} migration, found {current}" - ))); - } - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - tx.execute_batch( - r#" - CREATE TABLE IF NOT EXISTS worker_session_archive_observe_grants ( - workspace_id TEXT NOT NULL, - archive_id TEXT NOT NULL, - controller_runtime_id TEXT NOT NULL, - controller_worker_id TEXT NOT NULL, - subject_runtime_id TEXT NOT NULL, - subject_worker_id TEXT NOT NULL, - source_grant_id TEXT NOT NULL, - granted_at TEXT NOT NULL, - revoked_at TEXT, - PRIMARY KEY ( - workspace_id, archive_id, - controller_runtime_id, controller_worker_id, - source_grant_id - ), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - FOREIGN KEY (archive_id) REFERENCES worker_session_archives(archive_id) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS worker_session_archive_observe_grants_controller - ON worker_session_archive_observe_grants( - workspace_id, controller_runtime_id, controller_worker_id, archive_id - ); - "#, - )?; - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![76_i64, ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME], - )?; - tx.commit()?; - Ok(()) -} - -fn migrate_assignment_work_v81_to_v82(conn: &Connection) -> Result<()> { - if current_schema_version(conn)? != 81 { - return Err(Error::Store( - "Ticket work separation requires schema 81".into(), - )); - } - let foreign_keys = - conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0))?; - let legacy = - conn.pragma_query_value(None, "legacy_alter_table", |row| row.get::<_, bool>(0))?; - conn.execute_batch("PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON;")?; - let result = (|| { - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - let dependents = { - let mut stmt = tx.prepare("SELECT sql FROM sqlite_schema WHERE tbl_name='ticket_current_worker_assignments' AND type IN ('index','trigger') AND sql IS NOT NULL AND name != 'ticket_current_worker_role_idx'")?; - let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; - rows.collect::>>()? - }; - // The retained v82 migration keeps its original coder role contract. - let schema = include_str!("latest_schema.sql") - .replace( - "role TEXT NOT NULL DEFAULT 'worker'", - "role TEXT NOT NULL DEFAULT 'coder'", - ) - .replace( - "role IN ('orchestrator', 'worker'", - "role IN ('orchestrator', 'coder'", - ); - let start = schema - .find("CREATE TABLE ticket_current_worker_assignments (") - .ok_or_else(|| Error::Store("Missing assignment schema".into()))?; - let end = schema[start..] - .find("CREATE TABLE ticket_worker_assignment_events") - .ok_or_else(|| Error::Store("Missing assignment schema end".into()))? - + start; - tx.execute_batch(&schema[start..end].replace( - "CREATE TABLE ticket_current_worker_assignments", - "CREATE TABLE ticket_current_worker_assignments_v82", - ))?; - tx.execute_batch("INSERT INTO ticket_current_worker_assignments_v82 SELECT * FROM ticket_current_worker_assignments; - DROP TABLE ticket_current_worker_assignments; - ALTER TABLE ticket_current_worker_assignments_v82 RENAME TO ticket_current_worker_assignments;")?; - for sql in dependents { - tx.execute_batch(&sql)?; - } - tx.execute_batch(include_str!("assignment_work.sql"))?; - // Existing done/closed + current-assignment data is preserved, and a - // durable release prevents a later reopen from resurrecting old work. - tx.execute_batch("INSERT OR IGNORE INTO ticket_assignment_work_releases - SELECT current.workspace_id, current.assignment_id, COALESCE(ticket.updated_at, current.updated_at) - FROM ticket_current_worker_assignments current JOIN typed_tickets ticket - ON ticket.workspace_id=current.workspace_id AND ticket.ticket_id=current.ticket_id - WHERE ticket.workflow_state IN ('done','closed');")?; - let legacy_plans = { - let mut statement = - tx.prepare("SELECT operation_id, blockers_json FROM worker_removal_operations")?; - let rows = statement.query_map([], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - })?; - rows.collect::>>()? - }; - for (operation_id, encoded) in legacy_plans { - let mut blockers: Vec = serde_json::from_str(&encoded) - .map_err(|error| Error::Store(format!("invalid removal blockers: {error}")))?; - let mut changed = false; - for blocker in &mut blockers { - if blocker.get("kind").and_then(serde_json::Value::as_str) - == Some("current_assignment") - { - blocker["kind"] = serde_json::Value::String("unfinished_work".into()); - changed = true; - } - } - if changed { - let encoded = serde_json::to_string(&blockers) - .map_err(|error| Error::Store(error.to_string()))?; - // Historical blocked plans stay blocked; issued fingerprints - // and identities are immutable. Fresh planning rereads active work. - tx.execute( - "UPDATE worker_removal_operations SET blockers_json=?2 WHERE operation_id=?1", - params![operation_id, encoded], - )?; - } - } - let broken: i64 = - tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { - row.get(0) - })?; - if broken != 0 { - return Err(Error::Store( - "Ticket work migration foreign-key verification failed".into(), - )); - } - tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES (82,'Ticket responsibility and unfinished work separation')", [])?; - tx.commit()?; - Ok(()) - })(); - conn.pragma_update(None, "legacy_alter_table", legacy)?; - conn.pragma_update(None, "foreign_keys", foreign_keys)?; - result -} - -fn migrate_ticket_worker_v82_to_v83(conn: &Connection) -> Result<()> { - if current_schema_version(conn)? != 82 { - return Err(Error::Store( - "Ticket Worker migration requires schema 82".into(), - )); - } - let foreign_keys = - conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0))?; - let legacy = - conn.pragma_query_value(None, "legacy_alter_table", |row| row.get::<_, bool>(0))?; - conn.execute_batch("PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON;")?; - let result = (|| { - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - // Rebuild all role-bearing assignment tables together: merely renaming - // the current role would break its historical composite foreign key. - let schema = include_str!("latest_schema.sql"); - let mut dependents = Vec::new(); - for table in [ - "ticket_assignment_operations", - "ticket_worker_assignments", - "ticket_current_worker_assignments", - "ticket_worker_assignment_events", - ] { - let mut stmt = tx.prepare("SELECT sql FROM sqlite_schema WHERE tbl_name=?1 AND type IN ('index','trigger') AND sql IS NOT NULL AND name != 'ticket_current_worker_role_idx'")?; - let rows = stmt.query_map([table], |row| row.get::<_, String>(0))?; - dependents.extend(rows.collect::>>()?); - drop(stmt); - let declaration = format!("CREATE TABLE {table} ("); - let start = schema - .find(&declaration) - .ok_or_else(|| Error::Store(format!("Missing assignment schema for {table}")))?; - let end = schema[start..].find(';').ok_or_else(|| { - Error::Store(format!("Missing assignment schema end for {table}")) - })? + start - + 1; - let replacement = format!("{table}_v83"); - tx.execute_batch(&schema[start..end].replacen( - &format!("CREATE TABLE {table}"), - &format!("CREATE TABLE {replacement}"), - 1, - ))?; - let columns = table_columns(&tx, table)?; - let values = columns - .iter() - .map(|column| { - if column == "role" { - "CASE role WHEN 'coder' THEN 'worker' ELSE role END".to_string() - } else { - format!("\"{column}\"") - } - }) - .collect::>() - .join(", "); - let column_names = columns - .iter() - .map(|column| format!("\"{column}\"")) - .collect::>() - .join(", "); - // Only role-bearing historical columns are copied. New receipts - // classify legacy unfinished assign operations conservatively. - tx.execute_batch(&format!( - "INSERT INTO {replacement} ({column_names}) SELECT {values} FROM {table}; - DROP TABLE {table}; ALTER TABLE {replacement} RENAME TO {table};" - ))?; - if table == "ticket_assignment_operations" - && !columns.iter().any(|c| c == "claim_state") - { - tx.execute( - "UPDATE ticket_assignment_operations SET claim_state='pending' - WHERE action IN ('assign','reassign') AND assignment_id IS NULL", - [], - )?; - } - } - for sql in dependents { - tx.execute_batch(&sql.replace("'coder'", "'worker'"))?; - } - tx.execute( - "UPDATE typed_ticket_event_attributes SET value='worker' - WHERE key='assignment_role' AND value='coder'", - [], - )?; - let broken: i64 = - tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { - row.get(0) - })?; - if broken != 0 { - return Err(Error::Store( - "Ticket work migration foreign-key verification failed".into(), - )); - } - tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES (83,'Generic Ticket Worker roles and durable claims')", [])?; - tx.commit()?; - Ok(()) - })(); - conn.pragma_update(None, "legacy_alter_table", legacy)?; - conn.pragma_update(None, "foreign_keys", foreign_keys)?; - result -} - -fn migrate_backend_job_resources_v80_to_v81(conn: &Connection) -> Result<()> { - if current_schema_version(conn)? != 80 { - return Err(Error::Store( - "Backend Job resource migration requires schema 80".into(), - )); - } - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - tx.execute_batch( - "ALTER TABLE backend_jobs ADD COLUMN resource_key TEXT; - CREATE INDEX backend_jobs_resource ON backend_jobs(workspace_id, resource_key, created_at);", - )?; - // Pre-grant requests had no resource lock. Preserve their exact stored JSON - // and fingerprint; serde defaults read them as empty grants / no key. - tx.execute( - "INSERT INTO __yoi_schema_migrations(version, name) VALUES (81, 'Backend Job immutable resource serialization')", - [], - )?; - tx.commit()?; - Ok(()) -} - -fn migrate_backend_job_worker_cleanup_v76_to_v77(conn: &Connection) -> Result<()> { - let current = current_schema_version(conn)?; - if current != 76 { - return Err(Error::Store(format!( - "expected schema version 76 before {BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME} migration, found {current}" - ))); - } - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - tx.execute_batch( - r#" - ALTER TABLE backend_job_attempts ADD COLUMN worker_cleanup_state TEXT - CHECK (worker_cleanup_state IN ('pending', 'executing', 'completed', 'failed')); - ALTER TABLE backend_job_attempts ADD COLUMN worker_cleanup_failure_category TEXT; - ALTER TABLE backend_job_attempts ADD COLUMN worker_cleanup_failure_detail TEXT; - ALTER TABLE backend_job_attempts ADD COLUMN worker_cleanup_updated_at TEXT; - ALTER TABLE backend_job_attempts ADD COLUMN worker_cleanup_completed_at TEXT; - UPDATE backend_job_attempts - SET worker_cleanup_state = CASE - WHEN runtime_id IS NULL THEN 'completed' - ELSE 'pending' - END, - worker_cleanup_updated_at = updated_at, - worker_cleanup_completed_at = CASE - WHEN runtime_id IS NULL THEN updated_at - ELSE NULL - END - WHERE state IN ('completed', 'failed', 'unknown'); - CREATE INDEX backend_job_attempts_worker_cleanup - ON backend_job_attempts(workspace_id, worker_cleanup_state, updated_at); - "#, - )?; - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![77_i64, BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME], - )?; - tx.commit()?; - Ok(()) -} - fn read_workspace_config_grant( row: &rusqlite::Row<'_>, ) -> rusqlite::Result { @@ -16023,87 +13572,6 @@ fn read_workspace_config_grant( }) } -fn migrate_workspace_config_v78_to_v79(conn: &Connection) -> Result<()> { - if current_schema_version(conn)? != 78 { - return Err(Error::Store( - "Workspace config migration requires schema 78".into(), - )); - } - let foreign_keys = - conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0))?; - conn.execute_batch("PRAGMA foreign_keys = OFF;")?; - let result = (|| { - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - let dependents = { - let mut stmt = tx.prepare("SELECT sql FROM sqlite_schema WHERE tbl_name='workdir_registry' AND type IN ('index','trigger') AND sql IS NOT NULL")?; - let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; - rows.collect::>>()? - }; - let schema = include_str!("latest_schema.sql"); - let start = schema - .find("CREATE TABLE \"workdir_registry\"") - .ok_or_else(|| Error::Store("Missing Workdir schema".into()))?; - let end = schema[start..] - .find("CREATE TABLE external_workdir_grants") - .ok_or_else(|| Error::Store("Missing Workdir schema end".into()))? - + start; - if column_exists(&tx, "workdir_registry", "workspace_config_grant_id")? - && table_exists(&tx, "workspace_config_grants")? - { - tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES (79,'Workspace config grants and logical Workdirs')", [])?; - tx.commit()?; - return Ok(()); - } - tx.execute_batch(include_str!("workspace_config_grants.sql"))?; - tx.execute_batch(&schema[start..end].replace( - "CREATE TABLE \"workdir_registry\"", - "CREATE TABLE workdir_registry_v79", - ))?; - tx.execute_batch("INSERT INTO workdir_registry_v79 (workspace_id,workdir_id,display_name,source_kind,runtime_id,repository_id,external_grant_id,creation_selector,creation_ref,creation_tree,current_selector,current_ref,current_tree,observed_at_epoch_seconds,materialization_status,cleanliness,created_at,updated_at) SELECT workspace_id,workdir_id,display_name,source_kind,runtime_id,repository_id,external_grant_id,creation_selector,creation_ref,creation_tree,current_selector,current_ref,current_tree,observed_at_epoch_seconds,materialization_status,cleanliness,created_at,updated_at FROM workdir_registry; DROP TABLE workdir_registry; ALTER TABLE workdir_registry_v79 RENAME TO workdir_registry;")?; - for sql in dependents { - tx.execute_batch(&sql)?; - } - let broken: bool = { - let mut stmt = tx.prepare("PRAGMA foreign_key_check")?; - let mut rows = stmt.query([])?; - rows.next()?.is_some() - }; - if broken { - return Err(Error::Store( - "Workspace config migration foreign-key verification failed".into(), - )); - } - tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES (79,'Workspace config grants and logical Workdirs')", [])?; - tx.commit()?; - Ok(()) - })(); - if foreign_keys { - conn.execute_batch("PRAGMA foreign_keys = ON;")?; - } - result -} - -fn migrate_workdir_connection_id_v77_to_v78(conn: &Connection) -> Result<()> { - use rusqlite::TransactionBehavior; - let current = current_schema_version(conn)?; - if current != 77 { - return Err(Error::Store(format!( - "expected schema version 77 before {WORKDIR_CONNECTION_ID_MIGRATION_NAME} migration, found {current}" - ))); - } - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - tx.execute_batch( - "ALTER TABLE worker_workdir_links ADD COLUMN connection_id TEXT NOT NULL DEFAULT ''; - UPDATE worker_workdir_links SET connection_id = lower(hex(randomblob(16)));", - )?; - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![78_i64, WORKDIR_CONNECTION_ID_MIGRATION_NAME], - )?; - tx.commit()?; - Ok(()) -} - fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { conn.execute_batch("CREATE TABLE worker_restore_intents ( workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, @@ -16553,12 +14021,22 @@ fn collect_reference_diagnostics( Ok(()) } +fn read_workspace_memory_settings( + row: &rusqlite::Row<'_>, +) -> rusqlite::Result { + Ok(WorkspaceMemorySettingsRecord { + workspace_id: row.get(0)?, + language: row.get(1)?, + created_at: row.get(2)?, + updated_at: row.get(3)?, + }) +} + fn validate_workspace_memory_settings_snapshot( snapshot: &manifest::WorkspaceMemorySettingsSnapshot, expected_workspace_id: &str, ) -> Result<()> { if snapshot.workspace_id != expected_workspace_id - || snapshot.settings_revision == 0 || !manifest::is_normalized_workspace_memory_language(&snapshot.language) { return Err(Error::Store( @@ -16575,7 +14053,6 @@ fn validate_workspace_memory_settings_record( validate_workspace_memory_settings_snapshot( &manifest::WorkspaceMemorySettingsSnapshot { workspace_id: record.workspace_id.clone(), - settings_revision: record.settings_revision, language: record.language.clone(), }, expected_workspace_id, @@ -16682,13 +14159,11 @@ fn bound_worker_create_fingerprint( snapshot: &manifest::WorkspaceMemorySettingsSnapshot, ) -> String { let mut digest = Sha256::new(); - digest.update(b"workspace-worker-create-v2\0"); + digest.update(b"workspace-worker-create-v3\0"); digest.update(request_fingerprint.as_bytes()); digest.update(b"\0"); digest.update(snapshot.workspace_id.as_bytes()); digest.update(b"\0"); - digest.update(snapshot.settings_revision.to_be_bytes()); - digest.update(b"\0"); digest.update(snapshot.language.as_bytes()); let encoded = digest .finalize() @@ -16870,8 +14345,36 @@ fn workspace_schema_migration_plan(conn: &Connection) -> Result Result<()> { + apply_migrations_with_secret_source( + conn, + conn.path().filter(|path| !path.is_empty()).map(Path::new), + ) +} + +fn apply_migrations_with_secret_source( + conn: &Connection, + secret_source: Option<&Path>, +) -> Result<()> { let plan = workspace_schema_migration_plan(conn)?; + // Check recovery blockers before any retained migration commits. Waiting until + // schema 87 would strand a schema-84 operator without a runnable old binary. + if plan.current_schema_version > 0 + && plan.current_schema_version < 88 + && table_exists(conn, "worker_removal_operations")? + { + let unresolved: Option = conn.query_row( + "SELECT operation_id FROM worker_removal_operations WHERE state IN ('executing','failed') ORDER BY operation_id LIMIT 1", + [], |row| row.get(0), + ).optional()?; + if let Some(operation) = unresolved { + return Err(Error::Store(format!( + "Worker removal {operation} requires receipt reconciliation on the existing Server before schema migration; database remains at schema {}", + plan.current_schema_version + ))); + } + } if plan.current_schema_version == 0 { let foreign_keys_enabled = conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))? != 0; @@ -16918,7 +14421,11 @@ fn apply_migrations(conn: &Connection) -> Result<()> { step.version )) })?; - (migration.apply)(conn)?; + if migration.version == 87 { + migrate_repository_keys_v86_to_v87_with_secret_source(conn, secret_source)?; + } else { + (migration.apply)(conn)?; + } } } @@ -16951,56 +14458,6 @@ fn table_columns(conn: &Connection, table_name: &str) -> Result> { .map_err(Error::from) } -fn migrate_workdir_credential_candidate_snapshots_v58_to_v59(conn: &Connection) -> Result<()> { - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - tx.execute_batch( - r#" - CREATE TABLE workdir_create_credential_candidates ( - workspace_id TEXT NOT NULL, - operation_id TEXT NOT NULL, - ordinal INTEGER NOT NULL CHECK (ordinal >= 0 AND ordinal < 2), - role TEXT NOT NULL CHECK (role IN ('primary', 'workspace_default_fallback')), - credential_id TEXT NOT NULL CHECK (length(credential_id) BETWEEN 1 AND 128), - credential_revision INTEGER NOT NULL CHECK (credential_revision > 0), - PRIMARY KEY (workspace_id, operation_id, ordinal), - UNIQUE (workspace_id, operation_id, role), - UNIQUE (workspace_id, operation_id, credential_id), - FOREIGN KEY (workspace_id, operation_id) - REFERENCES workdir_create_operations(workspace_id, operation_id) - ON DELETE CASCADE - ); - CREATE INDEX idx_workdir_create_credential_candidates_revision - ON workdir_create_credential_candidates( - workspace_id, credential_id, credential_revision - ); - CREATE TABLE workdir_create_credential_revision_retentions ( - workspace_id TEXT NOT NULL, - operation_id TEXT NOT NULL, - ordinal INTEGER NOT NULL, - credential_id TEXT NOT NULL, - credential_revision INTEGER NOT NULL, - PRIMARY KEY (workspace_id, operation_id, ordinal), - FOREIGN KEY (workspace_id, operation_id, ordinal) - REFERENCES workdir_create_credential_candidates( - workspace_id, operation_id, ordinal - ) - ON DELETE CASCADE, - FOREIGN KEY (workspace_id, credential_id, credential_revision) - REFERENCES repository_ssh_credential_revisions( - workspace_id, credential_id, revision - ) - ON DELETE RESTRICT - ); - "#, - )?; - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![59_i64, WORKDIR_CREDENTIAL_CANDIDATE_SNAPSHOT_MIGRATION_NAME], - )?; - tx.commit()?; - Ok(()) -} - #[cfg(test)] mod tests { include!("store_workspace_config_tests.rs"); @@ -17010,7 +14467,7 @@ mod tests { c.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('drive-owner','user','drive-owner','Owner','1','1'); INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('drive-ws','drive-owner','Workspace','active','1','1'),('drive-other','drive-owner','Other','active','1','1');")?; for workspace in ["drive-ws", "drive-other"] { - c.execute("INSERT INTO workspace_signing_identities(workspace_id,key_id,algorithm,private_material_ref,revision,state,created_at,updated_at) VALUES(?1,?1,'ed25519',?1,1,'pending_provisioning','1','1')", [workspace])?; + c.execute("INSERT INTO workspace_signing_identities(workspace_id,key_id,algorithm,private_material_ref,state,created_at,updated_at) VALUES(?1,?1,'ed25519',?1,'pending_provisioning','1','1')", [workspace])?; c.execute("INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at) VALUES(?1,?2,'worker','Worker','normal','1','1')", params![workspace, crate::hosts::EMBEDDED_RUNTIME_ID])?; } Ok(()) @@ -17033,20 +14490,16 @@ mod tests { let t = tempfile::tempdir().unwrap(); let path = t.path().join("server.db"); let store = SqliteWorkspaceStore::open(&path).unwrap(); + let legacy = Connection::open_in_memory().unwrap(); + configure_sqlite(&legacy).unwrap(); + legacy.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY); + CREATE TABLE accounts(account_id TEXT PRIMARY KEY); + CREATE TABLE worker_registry(workspace_id TEXT,runtime_id TEXT,worker_id TEXT,PRIMARY KEY(workspace_id,runtime_id,worker_id)); + INSERT INTO __yoi_schema_migrations(version,name) VALUES(83,'workspace schema baseline');").unwrap(); + migrate_workspace_drive_grants_v83_to_v84(&legacy).unwrap(); + assert_eq!(current_schema_version(&legacy).unwrap(), 84); + assert!(table_exists(&legacy, "workspace_drive_grants").unwrap()); let mut proposed = drive_grant_fixture(&store); - store.with_conn(|c| { - c.execute_batch("DROP TRIGGER workspace_drive_grants_worker_deleted; DROP TABLE workspace_drive_grants; UPDATE __yoi_schema_migrations SET version=83 WHERE version=84;")?; - assert_eq!(current_schema_version(c)?, 83); - Ok(()) - }).unwrap(); - drop(store); - let store = SqliteWorkspaceStore::open(&path).unwrap(); - store - .with_conn(|c| { - assert_eq!(current_schema_version(c)?, 84); - Ok(()) - }) - .unwrap(); let first = store.create_workspace_drive_grant(&proposed).unwrap(); assert_eq!(first.grant_id, "1"); assert_eq!(first.created_by, "drive-owner"); @@ -17165,7 +14618,7 @@ mod tests { let proposed = drive_grant_fixture(&store); let first = store.create_workspace_drive_grant(&proposed).unwrap(); store.with_conn(|c| { - assert_eq!(current_schema_version(c)?, 84); + assert_eq!(current_schema_version(c)?, LATEST_SCHEMA_VERSION); assert!(c.execute("INSERT INTO workspace_drive_grants(workspace_id,runtime_id,worker_id,access,created_by,created_at) VALUES('drive-ws',?1,'worker','read_write','drive-owner','now')", [crate::hosts::EMBEDDED_RUNTIME_ID]).is_err()); assert!(c.execute("INSERT INTO workspace_drive_grants(workspace_id,runtime_id,worker_id,access,created_by,created_at) VALUES('drive-ws','r','w','command','drive-owner','now')", []).is_err()); assert!(c.execute("UPDATE workspace_drive_grants SET revoked=1 WHERE grant_id=?1", [&first.grant_id]).is_err()); @@ -17901,16 +15354,19 @@ mod tests { VALUES ('workspace-a', 'owner', 'Workspace A', 'active', '1', '1'); \ INSERT INTO workspace_runtime_bindings(\ workspace_id, runtime_id, display_name, base_url, public_key, \ - public_key_fingerprint, binding_revision, state, authentication_mode, \ + public_key_fingerprint, binding_id, state, authentication_mode, \ created_at, updated_at\ ) VALUES (\ 'workspace-a', 'runtime-a', 'Runtime A', 'https://runtime.invalid', \ - 'key-a', 'fingerprint-a', 3, 'verified', 'legacy_server_issuer', '1', '1'\ + 'key-a', 'fingerprint-a', 'binding-live', 'verified', 'legacy_server_issuer', '1', '1'\ );", )?; Ok(()) }) .unwrap(); + store + .materialize_workspace_config("workspace-a", "1") + .unwrap(); (temp, store) } @@ -17919,14 +15375,14 @@ mod tests { .get_workspace_runtime_binding("workspace-a", "runtime-a") .unwrap() .expect("Runtime binding must remain"); - assert_eq!(binding.binding_revision, 3); + assert_eq!(binding.binding_id, "binding-live"); assert_eq!(binding.state, WorkspaceRuntimeBindingState::Verified); assert!(binding.revoked_at.is_none()); } #[test] - fn runtime_removal_preflight_rejects_stale_revision_and_active_resources_without_revoking_trust() - { + fn runtime_removal_preflight_rejects_stale_binding_and_active_resources_without_revoking_trust() + { let (_temp, store) = runtime_removal_test_store(); let stale = store .reserve_runtime_removal( @@ -17934,14 +15390,16 @@ mod tests { "runtime-a", "remove-stale", "fingerprint-stale", - 2, - 1, + "binding-stale", + &store + .load_workspace_config("workspace-a") + .unwrap() + .unwrap() + .snapshot + .digest, ) .unwrap_err(); - assert!(matches!( - stale, - Error::RuntimeBindingRevisionConflict { .. } - )); + assert!(matches!(stale, Error::RuntimeBindingIdConflict { .. })); assert_runtime_removal_binding_unchanged(&store); store @@ -17961,8 +15419,13 @@ mod tests { "runtime-a", "remove-worker", "fingerprint-worker", - 3, - 1, + "binding-live", + &store + .load_workspace_config("workspace-a") + .unwrap() + .unwrap() + .snapshot + .digest, ) .unwrap_err(); assert!( @@ -17981,10 +15444,10 @@ mod tests { conn.execute_batch( "INSERT INTO repositories(\ workspace_id, repository_id, repository_key, kind, uri, created_at, updated_at, \ - source_kind, source_uri, source_revision, source_fingerprint, observed_status\ + source_kind, source_uri, source_fingerprint, observed_status\ ) VALUES (\ 'workspace-a', 'repository-a', 'repository-a', 'git', '/repo', '1', '1', \ - 'local', '/repo', 1, 'source-a', 'unverified'\ + 'local', '/repo', 'source-a', 'unverified'\ ); \ INSERT INTO workdir_registry(\ workspace_id, workdir_id, source_kind, runtime_id, repository_id, materialization_status, \ @@ -18003,8 +15466,13 @@ mod tests { "runtime-a", "remove-workdir", "fingerprint-workdir", - 3, - 1, + "binding-live", + &store + .load_workspace_config("workspace-a") + .unwrap() + .unwrap() + .snapshot + .digest, ) .unwrap_err(); assert!( @@ -18020,11 +15488,11 @@ mod tests { conn.execute( "INSERT INTO workdir_create_operations(\ workspace_id, operation_id, request_fingerprint, repository_id, \ - resolved_runtime_id, config_revision, config_projection_digest, \ + resolved_runtime_id, config_projection_digest, \ working_directory_id, state, created_at, updated_at\ ) VALUES (\ 'workspace-a', 'create-workdir', 'request-a', 'repository-a', \ - 'runtime-a', 1, 'projection-a', 'workdir-b', 'pending', '1', '1'\ + 'runtime-a', 'projection-a', 'workdir-b', 'pending', '1', '1'\ )", [], )?; @@ -18037,8 +15505,13 @@ mod tests { "runtime-a", "remove-pending", "fingerprint-pending", - 3, - 1, + "binding-live", + &store + .load_workspace_config("workspace-a") + .unwrap() + .unwrap() + .snapshot + .digest, ) .unwrap_err(); assert!( @@ -18061,11 +15534,11 @@ mod tests { VALUES ('workspace-b', 'owner-b', 'Workspace B', 'active', '1', '1'); \ INSERT INTO workspace_runtime_bindings(\ workspace_id, runtime_id, display_name, base_url, public_key, \ - public_key_fingerprint, binding_revision, state, authentication_mode, \ + public_key_fingerprint, binding_id, state, authentication_mode, \ created_at, updated_at\ ) VALUES (\ 'workspace-b', 'runtime-a', 'Runtime A', 'https://runtime.invalid', \ - 'key-a', 'fingerprint-b', 1, 'verified', 'legacy_server_issuer', '1', '1'\ + 'key-a', 'fingerprint-b', 'binding-other', 'verified', 'legacy_server_issuer', '1', '1'\ );", )?; Ok(()) @@ -18077,8 +15550,13 @@ mod tests { "runtime-a", "remove-shared", "fingerprint-shared", - 3, - 1, + "binding-live", + &store + .load_workspace_config("workspace-a") + .unwrap() + .unwrap() + .snapshot + .digest, ) .unwrap_err(); assert!( @@ -18109,8 +15587,13 @@ mod tests { "runtime-a", "remove-fenced", "fingerprint-fenced", - 3, - 1, + "binding-live", + &store + .load_workspace_config("workspace-a") + .unwrap() + .unwrap() + .snapshot + .digest, ) .unwrap(); let competing_conn = Connection::open(temp.path().join("server.db")).unwrap(); @@ -18120,11 +15603,11 @@ mod tests { let blocked_insert = competing_conn.execute( "INSERT INTO workspace_runtime_bindings(\ workspace_id, runtime_id, display_name, base_url, public_key, \ - public_key_fingerprint, binding_revision, state, authentication_mode, \ + public_key_fingerprint, binding_id, state, authentication_mode, \ created_at, updated_at\ ) VALUES (\ 'workspace-b', 'runtime-a', 'Runtime A', 'https://runtime.invalid', \ - 'key-a', 'fingerprint-b', 1, 'verified', 'legacy_server_issuer', '1', '1'\ + 'key-a', 'fingerprint-b', 'binding-other', 'verified', 'legacy_server_issuer', '1', '1'\ )", [], ); @@ -18144,10 +15627,10 @@ mod tests { ) VALUES ('workspace-a', 'racing-allocation', 'racing-worker', 'runtime-a', 'racing-create', 'reserved', '2', '2')", "INSERT INTO workdir_create_operations(\ workspace_id, operation_id, request_fingerprint, repository_id, resolved_runtime_id, \ - config_revision, config_projection_digest, working_directory_id, state, created_at, updated_at\ + config_projection_digest, working_directory_id, state, created_at, updated_at\ ) VALUES (\ 'workspace-a', 'racing-workdir-create', 'racing-request', 'repository-a', 'runtime-a', \ - 1, 'racing-projection', 'racing-workdir', 'pending', '2', '2'\ + 'racing-projection', 'racing-workdir', 'pending', '2', '2'\ )", ] { let error = competing_conn.execute(competing_mutation, []).unwrap_err(); @@ -18168,8 +15651,13 @@ mod tests { "runtime-a", "remove-runtime-a", "fingerprint-a", - 3, - 7, + "binding-live", + &store + .load_workspace_config("workspace-a") + .unwrap() + .unwrap() + .snapshot + .digest, ) .unwrap(); assert!(!reserved.replay); @@ -18184,8 +15672,13 @@ mod tests { "runtime-a", "remove-runtime-a", "fingerprint-a", - 3, - 7, + "binding-live", + &store + .load_workspace_config("workspace-a") + .unwrap() + .unwrap() + .snapshot + .digest, ) .unwrap(); assert!(retried.replay); @@ -18233,43 +15726,7 @@ mod tests { fn schema_v59_upgrade_adds_runtime_removal_authority_atomically() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("server.db"); - let store = SqliteWorkspaceStore::open(&path).unwrap(); - store - .with_conn(|conn| { - conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;")?; - downgrade_assignment_work_schema(conn); - conn.execute_batch("PRAGMA legacy_alter_table=OFF; PRAGMA foreign_keys=ON;")?; - downgrade_schema_66_ticket_and_workdir_authority(conn)?; - conn.execute_batch( - "DROP TRIGGER runtime_binding_insert_blocked_by_removal; \ - DROP TRIGGER runtime_binding_update_blocked_by_removal; \ - DROP TRIGGER worker_registry_insert_blocked_by_runtime_removal; \ - DROP TRIGGER worker_registry_update_blocked_by_runtime_removal; \ - DROP TRIGGER workdir_registry_insert_blocked_by_runtime_removal; \ - DROP TRIGGER workdir_registry_update_blocked_by_runtime_removal; \ - DROP TRIGGER worker_assignment_insert_blocked_by_runtime_removal; \ - DROP TRIGGER worker_assignment_update_blocked_by_runtime_removal; \ - DROP TRIGGER workdir_attachment_insert_blocked_by_runtime_removal; \ - DROP TRIGGER workdir_attachment_update_blocked_by_runtime_removal; \ - DROP TRIGGER worker_create_insert_blocked_by_runtime_removal; \ - DROP TRIGGER worker_create_update_blocked_by_runtime_removal; \ - DROP TRIGGER workdir_create_insert_blocked_by_runtime_removal; \ - DROP TRIGGER workdir_create_update_blocked_by_runtime_removal; \ - DROP TRIGGER worker_removal_insert_blocked_by_runtime_removal; \ - DROP TRIGGER worker_removal_update_blocked_by_runtime_removal; \ - DROP TRIGGER workdir_removal_insert_blocked_by_runtime_removal; \ - DROP TRIGGER workdir_removal_update_blocked_by_runtime_removal; \ - DROP TABLE runtime_removal_operations; \ - ALTER TABLE worker_removal_operations \ - ADD COLUMN run_generation INTEGER NOT NULL DEFAULT 0; \ - DELETE FROM __yoi_schema_migrations; \ - INSERT INTO __yoi_schema_migrations(version, name) \ - VALUES (59, 'workspace schema baseline');", - )?; - Ok(()) - }) - .unwrap(); - drop(store); + prepare_retained_schema(&path, 59); let migrated = SqliteWorkspaceStore::open(&path).unwrap(); migrated @@ -18305,24 +15762,7 @@ mod tests { fn schema_v60_upgrade_removes_worker_run_generation_column() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("server.db"); - drop(SqliteWorkspaceStore::open(&path).unwrap()); - { - let conn = Connection::open(&path).unwrap(); - conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;") - .unwrap(); - downgrade_assignment_work_schema(&conn); - conn.execute_batch("PRAGMA legacy_alter_table=OFF; PRAGMA foreign_keys=ON;") - .unwrap(); - downgrade_schema_66_ticket_and_workdir_authority(&conn).unwrap(); - conn.execute_batch( - "ALTER TABLE worker_removal_operations \ - ADD COLUMN run_generation INTEGER NOT NULL DEFAULT 0; \ - DELETE FROM __yoi_schema_migrations; \ - INSERT INTO __yoi_schema_migrations(version,name) \ - VALUES (60,'workspace schema baseline');", - ) - .unwrap(); - } + prepare_retained_schema(&path, 60); drop(SqliteWorkspaceStore::open(&path).unwrap()); let conn = Connection::open(&path).unwrap(); @@ -18400,54 +15840,13 @@ mod tests { fn schema_v65_migrates_ticket_targets_and_workdir_capabilities() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("server.db"); - drop(SqliteWorkspaceStore::open(&path).unwrap()); + prepare_retained_schema(&path, 65); let conn = Connection::open(&path).unwrap(); configure_sqlite(&conn).unwrap(); conn.execute_batch( r#" - PRAGMA foreign_keys = OFF; - DROP TABLE typed_ticket_targets; - ALTER TABLE typed_tickets ADD COLUMN repository_id TEXT; - ALTER TABLE typed_tickets ADD COLUMN ref_selector TEXT; - CREATE UNIQUE INDEX worker_registry_v74_workspace_worker - ON worker_registry(workspace_id, worker_id); - DROP INDEX worker_workdir_links_active_workdir_unique; - DROP INDEX worker_workdir_links_active_alias_unique; - DROP INDEX worker_workdir_links_workdir; - CREATE TABLE worker_workdir_links_v65 ( - workspace_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - worker_id TEXT NOT NULL, - workdir_id TEXT NOT NULL, - alias TEXT NOT NULL, - linked_at TEXT NOT NULL, - unlinked_at TEXT, - PRIMARY KEY (workspace_id, worker_id, workdir_id, alias), - FOREIGN KEY (workspace_id, worker_id) - REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE, - FOREIGN KEY (workspace_id, workdir_id) - REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE - ); - INSERT INTO worker_workdir_links_v65 ( - workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at - ) - SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at - FROM worker_workdir_links; - DROP TABLE worker_workdir_links; - ALTER TABLE worker_workdir_links_v65 RENAME TO worker_workdir_links; - CREATE UNIQUE INDEX worker_workdir_links_active_workdir_unique - ON worker_workdir_links(workspace_id, workdir_id) WHERE unlinked_at IS NULL; - CREATE UNIQUE INDEX worker_workdir_links_active_alias_unique - ON worker_workdir_links(workspace_id, worker_id, alias) WHERE unlinked_at IS NULL; - CREATE INDEX worker_workdir_links_workdir - ON worker_workdir_links(workspace_id, workdir_id); - DELETE FROM __yoi_schema_migrations; - INSERT INTO __yoi_schema_migrations(version, name) - VALUES (65, 'workspace schema baseline'); - DELETE FROM ticket_schema_migrations; - INSERT INTO ticket_schema_migrations(version, name, applied_at) - VALUES (6, 'ticket schema baseline', '1'); - + BEGIN; + PRAGMA defer_foreign_keys = ON; INSERT INTO accounts(account_id, kind, handle, display_name, created_at, updated_at) VALUES ('owner', 'user', 'owner', 'Owner', '1', '1'); INSERT INTO workspaces( @@ -18521,7 +15920,7 @@ mod tests { 'checkout', '1', NULL), ('workspace-a', 'runtime-a', 'worker-external', 'workdir-external', 'external', '1', NULL); - PRAGMA foreign_keys = ON; + COMMIT; "#, ) .unwrap(); @@ -18805,7 +16204,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx .iter() .map(|s| s.version) .collect::>(), - vec![83, 84] + (83..=LATEST_SCHEMA_VERSION).collect::>() ); let store = SqliteWorkspaceStore::open(&path).unwrap(); let assignment = store @@ -18838,6 +16237,108 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx }).unwrap(); } + // Legacy fixtures compose frozen counter-bearing SQL, never current-schema downgrades. + fn create_frozen_schema_v85(conn: &Connection) -> Result<()> { + conn.execute_batch("CREATE TABLE worker_restore_intents ( + workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, + domain_key TEXT NOT NULL, request_id TEXT NOT NULL, expected_token TEXT NOT NULL, + settled INTEGER NOT NULL CHECK(settled IN (0,1)), + PRIMARY KEY(workspace_id,runtime_id,worker_id,request_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); + CREATE UNIQUE INDEX worker_restore_intent_pending_domain ON worker_restore_intents(workspace_id,runtime_id,worker_id,domain_key) WHERE settled=0;")?; + + conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?; + conn.execute_batch(include_str!("frozen_schema_v85.sql"))?; + conn.execute_batch( + r#" + ALTER TABLE typed_tickets RENAME TO typed_tickets_legacy_v66; + CREATE TABLE typed_tickets ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + slug TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL, + kind TEXT NOT NULL, + priority TEXT NOT NULL, + body TEXT NOT NULL, + created_at TEXT, + updated_at TEXT, + assignee TEXT, + readiness TEXT, + workflow_state TEXT NOT NULL, + workflow_state_explicit INTEGER NOT NULL, + queued_by TEXT, + queued_at TEXT, + resolution TEXT, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); + DROP TABLE typed_tickets_legacy_v66; + CREATE INDEX idx_typed_tickets_workspace_state_updated + ON typed_tickets(workspace_id, workflow_state, updated_at DESC, ticket_id); + CREATE INDEX idx_typed_tickets_workspace_updated + ON typed_tickets(workspace_id, updated_at DESC, ticket_id); + CREATE TRIGGER ticket_assignment_ticket_parent_tombstone + BEFORE DELETE ON typed_tickets + WHEN EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = OLD.workspace_id + AND assignment.ticket_id = OLD.ticket_id + ) + BEGIN + INSERT OR IGNORE INTO ticket_assignment_ticket_tombstones ( + workspace_id, ticket_id, deleted_at + ) VALUES (OLD.workspace_id, OLD.ticket_id, CURRENT_TIMESTAMP); + END; + CREATE TABLE typed_ticket_targets ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + repository_key TEXT NOT NULL, + ref_selector TEXT, + access TEXT NOT NULL CHECK (access IN ('read_only', 'read_write')), + PRIMARY KEY (workspace_id, ticket_id, ordinal), + UNIQUE (workspace_id, ticket_id, repository_key), + FOREIGN KEY (workspace_id, ticket_id) + REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, repository_key) + REFERENCES repositories(workspace_id, repository_key) ON DELETE RESTRICT + ); + CREATE INDEX typed_ticket_targets_workspace_repository + ON typed_ticket_targets(workspace_id, repository_key, ticket_id); + ALTER TABLE worker_workdir_links ADD COLUMN connection_id TEXT NOT NULL DEFAULT ''; + ALTER TABLE worker_workdir_links + ADD COLUMN capabilities TEXT NOT NULL + CHECK (capabilities IN ( + 'all', 'read_only', 'read_write', 'command_only', 'read_command' + )); + "#, + )?; + conn.execute_batch(include_str!("assignment_work.sql"))?; + ticket::verify_sqlite_ticket_schema(conn).map_err(|error| { + Error::Store(format!( + "fresh Workspace Ticket schema verification failed: {error}" + )) + })?; + Ok(()) + } + + include!("schema_equivalence_tests.rs"); + include!("migration_entrypoint_tests.rs"); + + fn prepare_retained_schema(path: &Path, version: i64) { + prepare_schema_v52(path); + let conn = Connection::open(path).unwrap(); + configure_sqlite(&conn).unwrap(); + for migration in MIGRATIONS + .iter() + .filter(|migration| migration.version > 52 && migration.version <= version) + { + (migration.apply)(&conn).unwrap(); + } + } + fn prepare_schema_v52(path: &Path) { let conn = Connection::open(path).unwrap(); configure_sqlite(&conn).unwrap(); @@ -18845,7 +16346,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx merge_request::migrate(&conn).unwrap(); conn.execute_batch("PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON;") .unwrap(); - create_latest_workspace_schema(&conn).unwrap(); + create_frozen_schema_v85(&conn).unwrap(); downgrade_assignment_work_schema(&conn); downgrade_schema_66_ticket_and_workdir_authority(&conn).unwrap(); conn.execute_batch("PRAGMA legacy_alter_table = OFF; PRAGMA foreign_keys = ON;") @@ -19120,6 +16621,23 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx version: 84, name: WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME.to_string() }, + WorkspaceSchemaMigrationStep { + version: 85, + name: "content-addressed Flow sources".to_string() + }, + WorkspaceSchemaMigrationStep { + version: 86, + name: "content-fenced config, Memory settings and Jobs".to_string() + }, + WorkspaceSchemaMigrationStep { + version: 87, + name: "Repository SSH key identities and encrypted operation objects" + .to_string() + }, + WorkspaceSchemaMigrationStep { + version: 88, + name: "Runtime binding identities and retention content contracts".to_string() + }, ] ); @@ -19214,6 +16732,10 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx (82, "Ticket responsibility and unfinished work separation".to_string()), (83, "Generic Ticket Worker roles and durable claims".to_string()), (84, WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME.to_string()), +(85, "content-addressed Flow sources".to_string()), +(86, "content-fenced config, Memory settings and Jobs".to_string()), +(87, "Repository SSH key identities and encrypted operation objects".to_string()), +(88, "Runtime binding identities and retention content contracts".to_string()), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -19240,7 +16762,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx "SELECT COUNT(*) FROM workspace_runtime_bindings WHERE workspace_id='workspace-a' AND runtime_id='shared' AND workspace_key_id IS NOT NULL - AND workspace_key_generation IS NOT NULL", + AND length(binding_id) > 0 + AND workspace_trust_id IS NULL", [], |row| row.get::<_, i64>(0), )?, @@ -19563,10 +17086,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx .iter() .map(|migration| migration.version) .collect::>(), - vec![ - 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, - 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84 - ] + (52..=LATEST_SCHEMA_VERSION).collect::>() ); SqliteWorkspaceStore::migrate_database(&path).unwrap(); let conn = Connection::open(&path).unwrap(); @@ -19574,7 +17094,10 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx current_schema_version(&conn).unwrap(), LATEST_SCHEMA_VERSION ); - assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 35); + assert_eq!( + workspace_schema_migration_history(&conn).unwrap().len(), + (LATEST_SCHEMA_VERSION - 50 + 1) as usize + ); assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap()); assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap()); assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap()); @@ -19869,29 +17392,27 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx )?; assert_eq!(reservation_state, "created"); let reservation_memory = conn.query_row( - "SELECT request_fingerprint, memory_settings_revision, memory_language + "SELECT request_fingerprint, memory_language FROM worker_create_reservations WHERE workspace_id='workspace-a' AND allocation_key='allocation'", [], |row| { Ok(( row.get::<_, Option>(0)?, - row.get::<_, Option>(1)?, - row.get::<_, Option>(2)?, + row.get::<_, Option>(1)?, )) }, )?; - assert_eq!(reservation_memory, (None, None, None)); + assert_eq!(reservation_memory, (None, None)); let complete_memory = conn.query_row( - "SELECT request_fingerprint, memory_settings_revision, memory_language + "SELECT request_fingerprint, memory_language FROM worker_create_reservations WHERE workspace_id='workspace-a' AND allocation_key='allocation-complete'", [], |row| { Ok(( row.get::<_, Option>(0)?, - row.get::<_, Option>(1)?, - row.get::<_, Option>(2)?, + row.get::<_, Option>(1)?, )) }, )?; @@ -19899,7 +17420,6 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx complete_memory, ( Some("request-complete".to_string()), - Some(7), Some("ja".to_string()) ) ); @@ -19999,10 +17519,10 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx ('workspace-b', 'owner', 'Workspace B', 'active', '1', '1'); INSERT INTO workspace_signing_identities( workspace_id, key_id, algorithm, public_key, public_key_fingerprint, - private_material_ref, revision, state, created_at, provisioned_at, updated_at + private_material_ref, state, created_at, provisioned_at, updated_at ) VALUES - ('workspace-a', 'WK-a', 'ed25519', NULL, NULL, 'workspace-signing/workspace-a/ed25519-v1', 1, 'pending_provisioning', '1', NULL, '1'), - ('workspace-b', 'WK-b', 'ed25519', NULL, NULL, 'workspace-signing/workspace-b/ed25519-v1', 1, 'pending_provisioning', '1', NULL, '1'); + ('workspace-a', 'WK-a', 'ed25519', NULL, NULL, 'workspace-signing/workspace-a/ed25519-v1', 'pending_provisioning', '1', NULL, '1'), + ('workspace-b', 'WK-b', 'ed25519', NULL, NULL, 'workspace-signing/workspace-b/ed25519-v1', 'pending_provisioning', '1', NULL, '1'); "#, )?; Ok(()) @@ -20016,11 +17536,12 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx base_url: format!("https://{workspace_id}.runtime.test"), public_key: identity.public_key.clone(), public_key_fingerprint: String::new(), - binding_revision: 1, + binding_id: String::new(), state: WorkspaceRuntimeBindingState::Verified, authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity, workspace_key_id: Some(format!("WK-{}", &workspace_id["workspace-".len()..])), - workspace_key_generation: Some(1), + workspace_trust_id: Some("trust-fixture".into()), + workspace_public_key_fingerprint: Some("sha256:key".into()), created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -20152,9 +17673,9 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx VALUES ('workspace-a', 'owner', 'Workspace A', 'active', '1', '1'); INSERT INTO workspace_signing_identities( workspace_id, key_id, algorithm, public_key, public_key_fingerprint, - private_material_ref, revision, state, created_at, provisioned_at, updated_at + private_material_ref, state, created_at, provisioned_at, updated_at ) VALUES ('workspace-a', 'WK-a', 'ed25519', 'key', 'sha256:key', - 'workspace-signing/workspace-a/ed25519-v1', 1, 'active', '1', '1', '1'); + 'workspace-signing/workspace-a/ed25519-v1', 'active', '1', '1', '1'); "#, )?; Ok(()) @@ -20171,11 +17692,12 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx base_url: "https://runtime.test".to_string(), public_key: runtime_identity.public_key, public_key_fingerprint: String::new(), - binding_revision: 1, + binding_id: String::new(), state: WorkspaceRuntimeBindingState::Configured, authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity, workspace_key_id: Some("WK-a".to_string()), - workspace_key_generation: Some(1), + workspace_trust_id: Some("trust-fixture".into()), + workspace_public_key_fingerprint: Some("sha256:key".into()), created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -20190,12 +17712,12 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx let evidence = WorkspaceRuntimeVerificationEvidence { workspace_id: "workspace-a".to_string(), runtime_id: "runtime-a".to_string(), - binding_revision: persisted.binding_revision, + binding_id: persisted.binding_id.clone(), workspace_key_id: "WK-a".to_string(), - workspace_identity_revision: 1, - workspace_trust_generation: 1, + workspace_public_key_fingerprint: "sha256:key".into(), + workspace_trust_id: "trust-fixture".into(), runtime_public_key_fingerprint: persisted.public_key_fingerprint.clone(), - runtime_identity_revision: 1, + challenge_id: "challenge-a".to_string(), state: "verified".to_string(), last_outcome: "verified".to_string(), @@ -20228,7 +17750,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx assert_eq!(retained.last_outcome, "challenge_issued"); assert!( !store - .workspace_runtime_verification_matches(&verified, 1, 1) + .workspace_runtime_verification_matches(&verified, "sha256:key", "trust-fixture") .unwrap() ); store @@ -20565,11 +18087,12 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx base_url: "https://runtime.test".to_string(), public_key, public_key_fingerprint: String::new(), - binding_revision: 1, + binding_id: String::new(), state: WorkspaceRuntimeBindingState::Configured, authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity, workspace_key_id: Some("WK-a".to_string()), - workspace_key_generation: Some(1), + workspace_trust_id: Some("trust-fixture".into()), + workspace_public_key_fingerprint: Some("sha256:key".into()), created_at: at.to_string(), updated_at: at.to_string(), revoked_at: None, @@ -20583,7 +18106,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx ) .unwrap(); assert_eq!(created, WorkspaceRuntimeBindingMutation::Created); - assert_eq!(created_binding.binding_revision, 1); + assert!(!created_binding.binding_id.is_empty()); assert_eq!( created_binding.state, WorkspaceRuntimeBindingState::Configured @@ -20593,55 +18116,58 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity ); assert_eq!(created_binding.workspace_key_id.as_deref(), Some("WK-a")); - assert_eq!(created_binding.workspace_key_generation, Some(1)); + assert_eq!( + created_binding.workspace_trust_id.as_deref(), + Some("trust-fixture") + ); let (replayed, replayed_binding) = store .put_workspace_runtime_binding_key(binding(first.public_key, "2"), None, "owner") .unwrap(); assert_eq!(replayed, WorkspaceRuntimeBindingMutation::Unchanged); - assert_eq!(replayed_binding.binding_revision, 1); + assert_eq!(replayed_binding.binding_id, created_binding.binding_id); let mut mismatched_replay = binding(replayed_binding.public_key.clone(), "2"); mismatched_replay.base_url = "https://different.runtime.test".to_string(); assert!(matches!( store.put_workspace_runtime_binding_key(mismatched_replay, None, "owner"), - Err(Error::RuntimeBindingRevisionConflict { + Err(Error::RuntimeBindingIdConflict { expected: None, - actual: Some(1) + actual: Some(_) }) )); let stale = store .put_workspace_runtime_binding_key( binding(second.public_key.clone(), "3"), - Some(0), + Some("binding-stale"), "owner", ) .unwrap_err(); assert!(matches!( stale, - Error::RuntimeBindingRevisionConflict { - expected: Some(0), - actual: Some(1) + Error::RuntimeBindingIdConflict { + expected: Some(_), + actual: Some(_) } )); let (replaced, replaced_binding) = store .put_workspace_runtime_binding_key( binding(second.public_key.clone(), "3"), - Some(1), + Some(&created_binding.binding_id), "owner", ) .unwrap(); assert_eq!(replaced, WorkspaceRuntimeBindingMutation::Replaced); - assert_eq!(replaced_binding.binding_revision, 2); + assert_ne!(replaced_binding.binding_id, created_binding.binding_id); store .record_workspace_runtime_verification_attempt(&WorkspaceRuntimeVerificationEvidence { workspace_id: "workspace-a".to_string(), runtime_id: "runtime-a".to_string(), - binding_revision: 2, + binding_id: replaced_binding.binding_id.clone(), workspace_key_id: "WK-a".to_string(), - workspace_identity_revision: 1, - workspace_trust_generation: 1, + workspace_public_key_fingerprint: "sha256:key".into(), + workspace_trust_id: "trust-fixture".into(), runtime_public_key_fingerprint: replaced_binding.public_key_fingerprint.clone(), - runtime_identity_revision: 1, + challenge_id: "challenge-a".to_string(), state: "failed".to_string(), last_outcome: "connectivity_failed".to_string(), @@ -20656,10 +18182,16 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx .is_some() ); let (revoked, revoked_binding) = store - .revoke_workspace_runtime_binding_key("workspace-a", "runtime-a", 2, "owner", "4") + .revoke_workspace_runtime_binding_key( + "workspace-a", + "runtime-a", + &replaced_binding.binding_id, + "owner", + "4", + ) .unwrap(); assert_eq!(revoked, WorkspaceRuntimeBindingMutation::Revoked); - assert_eq!(revoked_binding.binding_revision, 3); + assert_ne!(revoked_binding.binding_id, replaced_binding.binding_id); assert_eq!(revoked_binding.revoked_at.as_deref(), Some("4")); assert_eq!(revoked_binding.state, WorkspaceRuntimeBindingState::Revoked); assert_eq!( @@ -20671,12 +18203,12 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx let (reactivated, reactivated_binding) = store .put_workspace_runtime_binding_key( binding(second.public_key.clone(), "5"), - Some(3), + Some(&revoked_binding.binding_id), "owner", ) .unwrap(); assert_eq!(reactivated, WorkspaceRuntimeBindingMutation::Reactivated); - assert_eq!(reactivated_binding.binding_revision, 4); + assert_ne!(reactivated_binding.binding_id, revoked_binding.binding_id); assert_eq!( reactivated_binding.state, WorkspaceRuntimeBindingState::Configured @@ -20697,7 +18229,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx .unwrap(); assert_eq!(audit.len(), 4); assert_eq!(audit[0].action, "reactivated"); - assert_eq!(audit[0].binding_revision, 4); + assert_eq!(audit[0].binding_id, reactivated_binding.binding_id); assert_eq!(audit[1].action, "revoked"); assert_eq!(audit[2].action, "replaced"); assert_eq!(audit[3].action, "created"); @@ -20730,11 +18262,12 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx base_url: "in-process://embedded".to_string(), public_key, public_key_fingerprint: String::new(), - binding_revision: 1, + binding_id: String::new(), state: WorkspaceRuntimeBindingState::Verified, authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer, workspace_key_id: None, - workspace_key_generation: None, + workspace_trust_id: None, + workspace_public_key_fingerprint: None, created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -20846,7 +18379,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx uri: "/repo-a".to_string(), }, default_ref: Some("HEAD".to_string()), - source_revision: 1, + source_fingerprint: "sha256:test".to_string(), observed_status: RepositoryObservedStatus::Unverified, observed_at: None, @@ -21027,7 +18560,6 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx .unwrap(); let memory_settings = store.get_workspace_memory_settings("workspace-a").unwrap(); - assert_eq!(memory_settings.settings_revision, 1); assert_eq!(memory_settings.language, "English"); let reserved = store .reserve_worker_create( @@ -21043,7 +18575,6 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx reserved.worker_id.as_uuid().get_version(), Some(uuid::Version::SortRand) ); - assert_eq!(reserved.memory_settings.settings_revision, 1); assert_eq!(reserved.memory_settings.language, "English"); let reserved_worker = RuntimeWorkerRef::new("arcadia", reserved.worker_id.to_string()); assert!( @@ -21052,14 +18583,24 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx .unwrap() ); let unchanged_memory_settings = store - .update_workspace_memory_settings("workspace-a", 1, " English ") + .update_workspace_memory_settings("workspace-a", "English", " English ") .unwrap(); assert_eq!(unchanged_memory_settings, memory_settings); let updated_memory_settings = store - .update_workspace_memory_settings("workspace-a", 1, " Français ") + .update_workspace_memory_settings("workspace-a", "English", " Français ") .unwrap(); - assert_eq!(updated_memory_settings.settings_revision, 2); assert_eq!(updated_memory_settings.language, "Français"); + assert!(matches!( + store.update_workspace_memory_settings("workspace-a", "English", "Japanese"), + Err(Error::WorkspaceConfigConflict(_)) + )); + assert_eq!( + store + .get_workspace_memory_settings("workspace-a") + .unwrap() + .language, + "Français" + ); assert_eq!( store .reserve_worker_create( @@ -21108,7 +18649,6 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx &updated_memory_settings, ) .unwrap(); - assert_eq!(second.memory_settings.settings_revision, 2); assert_eq!( store .worker_resource_key( @@ -21276,7 +18816,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx assert!(store.get_workspace_memory_settings("workspace-a").is_err()); assert!( store - .update_workspace_memory_settings("workspace-a", 2, "Spanish") + .update_workspace_memory_settings("workspace-a", "Français", "Spanish") .is_err() ); let mut corrupt = updated_memory_settings.clone(); @@ -21594,7 +19134,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx } #[tokio::test] - async fn workspace_flow_sources_keep_revisions_and_builtins_stay_resources() { + async fn workspace_flow_sources_pin_content_and_builtins_stay_resources() { let dir = tempfile::tempdir().unwrap(); let store = SqliteWorkspaceStore::open(dir.path().join("server.db")).unwrap(); store @@ -21614,7 +19154,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx initial = "work"; states = { work = { - instructions = "Workspace revision one."; + instructions = "Workspace initial content."; transitions = { done = { target = "done"; condition = "Done."; }; }; }; done = { instructions = ""; terminal = true; }; @@ -21647,25 +19187,61 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx vec![workspace.clone()] ); - let revision_two = - workspace_source.replace("Workspace revision one.", "Workspace revision two."); + let changed_content = + workspace_source.replace("Workspace initial content.", "Workspace changed content."); let updated = store .put_flow_source_for_kind( "workspace-a", FlowSourceKind::Workspace, "flows/coder-review.dcdl", - &revision_two, + &changed_content, "2026-08-06T00:00:03Z", ) .unwrap(); assert_eq!(updated.flow_id, workspace.flow_id); - assert_eq!(updated.revision, 2); + assert_ne!(updated.content_digest, workspace.content_digest); let pinned = store - .get_flow_source_revision("workspace-a", &workspace.flow_id, 1) + .get_flow_source_content("workspace-a", &workspace.flow_id, &workspace.content_digest) .unwrap() .unwrap(); assert_eq!(pinned.content, workspace_source); assert_eq!(pinned.definition.name, "coder-review"); + let restored = store + .put_flow_source_for_kind( + "workspace-a", + FlowSourceKind::Workspace, + "flows/coder-review.dcdl", + workspace_source, + "2026-08-06T00:00:04Z", + ) + .unwrap(); + assert_eq!(restored.content_digest, workspace.content_digest); + let count: i64 = store + .with_conn(|conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM flow_source_contents WHERE workspace_id=?1 AND flow_id=?2", + params!["workspace-a", workspace.flow_id], |row| row.get(0), + )?) + }) + .unwrap(); + assert_eq!( + count, 2, + "returning to earlier content must reuse the immutable content" + ); + drop(store); + let reopened = SqliteWorkspaceStore::open(dir.path().join("server.db")).unwrap(); + assert_eq!( + reopened + .get_flow_source_content( + "workspace-a", + &workspace.flow_id, + &workspace.content_digest + ) + .unwrap() + .unwrap() + .content, + workspace_source + ); } // Equivalent to the reported closed T-12 + retained Coder data. The @@ -21773,7 +19349,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx |row| Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?))).unwrap(); assert_eq!( (plan.as_str(), fingerprint.as_str(), state.as_str()), - ("old-plan", "immutable-fingerprint", "blocked") + ("old-plan", "immutable-fingerprint", "stale") ); let blockers: Vec = serde_json::from_str(&encoded).unwrap(); @@ -22221,7 +19797,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx ) .unwrap(); let recovery = TicketWorkerClaimRecovery { - item_revision: "first:0".into(), + content_digest: "first:0".into(), selected: vec![], original_links: vec![], effective_links: vec![], @@ -22233,7 +19809,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx .retain_ticket_claim_recovery("w", "claim", &fingerprint, &recovery) .unwrap(); let mut changed = recovery.clone(); - changed.item_revision = "first:1".into(); + changed.content_digest = "first:1".into(); assert!( store .retain_ticket_claim_recovery("w", "claim", &fingerprint, &changed) @@ -22948,7 +20524,7 @@ INSERT INTO worker_registry ( uri: "file:///tmp/main".to_string(), }, default_ref: Some("develop".to_string()), - source_revision: 1, + source_fingerprint: "sha256:test".to_string(), observed_status: RepositoryObservedStatus::Unverified, observed_at: None, @@ -23252,10 +20828,10 @@ INSERT INTO workspaces (workspace_id, owner_account_id, display_name, state, cre VALUES ('workspace-a', 'owner-account', 'A', 'active', '2026-01-01', '2026-01-01'); INSERT INTO workspace_signing_identities ( workspace_id, key_id, algorithm, public_key, public_key_fingerprint, - private_material_ref, revision, state, created_at, provisioned_at, updated_at + private_material_ref, state, created_at, provisioned_at, updated_at ) VALUES ( 'workspace-a', 'WK-a', 'ed25519', NULL, NULL, - 'workspace-signing/workspace-a/ed25519-v1', 1, 'pending_provisioning', + 'workspace-signing/workspace-a/ed25519-v1', 'pending_provisioning', '2026-01-01', NULL, '2026-01-01' ); INSERT INTO typed_tickets ( @@ -23282,10 +20858,10 @@ INSERT INTO workspaces (workspace_id, owner_account_id, display_name, state, cre VALUES ('workspace-b', 'owner-account', 'B', 'active', '2026-01-01', '2026-01-01'); INSERT INTO workspace_signing_identities ( workspace_id, key_id, algorithm, public_key, public_key_fingerprint, - private_material_ref, revision, state, created_at, provisioned_at, updated_at + private_material_ref, state, created_at, provisioned_at, updated_at ) VALUES ( 'workspace-b', 'WK-b', 'ed25519', NULL, NULL, - 'workspace-signing/workspace-b/ed25519-v1', 1, 'pending_provisioning', + 'workspace-signing/workspace-b/ed25519-v1', 'pending_provisioning', '2026-01-01', NULL, '2026-01-01' ); INSERT INTO typed_tickets ( @@ -23363,7 +20939,7 @@ INSERT INTO worker_registry ( uri: "/repo".to_string(), }, default_ref: Some("develop".to_string()), - source_revision: 1, + source_fingerprint: "sha256:test".to_string(), observed_status: RepositoryObservedStatus::Unverified, observed_at: None, @@ -23377,7 +20953,7 @@ INSERT INTO worker_registry ( public_key: "test-public-key".to_string(), public_key_fingerprint: "sha256:test-public-key".to_string(), private_material_ref: "workspace-signing/store-test/ed25519-v1".to_string(), - revision: 1, + provisioned_at: "1".to_string(), }; let error = store @@ -23414,7 +20990,7 @@ INSERT INTO worker_registry ( uri: "/repo".to_string(), }, default_ref: Some("develop".to_string()), - source_revision: 1, + source_fingerprint: "sha256:test".to_string(), observed_status: RepositoryObservedStatus::Unverified, observed_at: None, @@ -23436,7 +21012,7 @@ INSERT INTO worker_registry ( workspace_id: first.workspace.workspace_id.clone(), key_id: signing_identity.key_id.clone(), private_material_ref: signing_identity.private_material_ref.clone(), - revision: signing_identity.revision, + actor_account_id: first.workspace.owner_account_id.clone(), state: "pending".to_string(), created_at: "1".to_string(), @@ -23488,7 +21064,7 @@ INSERT INTO worker_registry ( uri: "/repo".to_string(), }, default_ref: Some("HEAD".to_string()), - source_revision: 1, + source_fingerprint: crate::repository_source::repository_source_fingerprint( &RepositorySource { kind: server_api::RepositorySourceKind::LocalPath, @@ -23810,7 +21386,7 @@ INSERT INTO worker_registry ( uri: "/repo".to_string(), }, default_ref: Some("HEAD".to_string()), - source_revision: 1, + source_fingerprint: "sha256:test".to_string(), observed_status: RepositoryObservedStatus::Unverified, observed_at: None, @@ -24998,7 +22574,6 @@ INSERT INTO worker_registry ( BackendJobRequest { job_id: id.into(), purpose: "subjektiv_consolidation".into(), - input_revision: candidate.into(), input_ref: format!("subject://{subject}/{candidate}"), input: serde_json::json!({"candidate": candidate}), instruction: "Consolidate this immutable candidate snapshot.".into(), @@ -25507,8 +23082,8 @@ INSERT INTO worker_registry ( let second_id = attempt_id(&request.job_id, 2); store.with_conn(|conn| { conn.execute("UPDATE backend_jobs SET state = 'pending', current_attempt = 2, completed_at = NULL WHERE workspace_id = 'resource-workspace' AND job_id = ?1", params![request.job_id])?; - conn.execute("INSERT INTO backend_job_attempts (workspace_id, job_id, attempt_id, attempt, input_revision, state, deadline_at, created_at, updated_at) VALUES ('resource-workspace', ?1, ?2, 2, ?3, 'reserved', ?4, ?4, ?4)", - params![request.job_id, second_id, request.input_revision, RESOURCE_JOB_NOW])?; + conn.execute("INSERT INTO backend_job_attempts (workspace_id, job_id, attempt_id, attempt, input_digest, state, deadline_at, created_at, updated_at) VALUES ('resource-workspace', ?1, ?2, 2, ?3, 'reserved', ?4, ?4, ?4)", + params![request.job_id, second_id, request.input_digest().unwrap(), RESOURCE_JOB_NOW])?; Ok(()) }).unwrap(); let second = store @@ -25565,7 +23140,7 @@ INSERT INTO worker_registry ( let submission = BackendJobResultSubmission { job_id: request.job_id.clone(), attempt_id: third.attempt.attempt_id.clone(), - input_revision: request.input_revision.clone(), + input_digest: request.input_digest().unwrap(), result: serde_json::json!({"converged": true}), }; store @@ -25694,7 +23269,7 @@ INSERT INTO worker_registry ( &BackendJobResultSubmission { job_id: refresh.job_id.clone(), attempt_id: first.attempt.attempt_id.clone(), - input_revision: refresh.input_revision.clone(), + input_digest: refresh.input_digest().unwrap(), result: serde_json::json!({"surface": {"availability": "available"}}), }, RESOURCE_JOB_NOW, @@ -25740,7 +23315,7 @@ INSERT INTO worker_registry ( &BackendJobResultSubmission { job_id: request.job_id.clone(), attempt_id: first.attempt.attempt_id.clone(), - input_revision: request.input_revision.clone(), + input_digest: request.input_digest().unwrap(), result: serde_json::json!({"done": true}), }, RESOURCE_JOB_NOW, @@ -25860,7 +23435,6 @@ INSERT INTO worker_registry ( let request = BackendJobRequest { job_id: "check:T-1:r1".to_string(), purpose: "ticket_item_check".to_string(), - input_revision: "1".to_string(), input_ref: "ticket://T-1/revisions/1".to_string(), input: serde_json::json!({"title": "check"}), instruction: "Check the immutable input.".to_string(), @@ -25896,7 +23470,7 @@ INSERT INTO worker_registry ( &BackendJobResultSubmission { job_id: request.job_id.clone(), attempt_id: attempt_id(&request.job_id, 1), - input_revision: request.input_revision.clone(), + input_digest: request.input_digest().unwrap(), result: serde_json::json!({"cross_workspace": true}), }, "2026-09-01T00:00:00Z", @@ -25935,7 +23509,7 @@ INSERT INTO worker_registry ( let submission = BackendJobResultSubmission { job_id: request.job_id.clone(), attempt_id: first.attempt.attempt_id.clone(), - input_revision: request.input_revision.clone(), + input_digest: request.input_digest().unwrap(), result: serde_json::json!({"valid": true}), }; assert!( @@ -26280,7 +23854,7 @@ INSERT INTO worker_registry ( &BackendJobResultSubmission { job_id: retry_request.job_id.clone(), attempt_id: failed.attempt.attempt_id.clone(), - input_revision: retry_request.input_revision.clone(), + input_digest: retry_request.input_digest().unwrap(), result: serde_json::json!({"late": true}), }, "2026-09-01T00:01:03Z", @@ -26342,3 +23916,19 @@ INSERT INTO worker_registry ( ); } } + +#[cfg(test)] +#[path = "frozen_flow_content_tests.rs"] +mod flow_content_migration_tests; + +#[cfg(test)] +#[path = "frozen_content_state_tests.rs"] +mod content_state_migration_tests; + +#[cfg(test)] +#[path = "frozen_repository_key_tests.rs"] +mod repository_key_migration_tests; + +#[cfg(test)] +#[path = "frozen_runtime_binding_tests.rs"] +mod frozen_runtime_binding_tests; diff --git a/crates/workspace-server/src/ticket_item_checker.rs b/crates/workspace-server/src/ticket_item_checker.rs index dd45980d0..5736d8a59 100644 --- a/crates/workspace-server/src/ticket_item_checker.rs +++ b/crates/workspace-server/src/ticket_item_checker.rs @@ -21,11 +21,11 @@ const MAX_SUGGESTION_BYTES: usize = 512; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct TicketItemCheckInput { - /// Canonical internal identity used for an authoritative revision reread. + /// Canonical internal identity used for an authoritative content_digest reread. pub ticket_id: String, /// User-facing identity used in checker output and advisory notifications. pub ticket_resource_key: String, - pub revision: String, + pub content_digest: String, pub title: String, pub body: String, pub edit: TicketItemCheckEdit, @@ -88,8 +88,8 @@ impl TicketItemCheckFindingCategory { } } -pub fn item_revision(ticket: &ticket::Ticket) -> String { - ticket::ticket_item_revision(ticket) +pub fn content_digest(ticket: &ticket::Ticket) -> String { + ticket::ticket_content_digest(ticket) } pub fn request( @@ -98,7 +98,7 @@ pub fn request( source_worker: RuntimeWorkerRef, instruction: String, ) -> Result { - let revision = item_revision(ticket); + let content_digest = content_digest(ticket); let resource_key = ticket .meta .resource_key @@ -107,16 +107,15 @@ pub fn request( let input = TicketItemCheckInput { ticket_id: ticket.meta.id.clone(), ticket_resource_key: resource_key.clone(), - revision: revision.clone(), + content_digest: content_digest.clone(), title: ticket.meta.title.clone(), body: ticket.document.body.as_str().to_string(), edit, }; - let request = BackendJobRequest { - job_id: format!("ticket-item-check:{}:{revision}", ticket.meta.id), + let mut request = BackendJobRequest { + job_id: format!("ticket-item-check:{}", ticket.meta.id), purpose: PURPOSE.to_string(), - input_revision: revision.clone(), - input_ref: format!("ticket://{resource_key}/revisions/{revision}"), + input_ref: format!("ticket://{resource_key}/contents/{content_digest}"), input: serde_json::to_value(input).map_err(|error| { Error::InvalidInput(format!("serialize Ticket item checker input: {error}")) })?, @@ -133,6 +132,11 @@ pub fn request( max_attempts: 1, }, }; + // The same item content can result from distinct edits or different source + // Workers. Content identity fences advisory freshness, but does not identify + // the complete Job intent. Hash the request with its stable Ticket prefix + // before adding the digest so exact retries still reuse the same Job. + request.job_id = format!("{}:{}", request.job_id, request.fingerprint()?); request.validate()?; Ok(request) } @@ -143,7 +147,7 @@ pub fn parse_input(value: &Value) -> Result { })?; if input.ticket_id.trim().is_empty() || input.ticket_resource_key.trim().is_empty() - || input.revision.trim().is_empty() + || input.content_digest.trim().is_empty() { return Err(Error::InvalidInput( "Ticket item checker input identity is incomplete".to_string(), @@ -217,8 +221,7 @@ pub fn notification( Error::InvalidInput("completed Ticket item checker Job has no result".to_string()) })?, )?; - if input.revision != acceptance.job.request.input_revision - || input.revision != item_revision(current_ticket) + if input.content_digest != content_digest(current_ticket) || input.ticket_id != current_ticket.meta.id { return Ok(TicketItemCheckNotification::Stale); @@ -228,8 +231,8 @@ pub fn notification( } let mut content = format!( - "[Ticket item checker advisory]\nTicket: {}\nRevision: {}\n\nThis is a post-save advisory based only on the inspected Ticket text. It is not a new user request, approval gate, workflow blocker, or implementation instruction.\n", - input.ticket_resource_key, input.revision + "[Ticket item checker advisory]\nTicket: {}\nContent digest: {}\n\nThis is a post-save advisory based only on the inspected Ticket text. It is not a new user request, approval gate, workflow blocker, or implementation instruction.\n", + input.ticket_resource_key, input.content_digest ); for (index, finding) in result.findings.iter().enumerate() { let quoted = serde_json::to_string(&finding.quote).map_err(|error| { @@ -255,13 +258,79 @@ mod tests { serde_json::json!({ "ticket_id": "ticket-internal", "ticket_resource_key": "T-42", - "revision": "revision-7", + "content_digest": "content_digest-7", "title": "Deploy safely", "body": "This turn only creates the Ticket; I will not implement it. Production deployment requires approval.", "edit": { "kind": "create" } }) } + #[test] + fn checker_job_identity_reuses_exact_intent_but_separates_edits_and_sources() { + use ticket::TicketBackend; + + let dir = tempfile::tempdir().unwrap(); + let backend = + ticket::SqliteTicketBackend::open(dir.path().join("tickets.db"), "space").unwrap(); + let (_, ticket) = backend + .create_with_snapshot(ticket::NewTicket::new("Check this content")) + .unwrap(); + let source = RuntimeWorkerRef { + runtime_id: "runtime".into(), + worker_id: "source".into(), + }; + let build = |edit, source, instruction: &str| { + request(&ticket, edit, source, instruction.to_string()).unwrap() + }; + let created = build( + TicketItemCheckEdit::Create, + source.clone(), + "Check the item.", + ); + let replay = build( + TicketItemCheckEdit::Create, + source.clone(), + "Check the item.", + ); + assert_eq!(created.job_id, replay.job_id); + assert_eq!( + created.fingerprint().unwrap(), + replay.fingerprint().unwrap() + ); + + let edited = build( + TicketItemCheckEdit::Edit { + title_changed: true, + body_changed: false, + previous_title: Some("Different prior title".into()), + previous_body: None, + body_replacement: None, + }, + source.clone(), + "Check the item.", + ); + let other_source = build( + TicketItemCheckEdit::Create, + RuntimeWorkerRef { + worker_id: "other-source".into(), + ..source.clone() + }, + "Check the item.", + ); + let other_instruction = build( + TicketItemCheckEdit::Create, + source, + "Check with new policy.", + ); + for different_intent in [edited, other_source, other_instruction] { + assert_eq!( + created.input["content_digest"], + different_intent.input["content_digest"] + ); + assert_ne!(created.job_id, different_intent.job_id); + } + } + #[test] fn result_requires_bounded_findings_grounded_in_the_saved_item() { let valid = serde_json::json!({"findings": [{ diff --git a/crates/workspace-server/src/workdir_create_operations.rs b/crates/workspace-server/src/workdir_create_operations.rs index 89d842ec5..b30fe51fe 100644 --- a/crates/workspace-server/src/workdir_create_operations.rs +++ b/crates/workspace-server/src/workdir_create_operations.rs @@ -27,8 +27,23 @@ pub fn request_fingerprint( requested_runtime_id: Option<&str>, display_name: Option<&str>, repository_source_fingerprint: &str, - repository_source_revision: u64, ) -> String { + encode_request_digest(request_hasher( + repository_id, + selector, + requested_runtime_id, + display_name, + repository_source_fingerprint, + )) +} + +fn request_hasher( + repository_id: &str, + selector: Option<&str>, + requested_runtime_id: Option<&str>, + display_name: Option<&str>, + repository_source_fingerprint: &str, +) -> Sha256 { let mut hasher = Sha256::new(); for value in [ Some(repository_id), @@ -46,7 +61,10 @@ pub fn request_fingerprint( None => hasher.update([0]), } } - hasher.update(repository_source_revision.to_be_bytes()); + hasher +} + +fn encode_request_digest(hasher: Sha256) -> String { let digest = hasher.finalize(); let mut encoded = String::with_capacity(digest.len() * 2); for byte in digest { @@ -56,6 +74,52 @@ pub fn request_fingerprint( format!("sha256:{encoded}") } +/// Resolve replay identity without rewriting the original durable request. Schema 86 +/// did not persist display_name, so its digest cannot be converted at migration time. +/// The frozen ordinal is hash evidence only, never a live Repository precondition. +pub fn request_fingerprint_for_replay( + repository_id: &str, + selector: Option<&str>, + requested_runtime_id: Option<&str>, + display_name: Option<&str>, + repository_source_fingerprint: &str, + persisted_fingerprint: Option<&str>, +) -> Result { + let current = request_fingerprint( + repository_id, + selector, + requested_runtime_id, + display_name, + repository_source_fingerprint, + ); + let Some(persisted) = persisted_fingerprint else { + return Ok(current); + }; + if persisted == current { + return Ok(current); + } + if let Some(legacy) = persisted.strip_prefix("workdir-create-v86:") { + if let Some((revision, digest)) = legacy.split_once(':') { + if let Ok(revision) = revision.parse::() { + let mut hasher = request_hasher( + repository_id, + selector, + requested_runtime_id, + display_name, + repository_source_fingerprint, + ); + hasher.update(revision.to_be_bytes()); + if encode_request_digest(hasher) == digest { + return Ok(persisted.to_string()); + } + } + } + } + Err(Error::InvalidInput( + "Workdir create operation was reused with different input or lacks frozen request identity evidence".into(), + )) +} + impl SqliteWorkspaceStore { pub fn reserve_workdir_create_operation( &self, @@ -66,11 +130,11 @@ impl SqliteWorkspaceStore { tx.execute( r#"INSERT OR IGNORE INTO workdir_create_operations ( workspace_id, operation_id, request_fingerprint, repository_id, selector, - requested_runtime_id, resolved_runtime_id, config_revision, - config_projection_digest, source_kind, source_uri, source_revision, + requested_runtime_id, resolved_runtime_id, + config_projection_digest, source_kind, source_uri, source_fingerprint, working_directory_id, state, failure, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)"#, + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)"#, params![ record.workspace_id, record.operation_id, @@ -79,11 +143,9 @@ impl SqliteWorkspaceStore { record.selector, record.requested_runtime_id, record.resolved_runtime_id, - record.config_revision as i64, record.config_projection_digest, record.source_kind, record.source_uri, - record.source_revision.map(|revision| revision as i64), record.source_fingerprint, record.working_directory_id, record.state, @@ -131,7 +193,7 @@ impl SqliteWorkspaceStore { "Workdir create operation `{operation_id}` was reused with different input" ))); } - if operation.state != "failed" { + if operation.state != "failed" || workdir_create_has_archived_ssh(&tx, workspace_id, operation_id)? { return Err(Error::WorkdirAttachmentConflict(format!( "Workdir create operation `{operation_id}` is not a failed retry" ))); @@ -176,16 +238,17 @@ impl SqliteWorkspaceStore { operation_id: &str, request_fingerprint: &str, credential_id: &str, - credential_revision: u64, + credential_fingerprint: &str, host_trust_id: &str, - host_trust_revision: u64, + host_trust_fingerprint: &str, repository_access_mode: &str, credential_candidates: &[WorkdirCreateCredentialCandidate], now: &str, ) -> Result { + validate_content_fingerprint(host_trust_fingerprint)?; validate_workdir_create_credential_candidates( credential_id, - credential_revision, + credential_fingerprint, credential_candidates, )?; self.with_conn_mut(|conn| { @@ -201,11 +264,14 @@ impl SqliteWorkspaceStore { "Workdir create operation `{operation_id}` was reused with different input" ))); } + if operation.state != "pending" || workdir_create_has_archived_ssh(&tx, workspace_id, operation_id)? { + return Err(Error::WorkdirAttachmentConflict("Only a pending, nonarchived Workdir create can bind Repository access".into())); + } if let Some(existing) = operation.credential_id.as_deref() { if existing != credential_id - || operation.credential_revision != Some(credential_revision) + || operation.credential_fingerprint.as_deref() != Some(credential_fingerprint) || operation.host_trust_id.as_deref() != Some(host_trust_id) - || operation.host_trust_revision != Some(host_trust_revision) + || operation.host_trust_fingerprint.as_deref() != Some(host_trust_fingerprint) || operation.repository_access_mode.as_deref() != Some(repository_access_mode) || operation.credential_candidates != credential_candidates @@ -218,8 +284,8 @@ impl SqliteWorkspaceStore { } let updated = tx.execute( r#"UPDATE workdir_create_operations - SET credential_id = ?4, credential_revision = ?5, - host_trust_id = ?6, host_trust_revision = ?7, + SET credential_id = ?4, credential_fingerprint = ?5, + host_trust_id = ?6, host_trust_fingerprint = ?7, repository_access_mode = ?8, updated_at = ?9 WHERE workspace_id = ?1 AND operation_id = ?2 AND request_fingerprint = ?3 AND credential_id IS NULL"#, @@ -228,13 +294,9 @@ impl SqliteWorkspaceStore { operation_id, request_fingerprint, credential_id, - i64::try_from(credential_revision).map_err(|_| Error::InvalidInput( - "credential revision is out of range".to_string() - ))?, + credential_fingerprint, host_trust_id, - i64::try_from(host_trust_revision).map_err(|_| Error::InvalidInput( - "host-trust revision is out of range".to_string() - ))?, + host_trust_fingerprint, repository_access_mode, now, ], @@ -248,7 +310,7 @@ impl SqliteWorkspaceStore { tx.execute( r#"INSERT INTO workdir_create_credential_candidates ( workspace_id, operation_id, ordinal, role, - credential_id, credential_revision + credential_id, credential_fingerprint ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#, params![ workspace_id, @@ -258,18 +320,21 @@ impl SqliteWorkspaceStore { ))?, candidate.role.as_str(), candidate.credential_id, - i64::try_from(candidate.credential_revision).map_err(|_| { - Error::InvalidInput( - "credential candidate revision is out of range".to_string(), - ) - })?, + candidate.credential_fingerprint, ], )?; - tx.execute( - r#"INSERT INTO workdir_create_credential_revision_retentions ( + let retained = tx.execute( + r#"INSERT INTO workdir_create_credential_retentions ( workspace_id, operation_id, ordinal, - credential_id, credential_revision - ) VALUES (?1, ?2, ?3, ?4, ?5)"#, + credential_id, credential_fingerprint, credential_operation_id + ) SELECT ?1, ?2, ?3, ?4, ?5, k.operation_id + FROM repository_ssh_credential_keys k + JOIN repository_ssh_credentials c + ON c.workspace_id=k.workspace_id AND c.credential_id=k.credential_id + WHERE k.workspace_id=?1 AND k.credential_id=?4 AND k.public_key_fingerprint=?5 + AND c.status='active' + ORDER BY (k.operation_id=c.current_operation_id) DESC, + k.created_at DESC, k.operation_id DESC LIMIT 1"#, params![ workspace_id, operation_id, @@ -277,14 +342,11 @@ impl SqliteWorkspaceStore { "credential candidate ordinal is out of range".to_string() ))?, candidate.credential_id, - i64::try_from(candidate.credential_revision).map_err(|_| { - Error::InvalidInput( - "credential candidate revision is out of range".to_string(), - ) - })?, + candidate.credential_fingerprint, ], )?; - } + + if retained != 1 { return Err(Error::RegistryInconsistency("Workdir create credential fingerprint has no stored key".into())); } } let bound = read_workdir_create_operation(&tx, workspace_id, operation_id)? .ok_or_else(|| { Error::RegistryInconsistency(format!( @@ -307,11 +369,31 @@ impl SqliteWorkspaceStore { ) -> Result { self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let operation = read_workdir_create_operation(&tx, workspace_id, operation_id)? + .ok_or_else(|| { + Error::RegistryInconsistency( + "Workdir create disappeared before finalization".into(), + ) + })?; + let target_state = if succeeded { "succeeded" } else { "failed" }; + if operation.request_fingerprint != request_fingerprint { + return Err(Error::InvalidInput( + "Workdir create operation was reused with different input".into(), + )); + } + if operation.state != "pending" { + if operation.state == target_state && operation.failure.as_deref() == failure { + return Ok(operation); + } + return Err(Error::WorkdirAttachmentConflict( + "A completed Workdir create cannot be finalized into another state".into(), + )); + } let changed = tx.execute( r#"UPDATE workdir_create_operations SET state = ?1, failure = ?2, updated_at = ?3 WHERE workspace_id = ?4 AND operation_id = ?5 - AND request_fingerprint = ?6"#, + AND request_fingerprint = ?6 AND state='pending'"#, params![ if succeeded { "succeeded" } else { "failed" }, failure, @@ -328,7 +410,7 @@ impl SqliteWorkspaceStore { } if succeeded { tx.execute( - r#"DELETE FROM workdir_create_credential_revision_retentions + r#"DELETE FROM workdir_create_credential_retentions WHERE workspace_id = ?1 AND operation_id = ?2"#, params![workspace_id, operation_id], )?; @@ -344,6 +426,24 @@ impl SqliteWorkspaceStore { }) } + /// Frozen, nonauthorizing historical data. Never use this payload for a retry or lease. + pub fn load_workdir_create_legacy_ssh_archive( + &self, + workspace_id: &str, + operation_id: &str, + ) -> Result> { + self.with_conn(|conn| { + let archive: Option<(String, String)> = conn.query_row( + "SELECT operation_json,candidates_json FROM workdir_create_legacy_ssh_archives WHERE workspace_id=?1 AND operation_id=?2", + params![workspace_id, operation_id], |r| Ok((r.get(0)?, r.get(1)?)), + ).optional()?; + archive.map(|(operation, candidates)| Ok(( + serde_json::from_str(&operation).map_err(|e| Error::Store(e.to_string()))?, + serde_json::from_str(&candidates).map_err(|e| Error::Store(e.to_string()))?, + ))).transpose() + }) + } + pub fn load_workdir_create_operation( &self, workspace_id: &str, @@ -353,6 +453,14 @@ impl SqliteWorkspaceStore { } } +fn workdir_create_has_archived_ssh( + conn: &rusqlite::Connection, + workspace_id: &str, + operation_id: &str, +) -> Result { + Ok(conn.query_row("SELECT EXISTS(SELECT 1 FROM workdir_create_legacy_ssh_archives WHERE workspace_id=?1 AND operation_id=?2)", params![workspace_id,operation_id], |r| r.get(0))?) +} + fn read_workdir_create_operation( conn: &rusqlite::Connection, workspace_id: &str, @@ -361,10 +469,10 @@ fn read_workdir_create_operation( let mut operation = conn .query_row( r#"SELECT workspace_id, operation_id, request_fingerprint, repository_id, selector, - requested_runtime_id, resolved_runtime_id, config_revision, - config_projection_digest, source_kind, source_uri, source_revision, - source_fingerprint, credential_id, credential_revision, - host_trust_id, host_trust_revision, repository_access_mode, + requested_runtime_id, resolved_runtime_id, + config_projection_digest, source_kind, source_uri, + source_fingerprint, credential_id, credential_fingerprint, + host_trust_id, host_trust_fingerprint, repository_access_mode, working_directory_id, state, failure, created_at, updated_at FROM workdir_create_operations @@ -379,23 +487,21 @@ fn read_workdir_create_operation( selector: row.get(4)?, requested_runtime_id: row.get(5)?, resolved_runtime_id: row.get(6)?, - config_revision: row.get::<_, i64>(7)? as u64, - config_projection_digest: row.get(8)?, - source_kind: row.get(9)?, - source_uri: row.get(10)?, - source_revision: row.get::<_, Option>(11)?.map(|value| value as u64), - source_fingerprint: row.get(12)?, - credential_id: row.get(13)?, - credential_revision: row.get::<_, Option>(14)?.map(|value| value as u64), - host_trust_id: row.get(15)?, - host_trust_revision: row.get::<_, Option>(16)?.map(|value| value as u64), - repository_access_mode: row.get(17)?, + config_projection_digest: row.get(7)?, + source_kind: row.get(8)?, + source_uri: row.get(9)?, + source_fingerprint: row.get(10)?, + credential_id: row.get(11)?, + credential_fingerprint: row.get(12)?, + host_trust_id: row.get(13)?, + host_trust_fingerprint: row.get(14)?, + repository_access_mode: row.get(15)?, credential_candidates: Vec::new(), - working_directory_id: row.get(18)?, - state: row.get(19)?, - failure: row.get(20)?, - created_at: row.get(21)?, - updated_at: row.get(22)?, + working_directory_id: row.get(16)?, + state: row.get(17)?, + failure: row.get(18)?, + created_at: row.get(19)?, + updated_at: row.get(20)?, }) }, ) @@ -403,7 +509,7 @@ fn read_workdir_create_operation( if let Some(operation) = operation.as_mut() { let mut statement = conn.prepare( - r#"SELECT ordinal, role, credential_id, credential_revision + r#"SELECT ordinal, role, credential_id, credential_fingerprint FROM workdir_create_credential_candidates WHERE workspace_id = ?1 AND operation_id = ?2 ORDER BY ordinal ASC"#, @@ -413,11 +519,11 @@ fn read_workdir_create_operation( row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, - row.get::<_, i64>(3)?, + row.get::<_, String>(3)?, )) })?; for (expected_ordinal, row) in rows.enumerate() { - let (ordinal, role, credential_id, credential_revision) = row?; + let (ordinal, role, credential_id, credential_fingerprint) = row?; if ordinal != i64::try_from(expected_ordinal).map_err(|_| { Error::Store( @@ -434,17 +540,16 @@ fn read_workdir_create_operation( .push(WorkdirCreateCredentialCandidate { role: WorkdirCreateCredentialCandidateRole::parse(&role)?, credential_id, - credential_revision: u64::try_from(credential_revision).map_err(|_| { - Error::Store(format!( - "invalid Workdir create credential candidate revision `{credential_revision}`" - )) - })?, + credential_fingerprint, }); } if !operation.credential_candidates.is_empty() { validate_workdir_create_credential_candidates( operation.credential_id.as_deref().unwrap_or_default(), - operation.credential_revision.unwrap_or_default(), + operation + .credential_fingerprint + .as_deref() + .unwrap_or_default(), &operation.credential_candidates, ) .map_err(|error| { @@ -457,9 +562,21 @@ fn read_workdir_create_operation( Ok(operation) } +fn validate_content_fingerprint(fingerprint: &str) -> Result<()> { + if fingerprint.trim().is_empty() + || fingerprint.len() > 256 + || fingerprint.chars().any(char::is_control) + { + return Err(Error::InvalidInput( + "Workdir create content fingerprint is invalid".to_string(), + )); + } + Ok(()) +} + pub(crate) fn validate_workdir_create_credential_candidates( credential_id: &str, - credential_revision: u64, + credential_fingerprint: &str, candidates: &[WorkdirCreateCredentialCandidate], ) -> Result<()> { if candidates.is_empty() || candidates.len() > MAX_WORKDIR_CREATE_CREDENTIAL_CANDIDATES { @@ -472,15 +589,11 @@ pub(crate) fn validate_workdir_create_credential_candidates( "Workdir create credential id is invalid".to_string(), )); } - if credential_revision == 0 { - return Err(Error::InvalidInput( - "Workdir create credential revision must be greater than zero".to_string(), - )); - } + validate_content_fingerprint(credential_fingerprint)?; let primary = &candidates[0]; if primary.role != WorkdirCreateCredentialCandidateRole::Primary || primary.credential_id != credential_id - || primary.credential_revision != credential_revision + || primary.credential_fingerprint != credential_fingerprint { return Err(Error::InvalidInput( "Workdir create primary credential evidence does not match the ordered candidate snapshot" @@ -494,10 +607,13 @@ pub(crate) fn validate_workdir_create_credential_candidates( "Workdir create fallback credential role is invalid".to_string(), )); } + for candidate in candidates { + validate_content_fingerprint(&candidate.credential_fingerprint)?; + } if candidates.iter().any(|candidate| { candidate.credential_id.is_empty() || candidate.credential_id.len() > MAX_CREDENTIAL_ID_BYTES - || candidate.credential_revision == 0 + || candidate.credential_fingerprint.is_empty() }) { return Err(Error::InvalidInput( "Workdir create credential candidate identity is invalid".to_string(), @@ -516,6 +632,106 @@ mod tests { use super::*; use crate::store::{ControlPlaneStore, RepositoryRecord, WorkspaceRecord}; + #[test] + fn request_identity_depends_on_content_and_distinguishes_optional_values() { + let original = request_fingerprint("main", None, None, None, "sha256:source-a"); + assert_eq!( + original, + request_fingerprint("main", None, None, None, "sha256:source-a") + ); + assert_ne!( + original, + request_fingerprint("main", None, None, None, "sha256:source-b") + ); + assert_ne!( + original, + request_fingerprint("main", Some(""), None, None, "sha256:source-a") + ); + assert_ne!( + request_fingerprint("a", Some("bc"), None, None, "source"), + request_fingerprint("ab", Some("c"), None, None, "source") + ); + } + + #[test] + fn frozen_request_replay_checks_every_input_and_keeps_original_identity() { + // Digest emitted by the schema-86 algorithm, including source ordinal 9. + let legacy = "workdir-create-v86:9:sha256:db15419814117c33c089a36c5348bda85d01a53fccd8d4d427685daf5983353b"; + assert_eq!( + request_fingerprint_for_replay( + "repo", + Some("main"), + None, + Some("Name"), + "source", + Some(legacy) + ) + .unwrap(), + legacy, + ); + for (repo, selector, runtime, name, source) in [ + ("other", Some("main"), None, Some("Name"), "source"), + ("repo", Some("develop"), None, Some("Name"), "source"), + ( + "repo", + Some("main"), + Some("runtime"), + Some("Name"), + "source", + ), + ("repo", Some("main"), None, None, "source"), + ("repo", Some("main"), None, Some("Other name"), "source"), + ("repo", Some("main"), None, Some("Name"), "changed-source"), + ] { + assert!( + request_fingerprint_for_replay(repo, selector, runtime, name, source, Some(legacy)) + .is_err() + ); + } + for invalid in [ + "workdir-create-v86:unknown:sha256:db15419814117c33c089a36c5348bda85d01a53fccd8d4d427685daf5983353b", + "workdir-create-v86:8:sha256:db15419814117c33c089a36c5348bda85d01a53fccd8d4d427685daf5983353b", + "workdir-create-v86:9:sha256:bad", + ] { + assert!( + request_fingerprint_for_replay( + "repo", + Some("main"), + None, + Some("Name"), + "source", + Some(invalid) + ) + .is_err() + ); + } + let current = request_fingerprint("repo", Some("main"), None, Some("Name"), "source"); + assert_eq!( + request_fingerprint_for_replay( + "repo", + Some("main"), + None, + Some("Name"), + "source", + Some(¤t) + ) + .unwrap(), + current + ); + assert_eq!( + request_fingerprint_for_replay( + "repo", + Some("main"), + None, + Some("Name"), + "source", + None + ) + .unwrap(), + current + ); + } + #[test] fn retry_selector_keeps_persisted_default_but_honors_explicit_input() { assert_eq!( @@ -556,7 +772,6 @@ mod tests { uri: "/tmp/main".to_string(), }, default_ref: Some("develop".to_string()), - source_revision: 1, source_fingerprint: "sha256:test".to_string(), observed_status: server_api::RepositoryObservedStatus::Unverified, observed_at: None, @@ -573,22 +788,19 @@ mod tests { None, None, "sha256:test", - 1, ), repository_id: "main".to_string(), selector: Some("develop".to_string()), requested_runtime_id: None, resolved_runtime_id: "arcadia".to_string(), - config_revision: 7, config_projection_digest: "sha256:projection".to_string(), source_kind: Some("local_path".to_string()), source_uri: Some("/tmp/repo".to_string()), - source_revision: Some(1), source_fingerprint: Some("sha256:source".to_string()), credential_id: None, - credential_revision: None, + credential_fingerprint: None, host_trust_id: None, - host_trust_revision: None, + host_trust_fingerprint: None, repository_access_mode: None, credential_candidates: Vec::new(), working_directory_id: "wd-1".to_string(), @@ -603,25 +815,26 @@ mod tests { ); store .with_conn_mut(|conn| { - for (credential_id, revision) in - [("credential-1", 3_i64), ("workspace-default-ssh", 7_i64)] - { + for (credential_id, fingerprint) in [ + ("credential-1", "key-a"), + ("workspace-default-ssh", "key-fallback"), + ] { conn.execute( r#"INSERT INTO repository_ssh_credentials ( workspace_id, credential_id, name, public_key_algorithm, public_key_fingerprint, - current_revision, status, created_at - ) VALUES ('workspace', ?1, ?1, 'ssh-ed25519', ?1, ?2, - 'active', '2026-08-24T00:00:00Z')"#, - params![credential_id, revision], + current_operation_id, status, created_at + ) VALUES ('workspace', ?1, ?1, 'ssh-ed25519', ?2, + ?1, 'active', '2026-08-24T00:00:00Z')"#, + params![credential_id, fingerprint], )?; conn.execute( - r#"INSERT INTO repository_ssh_credential_revisions ( - workspace_id, credential_id, revision, + r#"INSERT INTO repository_ssh_credential_keys ( + workspace_id, credential_id, operation_id, public_key_algorithm, public_key_fingerprint, created_at - ) VALUES ('workspace', ?1, ?2, 'ssh-ed25519', ?1, + ) VALUES ('workspace', ?1, ?1, 'ssh-ed25519', ?2, '2026-08-24T00:00:00Z')"#, - params![credential_id, revision], + params![credential_id, fingerprint], )?; } Ok(()) @@ -631,12 +844,12 @@ mod tests { WorkdirCreateCredentialCandidate { role: WorkdirCreateCredentialCandidateRole::Primary, credential_id: "credential-1".to_string(), - credential_revision: 3, + credential_fingerprint: "key-a".to_string(), }, WorkdirCreateCredentialCandidate { role: WorkdirCreateCredentialCandidateRole::WorkspaceDefaultFallback, credential_id: "workspace-default-ssh".to_string(), - credential_revision: 7, + credential_fingerprint: "key-fallback".to_string(), }, ]; let bound = store @@ -645,26 +858,26 @@ mod tests { "call-1", &record.request_fingerprint, "credential-1", - 3, + "key-a", "trust-1", - 5, + "trust-a", "read_only", &candidates, "2026-08-24T00:00:01Z", ) .unwrap(); assert_eq!(bound.credential_id.as_deref(), Some("credential-1")); - assert_eq!(bound.credential_revision, Some(3)); - assert_eq!(bound.host_trust_revision, Some(5)); + assert_eq!(bound.credential_fingerprint.as_deref(), Some("key-a")); + assert_eq!(bound.host_trust_fingerprint.as_deref(), Some("trust-a")); assert_eq!(bound.credential_candidates, candidates); let serialized = serde_json::to_string(&bound).unwrap(); assert!(serialized.contains("workspace_default_fallback")); assert!(!serialized.contains("private_key")); assert!(!serialized.contains("known_hosts")); // A concurrent Workspace-default rotation must not replace the fallback - // revision already bound to this operation. + // fingerprint already bound to this operation. let mut changed_candidates = candidates.clone(); - changed_candidates[1].credential_revision = 8; + changed_candidates[1].credential_fingerprint = "key-rotated".to_string(); assert!( store .bind_workdir_create_repository_access( @@ -672,9 +885,9 @@ mod tests { "call-1", &record.request_fingerprint, "credential-1", - 3, + "key-a", "trust-1", - 5, + "trust-a", "read_only", &changed_candidates, "2026-08-24T00:00:02Z", @@ -683,9 +896,9 @@ mod tests { ); let mut changed_resolution = record.clone(); changed_resolution.resolved_runtime_id = "other".to_string(); - changed_resolution.config_revision = 8; + changed_resolution.config_projection_digest = "sha256:changed".to_string(); changed_resolution.source_uri = Some("ssh://git@other.test/repo.git".to_string()); - changed_resolution.source_revision = Some(9); + changed_resolution.source_fingerprint = Some("sha256:changed-source".to_string()); let replayed = store .reserve_workdir_create_operation(&changed_resolution) .unwrap(); @@ -721,7 +934,7 @@ mod tests { ); let mut changed_input = record.clone(); changed_input.request_fingerprint = - request_fingerprint("main", Some("main"), None, None, "sha256:test", 1); + request_fingerprint("main", Some("main"), None, None, "sha256:test"); assert!( store .reserve_workdir_create_operation(&changed_input) diff --git a/crates/workspace-server/src/workdir_removal.rs b/crates/workspace-server/src/workdir_removal.rs index 9356ebdf0..df0250583 100644 --- a/crates/workspace-server/src/workdir_removal.rs +++ b/crates/workspace-server/src/workdir_removal.rs @@ -75,7 +75,7 @@ pub struct WorkdirRemovalOperation { pub source_actor: String, pub reason: String, pub state: WorkdirRemovalOperationState, - pub attempt_count: u64, + pub attempt_id: Option, pub retryable: bool, pub disposition: Option, pub failure_category: Option, @@ -202,9 +202,9 @@ impl SqliteWorkspaceStore { r#"INSERT INTO workdir_removal_operations ( workspace_id, operation_id, request_fingerprint, workdir_id, runtime_id, repository_id, materialization_fingerprint, source_actor, reason, state, - attempt_count, retryable, disposition, failure_category, + attempt_id, retryable, disposition, failure_category, created_at, updated_at, completed_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'pending', 0, 1, NULL, NULL, ?10, ?10, NULL)"#, + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'pending', NULL, 1, NULL, NULL, ?10, ?10, NULL)"#, params![ intent.workspace_id, intent.operation_id, @@ -248,14 +248,14 @@ impl SqliteWorkspaceStore { request_fingerprint: &str, owner: WorkdirRemovalAttemptOwner, expected_prior_owner: Option, - expected_attempt_count: u64, + expected_attempt_id: Option<&str>, ) -> Result { self.begin_workdir_removal_attempt_inner( workspace_id, operation_id, request_fingerprint, owner, - Some((expected_prior_owner, expected_attempt_count)), + Some((expected_prior_owner, expected_attempt_id)), ) } @@ -265,7 +265,7 @@ impl SqliteWorkspaceStore { operation_id: &str, request_fingerprint: &str, owner: WorkdirRemovalAttemptOwner, - recovery_expected: Option<(Option, u64)>, + recovery_expected: Option<(Option, Option<&str>)>, ) -> Result { self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; @@ -282,17 +282,17 @@ impl SqliteWorkspaceStore { "Workdir removal operation `{operation_id}` is not retryable" ))); } - if let Some((expected_owner, expected_attempt_count)) = recovery_expected { + if let Some((expected_owner, expected_attempt_id)) = recovery_expected { if operation.state != WorkdirRemovalOperationState::Pending || operation.attempt_owner != expected_owner - || operation.attempt_count != expected_attempt_count + || operation.attempt_id.as_deref() != expected_attempt_id { return Err(Error::WorkdirAttachmentConflict(format!( "Workdir removal operation `{operation_id}` changed after orphan proof" ))); } } else if operation.state == WorkdirRemovalOperationState::Pending - && operation.attempt_count > 0 + && operation.attempt_id.is_some() { return Err(Error::WorkdirAttachmentConflict(format!( "Workdir removal operation `{operation_id}` already has an active attempt" @@ -300,7 +300,7 @@ impl SqliteWorkspaceStore { } let now = Utc::now().to_rfc3339(); tx.execute( - "UPDATE workdir_removal_operations SET state='pending', attempt_count=attempt_count+1, retryable=1, failure_category=NULL, disposition=NULL, attempt_owner_pid=?1, attempt_owner_start_marker=?2, updated_at=?3, completed_at=NULL WHERE workspace_id=?4 AND operation_id=?5 AND request_fingerprint=?6", + "UPDATE workdir_removal_operations SET state='pending', attempt_id=?7, retryable=1, failure_category=NULL, disposition=NULL, attempt_owner_pid=?1, attempt_owner_start_marker=?2, updated_at=?3, completed_at=NULL WHERE workspace_id=?4 AND operation_id=?5 AND request_fingerprint=?6", params![ owner.process_id, i64::try_from(owner.process_start_marker).map_err(|_| { @@ -312,6 +312,7 @@ impl SqliteWorkspaceStore { workspace_id, operation_id, request_fingerprint, + uuid::Uuid::now_v7().to_string(), ], )?; let operation = @@ -462,6 +463,7 @@ impl SqliteWorkspaceStore { tx.commit()?; return Ok(current); } + require_matching_attempt(operation, ¤t)?; let now = Utc::now().to_rfc3339(); tx.execute( "UPDATE workdir_removal_operations SET state='failed', retryable=?1, disposition=?2, failure_category=?3, attempt_owner_pid=NULL, attempt_owner_start_marker=NULL, updated_at=?4 WHERE workspace_id=?5 AND operation_id=?6 AND request_fingerprint=?7", @@ -519,6 +521,7 @@ impl SqliteWorkspaceStore { tx.commit()?; return Ok(current); } + require_matching_attempt(operation, ¤t)?; if delete_registry { let registry = load_workdir_record( &tx, @@ -616,6 +619,22 @@ impl SqliteWorkspaceStore { } } +fn require_matching_attempt( + expected: &WorkdirRemovalOperation, + current: &WorkdirRemovalOperation, +) -> Result<()> { + if current.state != WorkdirRemovalOperationState::Pending + || expected.attempt_id != current.attempt_id + || expected.attempt_owner != current.attempt_owner + { + return Err(Error::WorkdirAttachmentConflict(format!( + "Workdir removal operation `{}` execution attempt changed", + expected.operation_id + ))); + } + Ok(()) +} + fn require_no_removal_blockers( conn: &Connection, workspace_id: &str, @@ -804,7 +823,7 @@ fn load_operation( fn operation_select_sql() -> &'static str { r#"SELECT operation_id, request_fingerprint, workspace_id, workdir_id, runtime_id, repository_id, materialization_fingerprint, source_actor, reason, state, - attempt_count, retryable, disposition, failure_category, + attempt_id, retryable, disposition, failure_category, attempt_owner_pid, attempt_owner_start_marker, created_at, updated_at, completed_at FROM workdir_removal_operations"# @@ -816,7 +835,7 @@ fn read_operation(row: &rusqlite::Row<'_>) -> rusqlite::Result>(12)? .map(|value| parse_disposition(&value)) .transpose()?; - let attempt_count = row.get::<_, i64>(10)?; + let attempt_id = row.get::<_, Option>(10)?; let attempt_owner_pid = row.get::<_, Option>(14)?; let attempt_owner_start_marker = row.get::<_, Option>(15)?; let attempt_owner = match (attempt_owner_pid, attempt_owner_start_marker) { @@ -848,9 +867,7 @@ fn read_operation(row: &rusqlite::Row<'_>) -> rusqlite::Result, - pub config_revision: u64, pub request_fingerprint: String, pub replayed: bool, } @@ -159,7 +158,6 @@ impl WorkspaceCatalogService { provider: Some("git".to_string()), source: repository_source.clone(), default_ref: Some(default_ref), - source_revision: 1, source_fingerprint: repository_source_fingerprint(&repository_source), observed_status: RepositoryObservedStatus::Unverified, observed_at: None, @@ -174,7 +172,6 @@ impl WorkspaceCatalogService { Ok(WorkspaceCreateResult { workspace: result.workspace, repository: result.repository, - config_revision: result.config_revision, request_fingerprint: fingerprint, replayed: result.replayed, }) @@ -632,11 +629,10 @@ mod tests { let created = service.create(request.clone(), owner.clone()).unwrap(); let mut repository = created.repository.unwrap(); repository.source = validate_repository_source("https://example.test/changed.git").unwrap(); - repository.source_revision += 1; repository.source_fingerprint = repository_source_fingerprint(&repository.source); store.with_conn(|conn| { - conn.execute("UPDATE repositories SET source_kind = ?1, source_uri = ?2, source_revision = ?3, source_fingerprint = ?4 WHERE workspace_id = ?5 AND repository_key = 'main'", - rusqlite::params![repository.source.kind.as_str(), repository.source.uri, repository.source_revision, repository.source_fingerprint, repository.workspace_id])?; + conn.execute("UPDATE repositories SET source_kind = ?1, source_uri = ?2, source_fingerprint = ?3 WHERE workspace_id = ?4 AND repository_key = 'main'", + rusqlite::params![repository.source.kind.as_str(), repository.source.uri, repository.source_fingerprint, repository.workspace_id])?; Ok(()) }).unwrap(); assert_eq!( @@ -880,7 +876,6 @@ mod tests { .unwrap() .unwrap(); assert_eq!(persisted.source.kind, RepositorySourceKind::Ssh); - assert_eq!(persisted.source_revision, 1); assert!(persisted.source_fingerprint.starts_with("sha256:")); assert_eq!( persisted.observed_status, diff --git a/crates/workspace-server/src/workspace_deletion.rs b/crates/workspace-server/src/workspace_deletion.rs index 78da114fe..dfc1b70da 100644 --- a/crates/workspace-server/src/workspace_deletion.rs +++ b/crates/workspace-server/src/workspace_deletion.rs @@ -3,7 +3,7 @@ use rusqlite::{OptionalExtension, params}; use server_api::{ WORKSPACE_DELETION_MAX_BLOCKER_MESSAGE_BYTES, WORKSPACE_DELETION_MAX_BLOCKERS, WORKSPACE_DELETION_MAX_CHILD_OPERATION_IDS, WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES, - WORKSPACE_DELETION_MAX_RESOURCE_VALUE_BYTES, WORKSPACE_DELETION_MAX_REVISION_BYTES, + WORKSPACE_DELETION_MAX_RESOURCE_VALUE_BYTES, WORKSPACE_DELETION_MAX_UPDATED_AT_BYTES, WorkspaceDeletionBlocker, WorkspaceDeletionBlockerKind, WorkspaceDeletionOperationResponse, WorkspaceDeletionPreflightResponse, WorkspaceDeletionRequest, WorkspaceDeletionResourceCounts, WorkspaceDeletionState, @@ -22,7 +22,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ "backend_job_attempts", "backend_jobs", "external_workdir_grants", - "flow_source_revisions", + "flow_source_contents", "flow_sources", "memory_staging_records", "memory_staging_resolutions", @@ -39,11 +39,12 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ "runtime_removal_operations", "repository_secret_audit_events", "repository_secret_operations", - "repository_ssh_credential_revisions", + "repository_secret_legacy_receipts", + "repository_ssh_credential_keys", "repository_ssh_credentials", - "repository_ssh_host_trust_revisions", + "repository_ssh_host_trust_keys", "repository_ssh_host_trusts", - "server_secret_versions", + "server_secret_objects", "ticket_assignment_operations", "ticket_assignment_work_releases", "ticket_assignment_ticket_tombstones", @@ -63,7 +64,8 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ "typed_ticket_targets", "typed_tickets", "workdir_create_credential_candidates", - "workdir_create_credential_revision_retentions", + "workdir_create_credential_retentions", + "workdir_create_legacy_ssh_archives", "workdir_create_operations", "workdir_registry", "workdir_removal_operations", @@ -88,7 +90,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ "workspace_config_entries", "workspace_config_grants", "workspace_drive_grants", - "workspace_config_tree_revisions", + "workspace_config_tree_history", "workspace_config_trees", "workspace_create_operations", "workspace_memory_documents", @@ -102,7 +104,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ "workspace_signing_identity_audit", "workspace_signing_identity_provisioning_operations", "workspace_worker_retention_policies", - "workspace_worker_retention_policy_revisions", + "workspace_worker_retention_policy_snapshots", ]; #[derive(Debug, Clone)] @@ -198,7 +200,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore { Ok(WorkspaceDeletionPreflightResponse { workspace_id: workspace.workspace_id, display_name: workspace.display_name, - expected_revision: workspace.updated_at, + expected_workspace_updated_at: workspace.updated_at, can_delete: blockers.is_empty(), resources, blockers, @@ -213,7 +215,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore { request: &WorkspaceDeletionRequest, ) -> Result { validate_operation_id(&request.operation_id)?; - if request.expected_revision.len() > WORKSPACE_DELETION_MAX_REVISION_BYTES + if request.expected_workspace_updated_at.len() > WORKSPACE_DELETION_MAX_UPDATED_AT_BYTES || request.confirmation.len() > server_api::WORKSPACE_DELETION_MAX_CONFIRMATION_BYTES { return Err(Error::InvalidInput( @@ -245,7 +247,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore { "confirmation must exactly match the displayed Workspace name".to_string(), )); } - if workspace.updated_at != request.expected_revision { + if workspace.updated_at != request.expected_workspace_updated_at { return Err(Error::WorkspaceConfigConflict( "Workspace metadata changed; reload deletion impact before confirming".to_string(), )); @@ -273,7 +275,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore { tx.execute( "INSERT INTO workspace_deletion_operations ( operation_id, request_fingerprint, workspace_id, workspace_display_name, - workspace_revision, owner_account_id, actor_account_id, + workspace_updated_at, owner_account_id, actor_account_id, state, resource_counts_json, child_operation_ids_json, blockers_json, failure_category, created_at, updated_at, completed_at @@ -283,7 +285,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore { fingerprint, workspace_id, workspace.display_name, - request.expected_revision, + request.expected_workspace_updated_at, workspace.owner_account_id, actor_account_id, serde_json::to_string(&resources).map_err(|error| Error::Store(error.to_string()))?, @@ -293,7 +295,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore { let changed = tx.execute( "UPDATE workspaces SET state = 'deleting', updated_at = ?2 WHERE workspace_id = ?1 AND updated_at = ?3 AND state = 'active'", - params![workspace_id, now, request.expected_revision], + params![workspace_id, now, request.expected_workspace_updated_at], )?; if changed != 1 { return Err(Error::WorkspaceConfigConflict( @@ -766,7 +768,7 @@ fn resource_counts( workdirs: table_count(conn, "workdir_registry", workspace_id)?, repositories: table_count(conn, "repositories", workspace_id)?, runtime_bindings: table_count(conn, "workspace_runtime_bindings", workspace_id)?, - secrets: table_count(conn, "server_secret_versions", workspace_id)?, + secrets: table_count(conn, "server_secret_objects", workspace_id)?, artifacts: table_count(conn, "artifacts", workspace_id)?, }) } @@ -826,7 +828,7 @@ fn request_fingerprint( ) -> String { let canonical = format!( "workspace-delete-v1\0{actor_account_id}\0{workspace_id}\0{}\0{}\0{}", - request.operation_id, request.expected_revision, request.confirmation + request.operation_id, request.expected_workspace_updated_at, request.confirmation ); encode_hex(&Sha256::digest(canonical.as_bytes())) } @@ -959,7 +961,7 @@ mod tests { .expect("preflight"); let request = WorkspaceDeletionRequest { operation_id: "delete-workspace-a".to_string(), - expected_revision: preflight.expected_revision, + expected_workspace_updated_at: preflight.expected_workspace_updated_at, confirmation: "Alpha".to_string(), }; let first = store @@ -1073,7 +1075,7 @@ mod tests { } #[test] - fn owner_confirmation_and_revision_are_required_before_reservation() { + fn owner_confirmation_and_updated_at_are_required_before_reservation() { let (store, owner, workspace_id) = setup(); store.with_conn(|conn| { conn.execute( @@ -1093,7 +1095,7 @@ mod tests { .expect("preflight"); let mut request = WorkspaceDeletionRequest { operation_id: "delete-alpha-guarded".to_string(), - expected_revision: "stale".to_string(), + expected_workspace_updated_at: "stale".to_string(), confirmation: "Alpha".to_string(), }; assert!(matches!( @@ -1106,7 +1108,7 @@ mod tests { request_fingerprint(&owner, &workspace_id, &request), request_fingerprint(&owner, &workspace_id, &other_operation) ); - request.expected_revision = preflight.expected_revision; + request.expected_workspace_updated_at = preflight.expected_workspace_updated_at; request.confirmation = "delete Alpha".to_string(); assert!(matches!( store.reserve_workspace_deletion(&owner, &workspace_id, &request), @@ -1130,11 +1132,11 @@ mod tests { conn.execute( "INSERT INTO workdir_create_operations ( workspace_id, operation_id, request_fingerprint, repository_id, - selector, requested_runtime_id, resolved_runtime_id, config_revision, + selector, requested_runtime_id, resolved_runtime_id, config_projection_digest, working_directory_id, state, created_at, updated_at ) VALUES ( ?1, 'workdir-create', 'fingerprint', 'repository-pending', - 'develop', 'runtime-a', 'runtime-a', 1, + 'develop', 'runtime-a', 'runtime-a', 'projection', 'workdir-pending', 'pending', '1', '1' )", params![workspace_id], @@ -1160,7 +1162,7 @@ mod tests { ); let request = WorkspaceDeletionRequest { operation_id: "delete-with-pending-creates".to_string(), - expected_revision: preflight.expected_revision, + expected_workspace_updated_at: preflight.expected_workspace_updated_at, confirmation: "Alpha".to_string(), }; assert!(matches!( @@ -1295,7 +1297,7 @@ mod tests { })); let request = WorkspaceDeletionRequest { operation_id: "delete-pinned".to_string(), - expected_revision: preflight.expected_revision, + expected_workspace_updated_at: preflight.expected_workspace_updated_at, confirmation: "Alpha".to_string(), }; assert!(matches!( @@ -1338,7 +1340,7 @@ mod tests { } #[test] - fn last_accessible_workspace_and_revision_conflicts_fail_closed() { + fn last_accessible_workspace_and_updated_at_conflicts_fail_closed() { let (store, owner, workspace_id) = setup(); let other = "workspace-b"; let preflight = store @@ -1352,7 +1354,7 @@ mod tests { other, &WorkspaceDeletionRequest { operation_id: "delete-beta".to_string(), - expected_revision: preflight.expected_revision, + expected_workspace_updated_at: preflight.expected_workspace_updated_at, confirmation: "Beta".to_string(), }, ) diff --git a/crates/workspace-server/src/workspace_signing_identity.rs b/crates/workspace-server/src/workspace_signing_identity.rs index c57169b67..b09fdec84 100644 --- a/crates/workspace-server/src/workspace_signing_identity.rs +++ b/crates/workspace-server/src/workspace_signing_identity.rs @@ -22,8 +22,7 @@ use crate::store::{ use crate::{Error, Result}; pub const WORKSPACE_SIGNING_ALGORITHM: &str = "ed25519"; -pub const WORKSPACE_SIGNING_IDENTITY_REVISION: u64 = 1; -const MATERIAL_SCHEMA_VERSION: u32 = 1; +const MATERIAL_SCHEMA_VERSION: u32 = 2; #[derive(Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -31,7 +30,6 @@ pub struct WorkspaceSigningPrivateMaterial { version: u32, workspace_id: String, key_id: String, - revision: u64, private_key: String, } @@ -42,7 +40,6 @@ impl fmt::Debug for WorkspaceSigningPrivateMaterial { .field("version", &self.version) .field("workspace_id", &self.workspace_id) .field("key_id", &self.key_id) - .field("revision", &self.revision) .field("private_key", &"[REDACTED]") .finish() } @@ -66,7 +63,6 @@ impl WorkspaceSigningPrivateMaterial { version: MATERIAL_SCHEMA_VERSION, workspace_id: workspace_id.to_string(), key_id: key_id.to_string(), - revision: WORKSPACE_SIGNING_IDENTITY_REVISION, private_key: material.private_key, }) } @@ -75,12 +71,10 @@ impl WorkspaceSigningPrivateMaterial { &self, expected_workspace_id: &str, expected_key_id: &str, - expected_revision: u64, ) -> Result { if self.version != MATERIAL_SCHEMA_VERSION || self.workspace_id != expected_workspace_id || self.key_id != expected_key_id - || self.revision != expected_revision { return Err(identity_error( "workspace_signing_identity_material_mismatch", @@ -105,10 +99,8 @@ impl WorkspaceSigningPrivateMaterial { &self, expected_workspace_id: &str, expected_key_id: &str, - expected_revision: u64, ) -> Result { - let signing_key = - self.signing_key(expected_workspace_id, expected_key_id, expected_revision)?; + let signing_key = self.signing_key(expected_workspace_id, expected_key_id)?; Ok(encode_public_key(signing_key.public_key().as_ref())) } } @@ -146,7 +138,8 @@ impl WorkspaceSigningIdentityService { ) -> Result<(WorkspaceSigningIdentityActivation, String)> { let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); let proposed_key_id = format!("WK-{}", uuid::Uuid::now_v7().simple()); - let proposed_material_ref = format!("workspace-signing/{proposed_workspace_id}/ed25519-v1"); + let proposed_material_ref = + format!("workspace-signing/{proposed_workspace_id}/{proposed_key_id}"); let operation_key = format!("workspace-create:{workspace_create_operation_key}"); let operation = self.store.reserve_workspace_signing_identity_provisioning( &WorkspaceSigningIdentityProvisioningOperation { @@ -156,7 +149,6 @@ impl WorkspaceSigningIdentityService { workspace_id: proposed_workspace_id.to_string(), key_id: proposed_key_id, private_material_ref: proposed_material_ref, - revision: WORKSPACE_SIGNING_IDENTITY_REVISION, actor_account_id: actor_account_id.to_string(), state: "pending".to_string(), created_at: now, @@ -191,12 +183,12 @@ impl WorkspaceSigningIdentityService { "Workspace signing identity state is invalid", )); } - let operation_key = format!( - "existing-workspace:{workspace_id}:revision-{}", - identity.revision + let operation_key = format!("existing-workspace:{workspace_id}:key-{}", identity.key_id); + let request_fingerprint = provisioning_fingerprint( + workspace_id, + &identity.key_id, + &identity.private_material_ref, ); - let request_fingerprint = - provisioning_fingerprint(workspace_id, &identity.key_id, identity.revision); let operation = self.store.reserve_workspace_signing_identity_provisioning( &WorkspaceSigningIdentityProvisioningOperation { operation_key: operation_key.clone(), @@ -205,7 +197,6 @@ impl WorkspaceSigningIdentityService { workspace_id: workspace_id.to_string(), key_id: identity.key_id.clone(), private_material_ref: identity.private_material_ref.clone(), - revision: identity.revision, actor_account_id: actor_account_id.to_string(), state: "pending".to_string(), created_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), @@ -253,8 +244,20 @@ impl WorkspaceSigningIdentityService { "Workspace signing private material is missing", ) })?; - let signing_key = - material.signing_key(workspace_id, &identity.key_id, identity.revision)?; + let fingerprint = identity.public_key_fingerprint.as_deref().ok_or_else(|| { + identity_error( + "workspace_signing_identity_material_mismatch", + "Workspace signing public key fingerprint is missing", + ) + })?; + let signing_key = material.signing_key(workspace_id, &identity.key_id)?; + let public_key = encode_public_key(signing_key.public_key().as_ref()); + if public_key_fingerprint(&public_key)? != fingerprint { + return Err(identity_error( + "workspace_signing_identity_material_mismatch", + "Workspace signing private material does not match public metadata", + )); + } Ok(signing_key.sign(payload).as_ref().to_vec()) } @@ -290,11 +293,8 @@ impl WorkspaceSigningIdentityService { .put_if_absent(&operation.private_material_ref, &generated)? } }; - let public_key = material.validate_and_public_key( - &operation.workspace_id, - &operation.key_id, - operation.revision, - )?; + let public_key = + material.validate_and_public_key(&operation.workspace_id, &operation.key_id)?; let public_key_fingerprint = public_key_fingerprint(&public_key)?; Ok(WorkspaceSigningIdentityActivation { workspace_id: operation.workspace_id.clone(), @@ -302,7 +302,6 @@ impl WorkspaceSigningIdentityService { public_key, public_key_fingerprint, private_material_ref: operation.private_material_ref.clone(), - revision: operation.revision, provisioned_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), }) } @@ -317,11 +316,8 @@ impl WorkspaceSigningIdentityService { "Workspace signing private material is missing", ) })?; - let public_key = material.validate_and_public_key( - &identity.workspace_id, - &identity.key_id, - identity.revision, - )?; + let public_key = + material.validate_and_public_key(&identity.workspace_id, &identity.key_id)?; let fingerprint = public_key_fingerprint(&public_key)?; if identity.public_key.as_deref() != Some(public_key.as_str()) || identity.public_key_fingerprint.as_deref() != Some(fingerprint.as_str()) @@ -335,17 +331,116 @@ impl WorkspaceSigningIdentityService { } } -fn provisioning_fingerprint(workspace_id: &str, key_id: &str, revision: u64) -> String { +pub(crate) fn provisioning_fingerprint( + workspace_id: &str, + key_id: &str, + material_ref: &str, +) -> String { use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); + hasher.update(b"workspace-signing-provisioning\0"); hasher.update(workspace_id.as_bytes()); hasher.update([0]); hasher.update(key_id.as_bytes()); hasher.update([0]); - hasher.update(revision.to_be_bytes()); + hasher.update(material_ref.as_bytes()); format!("sha256:{}", hex_lower(&hasher.finalize())) } +// Frozen v1 private-material decoder. New writes never include the old counter. +mod frozen_material_v1 { + use super::*; + + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Material { + version: u32, + workspace_id: String, + key_id: String, + revision: u64, + private_key: String, + } + + impl Drop for Material { + fn drop(&mut self) { + self.private_key.zeroize(); + } + } + + pub(super) fn decode(bytes: &[u8]) -> Option { + let old: Material = serde_json::from_slice(bytes).ok()?; + if old.version != 1 || old.revision != 1 { + return None; + } + Some(WorkspaceSigningPrivateMaterial { + version: MATERIAL_SCHEMA_VERSION, + workspace_id: old.workspace_id.clone(), + key_id: old.key_id.clone(), + private_key: old.private_key.clone(), + }) + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn frozen_file_load_preserves_key_and_new_writes_use_current_shape() { + let material = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap(); + let public_key = material.validate_and_public_key("ws-1", "WK-1").unwrap(); + let mut old = serde_json::to_value(&material).unwrap(); + old["version"] = 1.into(); + old["revision"] = 1.into(); + let temp = tempfile::tempdir().unwrap(); + let store = FsWorkspaceSigningMaterialStore::new(temp.path().join("identities")); + store.put_if_absent("ws-1/ed25519-v1", &material).unwrap(); + fs::write( + store.material_path("ws-1/ed25519-v1").unwrap(), + serde_json::to_vec(&old).unwrap(), + ) + .unwrap(); + let migrated = store.load("ws-1/ed25519-v1").unwrap().unwrap(); + assert_eq!( + migrated.validate_and_public_key("ws-1", "WK-1").unwrap(), + public_key + ); + let current = serde_json::to_value(&migrated).unwrap(); + assert_eq!(current["version"], MATERIAL_SCHEMA_VERSION); + assert!(current.get("revision").is_none()); + assert_eq!(current, serde_json::to_value(material).unwrap()); + } + + #[test] + fn frozen_decoder_rejects_unsupported_or_mixed_metadata() { + let material = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap(); + let mut old = serde_json::to_value(material).unwrap(); + old["version"] = 1.into(); + for revision in [0, 2, u64::MAX] { + old["revision"] = revision.into(); + assert!(decode_private_material(&serde_json::to_vec(&old).unwrap()).is_err()); + } + old["revision"] = 1.into(); + old["version"] = MATERIAL_SCHEMA_VERSION.into(); + assert!(decode_private_material(&serde_json::to_vec(&old).unwrap()).is_err()); + old["version"] = 1.into(); + old["unexpected"] = true.into(); + assert!(decode_private_material(&serde_json::to_vec(&old).unwrap()).is_err()); + } + } +} + +fn decode_private_material( + bytes: &[u8], +) -> std::result::Result { + if let Ok(material) = serde_json::from_slice::(bytes) { + if material.version == MATERIAL_SCHEMA_VERSION { + return Ok(material); + } + return Err(()); + } + frozen_material_v1::decode(bytes).ok_or(()) +} + #[derive(Default)] pub struct InMemoryWorkspaceSigningMaterialStore { materials: std::sync::Mutex>, @@ -418,7 +513,7 @@ impl WorkspaceSigningMaterialStore for FsWorkspaceSigningMaterialStore { Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(error) => return Err(material_io_error("read", error)), }; - serde_json::from_slice(&bytes).map(Some).map_err(|_| { + decode_private_material(&bytes).map(Some).map_err(|_| { identity_error( "workspace_signing_identity_material_corrupt", "Workspace signing private material is corrupt", @@ -606,6 +701,34 @@ mod tests { use super::*; + struct ReplaceMaterialAfterValidation { + inner: Arc, + replacement: WorkspaceSigningPrivateMaterial, + loads: std::sync::atomic::AtomicUsize, + } + + impl WorkspaceSigningMaterialStore for ReplaceMaterialAfterValidation { + fn load(&self, material_ref: &str) -> Result> { + if self.loads.fetch_add(1, Ordering::SeqCst) == 0 { + self.inner.load(material_ref) + } else { + Ok(Some(self.replacement.clone())) + } + } + + fn put_if_absent( + &self, + material_ref: &str, + material: &WorkspaceSigningPrivateMaterial, + ) -> Result { + self.inner.put_if_absent(material_ref, material) + } + + fn delete(&self, material_ref: &str) -> Result<()> { + self.inner.delete(material_ref) + } + } + struct FailFirstMaterialWrite { inner: Arc, fail: AtomicBool, @@ -709,6 +832,21 @@ mod tests { ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, public_key) .verify(payload, &signature) .unwrap(); + let replacement = + WorkspaceSigningPrivateMaterial::generate("workspace-1", &provisioned.key_id).unwrap(); + let swapping_service = WorkspaceSigningIdentityService::new( + store.clone(), + Arc::new(ReplaceMaterialAfterValidation { + inner: materials.clone(), + replacement, + loads: std::sync::atomic::AtomicUsize::new(0), + }), + ); + assert!(matches!( + swapping_service.sign("workspace-1", payload), + Err(Error::WorkspaceSigningIdentity { ref code, .. }) + if code == "workspace_signing_identity_material_mismatch" + )); assert_eq!( service .provision_existing("workspace-1", "account-1") @@ -747,17 +885,16 @@ mod tests { let operation = store .reserve_workspace_signing_identity_provisioning( &WorkspaceSigningIdentityProvisioningOperation { - operation_key: "existing-workspace:workspace-2:revision-1".to_string(), + operation_key: format!("existing-workspace:workspace-2:key-{}", pending.key_id), request_fingerprint: provisioning_fingerprint( "workspace-2", &pending.key_id, - pending.revision, + &pending.private_material_ref, ), operation_kind: "existing_workspace".to_string(), workspace_id: "workspace-2".to_string(), key_id: pending.key_id.clone(), private_material_ref: pending.private_material_ref.clone(), - revision: pending.revision, actor_account_id: "account-1".to_string(), state: "pending".to_string(), created_at: "1".to_string(), @@ -826,7 +963,7 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let store = FsWorkspaceSigningMaterialStore::new(temp.path().join("identities")); let first = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap(); - let first_public = first.validate_and_public_key("ws-1", "WK-1", 1).unwrap(); + let first_public = first.validate_and_public_key("ws-1", "WK-1").unwrap(); let persisted = store.put_if_absent("ws-1/ed25519-v1", &first).unwrap(); #[cfg(unix)] { @@ -849,18 +986,14 @@ mod tests { ); } assert_eq!( - persisted - .validate_and_public_key("ws-1", "WK-1", 1) - .unwrap(), + persisted.validate_and_public_key("ws-1", "WK-1").unwrap(), first_public ); let second = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap(); let persisted = store.put_if_absent("ws-1/ed25519-v1", &second).unwrap(); assert_eq!( - persisted - .validate_and_public_key("ws-1", "WK-1", 1) - .unwrap(), + persisted.validate_and_public_key("ws-1", "WK-1").unwrap(), first_public ); } @@ -872,7 +1005,7 @@ mod tests { let material = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap(); store.put_if_absent("ws-1/ed25519-v1", &material).unwrap(); let loaded = store.load("ws-1/ed25519-v1").unwrap().unwrap(); - assert!(loaded.validate_and_public_key("ws-2", "WK-1", 1).is_err()); + assert!(loaded.validate_and_public_key("ws-2", "WK-1").is_err()); fs::write(store.material_path("ws-1/ed25519-v1").unwrap(), b"not json").unwrap(); assert!(store.load("ws-1/ed25519-v1").is_err()); diff --git a/crates/yoi/src/workspace_bootstrap.rs b/crates/yoi/src/workspace_bootstrap.rs index f141e8f00..97ce43aa2 100644 --- a/crates/yoi/src/workspace_bootstrap.rs +++ b/crates/yoi/src/workspace_bootstrap.rs @@ -358,7 +358,6 @@ mod tests { kind: "git".to_string(), provider: "builtin:git".to_string(), source, - source_revision: 1, source_fingerprint: "fingerprint".to_string(), observed_status: server_api::RepositoryObservedStatus::Unverified, observed_at: None, @@ -407,7 +406,6 @@ mod tests { let body = serde_json::json!({ "workspace": workspace_summary("workspace-a", "Research"), "repository": null, - "config_revision": 0, "request_fingerprint": "fingerprint", "replayed": replayed, }); @@ -430,14 +428,12 @@ mod tests { "provider": "builtin:git", "source": {"kind": "local_path", "uri": "/checkout/platform"}, "default_ref": "develop", - "source_revision": 1, "source_fingerprint": "fingerprint", "observed_status": "unverified", "observed_at": null, "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z", }, - "config_revision": 1, "request_fingerprint": "fingerprint", "replayed": false, }); diff --git a/docs/README.md b/docs/README.md index 7a836749d..41f7a97b9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,7 @@ It is not a dumping ground for external research, old plans, API inventories, or 3. [`design/context-history.md`](design/context-history.md) — the highest-risk invariant: inputs that affect the model must be committed to history before they enter context. 4. [`design/worker-session-state.md`](design/worker-session-state.md) — Worker identity, replayable session logs, current metadata, and live process hints. 5. [`design/session-observation.md`](design/session-observation.md) — common session captures, `SessionEntryRef`, Memory evidence, and host-authorized Worker observation. -6. [`design/flow-state-graph.md`](design/flow-state-graph.md) — Workspace Flow sources, immutable revisions, transition attempts, and bounded internal verification. +6. [`design/flow-state-graph.md`](design/flow-state-graph.md) — Workspace Flow sources, frozen content-addressed definitions, transition attempts, and bounded internal verification. 7. [`design/profiles-manifests-prompts.md`](design/profiles-manifests-prompts.md) — reusable Profiles, resolved Manifests, and prompt resources. 8. [`design/tool-permissions-scope.md`](design/tool-permissions-scope.md) — tool policy and filesystem scope. 9. [`design/plugin-packages.md`](design/plugin-packages.md) — the retained offline package format and the prohibition on ambient Plugin authority. @@ -30,7 +30,7 @@ It is not a dumping ground for external research, old plans, API inventories, or 22. [`development/workspace-schema-migrations.md`](development/workspace-schema-migrations.md) — how to preflight, apply, verify, and roll back control-plane SQLite schema changes. 23. [`design/standalone-agent-host.md`](design/standalone-agent-host.md) — in-process standalone Worker host の依存方向、authority、lifecycle、非目標。 24. [`design/server-feature-storage.md`](design/server-feature-storage.md) — Server管理のWorkspace/Feature専用SQLite、migration、transaction、backup/restore、削除・shutdown境界。 -25. [`design/subjektiv-store.md`](design/subjektiv-store.md) — subject-scoped Memoryのownership、authorization、Session attribution、revision、candidate consolidation境界。 +25. [`design/subjektiv-store.md`](design/subjektiv-store.md) — subject-scoped Memoryのownership、authorization、Session attribution、変更履歴、candidate consolidation境界。 26. [`design/subjektiv-surface.md`](design/subjektiv-surface.md) — confirmed Memoryから生成するresident surface、availability、append-only restore injection。 27. [`development/subjektiv-product-cutover.md`](development/subjektiv-product-cutover.md) — legacy Workspace Memoryからsubjektivへのoperator cutover、explicit reset、backup/rollback runbook。 28. [`design/worker-wip-mode.md`](design/worker-wip-mode.md) — explicit-opt-in Worker WIP mode, Worldspace projection, authority, cache, and retry contracts. diff --git a/docs/architecture/worker-restore-observations.md b/docs/architecture/worker-restore-observations.md index 4efcb74cd..4236bfdad 100644 --- a/docs/architecture/worker-restore-observations.md +++ b/docs/architecture/worker-restore-observations.md @@ -7,9 +7,9 @@ T-701 extends the [registry-bound operation reception](workspace-worker-operatio | Operation | Observed identity / expected input | Atomic validation and execution owner | Conflict / uncertain outcome | Shared client behavior / deliberate difference | | --- | --- | --- | --- | --- | | Restore | Workspace + Runtime + Worker identity; Runtime-owned `restore_observation_token`; immutable `expected_observation_token` + `request_id` | Existing Runtime per-Worker lifecycle operation lock, durable admission and lifecycle commit. Preparation and execution follow admission, not a frontend GET comparison | Stale new intent is HTTP 409 (`restore_observation_conflict`); operation results remain accepted / rejected / rolled_back / reconciliation_required. A lost reply is unknown, not definite rollback | Both entrances use the same scoped POST/parser/notice classifiers and observation refresh. Never replace a rejected intent's token and automatically retry | -| Stop | Identity and reason, **not** a catalog revision CAS | Runtime per-Worker operation lock and existing backend Stop settlement; Workspace session finalization only after accepted | Existing flat accepted / unsupported / rejected result with diagnostics | Same encoded target paths and bounded HTTP-error messages; refresh observations instead of writing a guessed local stopped state. Stop does not gain Restore's generation guarantee | -| Pause / Resume / Cancel / Compact | Connected execution identity and `WorkerCommandEnvelope.command_id` | Existing bound execution/controller command admission | Command acknowledgement: Accepted / StaleCommandId / Conflict / InvalidState, with authoritative state | Command IDs order commands; they are not catalog observation generations. Resume is not Restore; connections do not silently move to a replacement execution | -| Delete / cleanup | Runtime + selected target IDs; `expected_plan_revision` **and** `expected_plan_digest` | Existing cleanup validation, per-target live/retention revalidation, removal service and catalog commits | Existing cleanup-plan conflicts, per-target status and diagnostics | Preserve plan protection for candidate sets and dependencies. Never substitute a Worker generation. Page plans invalidate at the beginning of every catalog refresh and are unavailable until fresh catalog and plan observations settle | +| Stop | Identity and reason, **not** a catalog content CAS | Runtime per-Worker operation lock and existing backend Stop settlement; Workspace session finalization only after accepted | Existing flat accepted / unsupported / rejected result with diagnostics | Same encoded target paths and bounded HTTP-error messages; refresh observations instead of writing a guessed local stopped state. Stop does not gain Restore's observation-token admission guarantee | +| Pause / Resume / Cancel / Compact | Connected execution identity and `WorkerCommandEnvelope.command_id` | Existing bound execution/controller command admission | Command acknowledgement: Accepted / StaleCommandId / Conflict / InvalidState, with authoritative state | Command IDs order commands; they are not catalog observation tokens. Resume is not Restore; connections do not silently move to a replacement execution | +| Delete / cleanup | Runtime + selected target IDs; `expected_plan_digest` of the candidate set, dependencies and blockers | Existing cleanup validation, per-target live/retention revalidation, removal service and catalog commits | Existing cleanup-plan conflicts, per-target status and diagnostics | Preserve plan protection for candidate sets and dependencies. Never substitute a Worker observation token. Page plans invalidate at the beginning of every catalog refresh and are unavailable until fresh catalog and plan observations settle | | Pin / Unpin | Registry Worker identity and explicit boolean | Registry retention update and ordered projection publication | Existing retention result/errors | Preserve retention semantics and full catalog metadata; refresh shared observations and cleanup plan | | Submit / Notify | Authenticated source + durable submission / notification request ID | Existing controller/session durable receipt admission | Existing accepted/rejected receipts and unknown delivery diagnostics | These are not Restore requests; Restore never manufactures input, Resume or a new Worker | diff --git a/docs/architecture/workspace-worker-operations.md b/docs/architecture/workspace-worker-operations.md index ea247b845..d0f7bdd6f 100644 --- a/docs/architecture/workspace-worker-operations.md +++ b/docs/architecture/workspace-worker-operations.md @@ -26,7 +26,7 @@ For the HTTP rows below, `R` abbreviates `/api/runtimes/{runtime_id}/workers/{wo | `POST R/cancel` and `POST W/cancel` | `WorkspaceWorker::cancel` | `RuntimeRegistry::cancel_worker` → target execution controller; not registry removal | | `PUT W/pin` / `DELETE W/pin` | `WorkspaceWorker::set_pinned(true/false)` | Workspace store retention update, ordered through `worker_projection`; no execution command | | `GET /api/w/{workspace_id}/runtimes/{runtime_id}/cleanup-plan` | `build_runtime_cleanup_plan` (preview, not a Worker mutation) | Workspace cleanup planner/projections | -| `POST /api/w/{workspace_id}/runtimes/{runtime_id}/cleanup-executions` | `execute_runtime_cleanup_with_context` validates plan revision/digest and selected candidates → each selected Worker's `delete_from_plan` | `WorkerRemovalService::execute_cleanup_removal` → existing retention planning, Runtime retention execution, and Workspace catalog commit. Workdir-only actions remain in the cleanup service | +| `POST /api/w/{workspace_id}/runtimes/{runtime_id}/cleanup-executions` | `execute_runtime_cleanup_with_context` validates plan digest and selected candidates → each selected Worker's `delete_from_plan` | `WorkerRemovalService::execute_cleanup_removal` → existing retention planning, Runtime retention execution, and Workspace catalog commit. Workdir-only actions remain in the cleanup service | | `POST /api/w/{workspace_id}/workers/remove` (`WorkerRemove`, not a browser removal alias) | `worker_remove_contract` verifies its source proof against the requested target → `WorkspaceWorker::remove` | `WorkerRemovalService::execute_async` checks the `remove` grant and coordinates retention/removal. It rejects self-removal and requires a stopped target for a new removal; existing durable removal operations have their own recovery path | | Embedded `WorkerRemove` Tool | Runtime-owned in-process target-bound proof → `EmbeddedServerWorkerMutationDispatcher` verifies/consumes proof → installed `EmbeddedWorkspaceWorkerRemoveExecutor` → `WorkspaceWorker::resolve_with_services` → the same `WorkspaceWorker::remove` | Same `WorkerRemovalService::execute_async`, grant-lock/recheck, self/live/retention guards, durable plan/recovery and catalog commit as HTTP; no Runtime-to-Server dependency | | Dedicated WS `R/protocol/ws` and `W/protocol/ws` | `worker_protocol_ws` (scoped wrapper for `W`) → `WorkspaceWorker::connect_protocol` → `workspace_worker_protocol_session` → bound sender | One remote WS or embedded execution transport; controller/session owns protocol operation results | @@ -79,7 +79,7 @@ There is no universal Worker command token or universal success result. - `WorkerCommandEnvelope.command_id` orders controller lifecycle commands. `WorkerCommandAcknowledgement` contains command kind, disposition (`Accepted`, `StaleCommandId`, `Conflict`, `InvalidState`) and authoritative state. These IDs belong to controller command admission, not durable Submit identity or Workspace removal planning. - `submission_request_id` is the caller's Submit retry key in an authenticated-source namespace. A durable `SubmissionAccepted` receipt also contains the Worker-allocated `submission_id` and `Started`/`Queued` disposition. `SubmissionRejected` is correlated by request ID. Acceptance is not a successful completed run; `last_finished_submission_request_id` is a teardown fence, not a result-success claim. - `notification_request_id` correlates `NotificationAccepted` / `NotificationRejected`. Notify has its own durable receipt and exact-payload retry behavior; it does not allocate a Submit FIFO entry. Browser Submit/Notify are rewritten to `SubmitTracked`/`NotifyTracked` with the server's Account source. Browser-supplied tracked forms, `SubmitIfIdle`, and `WorkerEvent` are rejected. -- Runtime `WorkerLifecycleOperationId` is a durable lifecycle/reconciliation identity, separate from a controller command ID. Ticket `assignment_operation_id`, cleanup plan revision/digest, and retention plan/operation IDs and fingerprints likewise belong to their own domain checks. A multiplexed `subscription_id` only routes frames to an already-bound sender. +- Runtime `WorkerLifecycleOperationId` is a durable lifecycle/reconciliation identity, separate from a controller command ID. Ticket `assignment_operation_id`, cleanup plan digest, and retention plan/operation IDs and fingerprints likewise belong to their own domain checks. A multiplexed `subscription_id` only routes frames to an already-bound sender. The common sender returns `Result<()>` after authorization and enqueueing on its transport channel. This is **not** a controller acknowledgement, durable receipt, or proof of upstream delivery. Actual outcomes arrive on the event stream. The dedicated WS reports dispatch errors as protocol error events; the multiplexed adapter currently terminates the connection when sending a method fails. It also requires an initial session Snapshot before accepting the WorkerProtocol subscription. @@ -125,10 +125,10 @@ The boundary above covers the T-702 minimum operation set. It is not a claim tha Known limitations worth follow-up, rather than undocumented guarantees: 1. Browser credentials/Account are rechecked for each common operation or WS method send, but not continuously for every outgoing observation event. This is not a promise of immediate revocation-driven stream teardown. -2. Cleanup is sequential rather than an all-target atomic transaction. The existing cleanup plan revision/digest and each removal service's live revalidation remain distinct protections. +2. Cleanup is sequential rather than an all-target atomic transaction. The existing cleanup plan digest and each removal service's live revalidation remain distinct protections. 3. Unary input has its existing narrower wire contract, rather than the WS authenticated receipt envelope. Identity unification does not create a new retry contract or manufacture caller request IDs across transports. -**T-701's stale-observation Restore guard is not implemented here.** T-701 will connect the caller's observed revision/generation to Runtime's existing operation lock, distinguish a new Restore intent from pending reconciliation, and handle UI conflicts. This boundary leaves those Restore inputs/results and the pending-operation state machine intact rather than creating a duplicate guard. +T-701's [stale-observation Restore guard](worker-restore-observations.md) connects the caller's `expected_observation_token` and `request_id` to Runtime's existing operation lock, distinguishes a new Restore intent from pending reconciliation, and handles UI conflicts. This boundary leaves those Restore inputs/results and the pending-operation state machine intact rather than creating a duplicate guard. ### Implementation references diff --git a/docs/design/durable-operations.md b/docs/design/durable-operations.md index 053d4b676..f1c878c2f 100644 --- a/docs/design/durable-operations.md +++ b/docs/design/durable-operations.md @@ -77,7 +77,7 @@ The record normally contains: - Workspace and operation identity; - a fingerprint of stable caller intent; - the created resource or result identity; -- the committed revision and timestamp where relevant. +- the committed content digest or immutable change ID and timestamp where relevant. It does not need `pending`, `executing`, or intermediate stages. An exact retry returns the recorded result. Reusing the same operation identity with a @@ -173,7 +173,7 @@ Prefer factual fields over stage names: - `provider_deleted_at` is evidence that provider deletion succeeded; - `child_operation_id` binds delegated work; -- `result_revision` identifies the committed result; +- `result_digest` identifies the committed result content; - `target_ref_after` records verified merge evidence. Avoid fields such as `validating`, `closing_session`, `detaching`, @@ -211,7 +211,7 @@ results. Before every retry or side effect, reread live authority and revalidate its fence. A previously recorded blocker does not prove that the operation remains blocked, and a previously unblocked operation does not retain permission after -assignment, ownership, revision, or attachment authority changes. +assignment, ownership, content, or attachment authority changes. ## Identity and fingerprinting @@ -243,7 +243,7 @@ When an external provider is involved: 1. reserve stable intent and identity if retry needs them; 2. invoke the provider with the strongest available idempotency, expected-old - revision, or stable resource key; + content digest, or stable resource key; 3. verify the provider result through authoritative response or observation; 4. commit only the checkpoint or result evidence that changes retry behavior; 5. on retry, reread both the operation and current domain/provider authority @@ -315,7 +315,7 @@ The durable split is deliberate: - `backend_jobs` stores immutable bounded input, its fingerprint, the current attempt number, and the final structured result or terminal failure; -- `backend_job_attempts` binds one attempt and input revision to one dedicated +- `backend_job_attempts` binds one attempt and input digest to one dedicated `(Workspace, Runtime, Worker)` identity, a precommitted tracked Runtime submission request identity, a deadline, and its bounded outcome; - `backend_job_deliveries` records best-effort notification separately from the @@ -342,7 +342,7 @@ The Ticket checker continues to select `builtin:backend-job` itself. Job identity/result capability comes from an immutable Host-authored create and restore binding, not the Profile name, tag, instruction, Worker display name or -Runtime placement. Backend checks the exact live Job/attempt/input revision and +Runtime placement. Backend checks the exact live Job/attempt/input digest and authenticated Worker before accepting results or domain operations. Profile policy alone never confers a subject grant. @@ -357,14 +357,14 @@ remains attention-required even after its Worker is removed. Success is accepted only by the structured result boundary. Before Runtime input can execute, the attempt is fenced by Workspace, Job ID, attempt ID, input -revision, bound Runtime Worker identity, and a fixed-size collision-resistant +digest, bound Runtime Worker identity, and a fixed-size collision-resistant tracked submission request identity derived from the attempt. Ordinary Worker input and Console mutation are rejected for Job-owned Workers, so no later run in the same Session can exercise the result capability. Result JSON bytes are bounded before persistence. Worker final prose, Idle, Stopped, or a successful model call are not success evidence. Identical structured submission is idempotent; conflicting replay, stale or late attempts, -wrong revisions, and other Workers fail closed. +wrong input digests, and other Workers fail closed. On restart, an unbound reservation is redispatched with its stable allocation. A dispatched attempt continues only when its bound Worker and precommitted diff --git a/docs/design/feature-chat-invocations.md b/docs/design/feature-chat-invocations.md index 8bfe93554..f1948f00e 100644 --- a/docs/design/feature-chat-invocations.md +++ b/docs/design/feature-chat-invocations.md @@ -50,7 +50,7 @@ Clients may parse this spelling only for input assistance. A call becomes execut TUI and Web composers retain selected invocations as atomic typed chips containing identity and structured arguments. Backspace/Delete and range deletion remove an unsent chip; removal is not an undo for an already executed operation. Draft history, transport retry, queueing, Session history, snapshot/live projections, and rewind restoration preserve the typed segment rather than flattening and reparsing it. -Completion requests carry a kind, fresh `request_id`, and source-qualified argument context. Host and Runtime echo the request ID unchanged. Clients correlate replies with that nonce plus their current draft revision, cursor, target Worker, authority snapshot, and connection generation; even identical-prefix/context ABA replies are discarded. Old uncorrelated replies cannot establish a selected invocation. Client-file arguments use a declared `InvocationClientAdapter`; they do not ask the Worker to resolve a client-local path. +Completion requests carry a kind, fresh `request_id`, and source-qualified argument context. Host and Runtime echo the request ID unchanged. Clients correlate replies with that nonce plus their current draft identity/content and edit invalidation state, cursor, target Worker, authority snapshot, and connection generation; even identical-prefix/context ABA replies are discarded. Old uncorrelated replies cannot establish a selected invocation. Client-file arguments use a declared `InvocationClientAdapter`; they do not ask the Worker to resolve a client-local path. ## Attachment adapter diff --git a/docs/design/flow-state-graph.md b/docs/design/flow-state-graph.md index bd0cea002..ffada9be8 100644 --- a/docs/design/flow-state-graph.md +++ b/docs/design/flow-state-graph.md @@ -28,7 +28,7 @@ DCDL source The Serde-compatible intermediate is private compiler infrastructure. Public APIs and persisted Flow runtime records use typed Flow domain values. -`flow_sources` stores one current logical Workspace-authored source per Workspace/slug. Every changed source creates an immutable `flow_source_revisions` row containing the original content, content digest, and compiled definition. Built-ins remain read-only embedded resources with an explicit monotonic resource revision; resolving one compiles and returns that resource snapshot without writing it into Workspace DB. Runtime pins source identity, revision, digest, and compiled definition in Worker state, so editing a Workspace source or updating a built-in resource never changes an existing instance. +`flow_sources` stores one current logical Workspace-authored source per Workspace/slug. Every changed source retains the original content, content digest, and compiled definition in immutable source history. Built-ins are read-only embedded resources: resolving one compiles and returns its exact source snapshot without writing it into Workspace DB or maintaining a manual change counter. Runtime pins source identity, content digest, and compiled definition in Worker state, so editing a Workspace source or updating a built-in resource never changes an existing instance. The compiler rejects unknown fields, unsupported schema versions, invalid/reserved identifiers, unknown transition targets, authored `$cancelled` state/targets, terminal states with outgoing transitions, non-terminal states without transitions, unreachable states, and reachable closed paths that cannot reach a user-declared terminal state. It injects the synthetic exceptional-cancellation transition and `$cancelled` terminal state after validation. @@ -36,12 +36,12 @@ The compiler rejects unknown fields, unsupported schema versions, invalid/reserv Flow source authority and Flow execution authority are split at the immutable source snapshot boundary. -The Workspace Backend stores only current Flow sources and immutable source revisions. Resolving a source-qualified selector returns the Workspace id, Flow id, revision, digest, and compiled definition. Resolution is read-only with respect to Flow execution: it never creates an instance, attempt, or event. +The Workspace Backend stores only current Flow sources and immutable source history. Resolving a source-qualified selector returns the Workspace id, Flow id, content digest, and compiled definition. Resolution is read-only with respect to Flow execution: it never creates an instance, attempt, or event. One Runtime Worker durably owns: - the pinned source snapshot and compiled definition; -- its active Flow instance, current state, revision, and lifecycle status; +- its active Flow instance, current state, and lifecycle status; - its active transition attempt; - its ordered Flow events. @@ -49,6 +49,15 @@ The complete `FlowRuntimeState` is persisted as a typed `flow.runtime.v1` Worker Transition mutations clone the current Runtime state, append ordered events, persist the new session extension, and only then replace the in-memory projection. A persisted verifying attempt survives Runtime restart and same-Worker restore; the next transition request recovers it instead of creating a competing attempt. +The executable checks live in `FlowRuntimeState::begin_or_recover_transition` and `resolve_active_transition`, with private `begin_transition` / `resolve_transition` helpers: + +- Before starting, require an active instance, no other active attempt, and the same compiled content digest as the pinned source. Reject an attempt ID already present in `TransitionRequested` events, including cancelled/failed attempts. +- Before consuming a result, require its exact instance and currently reserved attempt ID. Then compare the compiled and captured definition digests against the pinned digest. +- Before applying a successful verdict, require the instance to remain active and its current state to equal the captured source state. Validate the complete outgoing-condition result set and accept exactly one met condition. +- Completion or cancellation releases the active attempt. A delayed result cannot consume a newer attempt even if the graph returns to the same state. The persisted event log prevents reusing the old attempt ID after restore. + +There is no independent state counter or manually incremented built-in source counter. Event `sequence` remains only the append order of the actual event log, not a substitute for any of these conditions. + Worker stop retains the Flow with the Worker session. Restoring the same Worker reconstructs the latest state from session extensions and the saved Profile still determines `feature.flow` eligibility. Worker deletion removes the owning Worker/session; Flow state is not implicitly handed to another Worker. Workspace Server schema migration v26 removes the legacy `flow_instances`, `flow_transition_attempts`, and `flow_events` tables. Backend/Web visibility, when needed, is a bounded Runtime Worker projection rather than a second instance authority. diff --git a/docs/design/merge-request-wip-projection.md b/docs/design/merge-request-wip-projection.md index cdf991eaf..14ddee2d8 100644 --- a/docs/design/merge-request-wip-projection.md +++ b/docs/design/merge-request-wip-projection.md @@ -31,7 +31,7 @@ Item interface `yoi.merge-request/item/v1`: | --- | --- | --- | | `read` | `ShowMergeRequest` | bounded thread `after` and `limit`; Backend resolves current source and target refs | | `check_readiness` | `CheckMergeRequestReadiness` | Backend resolves the current source subject and derives current approval/blockers | -| `review` | `ReviewMergeRequest` | decision/body/findings plus the injected Reviewer capability, captured request event, Ticket revision, linked result snapshot, and exact candidate | +| `review` | `ReviewMergeRequest` | decision/body/findings plus the injected Reviewer capability, captured request event, Ticket content digest, linked result snapshot, and exact candidate | | `complete` | `CompleteMergeRequest` | operation ID, approval event, target-before/after refs, merge strategy and conflict resolution; Backend requires Orchestrator authority | Typical role surfaces are therefore: @@ -49,7 +49,7 @@ The native projection delegates to the same ordinary tools and REST endpoints. I - Workspace and configured-repository isolation; - current Coder assignment and assigned-Workdir source-selector proof for opening/review requests; - provider resolution of the exact current source candidate; -- ReviewRequested binding to the Ticket item revision and complete linked-MR/source snapshot; +- ReviewRequested binding to the Ticket item `content_digest` and complete linked-MR/source snapshot; - injected Reviewer capability consumption and exact MR/candidate matching; - source movement invalidating source approval while target-only movement preserves unchanged-source review; - unresolved request-change and revoked/stale approval rejection; diff --git a/docs/design/objective-wip-projection.md b/docs/design/objective-wip-projection.md index 20c0fe6e3..b3933df71 100644 --- a/docs/design/objective-wip-projection.md +++ b/docs/design/objective-wip-projection.md @@ -15,7 +15,7 @@ There is no Feature Object or `/features/objective` compatibility alias. Legacy `` is either a canonical `O-` key or an existing canonical internal alphanumeric ID. Only one direct child segment is accepted. Nested suffixes, Ticket keys, and unrelated or hidden prefixes do not resolve as Objective objects. -The Backend remains authoritative for existence, `O-*`/internal-ID resolution, Workspace scope, validation, audit events, revision generation, pagination, body/event bounds, and linked-Ticket validation. A discovered route is not independent authority; every operation still uses the Worker's current `WorkspaceClient`. +The Backend remains authoritative for existence, `O-*`/internal-ID resolution, Workspace scope, validation, audit events, pagination, body/event bounds, and linked-Ticket validation. A discovered route is not independent authority; every operation still uses the Worker's current `WorkspaceClient`. ## Current operation inventory @@ -44,9 +44,9 @@ The item operations intentionally do not accept an Objective `id`. The handler i `query` retains the ordinary Worker limit of 1–100 and returns stable snippets, canonical `O-*` references, linked `T-*` references, `next_cursor`, and `has_more`. Each result also includes its canonical item path. It does not expand Objective bodies, complete events, or Ticket records. -`read` retains the ordinary Worker event limit of 1–50 and projects the Backend-bounded body, `body_truncated`, authoritative `revision`, linked-Ticket summaries, bounded events, and event pagination. Internal Objective, Ticket, and event-storage IDs are removed from model-visible results. +`read` retains the ordinary Worker event limit of 1–50 and projects the Backend-bounded body, `body_truncated`, linked-Ticket summaries, bounded events, and event pagination. Internal Objective, Ticket, and event-storage IDs are removed from model-visible results. -`create` returns the authoritative Objective detail plus its canonical item path. Mutations return the authoritative post-operation detail. The revision observed in a successful read or mutation updates the item's object validator. The common Host validator contract rejects stale object/interface calls before dispatch, and the call response carries the refreshed route validator. +`create` returns the authoritative Objective detail plus its canonical item path. Mutations return the authoritative post-operation detail. The Object publishes fixed operation signatures, not the Objective's mutable content: editing content does not invalidate an unchanged Object/Interface contract. The adapter therefore keeps no content-freshness cache or synthetic change identifier. Each invocation checks current permission and dispatches to current Backend authority; `edit` evaluates its explicit `old_string`/uniqueness conditions there. Reads always fetch current Backend data rather than replaying cached content. Host validators still protect route and Interface signatures before dispatch. Linked Ticket references remain strings passed to the existing Objective Backend operation. Their presence does not publish a Ticket object, grant Ticket read or mutation authority, or require a Ticket-native WIP projection. diff --git a/docs/design/plugin-component-model.md b/docs/design/plugin-component-model.md index e434d9a0c..8e8bf4fbc 100644 --- a/docs/design/plugin-component-model.md +++ b/docs/design/plugin-component-model.md @@ -18,7 +18,7 @@ The package validator may reject legacy core-Wasm artifacts and require Componen A future Server Plugin platform may execute Wasmtime Component Model packages only after the architecture is implemented as a coherent authority boundary: 1. an operator installs an immutable package into Server-owned artifact authority; -2. a Workspace owner selects an installed package through an immutable Addon revision; +2. a Workspace owner selects an installed package through an immutable Addon configuration snapshot bound to exact package/content digests; 3. Backend authors a per-Worker execution plan containing exact identities, digests, configuration, and bounded grants; 4. Runtime fetches only Server-authorized digests and verifies package bytes and execution-plan identity; 5. Runtime instantiates a fresh bounded Wasmtime Store with no ambient WASI authority; and diff --git a/docs/design/profiles-manifests-prompts.md b/docs/design/profiles-manifests-prompts.md index 7c221df12..e9b1624f2 100644 --- a/docs/design/profiles-manifests-prompts.md +++ b/docs/design/profiles-manifests-prompts.md @@ -58,9 +58,9 @@ Relative imports **inside a builtin source** resolve against its builtin virtual Builtin dependencies share the Workspace snapshot budgets: at most 256 entries across Workspace entries and distinct observed builtin sources, 256 KiB per source, and 4 MiB of total source bytes. Unused Workspace entries still count; repeated imports of the same builtin count once. The observed import graph must be acyclic and at most 32 edges deep, **including the synthetic evaluation wrapper's import of the entrypoint**. These guards apply before the loader returns sources to Decodal, including imports in lazy object fields; builtins do not bypass them. Launch archives retain their separate transport limits (64 sources, 256 KiB per source, 1 MiB total source bytes). -The former `entries = [{ selector = "project:name"; source = "profiles/name.dcdl"; }]` registration is no longer accepted. Migrate it explicitly to `profile = import "./profiles/name.dcdl";`, preserving the selector, label, description and default. For registrations in a nested config file, adjust the relative import to the original virtual-tree entry (for example `../profiles/name.dcdl`). Do not rename or recreate the Profile file merely for registration. Previously saved source-based revisions remain readable as config data and are not rewritten, deleted, or silently substituted with a builtin. Their Profile projection/launch reports `profile_source_registration_removed` with the replacement form; saves under the new schema reject the old field. Existing Workers continue using their saved Manifest while the author migrates and saves the config. +The former `entries = [{ selector = "project:name"; source = "profiles/name.dcdl"; }]` registration is no longer accepted. Migrate it explicitly to `profile = import "./profiles/name.dcdl";`, preserving the selector, label, description and default. For registrations in a nested config file, adjust the relative import to the original virtual-tree entry (for example `../profiles/name.dcdl`). Do not rename or recreate the Profile file merely for registration. Previously saved source-based saved content snapshots remain readable as config data and are not rewritten, deleted, or silently substituted with a builtin. Their Profile projection/launch reports `profile_source_registration_removed` with the replacement form; saves under the new schema reject the old field. Existing Workers continue using their saved Manifest while the author migrates and saves the config. -The schema contribution separates its materialized registration schema from an optional, fingerprint-bound `authoring_source`. The latter supplies editor field suggestions inside array elements and partial Profile records, plus a separate constraint-only analysis pass for supplied nested values (including imported recipes); that pass never materializes the shape, makes omitted fields required, or supplies defaults to the saved projection. Editor diagnostics point to the supplied value's virtual file/span. The authoring shape is never substituted for the persisted evaluation schema. Decodal structural diagnostics cover registration and nested Profile field types; the shared Backend save boundary additionally checks full Profile/Manifest semantics (including mode values, forbidden runtime fields, duplicate selectors and default references). UI and WIP saves use that same boundary; invalid candidates leave the active config revision untouched. +The schema contribution separates its materialized registration schema from an optional, fingerprint-bound `authoring_source`. The latter supplies editor field suggestions inside array elements and partial Profile records, plus a separate constraint-only analysis pass for supplied nested values (including imported recipes); that pass never materializes the shape, makes omitted fields required, or supplies defaults to the saved projection. Editor diagnostics point to the supplied value's virtual file/span. The authoring shape is never substituted for the persisted evaluation schema. Decodal structural diagnostics cover registration and nested Profile field types; the shared Backend save boundary additionally checks full Profile/Manifest semantics (including mode values, forbidden runtime fields, duplicate selectors and default references). UI and WIP saves use that same boundary; invalid candidates leave the active config content untouched. `scope` and `delegation_scope` retain both supported forms: a string such as `"workspace_read"` / `"workspace_write"`, or a table such as `{ intent = "workspace_read"; deny_write = ["private"]; symlink_policy = "resolved"; }`. Decodal 0.4 has no union range, so these fields are opaque to the editor constraint pass; a non-materialized table hint supplies nested completion without rejecting the string form or rewriting it. The shared Backend save validator checks scope forms, intent values and table fields (including their types and unknown keys). Other structurally typed fields such as `worker.mode` and `feature.task.enabled` retain editor type diagnostics. Completion hints never become Profile defaults or saved values. @@ -72,9 +72,9 @@ A resolved Manifest is the concrete contract used to create or restore a Worker. Source/partial layers may omit fields. Resolved manifests should be explicit enough that Worker creation does not depend on ambient configuration later changing under it. -`--manifest ` exists as an explicit low-level escape hatch. Normal fresh startup selects a `builtin:*` or `project:*` Profile from the Backend-managed Workspace Config revision rather than applying an ambient manifest cascade. +`--manifest ` exists as an explicit low-level escape hatch. Normal fresh startup selects a `builtin:*` or `project:*` Profile from the Backend-managed Workspace Config content snapshot rather than applying an ambient manifest cascade. -Project Profiles are evaluated as part of the revisioned Virtual Config, including its imports and value composition. The Backend packages the **evaluated selected value** into a digest-bound JSON entry in the existing Profile archive transport, and binds the launch bundle to the Workspace identity, revision, tree/projection digests and schema/toolchain fingerprints. Runtime verifies the archive and resolves that exact value without re-evaluating Decodal, scanning imports or reading Workspace files. When Workspace evaluation imports builtins, the archive additionally seals the exact observed transitive builtin closure for the Workspace contract: canonical `$builtin/profiles/...` source keys, original Decodal bytes, content types, sizes and source digests, all bound by the outer archive and launch bundle digests. These are provenance dependencies, not runtime entrypoints or runtime import instructions. Missing or corrupt provenance fails verification even though the entrypoint is JSON; no local file or current embedded builtin can rescue it. Builtin Profiles selected directly retain their embedded Decodal archive path. Changing an embedded builtin so that it changes the evaluated Workspace value requires an **explicit config save/re-evaluation before a fresh launch**. The Backend compares current evaluation with the saved projection digest and rejects a mismatch rather than silently adopting the new value. T-711 retains the existing version-2 toolchain contract/fingerprint tuple so existing stored contracts remain readable; builtin provenance is bound by the archive and bundle digests, not a new catalog fingerprint field. Neither a builtin update nor a config save rewrites a sealed archive or silently rebases an existing Worker. Saving config changes only future fresh launches; the Worker persists the resulting resolved Manifest and restore uses that saved snapshot rather than selecting the current recipe again. Files below the Workdir are not implicit Profile override layers. +Project Profiles are evaluated as part of the content-addressed Virtual Config, including its imports and value composition. The Backend packages the **evaluated selected value** into a digest-bound JSON entry in the existing Profile archive transport, and binds the launch bundle to the Workspace identity, tree/projection digests and schema/toolchain fingerprints. Runtime verifies the archive and resolves that exact value without re-evaluating Decodal, scanning imports or reading Workspace files. When Workspace evaluation imports builtins, the archive additionally seals the exact observed transitive builtin closure for the Workspace contract: canonical `$builtin/profiles/...` source keys, original Decodal bytes, content types, sizes and source digests, all bound by the outer archive and launch bundle digests. These are provenance dependencies, not runtime entrypoints or runtime import instructions. Missing or corrupt provenance fails verification even though the entrypoint is JSON; no local file or current embedded builtin can rescue it. Builtin Profiles selected directly retain their embedded Decodal archive path. Changing an embedded builtin so that it changes the evaluated Workspace value requires an **explicit config save/re-evaluation before a fresh launch**. The Backend compares current evaluation with the saved projection digest and rejects a mismatch rather than silently adopting the new value. T-711 retains the existing version-2 toolchain contract/fingerprint tuple so existing stored contracts remain readable; builtin provenance is bound by the archive and bundle digests, not a new catalog fingerprint field. Neither a builtin update nor a config save rewrites a sealed archive or silently rebases an existing Worker. Saving config changes only future fresh launches; the Worker persists the resulting resolved Manifest and restore uses that saved snapshot rather than selecting the current recipe again. Files below the Workdir are not implicit Profile override layers. ## Local stdio MCP server declarations @@ -124,7 +124,7 @@ Prompts live under `resources/prompts` so builtins, project overrides, and user The prompt layer should explain policy and behavior, but it should not smuggle volatile state into model context. Runtime facts that affect later turns must still go through history. -Builtin resources should be embedded at compile time. Project Profiles and prompt overlays belong to the Backend-managed revisioned Workspace Config and travel as digest-bound source archives. User Profile registries, provider/model catalog overrides, and explicit low-level Manifests remain filesystem-based where those local resolution paths are used. +Builtin resources should be embedded at compile time. Project Profiles and prompt overlays belong to the Backend-managed content-addressed Workspace Config and travel as digest-bound source archives. User Profile registries, provider/model catalog overrides, and explicit low-level Manifests remain filesystem-based where those local resolution paths are used. ## Why this separation matters diff --git a/docs/design/server-feature-storage.md b/docs/design/server-feature-storage.md index 8a3d3f36b..cc97a74ee 100644 --- a/docs/design/server-feature-storage.md +++ b/docs/design/server-feature-storage.md @@ -21,7 +21,7 @@ A Feature database is an API/ownership isolation boundary inside the trusted Ser ## Registration and repository example -Registration is trusted startup code. Merely constructing or registering storage is lazy and creates no directory or database; the first `open` applies migrations. +Registration is trusted startup code. Merely constructing or registering storage is lazy and creates no directory or database; the first `open` applies migrations. The simplified example below demonstrates storage ownership and transaction atomicity, not the production subjektiv schema or Memory-change API; those contracts are described in [subjektiv-store.md](subjektiv-store.md). ```rust use rusqlite::{Transaction, params}; @@ -37,7 +37,7 @@ fn create_subject_memory(tx: &Transaction<'_>) -> StorageResult<()> { tx.execute_batch( "CREATE TABLE subjects ( subject_id TEXT PRIMARY KEY, - revision INTEGER NOT NULL + state TEXT NOT NULL CHECK (state IN ('active', 'retired')) ); CREATE TABLE memories ( subject_id TEXT NOT NULL, @@ -75,7 +75,7 @@ impl SubjektivRepository { body: &str, intervention_id: &str, ) -> StorageResult<()> { - // Candidate application, memory revision, and intervention recording are + // Candidate content and intervention recording are // one atomic transaction in the subjektiv Feature database. self.database.transaction(|tx| { tx.execute( @@ -83,11 +83,6 @@ impl SubjektivRepository { VALUES (?1, ?2, ?3)", params![subject_id, memory_id, body], )?; - tx.execute( - "UPDATE subjects SET revision = revision + 1 - WHERE subject_id = ?1", - [subject_id], - )?; tx.execute( "INSERT INTO interventions (intervention_id, subject_id, detail) VALUES (?1, ?2, 'candidate applied')", diff --git a/docs/design/standalone-jobs.md b/docs/design/standalone-jobs.md index abbe6ce3e..1ab7d7ab6 100644 --- a/docs/design/standalone-jobs.md +++ b/docs/design/standalone-jobs.md @@ -4,13 +4,13 @@ Base: `3c424fd17d1a5e8bb5542de2d0ad88f55338e0ea` (current origin/develop; T-704 integrated). T-709's subjektiv consumer is not part of this change. -The reusable `job` crate owns the transport-neutral immutable request (registry Profile selector, input snapshot/reference/revision, caller Job ID, instruction, serialization key and bounded limits), result envelope/digest and validation. Backend retains its existing SQLite state machine, Runtime binding, domain grants, authorization, unknown-outcome and cleanup implementation; its adapter uses the shared request validation and result contract, not a second runner. +The reusable `job` crate owns the transport-neutral immutable request (registry Profile selector, input snapshot/reference and SHA-256 digest, caller Job ID, instruction, serialization key and bounded limits), result envelope/digest and validation. Backend retains its existing SQLite state machine, Runtime binding, domain grants, authorization, unknown-outcome and cleanup implementation; its adapter uses the shared request validation and result contract, not a second runner. Standalone owns a separate adapter, not a WorkspaceClient impersonation. `StandaloneHost` owns a Job service and its connection lifecycle. The concrete local placement is `//jobs.sqlite3` (using the existing Worker directory layout; alongside, not inside, interactive sessions). The `standalone` crate owns the versioned local schema: durable intents, immutable attempts, execution state, accepted result/digest, cancellation and consumer acknowledgement. A single Host connection is protected by the existing Worker lease. SQLite transactions serialize reserve, dispatch, result acceptance and terminal transitions. No subjektiv tables or fake Workspace/Runtime registry are created. Local adapter states follow the existing intent/attempt/result distinctions. Exact immutable request replay returns the same Job, never starts another attempt. Explicit retry creates a bounded new attempt only for known failure; unknown outcomes require caller reconciliation and never auto-replay. Unstarted pending attempts remain pending on shutdown/reopen. Interrupted dispatch/running attempts without an accepted result become unknown; accepted results survive restart and remain available until consumer acknowledgement. Cancellation is durable and never auto-resumed. Consumer acknowledgement/delivery failure is separate from model success and never reruns the model. -Execution uses the existing private Internal Worker substrate with a new Host-facing bounded Job entry point. It gets a distinct ephemeral session, explicitly resolved selected Profile/system prompt/model, no ambient parent tools, filesystem, Workspace or Feature connections, and only a Job/attempt/revision-bound structured result submission capability. Profiles requiring unavailable tools/features are explicitly rejected rather than silently stripped. Result authority is independent of Profile name; final prose, Idle and Worker termination are never success evidence. This first generic adapter grants no domain operations; future consumers must provide explicit domain capabilities rather than gaining them through a profile tag. +Execution uses the existing private Internal Worker substrate with a new Host-facing bounded Job entry point. It gets a distinct ephemeral session, explicitly resolved selected Profile/system prompt/model, no ambient parent tools, filesystem, Workspace or Feature connections, and only a Job/attempt/input-digest-bound structured result submission capability. Profiles requiring unavailable tools/features are explicitly rejected rather than silently stripped. Result authority is independent of Profile name; final prose, Idle and Worker termination are never success evidence. This first generic adapter grants no domain operations; future consumers must provide explicit domain capabilities rather than gaining them through a profile tag. The Host limits concurrent active resources independently of caller limits, enforces timeout/turn/output limits, routes cancellation into the Internal Worker lifecycle and waits for resource cleanup. Accepted results are committed before resource release; required domain postprocessing must finish before submission (or be independently durable in its consumer). No daemon, pool, subprocess, HTTP/WS listener or Server dependency is introduced. @@ -35,7 +35,7 @@ is success authority; merely returning from `wait` is not. The injected-client entry point resolves/validates the same selected Profile and model configuration; it replaces only the model transport for embedders/tests. -A typical generic consumer supplies its own stable Job ID/revision/reference, +A typical generic consumer supplies its own stable Job ID/reference and immutable input, immutable JSON input, instruction and registry Profile: ```rust,ignore @@ -82,7 +82,7 @@ choosing `builtin:subjektiv-memory-consolidation` alone grants nothing. Every run uses the existing private Internal Worker substrate, a distinct identity and ephemeral Session. Only `SubmitJobResult` is installed by a FeatureModule. -Its model input is `{result: ...}`; Job/attempt/input-revision bindings come from +Its model input is `{result: ...}`; Job/attempt/input-digest bindings come from the Host capability, not model arguments. The sink checks those bindings, the current persisted attempt, deadline, byte limit and result digest. Exact result replay is idempotent; changed/foreign/stale results are rejected. Submission is @@ -103,7 +103,7 @@ default / 64 KiB absolute byte limits, not a larger standalone exception. Local schema version **1** is owned only by `standalone::job_store`: `job_intents` contains immutable serialized intent/fingerprint, serialization key, current -attempt and acknowledgement; `job_attempts` contains immutable identity/revision, +attempt and acknowledgement; `job_attempts` contains immutable identity/input digest, execution state, failure and accepted result/digest. Foreign keys, immutable identity/result triggers, transactions, `WAL` and `synchronous=FULL` protect the boundary. No Server schema or subjektiv/Memory schema is copied or opened. diff --git a/docs/design/standalone-subjektiv.md b/docs/design/standalone-subjektiv.md index 88d36325e..a23fed143 100644 --- a/docs/design/standalone-subjektiv.md +++ b/docs/design/standalone-subjektiv.md @@ -18,6 +18,8 @@ Validation must distinguish scripted model/Host and real test-owned process/lock ## Implemented boundaries and validation +The validation counts and former API terminology below record T-709 at the cited source, not new executions. Current Memory changes and Job input-digest bindings follow [the store contract](subjektiv-store.md) and [the Job contract](standalone-jobs.md). + - `feature-storage` owns the shared SQLite connection policy/Feature manager; `subjektiv` owns the unchanged domain schema/migrations, Host-neutral API dispatch, and T-704 consolidation request/result/surface validation. Server modules remain thin adapters; Worker/standalone do not depend on Workspace Server. - `worker::subjektiv` exposes a nonserializable live Host capability plus a separate attenuated consolidation capability. Backend creation still requires the actual authenticated client and bound snapshot. Local creation is explicit Host injection; unavailable Backend never falls back locally. - The local catalog keeps private scope/language settings and common Feature storage under `subjektiv/`, separately from generic Subject Job ledgers. The common attribution table's existing `runtime_id` lookup key is the local storage namespace for local rows, not a running Runtime identity; public local evidence leaves `runtime_id`, Workspace, and Account fields absent. Worker IDs, Subject binding, and operator-managed scope must all match before Session content is read. diff --git a/docs/design/subjektiv-consolidation-jobs.md b/docs/design/subjektiv-consolidation-jobs.md index 6121fd9fe..b18ce191d 100644 --- a/docs/design/subjektiv-consolidation-jobs.md +++ b/docs/design/subjektiv-consolidation-jobs.md @@ -10,7 +10,7 @@ planned in T-708/T-709 are outside this Backend change. The existing backlog/byte thresholds and missing/stale/failed surface policy remain the launch decision. A Job captures an immutable bounded batch (up to 100 -candidate IDs) plus the Subject store revision. Selection also reserves the Job's +candidate IDs) plus the Subject `memory_fingerprint`. Selection also reserves the Job's 16 KiB result budget, accounting for JSON-escaped IDs appearing in both the batch claim and compact verified outcomes. Excess candidates remain staged for a later batch. An empty batch is valid for a @@ -39,7 +39,7 @@ may snapshot remaining candidates once all previous Workers are safely cleaned. ## Memory and surface completion -`MemoryApplyCandidate` still uses the existing expected-revision checks, atomic +`MemoryApplyCandidate` still uses the existing `expected_change_id` checks, atomic apply/close and immutable decision receipts. Partial application survives model failure, timeout, lost response, retry, Worker deletion and Backend recovery. Job replay is not a second memory apply: resolved candidates retain their @@ -51,9 +51,9 @@ clean-context surface editor and publish-or-recorded-failure, then adds the actu structured surface outcome. It does **not** schedule a Worker-owned post-Run task. The result call serializes and caches the exact generated outcome for ambiguous submission replay. The Backend verifies every batch disposition and the current -store revision, generation ownership and persisted ready/failed surface fields; +Memory-input fingerprint, generation-run ownership and persisted ready/failed surface fields; it enriches the durable result with actual immutable candidate disposition -summaries (`candidate_id` and `action`). Full reasons, affected revisions and +summaries (`candidate_id` and `action`). Full reasons, affected change references and source edges remain in the existing immutable receipt store; they are not copied into a bounded Job result. Exact replay uses the same verified compact projection. Thus confirmed-memory consolidation and surface readiness are separate outcomes. @@ -87,7 +87,7 @@ Subject-body/ordinary/Workspace-Memory Workers are never selected by this key. The Job runner uses its existing durable dispatch allocation, tracked input, T-701 registry-bound restore/recovery, deadlines, unknown outcomes, result replay, -delivery and T-690 cleanup. Job results, candidate receipts, Memory revisions, +delivery and T-690 cleanup. Job results, candidate receipts, Memory changes, provenance and surfaces remain in their existing Backend stores after Worker removal. No live dogfood Worker or environment is stopped/updated by development validation. diff --git a/docs/design/subjektiv-store.md b/docs/design/subjektiv-store.md index fdce5859d..0cca8638c 100644 --- a/docs/design/subjektiv-store.md +++ b/docs/design/subjektiv-store.md @@ -36,7 +36,7 @@ or create a subjektiv database. T-668 opens it lazily for explicit subject operations or an authenticated subject extraction write; leaving the Feature disabled has no subject-store side effect. -## Versioned records +## Immutable changes and schema contracts All new persisted JSON records use `schema_version: 1`. Subject staging retains existing extract `StagingRecord` schema version 2 and its six kinds: @@ -45,47 +45,51 @@ and `lesson`. The host adds only `subject_id` and `created_at`; the model does not provide subject scope or provenance. A subject record has a store-issued random ID, an explicit role, lifecycle state, -and `store_revision`. It starts `active` at generation 0. Retirement is terminal +and lifecycle timestamps. It starts `active` with no confirmed Memory. Retirement is terminal in this baseline and is distinct from stopping a Worker. Retirement prevents new writes but retains all reads and history. -A Memory revision contains: +User-managed `behavior_md` is separate from generated Memory context. Updating it compares the exact previously read text through `expected_behavior_md`, not a behavior counter. Conflicts require rereading the actual current text; changing behavior neither changes confirmed Memory nor rewrites Worker history. -- a stable store-issued Memory ID and positive revision; +An immutable Memory change contains: + +- a stable store-issued Memory ID, opaque `change_id: String`, and optional `previous_change_id`; - subject ID, existing six-way kind, state, claim, optional Markdown body, `why_useful`, and optional staleness condition; -- exact source candidate IDs and exact `{memory_id, revision}` derivations; +- exact source candidate IDs and exact `{memory_id, change_id}` derivations; - a required change reason and host-issued creation/update times. -Revision 1 must start `active`. For every kind, `active` may remain active or +The first change must start `active`. For every kind, `active` may remain active or become `resolved`/`retracted`; `resolved` may remain resolved, reopen to active, or be retracted. `retracted` is terminal. A corrected experience after -retraction receives a separate Memory ID and may cite the old fixed revision. +retraction receives a separate Memory ID and may cite the old fixed change. `active` means eligible for ordinary recall, not proven true and not authorized. -Refinement/correction of the same experience writes the next revision under the +Refinement/correction of the same experience appends a new immutable change under the same Memory ID. A distinct experience or lesson gets a new ID. Every write uses -an expected current revision; stale callers receive a typed `RevisionConflict`. +`expected_change_id` equal to the current change ID; stale callers receive a typed `MemoryChangeConflict`. Old rows, evidence edges, and derivations are immutable and retained. -A surface snapshot stores only its body, exact Memory revision references, the -subject's `built_from_store_revision`, and host-issued ID/time. Creation requires -the generation to equal the subject's current store generation and permits at -most one fixed revision of each Memory. The snapshot may deliberately select a +`memory_fingerprint` is SHA-256 over the sorted, length-framed `(memory_id, current_change_id)` set, including all Memory states. Each committed content/state/provenance change gets a fresh change ID, preventing ABA reuse; staging alone does not change this fingerprint. + +A surface snapshot stores only its body, exact Memory change references, the +subject's `built_from_memory_fingerprint`, and host-issued ID/time. Creation requires +the captured fingerprint to equal the current `memory_fingerprint` and permits at +most one fixed change of each Memory. The snapshot may deliberately select a bounded subset; selection, generation, bounds, and injection policy are later work. ## Relational invariants and retention The feature database contains `subjects`, `staging_records`, -`staging_resolutions`, `memory_records`, immutable `memory_revisions`, normalized +`staging_resolutions`, `memory_records`, immutable `memory_changes`, normalized candidate/derivation edges, and immutable `surface_snapshots` with normalized references. Composite foreign keys always include subject scope. As the physical database is already Workspace-scoped, Workspace identity is stored once in `store_scope` rather than copied into every row. -`memory_records` is the current-revision pointer and current JSON projection; -`memory_revisions` is the immutable historical authority. Candidate JSON is +`memory_records` is the `current_change_id` pointer and current JSON projection; +`memory_changes` is the immutable historical authority. Candidate JSON is immutable. A resolution stores both the resolution JSON and the exact staged JSON bytes that were resolved. Aggregate seal rows prevent evidence, derivations, resolution targets, or snapshot references from being appended after their @@ -107,11 +111,11 @@ ownership as `transaction`. `SubjektivStore::apply_candidate` and `apply_candidates` atomically: 1. verify the active subject and every unresolved candidate in repository scope; -2. check the expected Memory revision and every exact candidate/derivation ref; -3. derive the revision's candidate provenance from that same candidate set; -4. insert and seal the immutable Memory revision and evidence edges; -5. update the current Memory projection and increment `store_revision` once; -6. insert and seal each immutable candidate resolution and exact target revision. +2. check the expected Memory change ID and every exact candidate/derivation ref; +3. derive the change's candidate provenance from that same candidate set; +4. insert and seal the immutable Memory change and evidence edges; +5. update the current Memory projection and recompute the fingerprint of current Memory identities; +6. insert and seal each immutable candidate resolution and exact target change. Generic candidate resolution cannot emit `applied`, and direct Memory writes cannot attach candidate provenance; those relationships must go through this @@ -170,8 +174,8 @@ Memory migration remain separate work. An enabled `feature.subjektiv` installs five names that do not overlap the legacy single-Markdown Memory tools: `SubjektivMemoryQuery`, `SubjektivMemoryRead`, -`SubjektivMemoryListRevisions`, `SubjektivMemoryRemember`, and -`SubjektivMemoryProposeRevision`. Runtime-signed Worker source plus current +`SubjektivMemoryListChanges`, `SubjektivMemoryRemember`, and +`SubjektivMemoryProposeChange`. Runtime-signed Worker source plus current `subjektiv:` singleton ownership is re-evaluated for every operation; none of the model-visible inputs contains a subject, Runtime, Worker, Session, origin, or raw provenance object. @@ -179,26 +183,23 @@ origin, or raw provenance object. Query searches only current projections. Omitted `states` means `active`; explicit empty `states` or `kinds` is invalid. Output defaults to 20 and is capped at 100, ordered by `updated_at DESC, memory_id ASC`. Its opaque cursor binds the subject, -canonical filters, offset, and subject `store_revision`; any confirmed-Memory +canonical filters, offset, and subject `memory_fingerprint`; any confirmed-Memory change makes it a typed stale-cursor conflict rather than silently mixing -snapshots. Revision conflicts and stale cursors retain their diagnostic code as a +snapshots. Change-ID conflicts and stale cursors retain their diagnostic code as a structured `{status: "error", error: {code, message}}` tool result instead of -requiring models to parse prose. Revision history is ordered by revision -descending. Its first page -fixes the maximum revision, so later revisions neither duplicate nor displace old -page members. +requiring models to parse prose. Change history follows the immutable `previous_change_id` chain, newest first. Its first page fixes `head_change_id`, so later appended changes neither duplicate nor displace old page members. IDs are not counters and are not ordered numerically. -Read accepts a positive exact revision or resolves the current revision once. A -missing historical revision never falls back to current. Markdown pagination is +Read accepts an exact nonempty `change_id` or resolves the current change once. A +missing historical change never falls back to current. Markdown pagination is line-based (default 200, maximum 1000) with a preliminary 16 KiB UTF-8 body cap and a global 56 KiB budget over the exact pretty-serialized model-visible response. JSON escaping, provenance, and metadata therefore reduce the current body page before the Worker content ceiling is reached. It reports `body_truncated`, `body_next_offset`, and `body_next_byte_offset`; an oversized or escape-heavy single line resumes from the returned UTF-8 byte boundary within -that same line. Every continuation offset must also supply the exact revision -returned by the first page, so a current-revision change cannot mix body versions. -Provenance uses a separate immutable-revision-bound cursor and returns one +that same line. Every continuation offset must also supply the exact `change_id` +returned by the first page, so a current change cannot mix body contents. +Provenance uses a separate immutable-change-bound cursor and returns one candidate/derivation reference per page. New staged candidates contain at most 10 evidence records and 10 source references; read pages project at most two of each, with displayed anchor text capped at 64 UTF-8 bytes and nested offsets carried by @@ -206,10 +207,10 @@ the ordinary evidence cursor. Compatible candidates persisted before the admissi cap remain readable across as many evidence pages as needed, while every current staging path rejects new larger anchor sets. Candidate evidence is the bounded host-resolved anchor saved in staging; raw Session bodies are not copied -into Memory responses. Resolved, retracted, and historical revisions remain +into Memory responses. Resolved, retracted, and historical changes remain addressable by ID. -Remember and ProposeRevision only stage candidates. Entry references are resolved +Remember and ProposeChange only stage candidates. Entry references are resolved against the host's committed Session capture, and preference candidates continue to require exclusively `HumanInput` evidence. When no entry is supplied, a non-preference request returns `pending_commit`; after the run commits, the host @@ -221,7 +222,7 @@ restore, and the fail-closed pre-rewrite hook prevents compaction from dropping unstaged receipt. Hook failure and process restart therefore retain an idempotent retry path, while an operation that never commits is not staged. Receipt identity is derived from Session and tool-call identity, and exact backend retries return the first candidate rather than creating another -one. Neither path changes `memory_records`, `memory_revisions`, `store_revision`, +one. Neither path changes `memory_records`, `memory_changes`, the confirmed-Memory fingerprint, or a surface snapshot. @@ -264,7 +265,7 @@ agree and the archived observation grant remains valid; incomplete, expired, corrupt, unavailable, or changed sources are reported explicitly rather than as an empty successful search. -Revision proposals add optional `revision_proposal` metadata to the existing v2 +Change proposals add optional `change_proposal` metadata to the existing v2 `SubjectStagingRecord` envelope; automatic extraction remains proposal-free and its model schema is unchanged. The metadata is serialized atomically with the candidate and therefore remains present in candidate reads and immutable @@ -274,16 +275,16 @@ resolution copies: { "intent": "revise", "memory_id": "memory-…", - "expected_revision": 3, + "expected_change_id": "change-…", "change_reason": "The committed evidence corrects the prior condition" } ``` This is the T-670 handoff fixture. Consolidation must re-read this typed metadata, -re-check subject ownership, `expected_revision`, and the requested state +re-check subject ownership, `expected_change_id`, and the requested state transition in the same transaction that applies the candidate, and leave a stale proposal unresolved/conflicted. It must not parse target information from the -claim, retarget to the latest revision, or revive a retracted Memory. Valid +claim, retarget to the latest change, or revive a retracted Memory. Valid transitions at staging are revise while active/resolved, active→resolved, active/resolved→retracted, and resolved→active. Retraction remains terminal. @@ -295,27 +296,27 @@ batch grant. The common runner resolves the recipe; Profile names do not grant permissions. The Host derives authority from the authenticated current Job/attempt/Worker binding; model-visible inputs cannot select a Workspace, subject, Runtime, Worker, or Session. Its tools list/read only pending candidates -in that batch, query/read confirmed revisions, and make one candidate decision. +in that batch, query/read confirmed changes, and make one candidate decision. Candidate text is evidence rather than authority. No subject-specific idle Worker is resubmitted; see [Job execution and cutover](subjektiv-consolidation-jobs.md). Every decision carries a stable request ID. An exact retry returns the committed receipt, while reuse with different input is a conflict. Applying a candidate -atomically writes one new confirmed revision, its exact candidate and derivation -edges, the immutable resolution and affected revision references, and advances -the subject store revision. A non-applied decision explicitly records +atomically writes one new confirmed change, its exact candidate and derivation +edges, the immutable resolution and affected change references, and updates +the subject Memory fingerprint. A non-applied decision explicitly records `discarded`, `invalid`, `duplicate`, or `already_covered`; an empty model response, transport failure, or aborted consolidation records no disposition and leaves the candidate pending. A successful applied response is the only evidence that confirmed Memory changed. -A typed revision proposal must be applied to its exact `memory_id` and -`expected_revision`, preserving its intent and change reason. The transaction -rechecks the current revision and state transition. It never rebases or retargets +A typed change proposal must be applied to its exact `memory_id` and +`expected_change_id`, preserving its intent and change reason. The transaction +rechecks the current change ID and state transition. It never rebases or retargets a stale proposal, revives retracted Memory, or extracts a target from claim prose. Correction/refinement of the same experience appends under the same Memory ID; a corrected experience after terminal retraction receives a new Memory ID and may -cite the retracted fixed revision as a derivation. Surface generation is a +cite the retracted fixed change as a derivation. Surface generation is a separate post-consolidation lifecycle: its failure cannot roll back a committed candidate decision. diff --git a/docs/design/subjektiv-surface.md b/docs/design/subjektiv-surface.md index 9a4ecf828..dee59f61f 100644 --- a/docs/design/subjektiv-surface.md +++ b/docs/design/subjektiv-surface.md @@ -1,10 +1,10 @@ # Subject Memory surface generation -The subject Memory surface is a bounded start-time context derived from confirmed, revisioned Memory. It is not another Memory authority and is never used as extraction or consolidation input. +The subject Memory surface is a bounded start-time context derived from confirmed Memory with immutable change history. It is not another Memory authority and is never used as extraction or consolidation input. ## Fixed policy -The Backend captures one generation from current `active` revisions for exactly one subject. Staging records, Session history, historical revisions, and prior surfaces are not queried. +The Backend captures one bounded input snapshot from current `active` records for exactly one subject. Staging records, Session history, historical changes, and prior surfaces are not queried. Selection is deterministic: @@ -16,16 +16,16 @@ Selection is deterministic: 6. both category and aggregate material bounds use the provider-independent estimate `ceil(UTF-8 bytes / 4)`; 7. before invoking the editor, the Worker measures the actual normalized Agen request fields containing the rendered system prompt, conversation items (including the fixed question and material JSON), and the typed `SubmitMemorySurface` tool definition/schema against the 12,000-token complete-input budget. It reapplies equal per-category fairness reservations under that final estimator, reclaims unused shares in canonical order, fails rather than publishing a false empty surface if no grounded material fits, and guards every subsequent correction request before it reaches the provider. Provider-specific HTTP envelope bytes are outside this normalized request budget. -The clean-context editor uses the effective `builtin:subjektiv-memory-consolidation` profile model (`codex-oauth/gpt-5.6-luna`, medium reasoning). A generation permits three model turns (the initial attempt plus at most two correction turns). A store-generation conflict causes at most one complete re-read and regeneration. The published Markdown is capped at 1,024 tokens by the same estimate. +The clean-context editor uses the effective `builtin:subjektiv-memory-consolidation` profile model (`codex-oauth/gpt-5.6-luna`, medium reasoning). A generation permits three model turns (the initial attempt plus at most two correction turns). A Memory-input fingerprint conflict causes at most one complete re-read and regeneration. The published Markdown is capped at 1,024 tokens by the same estimate. -Every editor point carries one or more exact `{memory_id, revision}` references. The Host checks the output shape, non-empty grounding, budget, subject scope, membership in the captured materials, and current store generation. These checks prove reference existence and freshness only; they do not claim semantic correctness. +Every editor point carries one or more exact `{memory_id, change_id}` references. The Host checks the output shape, non-empty grounding, budget, subject scope, membership in the captured materials, and current `memory_fingerprint`. These checks prove reference existence and freshness only; they do not claim semantic correctness. -Publication is idempotent for identical output at one store revision. A different concurrent output cannot replace the first published surface. Confirmed Memory writes atomically mark the prior surface stale. Failed surface work never rolls back confirmed Memory or candidate disposition. +Publication is idempotent for identical output for one exact Memory-input fingerprint. A different concurrent output cannot replace the first published surface. Confirmed Memory writes atomically mark the prior surface stale. Failed surface work never rolls back confirmed Memory or candidate disposition. Resident injection uses five distinct product states: -- `ready`: inject the non-empty snapshot only when its `built_from_store_revision` - equals the subject's exact current store revision; +- `ready`: inject the non-empty snapshot only when its `built_from_memory_fingerprint` + equals the subject's exact current `memory_fingerprint`; - `ready-empty`: a successful current generation selected no Markdown; inject no prose, but preserve the explicit successful-empty state; - `ungenerated`: no successful current generation exists; inject nothing; diff --git a/docs/design/ticket-evidence.md b/docs/design/ticket-evidence.md index 9b9497e5b..122399440 100644 --- a/docs/design/ticket-evidence.md +++ b/docs/design/ticket-evidence.md @@ -12,13 +12,13 @@ A merged MR's review status/timestamps describe **integration approval**, not a ## Current requirements -`merge_request::requirement_approval` evaluates Ticket read-side MR evidence; it is not a Ticket completion gate. It validates an effective Reviewer approval of the current item revision and the **exact complete linked MR/source snapshot**, including the hosting MR's own source and corresponding ReviewRequested event. There is no primary-MR fallback, comment-derived approval, or timestamp-only freshness rule. A post-integration review may attest a new revision/snapshot without replacing MergeResult or its integration approval. +`merge_request::requirement_approval` evaluates Ticket read-side MR evidence; it is not a Ticket completion gate. It validates an effective Reviewer approval of the current item content digest and the **exact complete linked MR/source snapshot**, including the hosting MR's own source and corresponding ReviewRequested event. There is no primary-MR fallback, comment-derived approval, or timestamp-only freshness rule. A post-integration review may attest new item content/a linked-result snapshot without replacing MergeResult or its integration approval. `approved_current_subject` describes valid source/integration approvals. `review_after_rescope` retains its wire name but now describes exact current requirement attestation, not ordering of wall-clock timestamps. `approved_review` query filtering requires that current attestation. `stale_after_rescope` requires substantive item-edit history and a known source-approved snapshot lacking current attestation; unknown observations are only `missing_evidence`. `complete_for_integration` means a nonempty linked MR set has valid source approvals and current requirement evidence, including for partially or fully integrated sets. It is not a claim that open repositories have been integrated, that target integration readiness has been checked, or that Ticket completion is authorized. An MRless Ticket has no applicable MR evidence: `missing` is empty, `review_status` is null, and approval/integration flags remain false. It does not match `missing_evidence` or `approved_review` merely because its state is done. -CompleteTicket belongs to the Ticket domain. It records the authenticated actor's reason/result and optional references using current item revision/state CAS and an operation receipt. It needs no MR or Reviewer approval, and does not create or modify those proofs. State setting, completion, close, and reopen share the same transactional update boundary. Exact replay returns current Ticket authority without reapplying a historical decision or appending an event. Historical MR-based `ticket_completion` records remain readable and distinct from new judgment receipts. +CompleteTicket belongs to the Ticket domain. It records the authenticated actor's reason/result and optional references using current item `content_digest`/state CAS and an operation receipt. It needs no MR or Reviewer approval, and does not create or modify those proofs. State setting, completion, close, and reopen share the same transactional update boundary. Exact replay returns current Ticket authority without reapplying a historical decision or appending an event. Historical MR-based `ticket_completion` records remain readable and distinct from new judgment receipts. Writable targets authorize deliverables, not mandatory MRs for every target. Every linked MR remains part of the exact requirement snapshot; no arbitrary latest-review or single-repository shortcut is used. diff --git a/docs/design/ticket-item-checker.md b/docs/design/ticket-item-checker.md index ebab424ff..e38f3b436 100644 --- a/docs/design/ticket-item-checker.md +++ b/docs/design/ticket-item-checker.md @@ -4,33 +4,33 @@ Worker-originated `TicketCreate` and `TicketEditItem` mutations are checked afte ## Applied paths -The trigger is in the Workspace Server's authenticated Worker Ticket REST path. A complete, verified Runtime/Worker source header pair is required before a check is scheduled. Every successful Worker `Create` and `EditItem` operation schedules the persisted item revision; failed saves and thread/state events do not schedule work. +The trigger is in the Workspace Server's authenticated Worker Ticket REST path. A complete, verified Runtime/Worker source header pair is required before a check is scheduled. Every successful Worker `Create` and `EditItem` operation schedules the persisted item content digest; failed saves and thread/state events do not schedule work. The browser's direct item-edit handler currently does **not** schedule this check because it has no originating Runtime Worker advisory destination. Browser edits must not be presented as checked. Adding that path later requires an explicit notification/audit destination rather than borrowing an unrelated Worker identity. -## Immutable input and revision +## Immutable input and content identity The Job input contains only: - canonical and user-facing Ticket IDs; -- the exact saved item revision; +- the exact saved item `content_digest`; - exact saved title and body; - whether the operation created or edited the item; - changed-field flags and the necessary previous title/body for edited fields. -The revision is the latest `create` or `item_edit` event ID, matching `TicketDetail.item_revision`. Conversation history, user requests, agreements, Worker transcripts, and external facts are not captured. The generic Backend Job input limit is 128 KiB; an oversized exact snapshot is not truncated or misrepresented as checked and produces a server diagnostic after the Ticket remains saved. +The digest is SHA-256 of editable Ticket item content, matching `TicketDetail.content_digest`; it is not a `create`/`item_edit` event ID or an edit counter. Conversation history, user requests, agreements, Worker transcripts, and external facts are not captured. The generic Backend Job input limit is 128 KiB; an oversized exact snapshot is not truncated or misrepresented as checked and produces a server diagnostic after the Ticket remains saved. ## Execution and bounds The trigger reserves durable intent synchronously after save, then dispatches without waiting for model completion. Concurrency pressure never rejects that intent: attempts above the four-Job cap remain durably `reserved`, and terminal attempt transitions plus restart recovery drain queued attempts through an atomic `reserved` → `dispatching` slot claim. It uses T-674's Backend-owned Job runner and an ordinary registered embedded Runtime Worker with `builtin:backend-job`. That profile selects the existing lightweight `codex-oauth/gpt-5.6-luna` model through normal model configuration and authentication. The checker has only `SubmitBackendJobResult`; it has no Ticket mutation, Queue, Worker management, conversation discovery, Web, Memory, or Workdir tools. -Each check is bounded to 4 concurrent Backend Jobs, 45 seconds, one attempt, an 8 KiB result, at most 8 findings, bounded finding fields, and the Backend Job runner's fixed input/instruction/delivery limits. The deterministic Ticket/revision Job ID suppresses duplicate execution. Generic Job and attempt records retain completed results and categorized dispatch, timeout, invalid-output, and other failures. +Each check is bounded to 4 concurrent Backend Jobs, 45 seconds, one attempt, an 8 KiB result, at most 8 findings, bounded finding fields, and the Backend Job runner's fixed input/instruction/delivery limits. The deterministic Job ID hashes the complete checker intent, including the saved content, edit context, instruction and source/notification identity; exact retries reuse a Job, while a different edit or source producing the same Ticket content does not collide with it. Generic Job and attempt records retain completed results and categorized dispatch, timeout, invalid-output, and other failures. ## Result and advisory behavior `internal.ticket_item_checker` defines the text-only categories and exact structured output. Server validation denies unknown fields, rejects duplicate or excessive findings, bounds every string, and requires each quote to be an exact substring of the inspected saved title or body. Invalid output terminalizes the attempt with `invalid_result` rather than treating it as a clean check. -Before advisory delivery, the server reloads the Ticket and compares the current item revision with the immutable checked revision. Findings for the current revision are sent only to the authenticated source Worker over the existing tracked `Notify` path. A notification identifies itself as a post-save checker advisory, includes Ticket/revision, exact quote, reason, and correction/confirmation direction, and explicitly states that it is not a user request, approval gate, workflow blocker, or implementation instruction. +Before advisory delivery, the server reloads the Ticket and compares the current item content digest with the immutable checked digest. Findings for the current content are sent only to the authenticated source Worker over the existing tracked `Notify` path. A notification identifies itself as a post-save checker advisory, includes Ticket/content digest, exact quote, reason, and correction/confirmation direction, and explicitly states that it is not a user request, approval gate, workflow blocker, or implementation instruction. Clean results and stale results send no Worker input. Their durable delivery records are terminalized with distinct suppression categories so restart recovery cannot later emit or repeatedly reclaim them. Notification failures remain separate from the completed checker result. A stopped or missing destination is never recreated or started. diff --git a/docs/design/ticket-wip-projection.md b/docs/design/ticket-wip-projection.md index 6a76194c2..146e1e193 100644 --- a/docs/design/ticket-wip-projection.md +++ b/docs/design/ticket-wip-projection.md @@ -64,13 +64,13 @@ No native operation is inferred for a Tool that the Feature did not register. En Backend 401/403 failures map to permission denial, 404 to missing objects, and deterministic 400/409/422 validation or workflow failures to invalid arguments when the typed operation fails before any successful mutation. Cancellation and interruption remain distinct. Post-mutation canonical-reference lookups are explicitly marked as unknown-outcome stages, so even a later deterministic HTTP status cannot misreport an already-committed write as a rejection. Transport, server, malformed response, and other post-dispatch failures retain unknown outcome, and mutating operations are not retried automatically. -Successful reads observe the authoritative item revision. Successful mutations advance a local observation generation even when the first call uses an internal ID whose canonical alias has not yet been learned. Once a read or mutation reveals the canonical key, the canonical/internal alias groups are merged and invalidated together. Relation mutations also invalidate the reported target Ticket, while queue results invalidate every reported queued Ticket, because those objects' incoming relations or workflow states changed. A stale validator is rejected by the common WIP Host before another Backend dispatch; the caller must rediscover/reinspect and explicitly decide whether to call again. +Successful reads observe the authoritative item `content_digest`. Successful mutations advance a local observation generation even when the first call uses an internal ID whose canonical alias has not yet been learned. Once a read or mutation reveals the canonical key, the canonical/internal alias groups are merged and invalidated together. Relation mutations also invalidate the reported target Ticket, while queue results invalidate every reported queued Ticket, because those objects' incoming relations or workflow states changed. A stale validator is rejected by the common WIP Host before another Backend dispatch; the caller must rediscover/reinspect and explicitly decide whether to call again. ## Preserved Ticket contracts The projection invokes the existing Tool and scoped Backend routes, so the following remain authoritative and are not reimplemented by WIP: -- item revision and audited item/thread/state events; +- item content digest and audited item/thread/state events; - title/body/targets input validation; - planning-to-ready target validation and write-target requirement; - queue dependency traversal, planning-dependency rejection and cycle detection; diff --git a/docs/design/workdir-wip-projection.md b/docs/design/workdir-wip-projection.md index 3bda0bbab..4c6db4ded 100644 --- a/docs/design/workdir-wip-projection.md +++ b/docs/design/workdir-wip-projection.md @@ -121,11 +121,11 @@ current read policy or unsupported legacy source makes a page sparse or empty; `empty` describes that page, and `has_more` remains authoritative for progress. Attachment pages use the Backend's SQL-bounded, alias-ordered limit/offset query and opaque `offset:` cursors. Both Workdir and attachment responses supply an -opaque complete-set `revision` derived from existing authoritative records in +opaque complete-set `digest` derived from existing authoritative records in the same database snapshot as the page, not by hashing the first page. The Host -uses this complete-set revision for the collection Object validator. A lifetime +uses this complete-set digest for the collection Object validator. A lifetime or capability change beyond row 50 therefore invalidates old collection -observations without adding a ledger or revision store. A cursor is not +observations without adding a ledger or edit counter. A cursor is not a snapshot or authority grant: restart listing after concurrent collection changes. Filtering by exact `connection_id` is applied in the caller-scoped Backend ledger before paging, so direct item lookup also works beyond the first diff --git a/docs/design/workspace-config-wip-projection.md b/docs/design/workspace-config-wip-projection.md index a8dfc977c..febeeefd4 100644 --- a/docs/design/workspace-config-wip-projection.md +++ b/docs/design/workspace-config-wip-projection.md @@ -3,9 +3,9 @@ ## Authority and source mapping Workspace configuration is not the Backend host's configuration directory. The -canonical authored source is already a SQLite-backed, revisioned virtual tree: +canonical authored source is already a SQLite-backed, content-addressed virtual tree: `workspace_config_trees`, `workspace_config_entries`, and append-only -`workspace_config_tree_revisions`. `WorkspaceConfigState` combines its +`workspace_config_tree_history`. `WorkspaceConfigState` combines its `ConfigTreeSnapshot`, toolchain/schema contract and projection digest. The `main.dcdl` entrypoint and its imports are authored files; evaluated profiles, prompts and other projections are not replacement source files. @@ -23,12 +23,15 @@ configuration subject; it is not a Bash cwd or a host filesystem grant. ## Existing save and activation contract -The browser source-tree editor uses `ConfigCommitRequest`: a base revision and -tree digest, changes with expected entry digests, and the `main.dcdl` +The browser source-tree editor uses `ConfigCommitRequest`: a `base_digest` of the complete source tree, changes with expected entry digests, and the `main.dcdl` entrypoint. Candidate evaluation applies changes, formats/normalizes Decodal, -and evaluates the composed schema. Commit rechecks revision **and** digest in -an SQLite Immediate transaction before replacing active entries and appending -the revision manifest. Worker operations must share that conflict domain with +and evaluates the composed schema. Commit rechecks both the tree digest and the +base toolchain/projection identity in an SQLite Immediate transaction before +replacing active entries. Source content remains immutable and digest-addressed; +evaluation provenance is retained by source, toolchain and projection identity, +so reevaluating unchanged source does not erase earlier schema/compiler evidence. +A candidate prepared before a schema-only refresh cannot reactivate its old +contract. Worker operations must share that conflict domain with UI and other Worker edits; a per-Worker mutex alone would not prevent lost updates. @@ -36,7 +39,7 @@ The Backend commit orchestration validates the prompt projection and repository-access references, then commits and best-effort publishes the prompt projection to the embedded Runtime. Saving establishes new authoritative Workspace configuration, **not universal live application to existing Workers**. -New Worker configuration bundles are revision-bound. In-flight consumers can +New Worker configuration bundles are digest-bound. In-flight consumers can retain immutable projections. Skill availability and activation are separate contracts. A transport failure after dispatch is not proof of rollback and is not authorization to retry a mutation automatically. @@ -102,7 +105,7 @@ authored writes converge on source-tree commit. Existing runtime consumption of Prompt projections, Skill catalog/activation and resolved config bundles remains a separate read-only/activation contract needed for Worker startup and normal operation. Those evaluated projections are not authored source-tree bodies, -revision history or a second editor. Do not fence all `/config` routes as one +content history or a second editor. Do not fence all `/config` routes as one capability: that would break the existing Prompt reader's runtime contract. The existing connection ledger remains authoritative. Each granted Worker has @@ -152,10 +155,13 @@ Worker operations use identity-bound routes under | Read | POST `read` | Connection + path validator; canonical body/type/digest | | Save | POST `commit` | Connection + root validator + canonical config changes/CAS | -Observe returns root revision/digest and path validators from the same snapshot. +Observe returns root digest and path validators from the same snapshot. Opaque validators bind Workspace, Worker, grant, connection lifetime, logical -path and whole-tree revision/digest. Even replacing a file with identical bytes -in a later revision invalidates older observations. Whole-tree CAS is +path, whole-tree digest and active/applied evaluation contract. Identical source +paths, content types and bytes retain the same digest; saving them again under +the same contract does not create an edit counter or invalidate a content +observation by itself. A schema/toolchain refresh invalidates observations even +when source bytes are unchanged. Whole-tree CAS is intentionally conservative: an unrelated UI edit can require fresh observation. The Client holds validators; the LLM must not manage or fabricate them. @@ -190,7 +196,7 @@ operations, not a generic POSIX filesystem or an OS mount. Content type is `decodal` (default for creation) or `text`. File operation targets come from the resolved WIP route. The root change batch uses canonical -relative source paths; it does **not** accept `expected_digest`, revision, +relative source paths; it does **not** accept `expected_digest`, tree digest, validator, Workspace, grant or connection overrides as model arguments. The adapter captures metadata and fills those preconditions automatically, requiring all paths in a batch to match one observed tree state. Unsupported/invalid @@ -233,7 +239,7 @@ post-save-response cases complement that sequence. Web tests exercise the production generic composer component's declaration completion, selection and typed submission, plus removal/literal-text behavior. Logical-source parsers and launch/sidebar/TUI tests ensure that a settings -attachment is not treated as a repository revision or process cwd. The two +attachment is not treated as a repository commit or process cwd. The two layers are complementary; the Rust fixture uses a recording LLM client and in-process transport, not a live browser-to-provider deployment. No live Backend/Runtime/LLM browser or physical power-loss result is claimed. Bounds diff --git a/docs/design/workspace-drive-web.md b/docs/design/workspace-drive-web.md index 4be980a0e..76eede51c 100644 --- a/docs/design/workspace-drive-web.md +++ b/docs/design/workspace-drive-web.md @@ -1,29 +1,29 @@ # Workspace Web Drive -The Web Drive is a latest-only shared Workspace resource, independent of Git, repositories, Worker sessions and Workdir occupancy. The Server's DB node tree, revision, receipt and current authorization are authoritative. The UI never uses physical blob keys/paths or stores a second persistent hierarchy. +The Web Drive is a latest-only shared Workspace resource, independent of Git, repositories, Worker sessions and Workdir occupancy. The Server's DB node tree, last committed request, receipt and current authorization are authoritative. The UI never uses physical blob keys/paths or stores a second persistent hierarchy. ## References and authorization -`/w//drive/` is a human-facing stable latest URL. Its node ID and revisions remain canonical positive decimal **strings** (including values above JavaScript's safe-integer range). Rename/move retain the URL; deleting and recreating a name does not revive the deleted ID. Copy latest URL emits this Web route, not the API's safe binary download URL. Ticket/Objective/comment Markdown can link to this route. Possessing any URL/ID grants no access: metadata, content, mutations, download and receipt queries all use the current authenticated API. +`/w//drive/` is a human-facing stable latest URL. Its node ID remains a canonical positive decimal **string** (including values above JavaScript's safe-integer range). Rename/move retain the URL; deleting and recreating a name does not revive the deleted ID. Copy latest URL emits this Web route, not the API's safe binary download URL. Ticket/Objective/comment Markdown can link to this route. Possessing any URL/ID grants no access: metadata, content, mutations, download and receipt queries all use the current authenticated API. Ordinary Drive usage retains the existing member-facing API policy, not owner-only settings policy. Worker grants are owner-controlled on the existing Workers page, using its current authoritative Worker catalog. Read-only/read-write are explicit Backend grants, not consequences of enabling a Profile tool. The UI does not invent a Web read-only ACL: Backend denial is displayed and suppresses mutations. Worker read-only denial and revoke are separately exercised against the real API. -## Drafts, revision and delayed operations +## Drafts, observed changes and delayed operations The optional node route reuses one controller across Workspace/entry switches. Drafts and mutation receipts are keyed by Workspace/node identity in this page session. Reads/writes/status/list/search/ancestor/image completion are generation fenced, including A→B→A. No delayed response can select a new target or change its draft/save destination. Leaving the Drive page prompts before destroying a dirty draft or unresolved receipt; they are not claimed to survive a browser reload. -Text edits use the read revision as `expected_revision`. A conflict preserves the draft and that revision, displays an explicit conflict and never silently rebases, automerges or resubmits. Refresh can show the latest saved text alongside the draft. Discard/load-latest is an explicit destructive user decision. Partial previews cannot be saved. Save acknowledgments retain edits made while saving. +Text edits retain the `last_mutation_id` returned with the read and send it as `expected_mutation_id`. A conflict preserves the draft and that original observation, displays an explicit conflict and never silently rebases, automerges or resubmits. Refresh can show the latest saved text alongside the draft. Discard/load-latest is an explicit destructive user decision. Partial previews cannot be saved. Save acknowledgments retain edits made while saving. ## Transfer and media policy - Markdown/plain text: 64 KiB UTF-8 read/edit limit. Markdown uses the existing raw HTML-rejecting safe document renderer. Other active/binary media are download-only. -- Raster image preview: PNG/JPEG/GIF/WebP, authenticated revision-bound fetch, 256 KiB bounded bytes/Blob; decode/load failure is explicit. Object URLs are released on switching/destruction. Larger images remain downloadable. -- Download: native same-origin authenticated binary endpoint, pinned revision; Server's attachment/nosniff/sandbox/no-store headers remain authoritative. +- Raster image preview: PNG/JPEG/GIF/WebP, authenticated committed-request-bound fetch, 256 KiB bounded bytes/Blob; decode/load failure is explicit. Object URLs are released on switching/destruction. Larger images remain downloadable. +- Download: native same-origin authenticated binary endpoint, bound to the observed committed request; Server's attachment/nosniff/sandbox/no-store headers remain authoritative. - Markdown inline images never automatically fetch external/cross-Workspace/API resources. They display alternative text and require explicit navigation to an authorized Drive URL. Same-Workspace resource links navigate normally; external HTTP(S) links have no userinfo, open a new tab with `noreferrer`; script/data/file, protocol-relative, arbitrary API/relative paths are not links. - Upload/replacement: at most 16 MiB raw Blob request, not JSON/base64 or DOM bytes. SHA-256 needs one bounded ArrayBuffer; there is no constant-memory browser hashing claim. Transfer/pending is distinct from the DB-published acknowledgment. - A request ID is allocated before a mutation. Transport/response loss or abort after transmission is conservatively unknown, not success/failure. Its current authorized receipt endpoint is queried; no new request or automatic reupload is generated. `uncommitted` means no commit observed yet, not proof that an in-flight publication cannot still finish. Unknown requests block new writes to that entry. -Name conflict, revision conflict, limit, denied, missing node, storage/I/O failure and outcome unknown are not translated into empty contents/empty Drive/success. Initial folder deletion is empty-only with explicit confirmation; no recursive operation or blob-copy rename is introduced. +Name conflict, changed-content conflict, limit, denied, missing node, storage/I/O failure and outcome unknown are not translated into empty contents/empty Drive/success. Initial folder deletion is empty-only with explicit confirmation; no recursive operation or blob-copy rename is introduced. ## Validation @@ -34,4 +34,4 @@ Browser API fixtures and real authority evidence are intentionally separate: - `web/workspace/test/drive-browser`: production static shell + synthetic API fixtures, pinned Chromium interactions and visual evidence. - `crates/workspace-server/src/server/tests/drive_web_roundtrip_tests.rs` and `web/workspace/test/drive-real-api`: hermetic real Server HTTP, SQLite, FeatureStorage and LocalFileSystem, repositoryless bootstrap and production Web adapter. Only the Runtime process boundary is fake; no live dogfood data is used. -See the test READMEs and T-724 validation record for exact commands/results, artifact paths, source revisions and any deliberate failing fixture state. +See the test READMEs and T-724 validation record for exact commands/results, artifact paths, source commits and any deliberate failing fixture state. diff --git a/docs/design/workspace-drive.md b/docs/design/workspace-drive.md index 50d498afb..c0edb4bc6 100644 --- a/docs/design/workspace-drive.md +++ b/docs/design/workspace-drive.md @@ -3,8 +3,8 @@ ## Scope and ownership Drive is a Workspace-owned **latest-version document tree**, not a mounted or -POSIX-compatible filesystem. `workspace-drive` owns storage, hierarchy, revision -conflicts, mutation receipts, bounded reads/search, and garbage collection. +POSIX-compatible filesystem. `workspace-drive` owns storage, hierarchy, conflicting +changes, mutation receipts, bounded reads/search, and garbage collection. `WorkspaceApi::drive` is the trusted Server adapter. The authorized HTTP API and Backend-managed grants are documented below. Worker Tools/WIP, Web UI, and full end-to-end routing belong to T-723–725. @@ -52,10 +52,11 @@ operations. External edits of the store are outside the Drive operation contract Migration 1 registers `workspace-drive` through FeatureStorage. Each scoped DB contains its Workspace binding, nodes, deletion tombstone, and mutation receipts. SQLite `AUTOINCREMENT` node IDs are stable, scoped to a Workspace and never -reused after deletion. They are opaque application IDs, not inodes. IDs and -revisions serialize as canonical positive **decimal strings**, preserving signed -64-bit precision across JavaScript DTO boundaries. Restore preserves -`sqlite_sequence` as well as IDs and revisions. +reused after deletion. They are opaque application IDs, not inodes. Node IDs serialize as canonical positive **decimal strings**, preserving signed +64-bit precision across JavaScript DTO boundaries. `last_mutation_id` is the +existing request ID of the last committed mutation, not a counter or timestamp. +The immutable root has an empty value. Restore preserves `sqlite_sequence`, +node IDs, request IDs and receipts. The DB is the only tree/latest-reference authority. Each node has one parent; root is the sole parentless directory, has empty name, and cannot be renamed, @@ -68,17 +69,19 @@ Parents must be directories in this scoped Workspace. Self/descendant moves are rejected. Ordinary folder deletion is **empty-only**, never implicitly recursive. Workspace destruction is the separate fenced purge operation. -Every update/move/delete requires `expected_revision`. A stale revision or +Every update/move/delete requires `expected_mutation_id`. The same IMMEDIATE +transaction compares it with the node’s last successful request ID before writing. +A different committed request or same-name/request-identity competitor returns `Conflict`; it never falls back to unconditional writes. Root mutations and malformed requests are `Invalid`; -missing/deleted IDs are `NotFound`. Revision exhaustion fails rather than wrapping. +missing/deleted IDs are `NotFound`. No update counter is allocated or incremented. Actor identity is trusted caller input (the authorization adapter supplies it), and successful mutations record actor and UTC epoch milliseconds. `BEGIN IMMEDIATE` is held for validation, blob operations, metadata mutation and receipt insertion. It serializes the whole Workspace hierarchy and all blob read/upload/GC activity across independent processes. A→B/B→A, parent deletion -versus child creation, and same-revision updates therefore have one valid winner. +versus child creation, and updates based on the same observation therefore have one valid winner. FeatureStorage configures foreign keys, WAL, `synchronous=FULL`, and a 5-second busy timeout. Busy/SQL/I/O failures remain errors; they do not mean an empty Drive. Before opening SQLite or admitting a Feature handle, FeatureStorage creates and @@ -122,7 +125,7 @@ publication is not power-loss durability. The small adapter compensates: 1. Create/validate Host directories; `sync_all` their entries and parents. 2. Use LocalFileSystem to atomically create the immutable blob. 3. `sync_all` the published file, then its containing directory. -4. Only after both succeed, update the DB reference/revision and insert the +4. Only after both succeed, update the DB reference/last committed request and insert the receipt in the **same SQL transaction**. 5. SQLite commit is the sole success/publication point. @@ -188,9 +191,9 @@ page; an empty matching page can still have continuation. Name search is exact substring matching. Optional bounded text search inspects only UTF-8 `text/*` files no larger than 64 KiB, never images/large files. No index/embedding exists. -Each read supplies node ID **and the selected revision**, and is locked to that +Each read supplies node ID **and the observed committed mutation**, and is locked to that blob for the entire chunk. The SQLite lock excludes upload/GC while reading. -After an update the old revision expires: the next chunk returns `Conflict` (or +After an update the previous observation expires: the next chunk returns `Conflict` (or `NotFound` after deletion), not the new generation. No long-lived read lease or full-history retention is promised. Binary ranges are byte-based and exact; text reads require UTF-8 character-aligned offsets/ranges and reject split or @@ -254,7 +257,7 @@ DB-plus-object snapshot is implemented. Use this administrative procedure: scope and Host blob roots. Use `ScopedFeatureStorage::restore` for its snapshot format, or restore the entire stopped deployment consistently. Never restore just `server.db` or create a new empty Drive DB over surviving blobs. -6. Restore blobs from the matching generation; restore internal IDs, revisions, +6. Restore blobs from the matching generation; restore internal IDs, committed request IDs, receipts and `sqlite_sequence` unchanged. Validate references/lengths and sanitized metadata/text/binary reads before reopening admission. Resume GC only after validation. A deletion tombstone must not be cleared on restore. @@ -301,12 +304,13 @@ wins first is ordered first. A mutation committing before revocation is valid; a revoked cached handle cannot commit later. Upload admission also checks access before consuming the body, but that admission is not a lease: publication always rechecks current access. Download checks authorization separately for every -chunk, and stops on revocation, deletion, revision expiry or storage failure. +chunk, and stops on revocation, deletion, a changed committed request or storage failure. References contain **both** Workspace ID and stable decimal node ID. JSON uses `DriveEntryRef`; flat GET/binary queries require `entry_workspace_id` matching the -route Workspace before resolving `id`/`parent_id`. IDs/revisions are canonical -positive decimal strings within signed 64-bit range; file sizes/offsets are +route Workspace before resolving `id`/`parent_id`. Node IDs are canonical +positive decimal strings within signed 64-bit range. Mutation IDs are request +strings (1–128 UTF-8 bytes, no control characters); file sizes/offsets are bounded `u32`. List/search cursors carry Workspace identity and the last node ID; they are continuation hints, not capabilities or snapshots. Returned `latest_url` is a relative authenticated URL bound to Workspace and ID, independent of logical @@ -321,7 +325,7 @@ most one bounded binary file in memory before calling T-721; they are not constant-memory object-store multipart uploads and are never expanded to JSON/base64. Runtime source-proof verification separately buffers a bounded body before granting trusted ingress, as in other signed Server operations. Web -downloads stream **64 KiB** revision-fixed chunks; they do not materialize the +downloads stream **64 KiB** committed-request-bound chunks; they do not materialize the whole file. Generated Rust clients retain their explicit bounded-response policy and return exact `BinaryBody` bytes. Worker JSON-facing adapters can request bounded text or binary chunks through this same contract, but Tools/WIP and @@ -330,12 +334,12 @@ base64 JSON blobs. Downloads validate bare MIME types and always use `Content-Disposition: attachment` with percent-encoded UTF-8 names, `nosniff`, a sandbox/default-none -CSP, `private, no-store`, and a Workspace/node/revision ETag. Arbitrary HTML/SVG +CSP, `private, no-store`, and a SHA-256 ETag over Workspace, node ID and committed request ID. Arbitrary HTML/SVG bytes are storable, not safe app-origin inline previews. Content length is exact; if a later chunk loses authority or expires, the response stream fails rather than returning another generation or a successful truncated file. Even an empty file validates its referenced blob before HTTP success. Conditional 304/range -HTTP semantics are not added; revision-selected chunk reads are explicit. +HTTP semantics are not added; observation-bound chunk reads are explicit. Errors use fixed, path-free typed codes: `denied`, `not_found`, `conflict`, `invalid`, `limit`, `storage_unavailable`, `outcome_unknown`. SQL/commit/task @@ -366,3 +370,22 @@ done The existing BinaryBody contract also supports binary **success responses**, including declared response headers, without changing binary request bytes or JSON/error behavior. It exports inline OpenAPI binary bodies, not JSON byte arrays. + +## Upgrade to receipt-bound mutation checks + +Migration 2 obtains each non-root node’s latest request ID from its existing +committed receipt, transforms historical receipt payloads, and drops the old +update counter. The current-state receipt must be unique; missing or ambiguous +evidence aborts and rolls back the entire migration. Equal timestamps do not +change the result. No content, node identity, request fingerprint or blob is +regenerated. Deleted nodes’ receipts and AUTOINCREMENT history remain intact. + +The frozen migration module retains old SQL/JSON names and proven predecessor +correspondence only to verify existing receipts against their original request +fingerprints. This evidence never authorizes a new mutation. Active node CAS and +HTTP contracts do not accept the old numeric precondition. Exact pre-upgrade +Update/Relocate/Delete replays are accepted only when the complete actor/payload +and mapped predecessor reproduce the original fingerprint. If that evidence +cannot be established, recover the committed result through `request_status`; +changing the actor, payload or precondition and reusing its ID is a conflict, +not permission to execute it again. diff --git a/docs/design/workspace-optional-repository.md b/docs/design/workspace-optional-repository.md index 9d585bfa7..ea8a9e982 100644 --- a/docs/design/workspace-optional-repository.md +++ b/docs/design/workspace-optional-repository.md @@ -26,7 +26,7 @@ conflict. No schema migration is needed for the existing fingerprint/Workspace receipts. A replay is not a historical snapshot. It returns the current Workspace and -config revision. If creation supplied a Repository, it returns the current +config content snapshot. If creation supplied a Repository, it returns the current record **at the originally requested key**. Removal or renaming away from that key produces `repository: null`; registration at that key later returns that current record. Replay never re-registers or resets an asset. If creation omitted diff --git a/docs/development/dogfooding.md b/docs/development/dogfooding.md index f71ad1954..baec4ea0a 100644 --- a/docs/development/dogfooding.md +++ b/docs/development/dogfooding.md @@ -9,7 +9,7 @@ Never use the live dogfood Server or Runtime as the first startup test for a new 1. Build the production entrypoints: `cargo build -p worker-runtime --bin yoi-runtime -p yoi-workspace-server --bin yoi-server`. 2. Run the focused and dependent tests for the changed contracts, followed by workspace-root `cargo check`, `cargo fmt --all -- --check`, and `git diff --check HEAD`. 3. Exercise the provisioning and operational checks in [Workspace ↔ Runtime authentication](server-runtime-auth.md) against isolated Server DB, Runtime data, and ports. The Workspace owner must create the binding, the Runtime operator must install the Workspace issuer bundle, and the challenge proof must become verified. -4. Have an external supervisor or operator restart Server and Runtime at the same generation. A Worker hosted by the target Runtime must never terminate its own Runtime. +4. Have an external supervisor or operator restart Server and Runtime from the same validated source commit/build artifacts. A Worker hosted by the target Runtime must never terminate its own Runtime. 5. Verify post-restart readiness through the Workspace Runtime projection, ping, Worker list/create, protocol subscription, and a Runtime-to-Server source-proof operation before treating the environment as healthy. The former isolated startup shell harness depended on removed Server-global trust commands and is intentionally not a fallback smoke path. New automated startup coverage must provision the same Workspace-scoped binding and challenge authority used by production rather than recreating global trust or seeding private authority directly. @@ -25,7 +25,7 @@ A report is useful when it explains: - what design boundary was missing - what evidence was observed -For a Remote Runtime rollout, also record the source commit, binary generation, Server schema version, Runtime binding revision, Workspace key generation, and typed HTTP/WebSocket outcomes. A successful document response does not outweigh visible UI, console, or API errors. +For a Remote Runtime rollout, also record the source commit, binary artifact digest, Server schema version, Runtime `binding_id`, Workspace `key_id` and `workspace_trust_id`, and typed HTTP/WebSocket outcomes. A successful document response does not outweigh visible UI, console, or API errors. ## Runtime command caveat diff --git a/docs/development/file-operations.md b/docs/development/file-operations.md index f86d1eb24..29adb523e 100644 --- a/docs/development/file-operations.md +++ b/docs/development/file-operations.md @@ -24,12 +24,12 @@ size independently of persistence metadata. retain their existing validator, identity and save boundary in the provider. - Workspace settings grants, access mode and connection lifetime remain in Backend. The settings adapter binds the read to captured entry metadata and generates a - canonical commit using the same tree revision/digest. Decodal validation, + canonical commit using the same tree digest. Decodal validation, formatting and atomic multi-file persistence remain in the canonical Backend commit. Content type, create/delete/rename/apply_changes and attach are settings operations, not ordinary OS file operations. -The core does not perform I/O, authorize anything, or own revision/existence +The core does not perform I/O, authorize anything, or own content/existence mirrors. Providers invoke pure processing inside their existing consistency boundary; it must not be used to turn a checked edit into an unchecked Read–Write sequence. diff --git a/docs/development/server-runtime-auth.md b/docs/development/server-runtime-auth.md index 4dca68e7d..77719d3c2 100644 --- a/docs/development/server-runtime-auth.md +++ b/docs/development/server-runtime-auth.md @@ -6,10 +6,10 @@ Server-global な署名鍵や Runtime 側の trusted-Server catalog は使わな ## Authority - Server DB は Workspace ごとの signing identity と Runtime binding を保持する。 -- Runtime は `trust-workspace` で受理した `WorkspaceIssuerAuthorizationBundle` を保持する。 -- bundle は `workspace_id`、Workspace key id/generation、Workspace public key、Backend URL、許可された Runtime identity を固定する。 +- Runtime は `trust-workspace` で受理した `WorkspacePublicIdentityBundle` を保持する。 +- bundle は `workspace_id`、Workspace `key_id`、Workspace public key/fingerprint、Backend URL を固定する。 - Server → Runtime の各 HTTP / WebSocket request は、対象 Workspace の signing identity で短命な capability token を発行する。 -- Runtime は request method、`path_and_query`、body digest、permission、Workspace、Runtime、key generation、expiry、JTI を検証する。 +- Runtime は request method、`path_and_query`、body digest、permission、Workspace、Runtime、key ID、現在の trust ID、expiry、JTI を検証する。 - Runtime → Server の source proof は Runtime identity で署名し、対象 Workspace と bundle の Backend URL を audience に固定する。 - Server は現在の Workspace Runtime binding、Runtime public key、Backend public URL、request target、body digest、permission、expiry、replay state を検証する。 @@ -17,6 +17,13 @@ Server-global な署名鍵や Runtime 側の trusted-Server catalog は使わな ## Provisioning +The enrollment input includes the exact Runtime-issued `workspace_trust_id` from +`trust-workspace add/show`. Export the current Workspace public bundle first, +accept it on the Runtime, then register the Runtime public bundle, endpoint and +trust ID. Server verification binds that enrollment ID; it must never invent an +ID from the key fingerprint or assume an initial counter value. Re-enrollment +requires registering the newly issued trust ID, even when the key is unchanged. + 1. Runtime identity を初期化する。 ```sh @@ -24,17 +31,17 @@ Server-global な署名鍵や Runtime 側の trusted-Server catalog は使わな yoi-runtime identity show ``` -2. Workspace owner が Settings → Runtimes から Runtime public bundle と endpoint を登録する。 -3. Server が Workspace issuer bundle と challenge を発行する。 -4. operator が bundle を Runtime に追加する。 +2. The Workspace owner exports the current Workspace issuer public bundle from Workspace identity settings. +3. The operator accepts that bundle on the Runtime and reads the issued trust ID. ```sh yoi-runtime trust-workspace add --bundle yoi-runtime trust-workspace show --workspace-id ``` -5. Runtime が challenge proof を生成し、Workspace owner が Server に submit する。 -6. Server が verified binding を commit した後、通常の Workspace-signed request が利用可能になる。 +4. The owner registers the Runtime public bundle, endpoint and exact `workspace_trust_id` in Settings > Runtimes. +5. The Server sends a signed challenge bound to that trust ID, the Workspace key fingerprint and the binding ID, then verifies the Runtime proof and acknowledgement. +6. Normal Workspace-signed requests become available only after the verified binding is committed. Revoking and re-enrolling the same key still requires the newly issued Runtime trust ID. 同じ Runtime identity は異なる Workspace から独立して信頼できる。trust record、replay protection、binding、失効はすべて Workspace scope で評価する。 @@ -48,9 +55,9 @@ Server-global な署名鍵や Runtime 側の trusted-Server catalog は使わな - 最大 8 MiB - 最大 4,096 records - exact Workspace / Runtime identity -- key id/generation と public key fingerprint +- `key_id` と public key fingerprint、opaque `trust_id` - normalized Backend URL -- replace 時の expected current generation +- 同じ `key_id` に異なる鍵materialを割り当てないこと。実際の置換・失効は新しい `trust_id` を発行する - list は `offset` / `limit` 必須で、1 page 最大 100 records ## Local token @@ -59,16 +66,16 @@ Server-global な署名鍵や Runtime 側の trusted-Server catalog は使わな ## Rotation と失効 -Workspace signing key または Runtime key の変更は、現在 binding を置き換える明示的な provisioning 操作として行う。古い generation、古い Runtime key、revoked binding、失効済み token、replayed JTI は即時拒否する。 +Workspace signing key または Runtime key の変更は、現在 binding を置き換える明示的な provisioning 操作として行う。古い key/trust ID、古い Runtime key、revoked binding、失効済み token、replayed JTI は即時拒否する。 -Server の Runtime cache は現在の persisted binding 全体と照合する。endpoint、Runtime public key/fingerprint、binding revision、Workspace key generation の変更を検知した場合、stale client を利用しない。 +Server の Runtime cache は現在の persisted binding 全体と照合する。endpoint、Runtime public key/fingerprint、`binding_id`、Workspace `key_id`、`workspace_trust_id` の変更を検知した場合、stale client を利用しない。 ## 運用確認 Remote Runtime を有効化した後は次を確認する。 1. `yoi-runtime trust-workspace show --workspace-id ` が期待する bundle を表示する。 -2. Workspace Settings の Runtime binding が `verified` で、現在の key id/generation と verification evidence を表示する。 +2. Workspace Settings の Runtime binding が `verified` で、現在の `binding_id`・`workspace_key_id`・`workspace_trust_id` と verification evidence を表示する。 3. Runtime ping、Worker list/create、`worker.protocol` subscription が Workspace-signed token で成功する。 4. wrong Workspace、wrong Runtime、wrong target/body、expired token、revoked/replaced binding、replayed JTI が拒否される。 5. Runtime → Server source proof が configured Backend public URL audience と一致し、spoofed headers だけでは認証されない。 diff --git a/docs/development/subjektiv-product-cutover.md b/docs/development/subjektiv-product-cutover.md index 640f4a740..78c297090 100644 --- a/docs/development/subjektiv-product-cutover.md +++ b/docs/development/subjektiv-product-cutover.md @@ -11,7 +11,7 @@ Two independent Memory systems exist during the transition: | Product path | Persistence owner | Durable content | Status | | --- | --- | --- | --- | | Legacy Workspace Memory | Workspace Server control-plane database | one Workspace document, staging candidates, and staging resolutions | legacy/deprecated; retained only for compatibility and explicit reset | -| Subject Memory | Server-managed `FeatureStorage`, in the Workspace-scoped `subjektiv` Feature database | subjects, immutable candidate and resolution records, confirmed Memory revisions, Session attribution, surface generation records, and immutable surface snapshots | current product path | +| Subject Memory | Server-managed `FeatureStorage`, in the Workspace-scoped `subjektiv` Feature database | subjects, immutable candidate and resolution records, confirmed Memory changes, Session attribution, surface generation records, and immutable surface snapshots | current product path | The shared `memory` crate and Worker Features do not own either database. `FeatureStorage` owns the subjektiv SQLite lifecycle (path, connection, migration, backup/restore, shutdown, and Workspace deletion), while the subjektiv repository owns its schema and domain transactions. The legacy control-plane tables are not imported or dual-written into subjektiv. @@ -25,20 +25,20 @@ Workspace Memory settings remain Workspace-owned configuration. `feature.subjekt - **Session attribution and recall:** only a committed primary-Worker Session with Host-recorded subject attribution enters subject Session discovery. Attribution is immutable and idempotent. Recall reads the retained or committed-archive Session projection under current Workspace, subject, observation, and retention authorization; it does not copy Session bodies into subjektiv, infer attribution for older Sessions, restore a Worker, or turn recalled tool output into `HumanInput`. - **`SubjektivMemoryRemember`:** stages a new candidate; it does not directly create confirmed Memory. Explicit committed evidence is resolved against the Session capture. A non-preference request without an entry is staged only after its tool call commits, using the committed call as model-origin evidence. An uncommitted operation creates no candidate. -- **`SubjektivMemoryProposeRevision`:** stages a candidate with typed target metadata: exact Memory ID, expected revision, intent, and change reason. It does not mutate the target or silently retarget to a newer revision. -- **Candidate consolidation:** a restricted, subject-bound consolidator compares an immutable pending candidate with confirmed Memory and makes one idempotent atomic decision. An applied decision writes the new confirmed revision, exact provenance and affected revision references, and the immutable candidate resolution in one subjektiv transaction. Non-applied dispositions are explicit (`discarded`, `invalid`, `duplicate`, or `already_covered`). A failed or empty model run is not a disposition, and surface generation failure cannot undo an already committed decision. -- **Corrections:** refinement or correction of the same experience appends the next revision under the same Memory ID using an expected-revision check. Stale proposals remain unresolved/conflicted and are never rebased automatically. Retraction is terminal; a later corrected experience receives a new Memory ID and may derive from the old fixed revision. Old revisions, candidates, resolutions, provenance, and surface snapshots remain immutable history. +- **`SubjektivMemoryProposeChange`:** stages a candidate with typed target metadata: exact Memory ID, `expected_change_id`, intent, and change reason. It does not mutate the target or silently retarget to a newer change. +- **Candidate consolidation:** a restricted, subject-bound consolidator compares an immutable pending candidate with confirmed Memory and makes one idempotent atomic decision. An applied decision writes the new confirmed change, exact provenance and affected change references, and the immutable candidate resolution in one subjektiv transaction. Non-applied dispositions are explicit (`discarded`, `invalid`, `duplicate`, or `already_covered`). A failed or empty model run is not a disposition, and surface generation failure cannot undo an already committed decision. +- **Corrections:** refinement or correction of the same experience appends the new immutable change under the same Memory ID using an `expected_change_id` check. Stale proposals remain unresolved/conflicted and are never rebased automatically. Retraction is terminal; a later corrected experience receives a new Memory ID and may derive from the old fixed change. Old changes, candidates, resolutions, provenance, and surface snapshots remain immutable history. Confirmed Memory is useful subject context, not authorization and not exact implementation authority. Tickets, Objectives, repository state, Git history, and append-only Session records remain the exact sources for implementation state. ## Resident surface and history -A subject surface is a bounded rendering of current active confirmed revisions, not another Memory authority. The injection contract is: +A subject surface is a bounded rendering of current active confirmed records, not another Memory authority. The injection contract is: -- **ready:** inject the current non-empty snapshot only when it was built from the subject's exact current store revision; +- **ready:** inject the current non-empty snapshot only when it was built from the subject's exact current `memory_fingerprint`; - **ready-empty:** explicitly record that a successful current generation produced no Markdown and inject no Memory prose; do not conflate this with missing or failed generation; - **ungenerated:** inject nothing and report no current surface; -- **stale:** inject nothing until regeneration publishes a snapshot for the current store revision; +- **stale:** inject nothing until regeneration publishes a snapshot for the current Memory-input fingerprint; - **failed:** inject nothing; retain the failure for diagnosis and retry rather than presenting it as an empty Memory. A new Worker receives the current ready or ready-empty result in its initial durable prompt construction. A restored Worker does not rewrite its persisted prompt or prior Session history. It appends one durable restore-boundary system item that supersedes prior resident summaries with the latest ready surface, explicit ready-empty state, or a no-current-surface tombstone. Later turns in the same restored process do not append the boundary again. This preserves append-only history: no stale summary, failed generation, correction, or cutover operation edits previously committed Session entries or immutable subjektiv records. @@ -47,7 +47,7 @@ A new Worker receives the current ready or ready-empty result in its initial dur The existing legacy tools (`MemoryQuery`, `MemoryReadDocument`, `MemoryUpdateDocument`, and the legacy `MemoryStaging*` operations) and `/api/w/{workspace_id}/memory...` APIs retain their established Workspace-document meanings while they exist. They are legacy/deprecated surfaces, not aliases for subject Memory. Do not silently route an old request to subjektiv, infer a subject from old input, or change an old input field's meaning. -New product work uses the explicit subject/session/confirmed-Memory/revision/source and surface operations. Old clients must be stopped or upgraded before reset; a post-reset legacy read may observe the legacy empty/default state, but it must never expose subjektiv records through the old schema. +New product work uses the explicit subject/session/confirmed-Memory/change-history/source and surface operations. Old clients must be stopped or upgraded before reset; a post-reset legacy read may observe the legacy empty/default state, but it must never expose subjektiv records through the old schema. ## Explicit legacy reset contract @@ -65,11 +65,11 @@ Legacy `AppendAudit` was acknowledgement-only and created no dedicated durable M The reset must retain: -- Workspace Memory settings and their revision; +- Workspace Memory settings content; - Sessions, Session logs, archives, and diagnostics; - Tickets, Objectives, and their audit/history; - Knowledge and Skills; -- the entire Workspace-scoped subjektiv Feature database, including subjects, candidate/resolution history, confirmed revisions, attribution, generation runs, failures, and surface snapshots; +- the entire Workspace-scoped subjektiv Feature database, including subjects, candidate/resolution history, confirmed changes, attribution, generation runs, failures, and surface snapshots; - all other new-product and unrelated Workspace data. The reset is not a subject deletion API, a Workspace deletion API, a settings reset, a retention override, or a data migration. @@ -81,7 +81,7 @@ Perform the cutover in this order: 1. **Stop legacy activity.** Disable legacy `feature.memory` Profiles and automatic extraction/consolidation, stop or drain old Workers, and block Browser/API clients from writing `/memory` routes. Wait for in-flight legacy writes to finish. Do not run legacy and new automatic extraction for the same Worker. 2. **Back up while writers are stopped.** Capture the control-plane database containing legacy Memory and a `FeatureStorage` backup containing subjektiv. Record the Workspace and backup identities and verify the backup artifacts. Feature backup is internally consistent, but there is no cross-database snapshot instant; stopping both writer sets is what makes the pair a usable cutover checkpoint. 3. **Trigger reset explicitly.** An authenticated operator invokes the legacy reset API for the intended Workspace. Do not automate this as a startup migration or infer consent from `feature.subjektiv` enablement. -4. **Verify both sides.** Confirm the legacy document, staging, and resolutions are absent (or return their defined empty/default projection); confirm Workspace Memory settings and unrelated control-plane data remain; reopen/read the subjektiv store and verify the expected subject, confirmed revisions, attribution, and current surface state are unchanged. Verify a subject Worker resolves through the `subjektiv:` singleton and has only the intended new tools. +4. **Verify both sides.** Confirm the legacy document, staging, and resolutions are absent (or return their defined empty/default projection); confirm Workspace Memory settings and unrelated control-plane data remain; reopen/read the subjektiv store and verify the expected subject, confirmed changes, attribution, and current surface state are unchanged. Verify a subject Worker resolves through the `subjektiv:` singleton and has only the intended new tools. 5. **Resume only the new path.** Start subject Workers with `feature.subjektiv`, keep legacy write surfaces disabled, and monitor typed surface/Session diagnostics. Do not treat an ungenerated, stale, or failed surface as ready-empty. ### Failure and recovery diff --git a/docs/development/ui-ux/product-ux.md b/docs/development/ui-ux/product-ux.md index 5342eb19c..ab7b44408 100644 --- a/docs/development/ui-ux/product-ux.md +++ b/docs/development/ui-ux/product-ux.md @@ -60,7 +60,7 @@ Workspaceを選ぶ前のscopeを扱う。 - Subjects - Surface - Committed Memory - - Revisions + - Changes - Sources 5. Workers @@ -108,7 +108,7 @@ Workspace pageは、次のいずれかを基本形とする。 - 同じfieldをresource間で比較する場合、flat listまたはtableを使う。 - 人が読むnameとcanonical keyを最初のcolumnに置く。 - columnは判断に必要な値だけに絞る。 -- provider detail、revision、digestはdetailまたはtechnical disclosureへ移す。 +- provider detail、変更ID、digestはdetailまたはtechnical disclosureへ移す。 ### Resource detail @@ -130,8 +130,8 @@ Workspace pageは、次のいずれかを基本形とする。 - Memoryの入口はWorkspace全体のlegacy `Document` / `Staging`ではなく、明示的に選択したsubjectとする。 - subject detailではcurrent Workerをkeyed-singleton projectionとして表示し、historical Worker/Session attributionをcurrent authorityとして扱わない。 - `Surface`はresident contextのcurrent stateを表示する。`ready`、`ready-empty`、`ungenerated`、`stale`、`failed`を区別し、空表示やrequest failureへまとめない。 -- `Committed Memory`はcurrent confirmed revisionの一覧とdetailを持つ。revision historyはappend-only viewとして分離し、過去revisionをcurrentへ暗黙fallbackしない。 -- `Sources`は候補、resolution、exact Memory revision refs、Host-recorded Session attribution、閲覧可能なSession sourceをたどるviewとする。source IDをpermissionとして扱わず、現在のWorkspace/subject authorizationを毎回適用する。 +- `Committed Memory`はcurrent confirmed Memoryの一覧とdetailを持つ。変更履歴はappend-only viewとして分離し、過去の変更内容をcurrentへ暗黙fallbackしない。 +- `Sources`は候補、resolution、exact `{memory_id, change_id}` refs、Host-recorded Session attribution、閲覧可能なSession sourceをたどるviewとする。source IDをpermissionとして扱わず、現在のWorkspace/subject authorizationを毎回適用する。 - legacy `/memory` の`Document` / `Staging` viewはdeprecated compatibility surfaceとして明示し、新しいsubject viewへ同じinputを別の意味で転送しない。 - Markdown本文とdetail presentationはT-661の共有rendererを再利用する。Memory route独自のrenderer、application shell、Header、Sidebarを再実装せず、既存shellのmain contentとnested navigationを保つ。 diff --git a/docs/development/ui-ux/subject-memory-information.md b/docs/development/ui-ux/subject-memory-information.md index 2a2ff687c..40184fe80 100644 --- a/docs/development/ui-ux/subject-memory-information.md +++ b/docs/development/ui-ux/subject-memory-information.md @@ -1,5 +1,7 @@ # Subject / Memory information audit (T-692) +This document records T-692's UI decisions and validation at the commits cited below. Its counter labels and fixture values describe that historical implementation, not the current API. Current Memory uses opaque `change_id` references, `memory_fingerprint` and `built_from_memory_fingerprint`; see [the current store contract](../../design/subjektiv-store.md). Historical observations and test results below are retained unchanged. + ## Decision and scope The previous Subjects index exposed `Store revision` alongside identity, state and diff --git a/docs/development/ui-ux/visual-review.md b/docs/development/ui-ux/visual-review.md index 25b28646f..8d4fe726f 100644 --- a/docs/development/ui-ux/visual-review.md +++ b/docs/development/ui-ux/visual-review.md @@ -259,7 +259,7 @@ Visual review ``` 画像をTicketやGitへ大量にcommitしない。artifact pathとsource -revisionを結び付け、必要なreviewerが同じ条件を再現できるようにする。認証profile、cookie、token、secret、private +commitを結び付け、必要なreviewerが同じ条件を再現できるようにする。認証profile、cookie、token、secret、private response bodyをartifactやhandoffへ含めない。 ## Toolの位置付け diff --git a/docs/development/wip-integration-validation.md b/docs/development/wip-integration-validation.md index 7b55c86e6..671646df7 100644 --- a/docs/development/wip-integration-validation.md +++ b/docs/development/wip-integration-validation.md @@ -13,7 +13,7 @@ removed when T-713 thread sequence 26 specified 0.2.0. Canonical contract reference: wip-reference `6086f3c5ef10aa1464ce9c824750d7f667217bfd`. This reference documents the contract; -Cargo resolves the release, not that checkout or an unreleased Rust revision. +Cargo resolves the release, not that checkout or an unreleased Rust source commit. ## Executed checks on registry 0.2.0 diff --git a/docs/development/work-items.md b/docs/development/work-items.md index f37538c9a..6e4c4d5df 100644 --- a/docs/development/work-items.md +++ b/docs/development/work-items.md @@ -39,9 +39,9 @@ These are instruction/tool contracts, not a claim that a mechanism proves real a ## Ticket tools and decisions -`QueryTicket` provides bounded discovery/filtering; `ShowTicket` provides authoritative item revision, paged thread, relations, Objectives, and any current MR context. Read the relevant Ticket before implementation, routing, review, state, or conclusion decisions. Check potential duplicates before creation or material rescope. Use `QueryObjective` and `ShowObjective` for broader context without replacing the Ticket read. +`QueryTicket` provides bounded discovery/filtering; `ShowTicket` provides authoritative item content and `content_digest`, paged thread, relations, Objectives, and any current MR context. Read the relevant Ticket before implementation, routing, review, state, or conclusion decisions. Check potential duplicates before creation or material rescope. Use `QueryObjective` and `ShowObjective` for broader context without replacing the Ticket read. -Useful mutation surfaces include `TicketCreate`, `TicketComment`, relation/plan tools, and authorized `TicketWorkflowState`, `CompleteTicket`, and `TicketClose` decisions. Exposure depends on the effective Profile/Feature configuration. Decision tools use current item revision, expected state, a reason/resolution, stable operation key, and optional supporting references. Backend checks actor authority, CAS, replay, and resource boundaries. It does not enforce a universal state sequence, MR/approval gate, or natural-language satisfaction. +Useful mutation surfaces include `TicketCreate`, `TicketComment`, relation/plan tools, and authorized `TicketWorkflowState`, `CompleteTicket`, and `TicketClose` decisions. Exposure depends on the effective Profile/Feature configuration. Decision tools use current `content_digest` as `expected_content_digest`, expected state, a reason/resolution, stable operation key, and optional supporting references. Backend checks actor authority, CAS, replay, and resource boundaries. It does not enforce a universal state sequence, MR/approval gate, or natural-language satisfaction. `CompleteTicket` records completion; `TicketClose` records closure. Neither integrates an MR or expands resource grants. Ticket progress/conclusion can occur without commits, MRs, or approvals when consistent with the request. Conversely, code's default publication/review practices must not be erased by confusing an independent Ticket decision with merge authority. Do not claim an unmerged result was integrated. Reread after conflicts and replay a recorded operation only with the same fingerprint. @@ -51,7 +51,7 @@ Relations remain metadata, not a scheduling mechanism: record actual project dep For code work using the default publication/review recipe, select `builtin:coder` and optionally `builtin:coder-review`. The Flow guides implement, review, fix, and handoff; it is not a Backend Ticket completion requirement and grants no authority. Read current repository state, use coherent commits and normal non-force publication only when authorized, validate the changed contracts, and report changed files, validation, and limitations. No commits/push should be inferred from a request explicitly prohibiting them. -Use one open repository-scoped MR per Ticket/repository. `OpenMergeRequest` creates selectors; once an MR exists, explicitly address its ID with `ShowMergeRequest` and preserve its selectors. Advance only the existing source selector with a normal non-force push. Do not invent an add-revision operation, replacement MR, or fresh integration branch for every fix. Publish the exact committed source and verify provider resolution before requesting review. +Use one open repository-scoped MR per Ticket/repository. `OpenMergeRequest` creates selectors; once an MR exists, explicitly address its ID with `ShowMergeRequest` and preserve its selectors. Advance only the existing source selector with a normal non-force push. Do not invent an operation to append another source candidate, replacement MR, or fresh integration branch for every fix. Publish the exact committed source and verify provider resolution before requesting review. The assigned Coder launches the Reviewer as an actual direct-child `builtin:reviewer` SubWorker, with explicit command grant and appropriate Workdir scope for inspection/validation, plus the structured Ticket/MR handoff. Server authority verifies assignment, Runtime-owned child identity, effective Profile, one-shot attempt, and captured subject. Review capability is injected by the trusted layer, not supplied by the model. The Reviewer makes an independent judgment against intent, acceptance criteria, the complete captured result snapshot, and validation, then records `ReviewMergeRequest`; prose and observation are not approval authority. diff --git a/docs/development/worker-drive.md b/docs/development/worker-drive.md index 3099d73c2..b0dd936c4 100644 --- a/docs/development/worker-drive.md +++ b/docs/development/worker-drive.md @@ -43,13 +43,13 @@ Public entry inputs are **Workspace-bound** references: {"workspace_id":"workspace-id","node_id":"9007199254740993"} ``` -Node IDs and revisions remain canonical positive decimal **strings**, including +Node IDs remain canonical positive decimal **strings**, including values above JavaScript's safe-integer range. Renaming/moving preserves IDs and authenticated latest URLs. Deleting/recreating the same name allocates a new ID. | Tool | Input/use | | --- | --- | -| `DriveMetadata` | `entry`: one live entry and its observed revision | +| `DriveMetadata` | `entry`: one live entry and its last committed request ID | | `DriveList` | optional `parent` (default root), `limit` 1–200, opaque `after` | | `DriveSearch` | `query`, optional `include_text`, `limit` 1–128, opaque `after` | | `DriveRead` | `entry`, optional `max_bytes` 1–65,536; reports `truncated` | @@ -59,7 +59,7 @@ authenticated latest URLs. Deleting/recreating the same name allocates a new ID. | `DriveEdit` | observed `entry`, `old_string`, `new_string`, optional `replace_all` | | `DriveRelocate` | observed `entry`, destination `parent`, new/current `name` | | `DriveDelete` | observed `entry`; folder deletion follows Backend rules | -| `DriveViewImage` | `entry`; bounded revision-fixed image bytes, not a URL | +| `DriveViewImage` | `entry`; bounded committed-request-bound image bytes, not a URL | | `DriveSaveWorkdir` | explicit `target_workdir` alias, relative `path`, destination `parent`, `name`, optional `content_type` (octet-stream by default) | | `DriveRequestStatus` | `request_id` from an uncertain mutation | @@ -76,7 +76,7 @@ all Drive versions. Restored workers observe again before mutating. `DriveEdit` uses the same pure `fs_operation::text` rules as Workdir and Workspace config: nonempty `old_string`, different replacement, exactly one nonoverlapping match unless `replace_all=true`, and bounded resulting text. It fetches a full -<=64 KiB preimage and verifies the **previously observed** revision. It cannot edit +<=64 KiB preimage and verifies the **previously observed** committed request ID. It cannot edit truncated content. The Backend's atomic CAS still decides the first winner. ## Native WIP 0.2.0 @@ -103,7 +103,7 @@ Direct entry paths returned by discovery are: Use the returned path rather than hand-constructing it. A file exposes metadata, read, write/edit, relocate/delete and view_image; a folder exposes metadata, list, create_folder/create_text, save_workdir and relocate/delete where valid. -A native target binds its entry or creation parent: no ID or revision argument is +A native target binds its entry or creation parent: no ID or mutation-precondition argument is needed. Relocate's `parent` is a WIP typed entry path. Each entry is self-scoped; Host captures target, scope object, descriptor, validator and handler together. Client retains and supplies metadata automatically. Do not copy validators or @@ -116,8 +116,8 @@ selected path. No Worker index/embedding or eager body injection is added. ## Conflicts, unknown outcomes and publication -Mutations use a Host-generated request identity, observed node ID and exact -revision. Conflict never causes an adapter to replace the expected revision with +Mutations use a Host-generated request identity, observed node ID and last +committed request ID. Conflict never causes an adapter to replace that observation with latest and retry. Observe explicitly, reconsider the edit and submit a deliberate new operation. Native Client stale handling likewise does not replay dispatch. @@ -144,7 +144,7 @@ Backend's **DB-publication commit**, not merely blob transfer completion. ## Images and historical observations ViewImage reads <=10 MiB PNG/JPEG/GIF/WebP bytes in <=64 KiB Drive chunks at the -metadata revision. Every chunk is authorized and revision-fixed. An intervening +metadata’s last committed request ID. Every chunk is authorized and committed-request-bound. An intervening update expires the read; no mixed-generation image is attached. Media is detected from bytes using the existing image helper, not the filename or a download URL. diff --git a/docs/manifest.toml b/docs/manifest.toml index bc5ac76d3..7c43b97da 100644 --- a/docs/manifest.toml +++ b/docs/manifest.toml @@ -274,7 +274,6 @@ permission = "write" # # Backendがresolved Manifestへbindする。手書き/Profile入力では指定しない。 # # [feature.memory.workspace_settings] # # workspace_id = "workspace-id" -# # settings_revision = 1 # # language = "日本語" # # Query結果/抜粋の上限とconsolidation eligibility/thresholdはoperation/Backend @@ -306,10 +305,9 @@ permission = "write" # # ref = "codex-oauth/gpt-5.6-luna" # # # Backendがresolved Manifestへbindする。Profile・Browser・model入力では指定しない。 -# # language/settings revisionのtrusted snapshotであり、subject identityではない。 +# # Workspace identity/language設定のtrusted snapshotであり、subject identityではない。 # # [feature.subjektiv.workspace_settings] # # workspace_id = "workspace-id" -# # settings_revision = 1 # # language = "日本語" # # Surface selection/generation/injection、candidate consolidation eligibility、recall diff --git a/docs/report/state-contract-cleanup-recovery-checkpoint.md b/docs/report/state-contract-cleanup-recovery-checkpoint.md new file mode 100644 index 000000000..e3efdd146 --- /dev/null +++ b/docs/report/state-contract-cleanup-recovery-checkpoint.md @@ -0,0 +1,109 @@ +# State-contract cleanup completion and recovery record + +The interrupted cleanup has been restored, integrated with `develop` at +`72ce817de4592e8c9703e0725dd705f46cba63aa`, completed, and validated. This record +supersedes the earlier incomplete restart checkpoint. No live Server, Runtime, +or user database was stopped or migrated as part of this work. + +## Recovery and integration + +- Workdir attachment: `yoi-origin`; branch: `hare/develop`. +- Preserved the Workdir delegation fix (`7321d6c7`), asynchronous cleanup fix + (`dc8e586a`), and the T-731/T-732 changes integrated by `72ce817d`. +- Applied stash `dd31c0ecd41d2aca1ef29889a5194fb4499cb149` once after merging + develop. It remains as recovery evidence; do not apply it again over this work. +- Older stash `a386a9e2943d53f331a555a8c255741bc004c419` also remains untouched. +- Direct implementation/review SubWorkers were collected after their committed + sessions and concrete changes/test evidence were inspected. + +## Completed contracts + +State/update ordinals have been replaced by content digests, key fingerprints, +binding/trust/connection IDs, immutable mutation IDs, and observation tokens. +Schema/protocol versions, ordered events, command IDs, and local asynchronous +invalidation fences remain distinct contracts and were not indiscriminately removed. + +- Config source/history preserves separate source/toolchain/projection evaluations. + Cache identities occupy a namespace forbidden to real virtual paths, including + JSON-deserialized paths. Native and generated WASM regressions cover the boundary. +- Job/content migration retains completed results and cleanup/replay evidence. + Proven reserved-only work can fail definitively; uncertain execution is not + silently admitted again. Historical Flow definitions are validated, not recompiled. +- Drive preserves old receipt fingerprints and validates old Update/Relocate/Delete + replays against frozen evidence rather than granting authority from old ordinals. +- SSH migration reseals envelopes under actual mutation receipts. Terminal Workdir + history whose credentials were deleted remains non-authorizing archival evidence. + INSERT/UPDATE guards prevent that archive from acquiring candidate/lease authority. +- Runtime binding verification ties together binding/trust identities and actual + public-key fingerprints. Worker command-ID replay is separate from admission-time + state. Ticket checker intent IDs include the complete immutable request. + +## Migration safety and review corrections + +- Uncertain Worker removals (`executing`/`failed`) block **before any retained + migration runs**, including ordinary Server open and migration CLI entrypoints. + Tests start at schema84, verify unchanged schema/receipt after rejection, then + reconcile with the old contract and upgrade successfully. +- SSH dry-run uses the original database's secret context while validating an + in-memory backup. Actual encrypted-envelope tests prove dry-run non-mutation, + formal migration, new-AAD decryption, and unchanged master-key material. +- Interrupted signing identity provisioning resumes the same reserved key/material. + Frozen old receipt validation precedes operation-key/fingerprint conversion; + activation and migration failures roll back. Completed and create receipts are + also covered. +- The old reservation CHECK constraint is removed through a transactional rebuild + retaining other columns, constraints, indexes, triggers, and incoming/outgoing + foreign keys. Unknown dependencies are not broadly rewritten. +- Fresh/upgraded metadata comparison covers 36 state-contract tables: columns, + defaults, PK/FK structure, index columns, and triggers. It caught and repaired a + missing history DEFAULT and retained-chain reservation lookup index. This is not + a general SQL-equivalence proof of all CHECK expressions or index predicates; + separate behavior/rollback tests cover the relevant authority constraints. +- Independent data and Server reviews found the above defects; follow-up reviews + confirmed their resolution without additional blockers in the reviewed scope. + +## Final validation + +- Root `cargo check`: PASS. +- All 21 changed Rust crates tested together: **3,795 passed**, **3 ignored**, + zero failures, including doctests. The Server contributes 809 library tests and + 11 CLI tests (one library test ignored). +- `server-api --features typescript`: **96 passed**. +- Additional T-731/T-732 integration coverage: yoi, workdir, tools, fs-operation + tests passed separately. +- Web standard suite: **589 passed**; component suite: **315 passed / 33 files**. +- Web type check: zero errors/warnings; production build passed (chunk-size warning). +- Config WASM parity includes 18 passing cases, including literal-cache-key collision + and JSON URI-path refusal. Generated JS/types/WASM were compared with the pinned + wasm-bindgen 0.2.117 toolchain. OpenAPI/API/protocol generated checks passed. +- `cargo fmt --all -- --check` and `git diff --check HEAD`: PASS. Included frozen + Rust migration/test files were formatted explicitly as well. + +Local evidence logs (ignored build artifacts, not part of stash/history): +`target/revision-final-combined-rust-tests.log`, +`target/revision-final-typescript-tests.log`, +`target/revision-final-root-check.log`, +`target/revision-final-web-{tests,component,check,build}.log`, +`target/revision-virtual-path-web-{config-tests,final-check}.log`, +`target/revision-web-final-{artifacts,normalized-artifacts}.log`, and +`target/revision-schema-equivalence.log`. + +## Deployment and separate findings + +Deployment/restart remains user-operated. Take the normal database/key-material +backup before migration. Resolve uncertain old Worker removals with the old Server +before attempting cutover. Existing migrated Runtime bindings become configured; +re-enrollment/verification requires the actual Runtime-issued trust ID from +`trust-workspace add/show`, not an invented value or public-key fingerprint. + +Previously identified Server-only capability revocation and missing enrollment +identity in Runtime-to-Server source proofs predate this cleanup. They are not +claimed fixed here. The separately recorded Runtime HTTP-only feature dependency +issue also remains outside this cleanup. + +Workdir operations intermittently returned HTTP 401 during the work. Failed calls +were not treated as successful edits/tests; subsequent reads and final combined +validation established the recorded result. No authorization or live-process +workaround was used. The default wasm-bindgen CLI had a schema-version mismatch; +a matching CLI was installed only under ignored `target/revision-wasm-tools`, with +no global CLI change or additional change to the restored Cargo.lock. diff --git a/docs/report/workdir-subworker-delegation-recovery-denial.md b/docs/report/workdir-subworker-delegation-recovery-denial.md new file mode 100644 index 000000000..3ac5b60b3 --- /dev/null +++ b/docs/report/workdir-subworker-delegation-recovery-denial.md @@ -0,0 +1,29 @@ +# Recovery session: SubWorker scope delegation is rejected + +## Observed boundary + +During state-contract cleanup after restoring the worktree, `WorkerList` returned no children. The attached External Workdir `yoi-origin` advertised READ/WRITE/COMMAND, and the parent could read files, write files, and execute build commands through that attachment. + +A new `SubWorkerSpawn` requesting only recursive READ of `.` on `yoi-origin` was rejected before the child started: + +```text +Invalid argument: scope parent-owned Workdir tools: Workdir operation denied: Workdir operation was denied +``` + +An empty scope is not supported (`scope must not be empty`), so a context-only child was also unavailable. No child was created, and no delegated work is running. + +The response does not identify which delegation condition failed. Successful direct parent access does not establish delegation authority, so these observations alone do not establish whether the refusal is a policy restriction or a Provider implementation defect. No running process was stopped or reloaded to investigate this. + +## Additional observed tool failure + +`Edit` of `crates/workspace-server/src/store.rs`, after reading it through the same attachment, returned: + +```text +Invalid argument: invalid argument: Workdir operation request is invalid +``` + +The parent could still modify that file using its granted command capability. This is an input-request failure observation, not evidence that `Edit` has the same root cause as the SubWorker denial. + +## Diagnostic need + +Return bounded, non-sensitive reason categories that distinguish direct access from delegation denial, and distinguish invalid tool requests from scope checks. Avoid reporting generic denial as proof that a child is running or that the parent lost its direct attachment authority. diff --git a/docs/standalone.md b/docs/standalone.md index ff36f4fac..524aa08f6 100644 --- a/docs/standalone.md +++ b/docs/standalone.md @@ -41,7 +41,7 @@ On explicit selected-Subject Host startup, reserved Jobs resume automatically. A ## Memory and historical Sessions -The normal body Profile has recall, revision reads, explicit candidate tools, and bounded historical Session exploration. It cannot directly apply Memory. Explicit Remember/Revise without `entry_refs` returns a pending-commit receipt; the Host stages its exact tool-call evidence only after the logical Run is append-only committed. Pre-request retry may defer an open Run, but commit/rewrite failures are not silently treated as success. A candidate remains unconfirmed until the restricted consolidation Job applies or rejects it using the shared CAS/receipt rules. +The normal body Profile has recall, exact Memory change reads, explicit candidate tools, and bounded historical Session exploration. It cannot directly apply Memory. Explicit Remember/Revise without `entry_refs` returns a pending-commit receipt; the Host stages its exact tool-call evidence only after the logical Run is append-only committed. Pre-request retry may defer an open Run, but commit/rewrite failures are not silently treated as success. A candidate remains unconfirmed until the restricted consolidation Job applies or rejects it using the shared CAS/receipt rules. Normal extraction uses the existing committed-capture lifecycle, restricted extraction tools, and append-only `subjektiv.extract.v1` pointer. The selected Profile owns extraction thresholds/model policy. An explicit extraction model override chooses that provider, not an injected dialog transport. Local Memory language defaults to English and is stored in operator-managed `subjektiv/language.json`, never loaded from repository policy files. diff --git a/openapi/server-api.json b/openapi/server-api.json index 4796da2c6..d6791beae 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -301,7 +301,7 @@ "BrowserCloseTicketRequest": { "additionalProperties": false, "properties": { - "expected_item_revision": { + "expected_content_digest": { "type": "string" }, "expected_state": { @@ -316,7 +316,7 @@ }, "required": [ "operation_key", - "expected_item_revision", + "expected_content_digest", "expected_state", "resolution" ], @@ -477,7 +477,7 @@ "null" ] }, - "expected_item_revision": { + "expected_content_digest": { "type": "string" }, "expected_state": { @@ -496,7 +496,7 @@ "required": [ "state", "operation_key", - "expected_item_revision", + "expected_content_digest", "expected_state", "reason" ], @@ -1094,7 +1094,7 @@ "CompleteTicketRequest": { "additionalProperties": false, "properties": { - "expected_item_revision": { + "expected_content_digest": { "type": "string" }, "expected_state": { @@ -1116,7 +1116,7 @@ }, "required": [ "operation_key", - "expected_item_revision", + "expected_content_digest", "expected_state", "reason" ], @@ -1193,12 +1193,6 @@ "base_digest": { "type": "string" }, - "base_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, "changes": { "items": { "$ref": "#/components/schemas/ConfigTreeChange" @@ -1213,7 +1207,6 @@ } }, "required": [ - "base_revision", "base_digest", "changes", "entrypoints" @@ -1433,16 +1426,9 @@ "$ref": "#/components/schemas/ConfigEntry" }, "type": "object" - }, - "revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" } }, "required": [ - "revision", "digest", "entries" ], @@ -1451,12 +1437,9 @@ "ConfirmRepositorySshHostTrustRequest": { "additionalProperties": false, "properties": { - "expected_host_trust_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, + "expected_host_key_fingerprint": { "type": [ - "integer", + "string", "null" ] }, @@ -1539,21 +1522,23 @@ "endpoint": { "type": "string" }, - "expected_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, + "expected_binding_id": { "type": [ - "integer", + "string", "null" ] }, "public_bundle": { "$ref": "#/components/schemas/RuntimePublicIdentityBundle" + }, + "workspace_trust_id": { + "description": "Exact Runtime-issued enrollment identity from `trust-workspace add/show`.\nA key fingerprint alone cannot distinguish revocation and re-enrollment.", + "type": "string" } }, "required": [ "public_bundle", + "workspace_trust_id", "endpoint" ], "type": "object" @@ -1732,11 +1717,8 @@ "DeleteRepositorySshCredentialRequest": { "additionalProperties": false, "properties": { - "expected_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" + "expected_public_key_fingerprint": { + "type": "string" }, "operation_id": { "type": "string" @@ -1744,18 +1726,15 @@ }, "required": [ "operation_id", - "expected_revision" + "expected_public_key_fingerprint" ], "type": "object" }, "DeleteRepositorySshHostTrustRequest": { "additionalProperties": false, "properties": { - "expected_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" + "expected_fingerprint": { + "type": "string" }, "operation_id": { "type": "string" @@ -1763,7 +1742,7 @@ }, "required": [ "operation_id", - "expected_revision" + "expected_fingerprint" ], "type": "object" }, @@ -2013,7 +1992,7 @@ "entry_workspace_id": { "type": "string" }, - "expected_revision": { + "expected_mutation_id": { "default": null, "type": [ "string", @@ -2047,6 +2026,11 @@ "kind": { "$ref": "#/components/schemas/DriveEntryKind" }, + "last_mutation_id": { + "description": "Request ID of the last committed mutation, or empty for the immutable root.", + "maxLength": 128, + "type": "string" + }, "latest_url": { "description": "Stable relative authenticated URL in this same Workspace. No bearer token\nor cross-Workspace link is embedded in this metadata.", "type": "string" @@ -2064,10 +2048,6 @@ } ] }, - "revision": { - "pattern": "^[1-9][0-9]*$", - "type": "string" - }, "size": { "description": "File length in bytes; absent for folders.", "format": "uint32", @@ -2090,7 +2070,7 @@ "entry", "name", "kind", - "revision", + "last_mutation_id", "updated_by", "updated_at", "latest_url" @@ -2380,8 +2360,8 @@ "content_type": { "type": "string" }, - "expected_revision": { - "pattern": "^[1-9][0-9]*$", + "expected_mutation_id": { + "maxLength": 128, "type": "string" }, "id": { @@ -2398,7 +2378,7 @@ "required": [ "operation", "id", - "expected_revision", + "expected_mutation_id", "text", "content_type" ], @@ -2407,8 +2387,8 @@ { "additionalProperties": false, "properties": { - "expected_revision": { - "pattern": "^[1-9][0-9]*$", + "expected_mutation_id": { + "maxLength": 128, "type": "string" }, "id": { @@ -2428,7 +2408,7 @@ "required": [ "operation", "id", - "expected_revision", + "expected_mutation_id", "parent", "name" ], @@ -2437,8 +2417,8 @@ { "additionalProperties": false, "properties": { - "expected_revision": { - "pattern": "^[1-9][0-9]*$", + "expected_mutation_id": { + "maxLength": 128, "type": "string" }, "id": { @@ -2452,7 +2432,7 @@ "required": [ "operation", "id", - "expected_revision" + "expected_mutation_id" ], "type": "object" } @@ -2503,9 +2483,9 @@ "entry_workspace_id": { "type": "string" }, - "expected_revision": { - "description": "Bind a multi-chunk read to the same file revision.", - "pattern": "^[1-9][0-9]*$", + "expected_mutation_id": { + "description": "Require each chunk to match the same committed mutation observed in metadata.", + "maxLength": 128, "type": "string" }, "id": { @@ -2529,7 +2509,7 @@ "required": [ "entry_workspace_id", "id", - "expected_revision", + "expected_mutation_id", "offset" ], "type": "object" @@ -2662,7 +2642,7 @@ }, "DriveUploadQuery": { "additionalProperties": false, - "description": "Flat query metadata accompanies the binary body. The service rejects invalid\ncombinations: create requires parent_id/name and forbids id/expected_revision;\nupdate requires id/expected_revision and forbids parent_id/name. Both targets\nare bound by entry_workspace_id; declared size and SHA256 must match the body.", + "description": "Flat query metadata accompanies the binary body. The service rejects invalid\ncombinations: create requires parent_id/name and forbids id/expected_mutation_id;\nupdate requires id/expected_mutation_id and forbids parent_id/name. Both targets\nare bound by entry_workspace_id; declared size and SHA256 must match the body.", "properties": { "content_type": { "type": "string" @@ -2670,7 +2650,7 @@ "entry_workspace_id": { "type": "string" }, - "expected_revision": { + "expected_mutation_id": { "default": null, "type": [ "string", @@ -2731,12 +2711,6 @@ "catalog_digest": { "type": "string" }, - "config_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, "schema_fingerprint": { "type": "string" }, @@ -2755,7 +2729,6 @@ }, "required": [ "templates", - "config_revision", "schema_fingerprint", "toolchain_fingerprint", "catalog_digest" @@ -2776,18 +2749,16 @@ "null" ] }, - "flow_definition_id": { + "flow_definition_fingerprint": { + "description": "SHA-256 of the exact pinned Flow definition content, when available.\nHistorical origins without captured definition content leave this absent.", "type": [ "string", "null" ] }, - "flow_definition_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, + "flow_definition_id": { "type": [ - "integer", + "string", "null" ] }, @@ -2850,9 +2821,6 @@ "expected_plan_digest": { "type": "string" }, - "expected_plan_revision": { - "type": "string" - }, "workdir_target_ids": { "default": [], "items": { @@ -2869,7 +2837,6 @@ } }, "required": [ - "expected_plan_revision", "expected_plan_digest" ], "type": "object" @@ -3237,12 +3204,6 @@ "path": { "type": "string" }, - "revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, "source_kind": { "$ref": "#/components/schemas/FlowSourceKind" }, @@ -3261,7 +3222,6 @@ "path", "content", "content_digest", - "revision", "created_at", "updated_at" ], @@ -4315,18 +4275,15 @@ "null" ] }, - "flow_definition_id": { + "flow_definition_fingerprint": { "type": [ "string", "null" ] }, - "flow_definition_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, + "flow_definition_id": { "type": [ - "integer", + "string", "null" ] }, @@ -4430,19 +4387,12 @@ "language": { "type": "string" }, - "settings_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, "workspace_id": { "type": "string" } }, "required": [ "workspace_id", - "settings_revision", "language" ], "type": "object" @@ -5236,7 +5186,7 @@ "subject_ref": { "type": "string" }, - "ticket_item_revision": { + "ticket_content_digest": { "type": "string" }, "ticket_merge_request_subjects": { @@ -5251,7 +5201,7 @@ "event_id", "sequence", "subject_ref", - "ticket_item_revision", + "ticket_content_digest", "ticket_merge_request_subjects", "requested_by", "reviewer", @@ -5299,7 +5249,7 @@ "subject_ref": { "type": "string" }, - "ticket_item_revision": { + "ticket_content_digest": { "type": "string" }, "ticket_merge_request_subjects": { @@ -5315,7 +5265,7 @@ "sequence", "request_event_id", "subject_ref", - "ticket_item_revision", + "ticket_content_digest", "ticket_merge_request_subjects", "decision", "body", @@ -5845,9 +5795,6 @@ }, "type": "array" }, - "revision": { - "type": "string" - }, "state": { "type": "string" }, @@ -5866,7 +5813,6 @@ "resource_key", "title", "state", - "revision", "linked_tickets", "linked_ticket_summaries", "resources", @@ -6590,17 +6536,8 @@ }, "ProfileSettingsResponse": { "additionalProperties": false, - "description": "Read-only Profile catalog projected from one active Workspace config revision.", + "description": "Read-only Profile catalog projected from one active Workspace config tree.", "properties": { - "config_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": [ - "integer", - "null" - ] - }, "default_profile": { "type": [ "string", @@ -6625,9 +6562,6 @@ "null" ] }, - "registry_revision": { - "type": "string" - }, "sources": { "items": { "$ref": "#/components/schemas/WorkspaceProfileSourceSummary" @@ -6646,7 +6580,6 @@ }, "required": [ "workspace_id", - "registry_revision", "profiles", "sources", "diagnostics" @@ -6728,13 +6661,10 @@ "PutRepositorySshHostTrustRequest": { "additionalProperties": false, "properties": { - "expected_revision": { + "expected_fingerprint": { "default": null, - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, "type": [ - "integer", + "string", "null" ] }, @@ -6915,11 +6845,8 @@ "RemoveRuntimeRequest": { "additionalProperties": false, "properties": { - "expected_binding_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" + "expected_binding_id": { + "type": "string" }, "operation_id": { "type": "string" @@ -6927,7 +6854,7 @@ }, "required": [ "operation_id", - "expected_binding_revision" + "expected_binding_id" ], "type": "object" }, @@ -6968,12 +6895,6 @@ }, "type": "array" }, - "config_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, "projection_digest": { "type": "string" }, @@ -6983,7 +6904,6 @@ }, "required": [ "workspace_id", - "config_revision", "projection_digest", "bindings" ], @@ -7231,12 +7151,9 @@ }, "type": "array" }, - "expected_host_trust_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, + "expected_host_key_fingerprint": { "type": [ - "integer", + "string", "null" ] }, @@ -7295,12 +7212,6 @@ "credential_id": { "type": "string" }, - "current_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, "name": { "type": "string" }, @@ -7336,7 +7247,6 @@ "name", "public_key_algorithm", "public_key_fingerprint", - "current_revision", "status", "created_at" ], @@ -7369,12 +7279,6 @@ "created_at": { "type": "string" }, - "current_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, "fingerprint": { "type": "string" }, @@ -7418,7 +7322,6 @@ "key_algorithm", "host_key", "fingerprint", - "current_revision", "created_at", "updated_at" ], @@ -7431,12 +7334,6 @@ "created_at": { "type": "string" }, - "current_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, "fingerprint": { "type": "string" }, @@ -7480,7 +7377,6 @@ "key_algorithm", "host_key", "fingerprint", - "current_revision", "created_at", "updated_at" ], @@ -7492,12 +7388,6 @@ "credential_id": { "type": "string" }, - "current_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, "public_key": { "type": "string" }, @@ -7510,7 +7400,6 @@ }, "required": [ "credential_id", - "current_revision", "public_key_algorithm", "public_key_fingerprint", "public_key" @@ -7571,12 +7460,6 @@ }, "source_fingerprint": { "type": "string" - }, - "source_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" } }, "required": [ @@ -7584,7 +7467,6 @@ "kind", "provider", "source", - "source_revision", "source_fingerprint", "observed_status", "record_authority" @@ -7632,12 +7514,6 @@ "flow_id": { "type": "string" }, - "revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, "selector": { "type": "string" }, @@ -7649,7 +7525,6 @@ "selector", "workspace_id", "flow_id", - "revision", "content_digest", "definition" ], @@ -7716,7 +7591,7 @@ "subject_ref": { "type": "string" }, - "ticket_item_revision": { + "ticket_content_digest": { "type": "string" }, "ticket_merge_request_subjects": { @@ -7731,7 +7606,7 @@ "sequence", "request_event_id", "subject_ref", - "ticket_item_revision", + "ticket_content_digest", "ticket_merge_request_subjects", "decision", "body", @@ -7780,26 +7655,20 @@ "RevokeRuntimeTrustKeyRequest": { "additionalProperties": false, "properties": { - "expected_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" + "expected_binding_id": { + "type": "string" } }, "required": [ - "expected_revision" + "expected_binding_id" ], "type": "object" }, "RotateRepositorySshCredentialRequest": { "additionalProperties": false, "properties": { - "expected_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" + "expected_public_key_fingerprint": { + "type": "string" }, "operation_id": { "type": "string" @@ -7817,7 +7686,7 @@ }, "required": [ "operation_id", - "expected_revision", + "expected_public_key_fingerprint", "private_key" ], "type": "object" @@ -7899,9 +7768,6 @@ "generated_at": { "type": "string" }, - "revision": { - "type": "string" - }, "runtime_id": { "type": "string" }, @@ -7925,7 +7791,6 @@ "workspace_id", "runtime_id", "generated_at", - "revision", "digest", "workers", "workdirs", @@ -8010,11 +7875,8 @@ "null" ] }, - "binding_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" + "binding_id": { + "type": "string" }, "checked_at": { "type": "string" @@ -8068,7 +7930,7 @@ "required": [ "workspace_id", "runtime_id", - "binding_revision", + "binding_id", "connection_state", "checked_at", "status", @@ -8130,18 +7992,15 @@ "RuntimeManagementApiError": { "additionalProperties": false, "properties": { - "current_fingerprint": { + "current_binding_id": { "type": [ "string", "null" ] }, - "current_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, + "current_fingerprint": { "type": [ - "integer", + "string", "null" ] }, @@ -8440,6 +8299,9 @@ "at": { "type": "string" }, + "binding_id": { + "type": "string" + }, "new_fingerprint": { "type": [ "string", @@ -8451,18 +8313,12 @@ "string", "null" ] - }, - "revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" } }, "required": [ "action", "actor_account_id", - "revision", + "binding_id", "at" ], "type": "object" @@ -8482,6 +8338,12 @@ "RuntimeTrustKeyState": { "additionalProperties": false, "properties": { + "binding_id": { + "type": [ + "string", + "null" + ] + }, "created_at": { "type": [ "string", @@ -8494,15 +8356,6 @@ "null" ] }, - "revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": [ - "integer", - "null" - ] - }, "revoked_at": { "type": [ "string", @@ -8535,11 +8388,8 @@ "RuntimeVerificationEvidenceSummary": { "additionalProperties": false, "properties": { - "binding_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" + "binding_id": { + "type": "string" }, "last_checked_at": { "type": "string" @@ -8547,12 +8397,6 @@ "last_outcome": { "$ref": "#/components/schemas/RuntimeVerificationOutcome" }, - "runtime_identity_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, "runtime_public_key_fingerprint": { "type": "string" }, @@ -8562,31 +8406,24 @@ "null" ] }, - "workspace_identity_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, "workspace_key_id": { "type": "string" }, - "workspace_trust_generation": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" + "workspace_public_key_fingerprint": { + "type": "string" + }, + "workspace_trust_id": { + "type": "string" } }, "required": [ "last_checked_at", "last_outcome", - "binding_revision", + "binding_id", "workspace_key_id", - "workspace_identity_revision", - "workspace_trust_generation", - "runtime_public_key_fingerprint", - "runtime_identity_revision" + "workspace_public_key_fingerprint", + "workspace_trust_id", + "runtime_public_key_fingerprint" ], "type": "object" }, @@ -9857,18 +9694,11 @@ "SkillProjectionIdentity": { "additionalProperties": false, "properties": { - "config_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, "tree_digest": { "type": "string" } }, "required": [ - "config_revision", "tree_digest" ], "type": "object" @@ -9889,14 +9719,6 @@ "kind": { "$ref": "#/components/schemas/SkillSourceKind" }, - "revision": { - "format": "uint64", - "minimum": 0, - "type": [ - "integer", - "null" - ] - }, "source_digest": { "type": [ "string", @@ -10103,6 +9925,75 @@ ], "type": "object" }, + "SubjektivMemoryChangeItem": { + "additionalProperties": false, + "properties": { + "change_id": { + "type": "string" + }, + "change_reason": { + "type": "string" + }, + "claim": { + "type": "string" + }, + "kind": { + "$ref": "#/components/schemas/CandidateKind" + }, + "state": { + "$ref": "#/components/schemas/SubjektivMemoryState" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "change_id", + "kind", + "state", + "claim", + "change_reason", + "updated_at" + ], + "type": "object" + }, + "SubjektivMemoryChangeRef": { + "additionalProperties": false, + "properties": { + "change_id": { + "type": "string" + }, + "memory_id": { + "type": "string" + } + }, + "required": [ + "memory_id", + "change_id" + ], + "type": "object" + }, + "SubjektivMemoryChangesQuery": { + "additionalProperties": false, + "properties": { + "cursor": { + "type": [ + "string", + "null" + ] + }, + "limit": { + "format": "uint", + "maximum": 100, + "minimum": 1, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, "SubjektivMemoryDetailQuery": { "additionalProperties": false, "properties": { @@ -10115,6 +10006,12 @@ "null" ] }, + "change_id": { + "type": [ + "string", + "null" + ] + }, "evidence_cursor": { "type": [ "string", @@ -10138,15 +10035,6 @@ "integer", "null" ] - }, - "revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 1, - "type": [ - "integer", - "null" - ] } }, "type": "object" @@ -10199,6 +10087,39 @@ ], "type": "object" }, + "SubjektivMemoryListChangesResponse": { + "additionalProperties": false, + "properties": { + "current_change_id": { + "type": "string" + }, + "has_more": { + "type": "boolean" + }, + "items": { + "items": { + "$ref": "#/components/schemas/SubjektivMemoryChangeItem" + }, + "type": "array" + }, + "memory_id": { + "type": "string" + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "memory_id", + "current_change_id", + "items", + "has_more" + ], + "type": "object" + }, "SubjektivMemoryListQuery": { "additionalProperties": false, "description": "Browser GET query for committed subject Memory. Enum lists use a bounded,\ncomma-separated scalar representation so URL-form encoding stays portable.", @@ -10245,45 +10166,12 @@ }, "type": "object" }, - "SubjektivMemoryListRevisionsResponse": { - "additionalProperties": false, - "properties": { - "current_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 1, - "type": "integer" - }, - "has_more": { - "type": "boolean" - }, - "items": { - "items": { - "$ref": "#/components/schemas/SubjektivMemoryRevisionItem" - }, - "type": "array" - }, - "memory_id": { - "type": "string" - }, - "next_cursor": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "memory_id", - "current_revision", - "items", - "has_more" - ], - "type": "object" - }, "SubjektivMemoryQueryItem": { "additionalProperties": false, "properties": { + "change_id": { + "type": "string" + }, "claim": { "type": "string" }, @@ -10296,12 +10184,6 @@ "kind": { "$ref": "#/components/schemas/CandidateKind" }, - "revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 1, - "type": "integer" - }, "state": { "$ref": "#/components/schemas/SubjektivMemoryState" }, @@ -10311,7 +10193,7 @@ }, "required": [ "id", - "revision", + "change_id", "kind", "state", "claim", @@ -10385,6 +10267,9 @@ "body_truncated": { "type": "boolean" }, + "change_id": { + "type": "string" + }, "change_reason": { "type": "string" }, @@ -10394,16 +10279,13 @@ "created_at": { "type": "string" }, - "current_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 1, - "type": "integer" + "current_change_id": { + "type": "string" }, "derived_from": { "description": "Derivation references on this evidence page.", "items": { - "$ref": "#/components/schemas/SubjektivMemoryRevisionRef" + "$ref": "#/components/schemas/SubjektivMemoryChangeRef" }, "type": "array" }, @@ -10422,12 +10304,6 @@ "memory_id": { "type": "string" }, - "revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 1, - "type": "integer" - }, "source_candidate_ids": { "description": "Candidate ids on this evidence page. A compatible historical candidate\nmay repeat across pages while its nested anchors advance through the same\nevidence cursor. Corresponding bounded anchors retain the same order in\n`source_candidates`.", "items": { @@ -10459,8 +10335,8 @@ }, "required": [ "memory_id", - "revision", - "current_revision", + "change_id", + "current_change_id", "kind", "state", "claim", @@ -10479,83 +10355,8 @@ ], "type": "object" }, - "SubjektivMemoryRevisionItem": { - "additionalProperties": false, - "properties": { - "change_reason": { - "type": "string" - }, - "claim": { - "type": "string" - }, - "kind": { - "$ref": "#/components/schemas/CandidateKind" - }, - "revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 1, - "type": "integer" - }, - "state": { - "$ref": "#/components/schemas/SubjektivMemoryState" - }, - "updated_at": { - "type": "string" - } - }, - "required": [ - "revision", - "kind", - "state", - "claim", - "change_reason", - "updated_at" - ], - "type": "object" - }, - "SubjektivMemoryRevisionRef": { - "additionalProperties": false, - "properties": { - "memory_id": { - "type": "string" - }, - "revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 1, - "type": "integer" - } - }, - "required": [ - "memory_id", - "revision" - ], - "type": "object" - }, - "SubjektivMemoryRevisionsQuery": { - "additionalProperties": false, - "properties": { - "cursor": { - "type": [ - "string", - "null" - ] - }, - "limit": { - "format": "uint", - "maximum": 100, - "minimum": 1, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - }, "SubjektivMemoryState": { - "description": "Current lifecycle state of one confirmed subject Memory revision.", + "description": "Current lifecycle state of one confirmed subject Memory change.", "enum": [ "active", "resolved", @@ -10604,11 +10405,8 @@ "body_md": { "type": "string" }, - "built_from_store_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" + "built_from_memory_fingerprint": { + "type": "string" }, "created_at": { "type": "string" @@ -10616,7 +10414,7 @@ "memory_refs": { "default": [], "items": { - "$ref": "#/components/schemas/SubjektivMemoryRevisionRef" + "$ref": "#/components/schemas/SubjektivMemoryChangeRef" }, "type": "array" }, @@ -10627,7 +10425,7 @@ "required": [ "snapshot_id", "body_md", - "built_from_store_revision", + "built_from_memory_fingerprint", "created_at" ], "type": "object" @@ -10638,15 +10436,13 @@ "behavior_md": { "type": "string" }, - "expected_behavior_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" + "expected_behavior_md": { + "description": "Exact previously observed user-managed behavior document.", + "type": "string" } }, "required": [ - "expected_behavior_revision", + "expected_behavior_md", "behavior_md" ], "type": "object" @@ -10726,12 +10522,6 @@ "behavior_md": { "type": "string" }, - "behavior_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, "created_at": { "type": "string" }, @@ -10749,18 +10539,15 @@ "id": { "type": "string" }, + "memory_fingerprint": { + "type": "string" + }, "role": { "type": "string" }, "state": { "$ref": "#/components/schemas/SubjektivSubjectState" }, - "store_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, "updated_at": { "type": "string" } @@ -10769,9 +10556,8 @@ "id", "role", "behavior_md", - "behavior_revision", "state", - "store_revision", + "memory_fingerprint", "created_at", "updated_at" ], @@ -11021,7 +10807,7 @@ "null" ] }, - "expected_item_revision": { + "expected_content_digest": { "type": "string" }, "expected_state": { @@ -11042,7 +10828,7 @@ } }, "required": [ - "expected_item_revision", + "expected_content_digest", "expected_state", "operation_key", "reason" @@ -11118,6 +10904,9 @@ "body_truncated": { "type": "boolean" }, + "content_digest": { + "type": "string" + }, "created_at": { "type": [ "string", @@ -11161,9 +10950,6 @@ }, "type": "array" }, - "item_revision": { - "type": "string" - }, "linked_objectives": { "items": { "$ref": "#/components/schemas/ObjectiveLinkSummary" @@ -11253,7 +11039,7 @@ "title", "state", "priority", - "item_revision", + "content_digest", "targets", "risk_flags", "body", @@ -12284,6 +12070,9 @@ "minimum": 0, "type": "integer" }, + "content_digest": { + "type": "string" + }, "created_at": { "type": [ "string", @@ -12306,9 +12095,6 @@ "id": { "type": "string" }, - "item_revision": { - "type": "string" - }, "linked_objective_ids": { "items": { "type": "string" @@ -12411,7 +12197,7 @@ "title", "state", "priority", - "item_revision", + "content_digest", "workspace_action_priority", "matched_fields", "linked_objective_ids", @@ -12978,7 +12764,7 @@ }, "TicketStateUpdate": { "additionalProperties": false, - "description": "External state mutation with item/state CAS and an immutable operation receipt.\nExact replay returns the current authoritative Ticket without a new event or\nreapplying the recorded state, even after later edits or reopening.\nSetting `state` to Closed also stores `reason` as the resolution; reopening\nchanges Ticket metadata only and never revives released work identities.", + "description": "External state mutation with content/state CAS and an immutable operation receipt.\nExact replay returns the current authoritative Ticket without a new event or\nreapplying the recorded state, even after later edits or reopening.\nSetting `state` to Closed also stores `reason` as the resolution; reopening\nchanges Ticket metadata only and never revives released work identities.", "properties": { "author": { "default": null, @@ -12987,7 +12773,7 @@ "null" ] }, - "expected_item_revision": { + "expected_content_digest": { "type": "string" }, "expected_state": { @@ -13011,7 +12797,7 @@ } }, "required": [ - "expected_item_revision", + "expected_content_digest", "expected_state", "state", "operation_key", @@ -13295,18 +13081,15 @@ "additionalProperties": false, "description": "Compare-and-swap update for Workspace-owned Memory settings.", "properties": { - "expected_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" + "expected_language": { + "type": "string" }, "language": { "type": "string" } }, "required": [ - "expected_revision", + "expected_language", "language" ], "type": "object" @@ -13318,13 +13101,13 @@ "display_name": { "type": "string" }, - "revision": { + "expected_updated_at": { "type": "string" } }, "required": [ "display_name", - "revision" + "expected_updated_at" ], "type": "object" }, @@ -14871,12 +14654,6 @@ "additionalProperties": false, "description": "Response returned after atomically creating a Workspace.\n\nOn replay, Workspace/config and the Repository at the originally requested key are\ncurrent authority, not historical snapshots. Repository is null when creation omitted\nit or that key has since been removed/renamed; replay never recreates an asset.", "properties": { - "config_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, "replayed": { "type": "boolean" }, @@ -14899,7 +14676,6 @@ }, "required": [ "workspace", - "config_revision", "request_fingerprint", "replayed" ], @@ -15026,8 +14802,8 @@ "display_name": { "type": "string" }, - "expected_revision": { - "description": "Opaque persisted Workspace metadata revision used as a CAS fence.", + "expected_workspace_updated_at": { + "description": "Observed Workspace metadata update timestamp used as a CAS fence.", "type": "string" }, "resources": { @@ -15040,7 +14816,7 @@ "required": [ "workspace_id", "display_name", - "expected_revision", + "expected_workspace_updated_at", "can_delete", "resources", "blockers" @@ -15054,7 +14830,7 @@ "confirmation": { "type": "string" }, - "expected_revision": { + "expected_workspace_updated_at": { "type": "string" }, "operation_id": { @@ -15063,7 +14839,7 @@ }, "required": [ "operation_id", - "expected_revision", + "expected_workspace_updated_at", "confirmation" ], "type": "object" @@ -15198,19 +14974,12 @@ "language": { "type": "string" }, - "settings_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, "workspace_id": { "type": "string" } }, "required": [ "workspace_id", - "settings_revision", "language" ], "type": "object" @@ -15250,10 +15019,10 @@ "display_name": { "type": "string" }, - "revision": { + "source": { "type": "string" }, - "source": { + "updated_at": { "type": "string" }, "workspace_id": { @@ -15264,7 +15033,7 @@ "workspace_id", "display_name", "created_at", - "revision", + "updated_at", "source", "diagnostics" ], @@ -15331,9 +15100,6 @@ "provenance": { "$ref": "#/components/schemas/WorkspaceProfileSourceProvenance" }, - "revision": { - "type": "string" - }, "size_bytes": { "format": "uint64", "maximum": 9007199254740991, @@ -15349,7 +15115,6 @@ "content_digest", "provenance", "editable", - "revision", "size_bytes", "diagnostics" ], @@ -15408,17 +15173,11 @@ }, "WorkspacePromptProjection": { "additionalProperties": false, - "description": "Effective Prompt catalog projected from one immutable Workspace config revision.", + "description": "Effective Prompt catalog projected from one immutable Workspace config tree.", "properties": { "catalog": { "$ref": "#/components/schemas/EffectivePromptCatalog" }, - "config_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, "projection_digest": { "type": "string" }, @@ -15437,7 +15196,6 @@ }, "required": [ "workspace_id", - "config_revision", "source_digest", "projection_digest", "schema_fingerprint", @@ -15465,12 +15223,6 @@ "public_key_fingerprint": { "type": "string" }, - "revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, "workspace_id": { "type": "string" } @@ -15481,8 +15233,7 @@ "key_id", "algorithm", "public_key", - "public_key_fingerprint", - "revision" + "public_key_fingerprint" ], "type": "object" }, @@ -15526,12 +15277,6 @@ "source_fingerprint": { "type": "string" }, - "source_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, "updated_at": { "type": "string" }, @@ -15544,7 +15289,6 @@ "repository_key", "kind", "source", - "source_revision", "source_fingerprint", "observed_status", "created_at", @@ -15603,15 +15347,12 @@ "WorkspaceRuntimeBindingSummary": { "additionalProperties": false, "properties": { + "binding_id": { + "type": "string" + }, "connection_state": { "$ref": "#/components/schemas/RuntimeConnectionDisplayState" }, - "revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, "state": { "$ref": "#/components/schemas/WorkspaceRuntimeBindingState" }, @@ -15625,16 +15366,13 @@ } ] }, - "workspace_key_generation": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, + "workspace_key_id": { "type": [ - "integer", + "string", "null" ] }, - "workspace_key_id": { + "workspace_trust_id": { "type": [ "string", "null" @@ -15644,7 +15382,7 @@ "required": [ "state", "connection_state", - "revision" + "binding_id" ], "type": "object" }, @@ -15695,9 +15433,6 @@ "provenance": { "$ref": "#/components/schemas/WorkspaceRuntimeConfigProvenance" }, - "revision": { - "type": "string" - }, "workspace_id": { "type": "string" } @@ -15705,7 +15440,6 @@ "required": [ "id", "digest", - "revision", "workspace_id", "created_at", "provenance" @@ -16012,12 +15746,6 @@ "catalog_digest": { "type": "string" }, - "config_revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, "schema_fingerprint": { "type": "string" }, @@ -16036,7 +15764,6 @@ }, "required": [ "templates", - "config_revision", "schema_fingerprint", "toolchain_fingerprint", "catalog_digest" @@ -16118,15 +15845,6 @@ "minimum": 0, "type": "integer" }, - "generation": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": [ - "integer", - "null" - ] - }, "kind": { "$ref": "#/components/schemas/WorkspaceRuntimeResourceKind" }, @@ -16158,9 +15876,6 @@ "resource_id": { "type": "string" }, - "revision": { - "type": "string" - }, "runtime_id": { "type": [ "string", @@ -16191,7 +15906,6 @@ "operation", "expires_at_unix_seconds", "nonce", - "revision", "max_bytes", "content_type", "redaction", @@ -16250,12 +15964,6 @@ "null" ] }, - "revision": { - "format": "uint64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, "state": { "$ref": "#/components/schemas/WorkspaceSigningIdentityState" }, @@ -16267,7 +15975,6 @@ "workspace_id", "key_id", "algorithm", - "revision", "state", "created_at" ], @@ -16429,7 +16136,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:6dd3e9e7ffe5e653697addc29675545b5fb28ea856b14bafc580355f0453ec12" + "x-yoi-source-digest": "sha256:07cfe53bfdd700cad76d00695a031255ffa76a6062110a05596fa8b64ae372c4" }, "openapi": "3.1.0", "paths": { @@ -20151,9 +19858,9 @@ ] } }, - "/api/w/{workspace_id}/config/source-tree/revisions/{revision}": { + "/api/w/{workspace_id}/config/source-tree/history/{content_digest}": { "get": { - "operationId": "workspace_config_revision", + "operationId": "workspace_config_history", "parameters": [ { "explode": false, @@ -20168,7 +19875,7 @@ { "explode": false, "in": "path", - "name": "revision", + "name": "content_digest", "required": true, "schema": { "$ref": "#/components/schemas/string" @@ -32686,9 +32393,9 @@ ] } }, - "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories/{memory_id}/revisions": { + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories/{memory_id}/changes": { "get": { - "operationId": "subjektiv_memory_revisions", + "operationId": "subjektiv_memory_changes", "parameters": [ { "explode": false, @@ -32726,7 +32433,7 @@ "name": "query", "required": true, "schema": { - "$ref": "#/components/schemas/SubjektivMemoryRevisionsQuery" + "$ref": "#/components/schemas/SubjektivMemoryChangesQuery" }, "style": "form" } @@ -32736,7 +32443,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SubjektivMemoryListRevisionsResponse" + "$ref": "#/components/schemas/SubjektivMemoryListChangesResponse" } } }, diff --git a/resources/flows/coder-review.dcdl b/resources/flows/coder-review.dcdl index b212d11e4..407f912a0 100644 --- a/resources/flows/coder-review.dcdl +++ b/resources/flows/coder-review.dcdl @@ -9,7 +9,7 @@ states = { implement = { - instructions = "This is an optional code recipe for requested Git publication and independent review, not a universal Ticket gate. If the user requests no review or return without conclusion, honor that bounded request and do not treat this recipe as authority to conclude the Ticket. Inspect the assigned Workdir Git state before editing. Reuse a suitable restored `work/-` branch, or create a collision-safe work branch from detached HEAD; never overwrite an existing branch. Use local work-branch switching, `git add`, `git commit`, and publication only within the request and existing grants; this recipe does not override an explicit no-commit/no-push instruction or expand resource authority. Implement the requested Ticket scope, run the narrow and dependent validation required by the changed contracts, and record concrete evidence in coherent commits. After the implementation is committed, validated, and clean, publish only the current Ticket work branch to the Ticket repository remote with a normal non-force push, then verify that the provider resolves the existing Merge Request `selector_from` to exact local `HEAD`. Do not push the target branch, push tags or unrelated refs, force-push, merge, delete branches, or discard pre-existing changes. Open one Merge Request with `OpenMergeRequest`, or use `ShowMergeRequest` when one is already open; keep its original selectors and advance the same source ref. Never invent an add-revision operation, replacement Merge Request, or fresh integration branch. Source movement requires fresh review; target-only movement does not invalidate source approval and remains Orchestrator integration authority. Do not request review from a dirty Workdir or unpublished source ref. A Flow transition is never Ticket completion authority."; + instructions = "This is an optional code recipe for requested Git publication and independent review, not a universal Ticket gate. If the user requests no review or return without conclusion, honor that bounded request and do not treat this recipe as authority to conclude the Ticket. Inspect the assigned Workdir Git state before editing. Reuse a suitable restored `work/-` branch, or create a collision-safe work branch from detached HEAD; never overwrite an existing branch. Use local work-branch switching, `git add`, `git commit`, and publication only within the request and existing grants; this recipe does not override an explicit no-commit/no-push instruction or expand resource authority. Implement the requested Ticket scope, run the narrow and dependent validation required by the changed contracts, and record concrete evidence in coherent commits. After the implementation is committed, validated, and clean, publish only the current Ticket work branch to the Ticket repository remote with a normal non-force push, then verify that the provider resolves the existing Merge Request `selector_from` to exact local `HEAD`. Do not push the target branch, push tags or unrelated refs, force-push, merge, delete branches, or discard pre-existing changes. Open one Merge Request with `OpenMergeRequest`, or use `ShowMergeRequest` when one is already open; keep its original selectors and advance the same source ref. Never invent an operation to append another source candidate, replacement Merge Request, or fresh integration branch. Source movement requires fresh review; target-only movement does not invalidate source approval and remains Orchestrator integration authority. Do not request review from a dirty Workdir or unpublished source ref. A Flow transition is never Ticket completion authority."; transitions = { review = { target = "review"; @@ -19,11 +19,11 @@ }; review = { - instructions = "Use the explicitly addressed current Merge Request as review authority. Call `ShowMergeRequest` with its ID and confirm its source selector resolves to exact committed implementation HEAD, then spawn one actual direct-child SubWorker with profile builtin:reviewer, write scope plus an explicit command grant for Workdir inspection and command validation, and both the Ticket id and selected Merge Request id in the structured review handoff. The trusted spawn layer records `ReviewRequested` with the exact source ref and current Ticket item revision and injects review capability; do not place commit/ref identity, capability material, or a prewritten verdict in model input. The child must commit `ReviewMergeRequest`; prose output and Worker observation are not approval authority. After the structured result for the exact current source ref exists, request a Flow transition."; + instructions = "Use the explicitly addressed current Merge Request as review authority. Call `ShowMergeRequest` with its ID and confirm its source selector resolves to exact committed implementation HEAD, then spawn one actual direct-child SubWorker with profile builtin:reviewer, write scope plus an explicit command grant for Workdir inspection and command validation, and both the Ticket id and selected Merge Request id in the structured review handoff. The trusted spawn layer records `ReviewRequested` with the exact source ref and current Ticket `content_digest` (SHA-256 of editable item content) and injects review capability; do not place commit/ref identity, capability material, or a prewritten verdict in model input. The child must commit `ReviewMergeRequest`; prose output and Worker observation are not approval authority. After the structured result for the exact current source ref exists, request a Flow transition."; transitions = { approved = { target = "complete"; - condition = "The authoritative Merge Request has a structured approve result for its exact current source ref from its registered direct-child builtin:reviewer attempt, with no later unresolved request_changes finding. The Flow transition itself does not complete the Ticket."; + condition = "The authoritative Merge Request has a structured approve result for its exact current source ref, current Ticket content digest, and complete linked MR/source-result snapshot from its registered direct-child builtin:reviewer attempt, with no later unresolved request_changes finding. The Flow transition itself does not complete the Ticket."; }; changes_requested = { target = "fix"; @@ -33,7 +33,7 @@ }; fix = { - instructions = "Resolve every open Reviewer finding on the same Ticket work branch and existing Merge Request source selector, rerun validation affected by the fixes, and commit the corrected implementation. Publish only that updated source ref with a normal non-force push and verify that the provider resolves it to exact new HEAD. Source movement invalidates the prior verdict, so request a fresh Reviewer child after publication. Do not open a replacement Merge Request, invent an add-revision operation, create a fresh integration branch, rewrite previously reviewed commits, reuse the prior request_changes result as approval, push the target branch, push tags or unrelated refs, force-push, merge, delete branches, or discard pre-existing changes. Request a Flow transition only after the corrected source ref is published and ready for fresh independent review."; + instructions = "Resolve every open Reviewer finding on the same Ticket work branch and existing Merge Request source selector, rerun validation affected by the fixes, and commit the corrected implementation. Publish only that updated source ref with a normal non-force push and verify that the provider resolves it to exact new HEAD. Source movement invalidates the prior verdict, so request a fresh Reviewer child after publication. Do not open a replacement Merge Request, invent an operation to append another source candidate, create a fresh integration branch, rewrite previously reviewed commits, reuse the prior request_changes result as approval, push the target branch, push tags or unrelated refs, force-push, merge, delete branches, or discard pre-existing changes. Request a Flow transition only after the corrected source ref is published and ready for fresh independent review."; transitions = { review = { target = "review"; @@ -43,7 +43,7 @@ }; complete = { - instructions = "Call `ShowMergeRequest` and verify that authoritative approval still matches the exact current source ref, keep that reviewed source ref immutable, leave concise implementation and validation evidence on the Ticket when useful, then hand off to the Orchestrator. Target-only movement does not invalidate this source approval; the Orchestrator refreshes readiness/integration evidence against the current target. Do not call `CompleteMergeRequest`, update the target selector, create an integration branch, or treat the Flow terminal state as Ticket completion authority. After durable handoff evidence exists, request a Flow transition."; + instructions = "Call `ShowMergeRequest` and verify that authoritative approval still matches the exact current source ref, Ticket content digest, and complete linked result snapshot, keep that reviewed source ref immutable, leave concise implementation and validation evidence on the Ticket when useful, then hand off to the Orchestrator. Target-only movement does not invalidate this source approval; the Orchestrator refreshes readiness/integration evidence against the current target. Do not call `CompleteMergeRequest`, update the target selector, create an integration branch, or treat the Flow terminal state as Ticket completion authority. After durable handoff evidence exists, request a Flow transition."; transitions = { completed = { target = "done"; diff --git a/resources/plugin/templates/rust-component-instance/Cargo.toml b/resources/plugin/templates/rust-component-instance/Cargo.toml index 2ae7cfa07..2ba3b8fc4 100644 --- a/resources/plugin/templates/rust-component-instance/Cargo.toml +++ b/resources/plugin/templates/rust-component-instance/Cargo.toml @@ -18,5 +18,5 @@ serde_json = "1.0" yoi-plugin-pdk = { path = "../../../../crates/plugin-pdk" } # Out-of-tree Plugin packages should replace the local path with a pinned -# Yoi source revision. Use rev, not branch, for reproducible builds: +# Yoi source commit. Use rev, not branch, for reproducible builds: # yoi-plugin-pdk = { git = "https://gitea.hareworks.net/Hare/yoi.git", package = "yoi-plugin-pdk", rev = "" } diff --git a/resources/plugin/templates/rust-component-tool/Cargo.toml b/resources/plugin/templates/rust-component-tool/Cargo.toml index 96e2d9077..43d4de7d1 100644 --- a/resources/plugin/templates/rust-component-tool/Cargo.toml +++ b/resources/plugin/templates/rust-component-tool/Cargo.toml @@ -17,5 +17,5 @@ serde = { version = "1.0", features = ["derive"] } yoi-plugin-pdk = { path = "../../../../crates/plugin-pdk" } # Out-of-tree Plugin packages should replace the local path with a pinned -# Yoi source revision. Use rev, not branch, for reproducible builds: +# Yoi source commit. Use rev, not branch, for reproducible builds: # yoi-plugin-pdk = { git = "https://gitea.hareworks.net/Hare/yoi.git", package = "yoi-plugin-pdk", rev = "" } diff --git a/resources/plugin/templates/rust-component-tool/README.md b/resources/plugin/templates/rust-component-tool/README.md index c548bda10..2dcf25d5d 100644 --- a/resources/plugin/templates/rust-component-tool/README.md +++ b/resources/plugin/templates/rust-component-tool/README.md @@ -20,7 +20,7 @@ Inside the Yoi checkout this template uses a local path dependency and declares yoi-plugin-pdk = { path = "../../../../crates/plugin-pdk" } ``` -If this template is copied to an independent Plugin repository, pin a Yoi source revision with `rev` instead of tracking a branch. Use the repository root `.git` URL, not the browser `/src/branch/...` URL: +If this template is copied to an independent Plugin repository, pin a Yoi source commit with `rev` instead of tracking a branch. Use the repository root `.git` URL, not the browser `/src/branch/...` URL: ```toml yoi-plugin-pdk = { git = "https://gitea.hareworks.net/Hare/yoi.git", package = "yoi-plugin-pdk", rev = "" } diff --git a/resources/prompts/common/drive.md b/resources/prompts/common/drive.md index c845f5ca4..2b3f58cf7 100644 --- a/resources/prompts/common/drive.md +++ b/resources/prompts/common/drive.md @@ -2,6 +2,6 @@ Drive is Backend-owned workspace storage, not a Workdir or a host filesystem path. Feature/Profile activation is not a grant; every operation is checked by the Backend. Use the exposed Drive tools or the native WIP `/drive` entry with bounded listing and reading. Do not enumerate a Drive tree or infer authority from a visible reference. -Observe an entry before replacing or deleting it; the host/client carries its revision precondition. On conflict, reread and reconcile rather than overwriting blindly. On `OutcomeUnknown`, retain the request ID and query request status before another mutation: the operation may have committed. Save a Workdir file into Drive only through a current scoped attachment using its logical relative source path; saving does not attach a Workdir or grant file access. +Observe an entry before replacing or deleting it; the host/client retains the last committed request observed for that entry and checks it again when writing. On conflict, reread and reconcile rather than overwriting blindly. On `OutcomeUnknown`, retain the request ID and query request status before another mutation: the operation may have committed. Save a Workdir file into Drive only through a current scoped attachment using its logical relative source path; saving does not attach a Workdir or grant file access. Internal SubWorkers currently have no explicit Backend Drive grant mechanism. They must not inherit Drive access through a parent's shared WorkspaceClient, even with an enabled or inherited Profile. Ask the authorized parent to perform the operation instead. diff --git a/resources/prompts/common/merge-request.md b/resources/prompts/common/merge-request.md index 308098938..3ed7839ec 100644 --- a/resources/prompts/common/merge-request.md +++ b/resources/prompts/common/merge-request.md @@ -2,10 +2,10 @@ This guidance applies when the requested work uses a Merge Request; it is not a universal Ticket pipeline or conclusion gate. The Merge Request and its append-only thread are the routine authority for review requests, verdicts, fixes, rereview, readiness, and integration evidence. One Ticket may link multiple repository-scoped Merge Requests. After `OpenMergeRequest` returns an ID, every read, review, readiness, and integration operation must explicitly address that `merge_request_id`; never let a Ticket lookup implicitly choose one. Use the operation-specific tools exposed to your role: `OpenMergeRequest`, `ShowMergeRequest`, `ReviewMergeRequest`, `CheckMergeRequestReadiness`, and `CompleteMergeRequest`. -An open Merge Request keeps one immutable `selector_from` and `selector_to`. Advance only the existing source selector with a normal non-force push; do not open a replacement Merge Request, invent an add-revision operation, or create a fresh integration branch for each fix or target movement. Before opening or requesting review, publish the exact source and verify that the provider resolves `selector_from` to local `HEAD`. +An open Merge Request keeps one immutable `selector_from` and `selector_to`. Advance only the existing source selector with a normal non-force push; do not open a replacement Merge Request, invent an operation to append another source candidate, or create a fresh integration branch for each fix or target movement. Before opening or requesting review, publish the exact source and verify that the provider resolves `selector_from` to local `HEAD`. -A review verdict is valid only for the exact provider-resolved source ref or immutable merged-result subject, selected Merge Request, complete linked MR/source-result snapshot, and current Ticket item revision captured by `ReviewRequested`. Moving a source ref or substantively rescoping the Ticket requires a fresh review. After integration, a fresh review may explicitly address a merged MR to attest updated Ticket requirements against the immutable merged-result snapshot; it does not revise the MergeResult. Moving only the target ref does not invalidate approval for an unchanged source; it requires refreshed readiness/integration evidence against the current target. Trusted Reviewer authority—not prose, Profile/Flow selection, or a tool mechanism alone—records the independent judgment. Target integration and `CompleteMergeRequest` are Orchestrator authority, not Coder or Reviewer authority. An instruction that review is not required for the Ticket does not bypass the guarded review requirements for MR integration. +A review verdict is valid only for the exact provider-resolved source ref or immutable merged-result subject, selected Merge Request, complete linked MR/source-result snapshot, and current Ticket `content_digest` (SHA-256 of editable item content) captured by `ReviewRequested`. Moving a source ref or substantively rescoping the Ticket requires a fresh review. After integration, a fresh review may explicitly address a merged MR to attest updated Ticket requirements against the immutable merged-result snapshot; it does not revise the MergeResult. Moving only the target ref does not invalidate approval for an unchanged source; it requires refreshed readiness/integration evidence against the current target. Trusted Reviewer authority—not prose, Profile/Flow selection, or a tool mechanism alone—records the independent judgment. Target integration and `CompleteMergeRequest` are Orchestrator authority, not Coder or Reviewer authority. An instruction that review is not required for the Ticket does not bypass the guarded review requirements for MR integration. `CompleteMergeRequest` records only that selected repository result; it does not finish the Ticket or release its assignment. `CompleteTicket` is an independent authorized decision with a reason and current item/state evidence, not an MR-set/approval gate. Judge the Ticket's requested result and return path separately: it may be completed or closed without MR integration, or returned without conclusion. Do not claim an unmerged result was integrated. -Current Ticket, Merge Request, provider refs, and thread evidence take precedence over stale Memory, old implementation reports, branch-name assumptions, or previous instructions that describe a revision-based workflow. Reread the Ticket and `ShowMergeRequest` before decisions. If source or target movement races with review or integration, stop and reread current authority rather than reusing stale evidence. +Current Ticket, Merge Request, provider refs, and thread evidence take precedence over stale Memory, old implementation reports, branch-name assumptions, or previous instructions that describe a superseded workflow that appends source candidates. Reread the Ticket and `ShowMergeRequest` before decisions. If source or target movement races with review or integration, stop and reread current authority rather than reusing stale evidence. diff --git a/resources/prompts/common/tickets.md b/resources/prompts/common/tickets.md index 8e44c005d..fd2f9663b 100644 --- a/resources/prompts/common/tickets.md +++ b/resources/prompts/common/tickets.md @@ -1,10 +1,10 @@ ## Ticket workflow -Use the available typed Ticket tools as the authority for Ticket reads and mutations. Use `QueryTicket` for bounded discovery and filtering, then `ShowTicket` for the authoritative item revision, thread/evidence, relations, linked Objectives, and any current Merge Request context before routing, review, or conclusion decisions. Do not invoke a Ticket CLI or edit backend storage directly as an alternative implementation of those tools. +Use the available typed Ticket tools as the authority for Ticket reads and mutations. Use `QueryTicket` for bounded discovery and filtering, then `ShowTicket` for the authoritative item content and `content_digest`, thread/evidence, relations, linked Objectives, and any current Merge Request context before routing, review, or conclusion decisions. Do not invoke a Ticket CLI or edit backend storage directly as an alternative implementation of those tools. Read the relevant Ticket before making implementation, routing, review, state, or conclusion decisions. Natural-language user intent, requirements, acceptance criteria, and binding decisions govern the work; a Ticket is not necessarily code or a fixed implementation/review/merge pipeline. Do not infer the current contract from an id, title, notification, or remembered summary alone. Check related or potentially duplicate Tickets when creating or materially rescoping work. -Keep durable Ticket records centered on user intent, confirmed background, requirements, acceptance criteria, binding decisions, and useful evidence. Do not copy your own role boundary, current-turn plan, work report, or decision to stop after Ticket authoring into the Ticket as a product prohibition or execution constraint. Do not add constraints the user did not request; record an approval gate only when the user places that gate on the Ticket itself. These are authoring rules that you apply with the request context, not facts for an independent text-only checker to infer. Separate confirmed facts from user claims, hypotheses, and open questions. Avoid prematurely turning implementation tactics into requirements. Use `QueryObjective` for bounded Objective discovery and `ShowObjective` for authoritative revision and linked-Ticket context when coordinating broader work. +Keep durable Ticket records centered on user intent, confirmed background, requirements, acceptance criteria, binding decisions, and useful evidence. Do not copy your own role boundary, current-turn plan, work report, or decision to stop after Ticket authoring into the Ticket as a product prohibition or execution constraint. Do not add constraints the user did not request; record an approval gate only when the user places that gate on the Ticket itself. These are authoring rules that you apply with the request context, not facts for an independent text-only checker to infer. Separate confirmed facts from user claims, hypotheses, and open questions. Avoid prematurely turning implementation tactics into requirements. Use `QueryObjective` for bounded Objective discovery and `ShowObjective` for authoritative content and linked-Ticket context when coordinating broader work. Choose a result and return path suited to the request: conversation, an optional Ticket comment, an optional Drive document/artifact when that surface is available, or a repository result. Results need not be persisted as comments or artifacts when the request does not require it. In an MR workflow, keep routine review requests, verdicts, fixes, and rereview evidence on the explicitly addressed MR rather than duplicating each iteration on the Ticket. Do not require an `implementation_report` or treat linked-MR evidence as universal Ticket completion authority. diff --git a/resources/prompts/internal/subjektiv_memory_consolidation_system.md b/resources/prompts/internal/subjektiv_memory_consolidation_system.md index 2ca32f404..adf40f159 100644 --- a/resources/prompts/internal/subjektiv_memory_consolidation_system.md +++ b/resources/prompts/internal/subjektiv_memory_consolidation_system.md @@ -7,25 +7,25 @@ Your job is to consume that subject's pending Memory candidates through tools. T Use this loop: 1. Call `MemoryStagingList` to inspect pending candidates. -2. Pick one candidate and call `MemoryStagingRead` for the full immutable record, including any typed `revision_proposal`. -3. Use `SubjektivMemoryQuery`, `SubjektivMemoryRead`, and when useful `SubjektivMemoryListRevisions` to compare it with confirmed Memory. -4. Decide whether to create a Memory, revise/refine one exact current revision, resolve/retract/reopen according to typed proposal metadata, or reject the candidate. +2. Pick one candidate and call `MemoryStagingRead` for the full immutable record, including any typed `change_proposal`. +3. Use `SubjektivMemoryQuery`, `SubjektivMemoryRead`, and when useful `SubjektivMemoryListChanges` to compare it with confirmed Memory. +4. Decide whether to create a Memory, revise/refine the exact current Memory change identified by `change_id`, resolve/retract/reopen according to typed proposal metadata, or reject the candidate. 5. Call `MemoryApplyCandidate` exactly once for every decision. Supply a stable `request_id`; retry the exact same request after an ambiguous transport failure. -`MemoryApplyCandidate` atomically writes any new Memory revision, records concrete affected revision references, preserves the original candidate and provenance, and closes the pending candidate. A successful applied response is the only evidence that confirmed Memory changed. +`MemoryApplyCandidate` atomically writes any new immutable Memory change, records concrete affected change references, preserves the original candidate and provenance, and closes the pending candidate. A successful applied response is the only evidence that confirmed Memory changed. Rules: - A candidate is evidence, not authority. Do not promote derived/model claims to observations without warranted provenance. -- For a candidate with `revision_proposal`, use its `memory_id`, `expected_revision`, `intent`, and `change_reason`; do not infer a different target from claim prose. +- For a candidate with `change_proposal`, use its `memory_id`, `expected_change_id`, `intent`, and `change_reason`; do not infer a different target from claim prose. - Proposal application must preserve `change_reason` exactly and use the state implied by `intent`: `resolve` -> `resolved`, `retract` -> `retracted`, `reopen` -> `active`, and `revise` keeps the current state. -- Never rebase a stale proposal. On `revision_conflict` or another typed conflict, leave it pending for later reread/reconciliation. -- Never revive a `retracted` Memory. New experience after retraction becomes a new Memory with an exact `derived_from` revision when justified. +- Never rebase a stale proposal. On `change_conflict` or another typed conflict, leave it pending for later reread/reconciliation. +- Never revive a `retracted` Memory. New experience after retraction becomes a new Memory with an exact `derived_from` change reference when justified. - `resolved` -> `active` is allowed only for a valid `reopen` proposal. - New or revised prose must remain concise and useful. Preserve exact identifiers, paths, commands, Ticket IDs, branch names, and quoted user text when relevant. - `source_candidate_ids` are host-derived from the applied candidate. Do not attempt to author them. -- `derived_from` must contain only exact existing Memory revision refs returned by reads. -- Applied responses return affected refs with `{memory_id, revision, operation}`. `duplicate` and `already_covered` may reference exact existing revisions; `discarded` and `invalid` require a concrete reason. +- `derived_from` must contain only exact existing Memory change refs returned by reads. +- Applied responses return affected refs with `{memory_id, change_id, operation}`. `duplicate` and `already_covered` may reference exact existing changes; `discarded` and `invalid` require a concrete reason. - For a Backend Job, finish candidate decisions before calling `SubmitBackendJobResult` with `{result:{subject_id,candidate_ids}}`, using exactly the subject_id and complete ordered candidate_ids batch supplied by the immutable Job input. Every batch candidate must have a durable disposition; do not omit candidates or include newly arriving ones. Do not supply `surface`; the result tool awaits clean-context Host surface generation and fills its actual outcome before submission. Only an accepted structured result completes a Job; final prose is not success. - `surface_dirty: true` means confirmed Memory changed. Surface failure never rolls back an already committed candidate decision; partial application survives cancellation and failure. After a definitive result rejection, correct the immutable subject/batch fields or retry to rebuild a stale surface. After any successful or ambiguous result response (including transport errors), retry only the identical input; do not rebuild or change an outcome that may already have been accepted. - A surface failure notification is not proof of a persisted failed marker. The Host must reject a mismatched failure acknowledgement, including when an existing ready surface was preserved. No result is submitted in that case; confirmed candidate decisions remain intact. diff --git a/resources/prompts/internal/subjektiv_memory_surface_system.md b/resources/prompts/internal/subjektiv_memory_surface_system.md index d403fb5fb..a5cf01362 100644 --- a/resources/prompts/internal/subjektiv_memory_surface_system.md +++ b/resources/prompts/internal/subjektiv_memory_surface_system.md @@ -1,6 +1,6 @@ # Subject Memory surface editor -You are a clean-context editor for one subject's confirmed Memory surface. The Host has already selected a deterministic, bounded set of active current Memory revisions. Those materials are the only facts and references you may use. +You are a clean-context editor for one subject's confirmed Memory surface. The Host has already selected a deterministic, bounded set of active current Memory records with fixed change IDs. Those materials are the only facts and references you may use. Answer this fixed question: @@ -9,7 +9,7 @@ Answer this fixed question: Call `SubmitMemorySurface` exactly once with `{ "points": [...] }`. Each point has: - `body_md`: concise Markdown worth carrying into every next task. -- `memory_refs`: one or more exact `{memory_id, revision}` values from the supplied materials that ground that point. +- `memory_refs`: one or more exact `{memory_id, change_id}` values from the supplied materials that ground that point. Rules: diff --git a/resources/prompts/role/coder.md b/resources/prompts/role/coder.md index 743d20f41..927d9fa9d 100644 --- a/resources/prompts/role/coder.md +++ b/resources/prompts/role/coder.md @@ -2,7 +2,7 @@ You are the assigned Coder. Implement the requested scope in the provided Workdi Return the implementation summary, changed files, validation, and limitations in conversation and optionally a Ticket comment or available Drive result when useful or requested. Ticket conclusion is a separate authorized decision based on actual satisfaction and user intent, not an automatic result of a commit, approval, or terminal Flow state. -Use each existing repository-scoped Merge Request as the routine authority for its review requests, verdicts, fixes, and rereview cycles. A Ticket may have multiple Merge Requests, but at most one open Merge Request for a given repository. `OpenMergeRequest` creates that repository's selector-based Merge Request; if one already exists, use its explicit ID with `ShowMergeRequest`, keep its original selectors, and advance only that same source ref with a normal non-force push. Never invent an add-revision operation, replacement Merge Request, or fresh integration branch. Source movement requires fresh review of the exact new ref. Target-only movement does not invalidate source approval and is handled later by Orchestrator integration authority. +Use each existing repository-scoped Merge Request as the routine authority for its review requests, verdicts, fixes, and rereview cycles. A Ticket may have multiple Merge Requests, but at most one open Merge Request for a given repository. `OpenMergeRequest` creates that repository's selector-based Merge Request; if one already exists, use its explicit ID with `ShowMergeRequest`, keep its original selectors, and advance only that same source ref with a normal non-force push. Never invent an operation to append another source candidate, replacement Merge Request, or fresh integration branch. Source movement requires fresh review of the exact new ref. Target-only movement does not invalidate source approval and is handled later by Orchestrator integration authority. Do not add a Ticket comment for each review or fix iteration. An optional Ticket comment can record a blocker or decision requiring Orchestrator attention or concise final implementation and validation evidence when useful or requested; an approved handoff is not mandatory for every task. diff --git a/resources/prompts/role/orchestrator.md b/resources/prompts/role/orchestrator.md index 4bcaad8c0..84b5fe42f 100644 --- a/resources/prompts/role/orchestrator.md +++ b/resources/prompts/role/orchestrator.md @@ -10,23 +10,23 @@ The following Git/MR practices apply to code work using that recipe, not to gene The assigned Coder owns its review/fix loop and launches Reviewer SubWorkers itself. Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers, and do not select a Reviewer profile through the generic WorkerSpawn path. If durable `Review` evidence for the current provider-resolved `selector_from` subject is missing, indeterminate, revoked, cancelled, or requests changes, do not integrate that MR; return the review requirement to the same assigned Coder when that work is requested. Never compensate by creating an independent Reviewer Worker or treating review status alone as a Ticket-state requirement. -Treat every linked Merge Request as an explicitly addressed implementation result; never invent a primary MR. For each open MR, a current provider-resolved source ref, repository evidence, an effective approval for that exact subject and current Ticket item revision, and no unresolved request-changes are required before integration. Do not require an `implementation_report`. Reviewer authority—not Backend parsing—judges whether the natural-language Ticket requirements and validation evidence are satisfied. Recheck `ShowTicket`, each `ShowMergeRequest`, and each `CheckMergeRequestReadiness` immediately before guarded integration. Source movement requires a fresh Reviewer attempt. Target-only movement preserves source approval and requires refreshed integration evidence against the current target. +Treat every linked Merge Request as an explicitly addressed implementation result; never invent a primary MR. For each open MR, a current provider-resolved source ref, repository evidence, an effective approval for that exact subject and current Ticket content digest, and no unresolved request-changes are required before integration. Do not require an `implementation_report`. Reviewer authority—not Backend parsing—judges whether the natural-language Ticket requirements and validation evidence are satisfied. Recheck `ShowTicket`, each `ShowMergeRequest`, and each `CheckMergeRequestReadiness` immediately before guarded integration. Source movement requires a fresh Reviewer attempt. Target-only movement preserves source approval and requires refreshed integration evidence against the current target. Before integrating an MR, run `CheckMergeRequestReadiness` with its explicit ID and reread the Ticket, current assignment, and exact approved subject. Treat that MR's provider-resolved `selector_from` as the `merge_from` branch and `selector_to` as the `merge_to` branch. Obtain their exact approved source hash and `target_ref_before`, selected merge strategy, and approval event from that Merge Request's evidence. -Perform integration through normal source-control operations in the bound Orchestrator Workdir. Treat its current checkout, branch attachment, and tracking state as execution state to inspect and adjust, not by themselves as evidence of a missing integration capability. Ensure the Workdir is clean, resolve the required branches through the configured repository when necessary, verify `merge_from` points exactly to the approved source hash and `merge_to` points exactly to `target_ref_before`, switch to `merge_to`, merge `merge_from` with the approved strategy, and validate the resulting revision and tree. +Perform integration through normal source-control operations in the bound Orchestrator Workdir. Treat its current checkout, branch attachment, and tracking state as execution state to inspect and adjust, not by themselves as evidence of a missing integration capability. Ensure the Workdir is clean, resolve the required branches through the configured repository when necessary, verify `merge_from` points exactly to the approved source hash and `merge_to` points exactly to `target_ref_before`, switch to `merge_to`, merge `merge_from` with the approved strategy, and validate the resulting commit and tree. -Push the resulting `merge_to` branch through its configured normal push path. Preserve repository consistency guards: never rewrite history, bypass branch or Worktree safety, update an unrelated ref, or integrate a source revision different from the reviewed subject. Treat an integration blocker as authoritative only when supported by a concrete source-control or provider failure; before proposing a new control-plane capability, verify that the required operation cannot be expressed through the existing bound Workdir and repository provider. +Push the resulting `merge_to` branch through its configured normal push path. Preserve repository consistency guards: never rewrite history, bypass branch or Worktree safety, update an unrelated ref, or integrate a source commit different from the reviewed subject. Treat an integration blocker as authoritative only when supported by a concrete source-control or provider failure; before proposing a new control-plane capability, verify that the required operation cannot be expressed through the existing bound Workdir and repository provider. After each repository push, verify the provider resolves `merge_to` exactly to `target_ref_after`, then call `CompleteMergeRequest` with that explicit MR ID, before/after evidence, authoritative approval event, and merge strategy. `CompleteMergeRequest` records and verifies only that already-applied repository integration; it does not update the branch itself, and it does not complete the Ticket. Partial integration is valid: accurately report remaining linked MRs and follow the requested continuation or return path; do not claim they were integrated. -If a repository push succeeds but MR completion recording fails, do not push again or invent a new result. Retry the same `CompleteMergeRequest` operation and evidence. Separately judge whether the Ticket's requested result has been satisfied and whether the user requested completion, closure, continuation, or return without conclusion. For an authorized completion decision, reread `ShowTicket` and call `CompleteTicket` with current item revision, expected state, a stable operation key, a reason, and optional supporting references. Generic Ticket completion does not require an MR, integration, or Reviewer approval and does not authorize any of them. The Backend validates actor authority and item/state CAS; it does not judge natural-language satisfaction. Retry an already-recorded operation only with the same fingerprint. Terminal Ticket responsibility remains visible after unfinished work ends. +If a repository push succeeds but MR completion recording fails, do not push again or invent a new result. Retry the same `CompleteMergeRequest` operation and evidence. Separately judge whether the Ticket's requested result has been satisfied and whether the user requested completion, closure, continuation, or return without conclusion. For an authorized completion decision, reread `ShowTicket` and call `CompleteTicket` with `expected_content_digest` from current `content_digest`, expected state, a stable operation key, a reason, and optional supporting references. Generic Ticket completion does not require an MR, integration, or Reviewer approval and does not authorize any of them. The Backend validates actor authority and item/state CAS; it does not judge natural-language satisfaction. Retry an already-recorded operation only with the same fingerprint. Terminal Ticket responsibility remains visible after unfinished work ends. Do not create or delegate an implementation worktree/branch until the Ticket records enough agreed intent, requirements, and acceptance criteria to bound the work. Workspace roots, cwd, profile selector, and launch-prompt configuration are control-plane/environment facts rather than user instructions. If the launch input names explicit Git/worktree operation targets, use those paths only for that operation and do not substitute heuristic roots. -Use `WorkerRemove` only for an eligible terminal or authoritatively reassigned non-internal Worker after its requested work and handoffs are complete; implementation, review, integration, and reporting matter only when requested for that work. Do not remove a Worker merely because one turn completed or it is temporarily idle; retain it while requested continuation, review, or request-changes work can still return. The Worker must already be stopped, must not be restoring, must have no unfinished Ticket work, pending notification, Reviewer handoff, legal hold, or pin, and must not be this Orchestrator. Immediately before removal, reread authoritative Ticket state, assignment, thread/review evidence, and the target Worker through `WorkerList`, then call `WorkerRemove` with a concise reason. Backend authority captures the current Worker revision internally and revalidates removal guards; do not guess policy or supply lifecycle authority in model input. After removal, reread the Worker catalog and attachment state. Treat unfinished-work, running/restoring, retention-policy, attachment-close, and attachment-release conflicts as authoritative failures. `WorkerRemove` releases the Worker attachment but deliberately preserves the Workdir materialization. +Use `WorkerRemove` only for an eligible terminal or authoritatively reassigned non-internal Worker after its requested work and handoffs are complete; implementation, review, integration, and reporting matter only when requested for that work. Do not remove a Worker merely because one turn completed or it is temporarily idle; retain it while requested continuation, review, or request-changes work can still return. The Worker must already be stopped, must not be restoring, must have no unfinished Ticket work, pending notification, Reviewer handoff, legal hold, or pin, and must not be this Orchestrator. Immediately before removal, reread authoritative Ticket state, assignment, thread/review evidence, and the target Worker through `WorkerList`, then call `WorkerRemove` with a concise reason. Backend authority captures the current Worker state and removal conditions internally and revalidates removal guards; do not guess policy or supply lifecycle authority in model input. After removal, reread the Worker catalog and attachment state. Treat unfinished-work, running/restoring, retention-policy, attachment-close, and attachment-release conflicts as authoritative failures. `WorkerRemove` releases the Worker attachment but deliberately preserves the Workdir materialization. Worker cleanup is a separate lifecycle decision owned by this Orchestrator, not an automatic consequence of a Ticket state, result report, or Flow transition. Never predeclare `delete_on_completion`, `retain_on_completion`, or equivalent retention policy when launching or reserving a Worker. When the requested handoff and any authorized conclusion are complete, perform one cleanup pass before ending the orchestration turn if cleanup is in scope: reread the current Ticket and `WorkerList`, verify the assigned Worker has no unfinished Ticket work (terminal current responsibility may remain), then inspect its Worker state, Workdir attachment and occupancy, repository cleanliness, ownership, provider availability, and any other current use of that Workdir. For Worker control, use the exact subject returned by `WorkerList`. If the Worker is active, call `WorkerStop` and reread its terminal status before `WorkerRemove`; idle status, Worker self-report, or review approval alone is not removal authority. Retain an existing or still-needed Workdir. Delete only a Ticket-dedicated Workdir that this Orchestrator created or selected and current authority proves is no longer needed, clean, and unoccupied. diff --git a/resources/prompts/role/reviewer.md b/resources/prompts/role/reviewer.md index 8ea4beb7c..521731c11 100644 --- a/resources/prompts/role/reviewer.md +++ b/resources/prompts/role/reviewer.md @@ -2,7 +2,7 @@ You are the Ticket Reviewer role running as an actual Runtime-owned direct child This role is the trusted independent review path for an explicitly requested MR review, not a prerequisite for every Ticket conclusion. Review capability authenticates the recorded attempt; it does not supply the verdict or replace real judgment. -Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only, never as a supplied verdict. Review the host-captured explicit Merge Request, `ReviewRequested.subject_ref`, `ticket_item_revision`, and exact linked Merge Request subject snapshot against Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. When a Ticket has multiple MRs, judge the selected MR in the context of that complete linked result set without reusing another MR's approval; an `approve` decision attests that the captured full snapshot satisfies the current Ticket requirements and reported validation, not merely that the selected diff is locally acceptable. Use the available Workdir inspection and command tools for focused validation, but do not intentionally modify implementation files, merge, close, update a repository ref, or take over implementation. +Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only, never as a supplied verdict. Review the host-captured explicit Merge Request, `ReviewRequested.subject_ref`, `ticket_content_digest` (SHA-256 of editable Ticket content), and exact linked Merge Request subject snapshot against Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. When a Ticket has multiple MRs, judge the selected MR in the context of that complete linked result set without reusing another MR's approval; an `approve` decision attests that the captured full snapshot satisfies the current Ticket requirements and reported validation, not merely that the selected diff is locally acceptable. Use the available Workdir inspection and command tools for focused validation, but do not intentionally modify implementation files, merge, close, update a repository ref, or take over implementation. Your prose response is not review authority. Before finishing, call `ReviewMergeRequest` exactly once with `approve` or `request_changes`, a bounded evidence summary, and concrete structured findings. Capability authority and subject identity are injected by your child Workspace client and are not model inputs. For an open MR, the Server re-resolves `selector_from`; if it moved, submission records cancellation and fails rather than approving stale work. For an explicitly addressed merged MR, the captured subject is the immutable MergeResult source and the review provides fresh requirement judgment after rescope without changing integration evidence. A verdict applies only to that captured subject; target-only movement does not invalidate approval for an unchanged source, and target integration remains Orchestrator authority. diff --git a/resources/prompts/role/ticket-worker.md b/resources/prompts/role/ticket-worker.md index a992b6c4b..cac0195f2 100644 --- a/resources/prompts/role/ticket-worker.md +++ b/resources/prompts/role/ticket-worker.md @@ -2,6 +2,6 @@ You are the assigned Ticket Worker. Carry out the user's requested work and keep Read the current Ticket with `ShowTicket` before acting. Follow user intent and the Ticket's requirements and acceptance criteria without inventing deliverables or approval gates. The committed user request supplies bounded action context, not control-plane identity or authority. Use only explicitly granted resources and available typed tools. Assignment and Profile selection do not expand resource grants; the typed Backend is authoritative for actor identity, current assignment, Ticket decisions, and resource access. Treat a Backend rejection as authoritative rather than trying a CLI, direct storage access, or another path around it. -Choose the requested return path rather than always concluding the Ticket. Return results in conversation, optionally use `TicketComment` for useful progress, results, blockers, or decisions, and optionally publish an available Drive document/artifact when useful or requested. No comment or artifact is universally required. Use `TicketWorkflowState` for authorized progress-display updates with an explanation; it does not launch work, enforce a state sequence, or grant additional resources. When the work meets its acceptance criteria and the request calls for completion, use `CompleteTicket` with a natural-language reason describing the result and optional supporting references. Use authorized `TicketClose` for requested closure when available. A request to return without conclusion must not become an automatic completion or closure decision. Use the current item revision and expected state from `ShowTicket` and a stable operation key for exact replay of each decision; reread after a conflict rather than guessing. The Backend checks authority and CAS, not real judgment of natural-language satisfaction. +Choose the requested return path rather than always concluding the Ticket. Return results in conversation, optionally use `TicketComment` for useful progress, results, blockers, or decisions, and optionally publish an available Drive document/artifact when useful or requested. No comment or artifact is universally required. Use `TicketWorkflowState` for authorized progress-display updates with an explanation; it does not launch work, enforce a state sequence, or grant additional resources. When the work meets its acceptance criteria and the request calls for completion, use `CompleteTicket` with a natural-language reason describing the result and optional supporting references. Use authorized `TicketClose` for requested closure when available. A request to return without conclusion must not become an automatic completion or closure decision. Use the current `content_digest` as `expected_content_digest` and expected state from `ShowTicket` and a stable operation key for exact replay of each decision; reread after a conflict rather than guessing. The Backend checks authority and CAS, not real judgment of natural-language satisfaction. Completion evidence should describe what was done, how it was checked, and any remaining limitations. A commit, Merge Request, approval, or fixed workflow sequence is not a prerequisite for Ticket progress or completion. Do not claim work or validation that did not occur. Ticket completion is separate from Merge Request review or integration and does not confer either authority. If the request needs unavailable resources or authority, report the blocker and ask for the missing grant rather than assuming it. diff --git a/tools/web-ux/README.md b/tools/web-ux/README.md index 57e9443c6..f98ea3272 100644 --- a/tools/web-ux/README.md +++ b/tools/web-ux/README.md @@ -11,7 +11,7 @@ README documents the tool; running the tool is not itself visual review completi The workbench produces a **review context bundle** rather than treating a screenshot as evidence by itself. Every capture records the persona, route, viewport, theme, intended user goal, expected data state, sanitized document URL/status, console/page/request failures, screenshot hashes, an -accessibility snapshot, source revision, and browser version. +accessibility snapshot, source Git commit SHA, and browser version. ## Environment diff --git a/tools/web-ux/browser-tests/console_history_fixture_server.ts b/tools/web-ux/browser-tests/console_history_fixture_server.ts index f4c79d17a..484508c1d 100644 --- a/tools/web-ux/browser-tests/console_history_fixture_server.ts +++ b/tools/web-ux/browser-tests/console_history_fixture_server.ts @@ -164,6 +164,9 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { if (url.pathname === `/api/w/${workspaceId}/working-directories`) { return json({ workspace_id: workspaceId, items: [], diagnostics: [] }); } + if (url.pathname === `/api/w/${workspaceId}/workers`) { + return json({ workspace_id: workspaceId, limit: 200, items: [worker], source: "fixture", diagnostics: [] }); + } if ( url.pathname === `/api/w/${workspaceId}/workers/${workerResourceKey}` || url.pathname === `/api/w/${workspaceId}/runtimes/${runtimeId}/workers/${workerId}` @@ -191,7 +194,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { identity: { session_id: "session-a", segment_id: "segment-a", entry_count: 36 }, snapshot: { pending_submissions: { - revision: 0, + notification_count: hasNotifications ? 2 : 0, ...(hasNotifications && pendingMode !== "legacy" ? { notification_previews: ["First notification", "Long notification — " + "notification text ".repeat(30)] } : {}), head_id: submissions[0]?.submission_id ?? null, diff --git a/tools/web-ux/browser-tests/console_history_shell_test.ts b/tools/web-ux/browser-tests/console_history_shell_test.ts index 909e8e693..814581d4e 100644 --- a/tools/web-ux/browser-tests/console_history_shell_test.ts +++ b/tools/web-ux/browser-tests/console_history_shell_test.ts @@ -501,12 +501,16 @@ async function checkConsoleHistory(viewportHeight: number): Promise { }); assert(centering <= 1, `turn marker is not vertically centered (${centering}px)`); assertEquals(await transcript.getByText("searched 1 time・ran 1 command", { exact: true }).count(), 12); - await page.getByRole("button", { name: "Normal", exact: true }).click(); + const overview = page.getByRole("switch", { name: "Overview", exact: true }); + assertEquals(await overview.getAttribute("aria-checked"), "true"); + await overview.click(); + assertEquals(await overview.getAttribute("aria-checked"), "false"); const rowIds = await transcript.locator("[data-console-line-id]").evaluateAll((rows) => rows.map((row) => row.getAttribute("data-console-line-id"))); assertEquals(rowIds.length, 48); assertEquals(new Set(rowIds).size, rowIds.length); - await page.getByRole("button", { name: "Overview", exact: true }).click(); + await overview.click(); + assertEquals(await overview.getAttribute("aria-checked"), "true"); assertEquals(await transcript.getByText("searched 1 time・ran 1 command", { exact: true }).count(), 12); const sidebar = page.locator(".sidebar-frame"); const settleSidebar = () => sidebar.evaluate(async (element) => { diff --git a/tools/web-ux/browser-tests/console_live_fixture_server.ts b/tools/web-ux/browser-tests/console_live_fixture_server.ts index 78705e73c..4bf4f73e3 100644 --- a/tools/web-ux/browser-tests/console_live_fixture_server.ts +++ b/tools/web-ux/browser-tests/console_live_fixture_server.ts @@ -99,7 +99,7 @@ const entries = [ }, ]; const session = { - pending_submissions: { revision: 0, notification_count: 0, head_id: null, submissions: [] }, + pending_submissions: { notification_count: 0, head_id: null, submissions: [] }, entries, }; const state = { last_command_id: 0, state: { kind: "idle" } }; @@ -250,6 +250,9 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { if (url.pathname === `/api/w/${workspaceId}/working-directories`) { return json({ workspace_id: workspaceId, items: [], diagnostics: [] }); } + if (url.pathname === `/api/w/${workspaceId}/workers`) { + return json({ workspace_id: workspaceId, limit: 200, items: [worker], source: "fixture", diagnostics: [] }); + } if (url.pathname === `/api/w/${workspaceId}/workers/W-901` || url.pathname === workerPath) { return json(worker); } diff --git a/tools/web-ux/browser-tests/console_live_shell_test.ts b/tools/web-ux/browser-tests/console_live_shell_test.ts index 84c044643..e1578438c 100644 --- a/tools/web-ux/browser-tests/console_live_shell_test.ts +++ b/tools/web-ux/browser-tests/console_live_shell_test.ts @@ -82,18 +82,23 @@ Deno.test("production live Console completes, edits/removes typed drafts and sta await observePicker(page); page.setDefaultTimeout(5000); const errors: string[] = []; + const supersededCatalogReads: string[] = []; const acknowledgedDeletes = new Set(); page.on("console", (message) => { if (message.type() === "error") errors.push(message.text()); }); page.on("pageerror", (error) => errors.push(String(error))); - page.on( - "requestfailed", - (request) => - errors.push( - `failed ${request.method()} ${request.url()} ${request.failure()?.errorText}`, - ), - ); + page.on("requestfailed", (request) => { + // Subscription snapshots deliberately supersede the bootstrap GET. + if ( + request.url() === `${base}/api/w/console-live-review/workers` && + request.method() === "GET" && + request.failure()?.errorText === "net::ERR_ABORTED" + ) supersededCatalogReads.push(request.url()); + else errors.push( + `failed ${request.method()} ${request.url()} ${request.failure()?.errorText}`, + ); + }); page.on("response", (response) => { if (response.request().method() === "DELETE" && response.status() === 204) { acknowledgedDeletes.add(response.url()); @@ -248,6 +253,7 @@ Deno.test("production live Console completes, edits/removes typed drafts and sta theme, width, errors: recordedErrors, + supersededCatalogReads, acknowledgedDeleteDiagnostics, acknowledgedDeleteResponseStatus: releasedResponse.status(), picker: pickerEvidence, diff --git a/tools/web-ux/browser-tests/console_pending_shell_test.ts b/tools/web-ux/browser-tests/console_pending_shell_test.ts index 57e6f7a16..6c498d74c 100644 --- a/tools/web-ux/browser-tests/console_pending_shell_test.ts +++ b/tools/web-ux/browser-tests/console_pending_shell_test.ts @@ -54,15 +54,33 @@ Deno.test("Console pending scopes are independent and match Tasks row typography try { const page = await context.newPage(); const errors: string[] = []; + const supersededCatalogReads: string[] = []; page.on("pageerror", (error) => errors.push(String(error))); page.on("console", (message) => { if (message.type() === "error") errors.push(message.text()); }); - page.on("requestfailed", (request) => errors.push(request.url())); + page.on("requestfailed", (request) => { + // The subscription snapshot supersedes the bootstrap catalog GET. + // Keep this intentional AbortController cancellation separate from + // transport failures; each mode still awaits a completed catalog GET. + if ( + request.url() === `${url}/api/w/console-history-review/workers` && + request.method() === "GET" && + request.failure()?.errorText === "net::ERR_ABORTED" + ) supersededCatalogReads.push(request.url()); + else errors.push(`${request.url()}: ${request.failure()?.errorText}`); + }); for (const mode of ["both", "queue", "notifications", "legacy", "empty"]) { + const catalogFinished = page.waitForEvent("requestfinished", { + predicate: (request) => + request.url() === `${url}/api/w/console-history-review/workers` && + request.method() === "GET", + timeout: 10_000, + }); await page.goto( `${url}/w/console-history-review/workers/W-900-console-fixture/console?pending=${mode}`, ); + await catalogFinished; await page.locator(".task-mini-row").waitFor(); const metadata = page.getByLabel("Worker model and context", { exact: true }); const statusGeometry = await metadata.evaluate((status) => { @@ -257,7 +275,12 @@ Deno.test("Console pending scopes are independent and match Tasks row typography await page.getByText("2 pending · Preview unavailable", { exact: true }).waitFor(); } } + assertEquals(await page.locator(".workspace-alert").count(), 0); assertEquals(errors, []); + console.log(JSON.stringify({ + colorScheme, width, supersededCatalogReads, + unexpectedErrors: errors, + })); } finally { await context.close(); } 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 d4de0cc06..2932d4c36 100644 --- a/tools/web-ux/browser-tests/memory_document_fixture_server.ts +++ b/tools/web-ux/browser-tests/memory_document_fixture_server.ts @@ -26,7 +26,7 @@ const subjectPageCursor = "fixture-subject-page-2"; const emptySubjectPageCursor = "fixture-empty-subject-page"; const errorSubjectPageCursor = "fixture-error-subject-page"; const memoryPageCursor = "fixture-memory-page-2"; -const revisionPageCursor = "fixture-revision-page-2"; +const changePageCursor = "fixture-change-page-2"; const createdSubjectIds = new Set(); const subjectCreateRequests: unknown[] = []; let createdSubjectCount = 0; @@ -51,7 +51,7 @@ const surfaceBodyMd = const detailBodyMd = `# Committed Memory detail -This immutable revision preserves its candidate evidence, source refs, and derivation refs. +This immutable change preserves its candidate evidence, source refs, and derivation refs. | Local table overflow | Value | | --- | --- | @@ -70,18 +70,17 @@ const subjects = [ representativeSubjectId, "Release coordination", "active", - 42, + "fingerprint-current", "Prefer explicit evidence over assumptions.\nAsk before irreversible actions, and state uncertainty plainly.", - 3, connectedWorker("Release coordination Worker with a deliberately long display name"), ), - subject(emptySubjectId, "Empty ready subject", "active", 0), - subject(staleSubjectId, "Stale surface subject", "active", 19), - subject(failedSubjectId, "Failed surface subject", "active", 8), - subject(ungeneratedSubjectId, "Ungenerated subject", "active", 0), - subject(errorSubjectId, "Unavailable subject", "retired", 3), + subject(emptySubjectId, "Empty ready subject", "active", "fingerprint-0"), + subject(staleSubjectId, "Stale surface subject", "active", "fingerprint-19"), + subject(failedSubjectId, "Failed surface subject", "active", "fingerprint-8"), + subject(ungeneratedSubjectId, "Ungenerated subject", "active", "fingerprint-0"), + subject(errorSubjectId, "Unavailable subject", "retired", "fingerprint-3"), ]; -const pagedSubject = subject(pagedSubjectId, "Subject on the next page", "active", 1); +const pagedSubject = subject(pagedSubjectId, "Subject on the next page", "active", "fingerprint-1"); function connectedWorker(displayName: string) { return { @@ -111,18 +110,16 @@ function subject( id: string, role: string, state: "active" | "retired", - storeRevision: number, + memoryFingerprint: string, behaviorMd = "", - behaviorRevision = 0, currentWorker?: ReturnType, ) { return { id, role, behavior_md: behaviorMd, - behavior_revision: behaviorRevision, state, - store_revision: storeRevision, + memory_fingerprint: memoryFingerprint, created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-02T03:04:05Z", ...(currentWorker ? { current_worker: currentWorker } : {}), @@ -134,14 +131,14 @@ function memoriesFor(subjectId: string) { if (subjectId === emptySubjectId || subjectId === ungeneratedSubjectId) return []; if (subjectId === staleSubjectId) { return [ - memorySummary("stale-memory-1", 2, "lesson", "resolved", "A resolved stale-surface lesson"), + memorySummary("stale-memory-1", "change-earlier", "lesson", "resolved", "A resolved stale-surface lesson"), ]; } if (subjectId === failedSubjectId) { return [ memorySummary( "failed-memory-1", - 1, + "change-original", "constraint", "retracted", "A retracted failed-surface constraint", @@ -151,21 +148,21 @@ function memoriesFor(subjectId: string) { return [ memorySummary( representativeMemoryId, - 3, + "change-current", "decision", "active", "Keep subject Memory provenance typed and visible", ), memorySummary( "memory-resolved-0002", - 2, + "change-earlier", "lesson", "resolved", "The bounded reader must preserve continuations", ), memorySummary( "memory-retracted-0003", - 1, + "change-original", "working_assumption", "retracted", "Memory list end marker for scroll validation", @@ -175,18 +172,18 @@ function memoriesFor(subjectId: string) { function memorySummary( id: string, - revision: number, + changeId: string, kind: string, state: string, claim: string, ) { return { id, - revision, + change_id: changeId, kind, state, claim, - excerpt: `Read-only ${state} Memory with exact revision and provenance metadata.`, + excerpt: `Read-only ${state} Memory with exact change ID and provenance metadata.`, updated_at: "2026-01-02T03:04:05Z", }; } @@ -200,10 +197,10 @@ function surfaceFor(subjectId: string) { snapshot_id: "surface-snapshot-0042", body_md: surfaceBodyMd, memory_refs: [ - { memory_id: representativeMemoryId, revision: 3 }, - { memory_id: derivedMemoryId, revision: 4 }, + { memory_id: representativeMemoryId, change_id: "change-current" }, + { memory_id: derivedMemoryId, change_id: "change-parent" }, ], - built_from_store_revision: 42, + built_from_memory_fingerprint: "fingerprint-current", created_at: "2026-01-02T03:04:05Z", }, }; @@ -216,7 +213,7 @@ function surfaceFor(subjectId: string) { snapshot_id: "surface-snapshot-empty", body_md: "", memory_refs: [], - built_from_store_revision: 0, + built_from_memory_fingerprint: "fingerprint-0", created_at: "2026-01-02T03:04:05Z", }, }; @@ -226,27 +223,28 @@ function surfaceFor(subjectId: string) { return { subject_id: subjectId, availability: "ungenerated" }; } -function memoryDetail(memoryId: string, requestedRevision: number | null) { - const revision = requestedRevision && requestedRevision <= 3 ? requestedRevision : 3; - const states = { 1: "retracted", 2: "resolved", 3: "active" } as const; +function memoryDetail(memoryId: string, requestedChangeId: string | null) { + const currentChangeId = memoryId === derivedMemoryId ? "change-parent" : "change-current"; + const changeId = requestedChangeId ?? currentChangeId; + const states: Record = { "change-original": "retracted", "change-earlier": "resolved", "change-current": "active", "change-parent": "active" }; return { memory_id: memoryId, - revision, - current_revision: 3, + change_id: changeId, + current_change_id: currentChangeId, kind: "decision", - state: states[revision as keyof typeof states], - claim: revision === 3 + state: states[changeId], + claim: changeId === "change-current" ? "Keep subject Memory provenance typed and visible" - : `Historical provenance decision, revision ${revision}`, + : `Historical provenance decision, change ${changeId}`, body_md: detailBodyMd, why_useful: "Future readers can audit why a committed Memory exists without exposing unbounded session content.", staleness: null, - change_reason: revision === 3 + change_reason: changeId === "change-current" ? "Clarified the durable read contract." - : "Immutable historical revision.", + : "Immutable historical change.", created_at: "2026-01-01T00:00:00Z", - updated_at: `2026-01-0${revision}T03:04:05Z`, + updated_at: "2026-01-02T03:04:05Z", body_offset: 0, body_byte_offset: 0, body_truncated: false, @@ -264,7 +262,6 @@ function memoryDetail(memoryId: string, requestedRevision: number | null) { worker_id: "worker-fixture", flow_selector: "builtin:coder-review", flow_definition_id: "flow-definition-fixture", - flow_definition_revision: 7, }, excerpt: "Use normal explicit subject routes and preserve provenance.", summary: "The product contract was explicitly requested.", @@ -279,20 +276,20 @@ function memoryDetail(memoryId: string, requestedRevision: number | null) { origin: { kind: "human_input", account_id: "account-fixture" }, evidence_kind: "message", label: "Memory information design source", - summary: "Bounded source reference retained with the committed revision.", + summary: "Bounded source reference retained with the committed change.", }], source_refs_total: 1, source_refs_truncated: false, }], - derived_from: [{ memory_id: derivedMemoryId, revision: 4 }], + derived_from: [{ memory_id: derivedMemoryId, change_id: "change-parent" }], evidence_has_more: false, }; } -function revisions(memoryId: string, cursor: string | null) { +function changes(memoryId: string, cursor: string | null) { const all = [ { - revision: 3, + change_id: "change-current", kind: "decision", state: "active", claim: "Keep subject Memory provenance typed and visible", @@ -300,28 +297,28 @@ function revisions(memoryId: string, cursor: string | null) { updated_at: "2026-01-03T03:04:05Z", }, { - revision: 2, + change_id: "change-earlier", kind: "decision", state: "resolved", - claim: "Historical provenance decision, revision 2", + claim: "Historical provenance decision, change change-earlier", change_reason: "Resolved after endpoint integration.", updated_at: "2026-01-02T03:04:05Z", }, { - revision: 1, + change_id: "change-original", kind: "decision", state: "retracted", - claim: "Historical provenance decision, revision 1", + claim: "Historical provenance decision, change change-original", change_reason: "Retracted the legacy shape.", updated_at: "2026-01-01T03:04:05Z", }, ]; - const continued = cursor === revisionPageCursor; + const continued = cursor === changePageCursor; return { memory_id: memoryId, - current_revision: 3, + current_change_id: "change-current", items: continued ? all.slice(2) : all.slice(0, 2), - ...(continued ? {} : { next_cursor: revisionPageCursor }), + ...(continued ? {} : { next_cursor: changePageCursor }), has_more: !continued, }; } @@ -410,6 +407,9 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { if (url.pathname === `/api/w/${workspaceId}/working-directories`) { return json({ workspace_id: workspaceId, items: [], diagnostics: [] }); } + if (url.pathname === `/api/w/${workspaceId}/workers`) { + return json({ workspace_id: workspaceId, limit: 200, items: [], source: "fixture", diagnostics: [] }); + } if (url.pathname === `/api/w/${workspaceId}/workers/launch-options`) { return json({ workspace_id: workspaceId, @@ -462,7 +462,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { } createdSubjectCount += 1; const id = `created-subject-${String(createdSubjectCount).padStart(4, "0")}`; - const created = subject(id, payload.role, "active", 0, payload.behavior_md); + const created = subject(id, payload.role, "active", "fingerprint-empty", payload.behavior_md); createdSubjectIds.add(id); subjects.unshift(created); return json(created, 201); @@ -493,21 +493,20 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { if (parts.length === 2 && parts[1] === "behavior" && request.method === "PATCH") { const payload = await request.json().catch(() => null) as { - expected_behavior_revision?: unknown; + expected_behavior_md?: unknown; behavior_md?: unknown; } | null; if ( !payload || - typeof payload.expected_behavior_revision !== "number" || + typeof payload.expected_behavior_md !== "string" || typeof payload.behavior_md !== "string" ) { return json({ error: "Bad Request", message: "invalid behavior update" }, 400); } - if (payload.expected_behavior_revision !== foundSubject.behavior_revision) { - return json({ error: "Conflict", message: "subject behavior revision conflict" }, 409); + if (payload.expected_behavior_md !== foundSubject.behavior_md) { + return json({ error: "Conflict", message: "subject behavior content conflict" }, 409); } foundSubject.behavior_md = payload.behavior_md; - foundSubject.behavior_revision += 1; foundSubject.updated_at = "2026-01-03T04:05:06Z"; return json(foundSubject); } @@ -518,7 +517,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { const paginated = subjectId === representativeSubjectId; const continued = paginated && url.searchParams.get("cursor") === memoryPageCursor; const items = continued - ? [memorySummary("memory-page-two", 1, "lesson", "active", "Memory on the next page")] + ? [memorySummary("memory-page-two", "change-original", "lesson", "active", "Memory on the next page")] : memoriesFor(subjectId); return json({ items, @@ -528,12 +527,14 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { } if (parts.length >= 3 && parts[1] === "memories") { const memoryId = parts[2]; - if (parts.length === 4 && parts[3] === "revisions") { - return json(revisions(memoryId, url.searchParams.get("cursor"))); + if (parts.length === 4 && parts[3] === "changes") { + return json(changes(memoryId, url.searchParams.get("cursor"))); } if (parts.length === 3) { - const requested = url.searchParams.get("revision"); - return json(memoryDetail(memoryId, requested === null ? null : Number(requested))); + const requested = url.searchParams.get("change_id"); + const knownChanges = memoryId === derivedMemoryId ? ["change-parent"] : ["change-original", "change-earlier", "change-current"]; + if (requested !== null && !knownChanges.includes(requested)) return json({}, 404); + return json(memoryDetail(memoryId, requested)); } } } 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 615b280df..0a415ab5a 100644 --- a/tools/web-ux/browser-tests/memory_document_shell_test.ts +++ b/tools/web-ux/browser-tests/memory_document_shell_test.ts @@ -323,18 +323,18 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco assertEquals(await detailPage.getByText("candidate-release-decision-0001").count(), 1); assertEquals(await detailPage.getByText("Memory information design source").count(), 1); assertEquals(await detailPage.getByText("memory-derived-source-0002").count(), 1); - assertEquals(await detailPage.getByRole("heading", { name: "Revision history" }).count(), 1); - assertEquals(await detailPage.getByText("Memory revision 3", { exact: true }).count(), 1); - assertEquals(await detailPage.getByText("Memory revision 2", { exact: true }).count(), 1); - assertEquals(await detailPage.getByText("Memory revision 1", { exact: true }).count(), 0); - await detailPage.getByRole("navigation", { name: "Revision history pages" }).getByRole( + assertEquals(await detailPage.getByRole("heading", { name: "Change history" }).count(), 1); + assertEquals(await detailPage.getByText("Memory change change-current", { exact: true }).count(), 1); + assertEquals(await detailPage.getByText("Memory change change-earlier", { exact: true }).count(), 1); + assertEquals(await detailPage.getByText("Memory change change-original", { exact: true }).count(), 0); + await detailPage.getByRole("navigation", { name: "Change history pages" }).getByRole( "link", { name: "Next page →" }, ).click(); - await detailPage.getByText("Memory revision 1", { exact: true }).waitFor(); - assert(new URL(detailPage.url()).searchParams.has("revision_cursor")); + await detailPage.getByText("Memory change change-original", { exact: true }).waitFor(); + assert(new URL(detailPage.url()).searchParams.has("change_cursor")); assertEquals( - await detailPage.getByRole("navigation", { name: "Revision history pages" }).getByRole( + await detailPage.getByRole("navigation", { name: "Change history pages" }).getByRole( "link", { name: "First page" }, ).count(), @@ -497,7 +497,7 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco path: join(createInteractionArtifacts, "behavior-edit-desktop-light.png"), }); await createPage.getByRole("button", { name: "Save behavior" }).click(); - await createPage.getByText(/Behavior storage confirmed at revision 1/).waitFor(); + await createPage.getByText(/Behavior storage confirmed/).waitFor(); assert( (await createPage.getByRole("status").filter({ hasText: "This page does not confirm application", diff --git a/tools/web-ux/browser-tests/ticket_fixture_server.ts b/tools/web-ux/browser-tests/ticket_fixture_server.ts index 2d7934930..e028071df 100644 --- a/tools/web-ux/browser-tests/ticket_fixture_server.ts +++ b/tools/web-ux/browser-tests/ticket_fixture_server.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; // Synthetic API fixture serving the production static UI; no Worker/Git execution. import { extname, join, normalize } from "jsr:@std/path@1.1.4"; import { @@ -11,6 +12,11 @@ const root = Deno.args[1]; const legacy = Deno.args.includes("--legacy"); const tickets = new Map(); const bodies: unknown[] = []; +function contentDigest(value: TicketDetail): string { + return createHash("sha256").update("ticket.content.sha256.v1\0").update(JSON.stringify([ + value.id, value.title, value.body, value.targets.map(({ repository_key, ref_selector, access }) => ({ repository_key, ref_selector, access })), + ])).digest("hex"); +} function ticket(key: string) { if (!tickets.has(key)) { const value: TicketDetail = fixtureDetail(key); @@ -49,7 +55,9 @@ function ticket(key: string) { } tickets.set(key, value); } - const value = structuredClone(tickets.get(key)!); + const stored = tickets.get(key)!; + stored.content_digest = contentDigest(stored); + const value = structuredClone(stored); if (legacy) { const wire = value as unknown as Record; wire.current_coder = wire.current_worker; @@ -114,7 +122,6 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { kind: "git", provider: "git", source: { kind: "local_path", uri: "/fixture/main" }, - source_revision: 1, source_fingerprint: "fixture", observed_status: "ready", record_authority: "fixture", @@ -252,8 +259,11 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { }); } const stored = tickets.get(key) ?? (ticket(key), tickets.get(key)!); - if (body.expected_item_revision && body.expected_item_revision !== stored.item_revision) { - return Response.json({ message: "Ticket revision conflict; refresh" }, { status: 409 }); + if (body.expected_content_digest && body.expected_content_digest !== stored.content_digest) { + return Response.json({ message: "Ticket content conflict; refresh" }, { status: 409 }); + } + if ((tm[2] === "/state" || tm[2] === "/close") && body.expected_state !== stored.state) { + return Response.json({ message: "Ticket state conflict; refresh" }, { status: 409 }); } if (tm[2] === "/state") { stored.state = body.state; @@ -289,7 +299,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { if (body.title) stored.title = body.title; if (body.body) stored.body = body.body; } - stored.item_revision += "x"; + stored.content_digest = contentDigest(stored); stored.event_count = stored.events.length; } return Response.json(ticket(key)); diff --git a/tools/web-ux/browser-tests/ticket_shell_test.ts b/tools/web-ux/browser-tests/ticket_shell_test.ts index b6ef1df4a..f0928142c 100644 --- a/tools/web-ux/browser-tests/ticket_shell_test.ts +++ b/tools/web-ux/browser-tests/ticket_shell_test.ts @@ -166,7 +166,7 @@ Deno.test("Ticket progress, comment results and reopen work without Git while re state: "planning", reason: "Fixture reset", expected_state: detail.state, - expected_item_revision: detail.item_revision, + expected_content_digest: detail.content_digest, }, }); } diff --git a/tools/web-ux/browser-tests/workdir_fixture.ts b/tools/web-ux/browser-tests/workdir_fixture.ts index 4e3597870..6d10a1a33 100644 --- a/tools/web-ux/browser-tests/workdir_fixture.ts +++ b/tools/web-ux/browser-tests/workdir_fixture.ts @@ -41,7 +41,6 @@ export function workdirFixture() { workspace_id: workspace, runtime_id: "fixture-runtime", generated_at: "2026-01-01T00:00:00Z", - revision: "plan-1", digest: "digest-1", workers: [], diagnostics: [], diff --git a/tools/web-ux/browser-tests/workdir_removal_shell_test.ts b/tools/web-ux/browser-tests/workdir_removal_shell_test.ts index a94f9f5bf..cd1c3f661 100644 --- a/tools/web-ux/browser-tests/workdir_removal_shell_test.ts +++ b/tools/web-ux/browser-tests/workdir_removal_shell_test.ts @@ -113,7 +113,7 @@ Deno.test("ordinary removal refreshes failed and successful inventory, supports assertEquals(await page.locator("thead th").allTextContents(), [ "Workdir", "Repository", - "Revision", + "Checkout", "Status", "Cleanliness", "Occupied by", diff --git a/tools/web-ux/browser-tests/workspace_home_fixture_server.ts b/tools/web-ux/browser-tests/workspace_home_fixture_server.ts index 5d478044f..50a0a78e3 100644 --- a/tools/web-ux/browser-tests/workspace_home_fixture_server.ts +++ b/tools/web-ux/browser-tests/workspace_home_fixture_server.ts @@ -101,7 +101,16 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { diagnostics: [], }); } - // Keyboard focus can preload the Tickets route before navigation. + // Keyboard focus can preload the Tickets and Workers routes before navigation. + if (path === "/workers") { + return json({ + workspace_id: workspaceId, + items: [], + diagnostics: [], + limit: 100, + source: "fixture", + }); + } if (path === "/orchestrator") { return json({ workspace_id: workspaceId, diff --git a/tools/web-ux/browser-tests/workspace_settings_shell_test.ts b/tools/web-ux/browser-tests/workspace_settings_shell_test.ts index b323a1c24..9bf1cc1e8 100644 --- a/tools/web-ux/browser-tests/workspace_settings_shell_test.ts +++ b/tools/web-ux/browser-tests/workspace_settings_shell_test.ts @@ -228,7 +228,7 @@ Deno.test("production Workspace Settings separates read/edit, discloses details, const reset = await context.request.put(`${url}/api/w/home-long/settings`, { data: { display_name: metadataFixture("home-long").display_name, - revision: current.revision, + expected_updated_at: current.updated_at, }, }); assertEquals(reset.status(), 200); diff --git a/tools/web-ux/drive-fixture/api.ts b/tools/web-ux/drive-fixture/api.ts index 2eef04cd1..1039f5e08 100644 --- a/tools/web-ux/drive-fixture/api.ts +++ b/tools/web-ux/drive-fixture/api.ts @@ -21,7 +21,7 @@ export type FixtureLog = { method: string; requestId: string | null; nodeId: string | null; - expectedRevision: string | null; + expectedMutationId: string | null; }; const encoder = new TextEncoder(); const rootId = "9007199254740993"; @@ -68,7 +68,7 @@ export function createDriveFixture() { parent: parent === null ? null : ref(parent), name, kind, - revision: "1", + last_mutation_id: "1", size: kind === "file" ? bytes.length : null, content_type: contentType, updated_by: "fixture-worker", @@ -140,16 +140,13 @@ export function createDriveFixture() { state.next = id.includes("paged") ? 300n : 100n; return state; } - function bump(node: Node) { - node.entry.revision = String(BigInt(node.entry.revision) + 1n); - } - function mutate(id: string, mutation: DriveMutation): DriveEntry | null { + function mutate(id: string, requestId: string, mutation: DriveMutation): DriveEntry | null { const state = ws(id); const target = "id" in mutation ? state.nodes.get(mutation.id.node_id) : null; if ("id" in mutation && (mutation.id.workspace_id !== id || !target)) throw "not_found"; if ( - target && "expected_revision" in mutation && - target.entry.revision !== mutation.expected_revision + target && "expected_mutation_id" in mutation && + target.entry.last_mutation_id !== mutation.expected_mutation_id ) throw "conflict"; if ("parent" in mutation) { const parent = state.nodes.get(mutation.parent.node_id); @@ -177,14 +174,14 @@ export function createDriveFixture() { if (mutation.operation === "relocate") { target!.entry.parent = mutation.parent; target!.entry.name = mutation.name; - bump(target!); + target!.entry.last_mutation_id = requestId; return target!.entry; } if (mutation.operation === "update_text") { target!.bytes = encoder.encode(mutation.text); target!.entry.size = target!.bytes.length; target!.entry.content_type = mutation.content_type; - bump(target!); + target!.entry.last_mutation_id = requestId; return target!.entry; } const nodeId = String(state.next++); @@ -196,7 +193,7 @@ export function createDriveFixture() { parent: mutation.parent, name: mutation.name, kind: mutation.operation === "create_folder" ? "folder" : "file", - revision: "1", + last_mutation_id: requestId, size: mutation.operation === "create_folder" ? null : bytes.length, content_type: mutation.operation === "create_text" ? mutation.content_type : null, updated_by: "fixture-user", @@ -233,7 +230,7 @@ export function createDriveFixture() { method: request.method, requestId: url.searchParams.get("request_id"), nodeId: url.searchParams.get("id") ?? url.searchParams.get("parent_id"), - expectedRevision: url.searchParams.get("expected_revision"), + expectedMutationId: url.searchParams.get("expected_mutation_id"), }; log.push(event); if (id.includes("denied")) return failure("denied"); @@ -287,7 +284,7 @@ export function createDriveFixture() { event.requestId = requestId; if (payload) { event.nodeId = payload.mutation.id?.node_id ?? payload.mutation.parent?.node_id ?? null; - event.expectedRevision = payload.mutation.expected_revision ?? null; + event.expectedMutationId = payload.mutation.expected_mutation_id ?? null; } if (state.receipts.has(requestId)) return Response.json(state.receipts.get(requestId)); const fault = faults.get(id); @@ -296,7 +293,7 @@ export function createDriveFixture() { return failure("conflict"); } let entry: DriveEntry | null; - if (path === "/mutate") entry = mutate(id, payload.mutation); + if (path === "/mutate") entry = mutate(id, requestId, payload.mutation); else { const bytes = new Uint8Array(await request.arrayBuffer()); if (bytes.length > 16 * 1024 * 1024) return failure("limit"); @@ -308,17 +305,17 @@ export function createDriveFixture() { ) return failure("invalid"); const content_type = url.searchParams.get("content_type")!; if (url.searchParams.get("operation") === "create") { - entry = mutate(id, { + entry = mutate(id, requestId, { operation: "create_text", parent: { workspace_id: id, node_id: url.searchParams.get("parent_id")! }, name: url.searchParams.get("name")!, text: "", content_type, }); - } else {entry = mutate(id, { + } else {entry = mutate(id, requestId, { operation: "update_text", id: { workspace_id: id, node_id: url.searchParams.get("id")! }, - expected_revision: url.searchParams.get("expected_revision")!, + expected_mutation_id: url.searchParams.get("expected_mutation_id")!, text: "", content_type, });} @@ -370,8 +367,8 @@ export function createDriveFixture() { } if (path === "/download" || path === "/read-chunk") { if ( - url.searchParams.has("expected_revision") && - url.searchParams.get("expected_revision") !== node.entry.revision + url.searchParams.has("expected_mutation_id") && + url.searchParams.get("expected_mutation_id") !== node.entry.last_mutation_id ) return failure("conflict"); if (faults.get(id) === "image_error" && node.entry.content_type?.startsWith("image/")) { return failure("storage_unavailable"); diff --git a/tools/web-ux/drive-fixture/workers.ts b/tools/web-ux/drive-fixture/workers.ts index 7ec1504b5..86f4498ef 100644 --- a/tools/web-ux/drive-fixture/workers.ts +++ b/tools/web-ux/drive-fixture/workers.ts @@ -67,7 +67,6 @@ export function cleanupPlan(workspaceId: string, runtimeId: string): RuntimeClea diagnostics: [], digest: "fixture-cleanup", generated_at: "2026-01-02T00:00:00Z", - revision: "1", workers: [], workdirs: [], }; diff --git a/tools/web-ux/drive-grants/visual-review.ts b/tools/web-ux/drive-grants/visual-review.ts index da580dc37..506eebd4e 100644 --- a/tools/web-ux/drive-grants/visual-review.ts +++ b/tools/web-ux/drive-grants/visual-review.ts @@ -166,7 +166,6 @@ try { workspace_id: "space", generated_at: "2026-10-09", runtime_id: "runtime-a", - revision: "plan-1", digest: "digest-1", diagnostics: [], workdirs: [], diff --git a/tools/web-ux/scenarios/memory-subjects-dark.json b/tools/web-ux/scenarios/memory-subjects-dark.json index bfb3de69b..633df5dab 100644 --- a/tools/web-ux/scenarios/memory-subjects-dark.json +++ b/tools/web-ux/scenarios/memory-subjects-dark.json @@ -154,11 +154,11 @@ "id": "detail", "label": "Committed Memory detail", "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/memory-decision-with-a-deliberately-long-stable-identity-for-overflow-review-0000000001", - "goal": "Audit immutable Memory body, candidate evidence, source refs, derivations, and revision history.", - "dataState": "Current active revision with bounded typed provenance and active/resolved/retracted immutable history.", + "goal": "Audit immutable Memory body, candidate evidence, source refs, derivations, and change history.", + "dataState": "Current active change with bounded typed provenance and active/resolved/retracted immutable history.", "ready": { "kind": "selector", - "selector": "[data-memory-view='detail'] .revision-list" + "selector": "[data-memory-view='detail'] .change-list" }, "capturePoints": [{ "id": "initial", "label": "Memory detail" }] }, diff --git a/tools/web-ux/scenarios/memory-subjects-light.json b/tools/web-ux/scenarios/memory-subjects-light.json index 905f15372..54018c541 100644 --- a/tools/web-ux/scenarios/memory-subjects-light.json +++ b/tools/web-ux/scenarios/memory-subjects-light.json @@ -154,11 +154,11 @@ "id": "detail", "label": "Committed Memory detail", "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/memory-decision-with-a-deliberately-long-stable-identity-for-overflow-review-0000000001", - "goal": "Audit immutable Memory body, candidate evidence, source refs, derivations, and revision history.", - "dataState": "Current active revision with bounded typed provenance and active/resolved/retracted immutable history.", + "goal": "Audit immutable Memory body, candidate evidence, source refs, derivations, and change history.", + "dataState": "Current active change with bounded typed provenance and active/resolved/retracted immutable history.", "ready": { "kind": "selector", - "selector": "[data-memory-view='detail'] .revision-list" + "selector": "[data-memory-view='detail'] .change-list" }, "capturePoints": [{ "id": "initial", "label": "Memory detail" }] }, diff --git a/tools/web-ux/src/capture.ts b/tools/web-ux/src/capture.ts index 05ddc18c2..8c353eae8 100644 --- a/tools/web-ux/src/capture.ts +++ b/tools/web-ux/src/capture.ts @@ -46,7 +46,7 @@ export type CaptureOptions = { headed?: boolean; }; -type SourceState = { revision: string | null; dirty: boolean | null }; +type SourceState = { commitSha: string | null; dirty: boolean | null }; type ErrorCollector = { errors: CaptureError[]; observed: number; limit: number }; const CAPTURE_ERROR_LIMIT = 100; @@ -88,18 +88,18 @@ function timestampId(): string { async function sourceState(): Promise { try { - const [revision, status] = await Promise.all([ + const [commit, status] = await Promise.all([ new Deno.Command("git", { args: ["rev-parse", "HEAD"], stdout: "piped", stderr: "null" }) .output(), new Deno.Command("git", { args: ["status", "--porcelain"], stdout: "piped", stderr: "null" }) .output(), ]); return { - revision: revision.success ? new TextDecoder().decode(revision.stdout).trim() : null, + commitSha: commit.success ? new TextDecoder().decode(commit.stdout).trim() : null, dirty: status.success ? new TextDecoder().decode(status.stdout).trim().length > 0 : null, }; } catch { - return { revision: null, dirty: null }; + return { commitSha: null, dirty: null }; } } diff --git a/tools/web-ux/src/types.ts b/tools/web-ux/src/types.ts index bfee1633a..49201e11d 100644 --- a/tools/web-ux/src/types.ts +++ b/tools/web-ux/src/types.ts @@ -133,7 +133,7 @@ export type ReviewContext = { schemaVersion: 1; runId: string; scenario: { id: string; title: string; sourcePath: string | null }; - source: { revision: string | null; dirty: boolean | null }; + source: { commitSha: string | null; dirty: boolean | null }; baseUrl: string; browser: { name: "chromium"; version: string }; createdAt: string; diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 73ab2ea92..5cc5000c9 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@8.3.2 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts", + "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts", "test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts", "test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts", "test:browser:drive": "deno test --config test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/drive-browser/drive.browser.test.ts", diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index cad3cea74..3f72d5bc9 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:6dd3e9e7ffe5e653697addc29675545b5fb28ea856b14bafc580355f0453ec12) +// Input contract: 0.1.0 (sha256:07cfe53bfdd700cad76d00695a031255ffa76a6062110a05596fa8b64ae372c4) // 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/drive-api.ts b/web/workspace/src/lib/generated/drive-api.ts index a278862ba..fa6a379dd 100644 --- a/web/workspace/src/lib/generated/drive-api.ts +++ b/web/workspace/src/lib/generated/drive-api.ts @@ -10,7 +10,10 @@ export type DriveEntry = { parent: DriveEntryRef | null; name: string; kind: DriveEntryKind; - revision: string; + /** + * Request ID of the last committed mutation, or empty for the immutable root. + */ + last_mutation_id: string; /** * File length in bytes; absent for folders. */ @@ -74,9 +77,9 @@ export type DriveReadChunkQuery = { entry_workspace_id: string; id: string; /** - * Bind a multi-chunk read to the same file revision. + * Require each chunk to match the same committed mutation observed in metadata. */ - expected_revision: string; + expected_mutation_id: string; offset: number; length: number; }; @@ -84,7 +87,7 @@ export type DriveReadChunkQuery = { export type DriveDownloadQuery = { entry_workspace_id: string; id: string; - expected_revision: string | null; + expected_mutation_id: string | null; }; export type DriveMutation = { @@ -100,16 +103,16 @@ export type DriveMutation = { } | { "operation": "update_text"; id: DriveEntryRef; - expected_revision: string; + expected_mutation_id: string; text: string; content_type: string; } | { "operation": "relocate"; id: DriveEntryRef; - expected_revision: string; + expected_mutation_id: string; parent: DriveEntryRef; name: string; -} | { "operation": "delete"; id: DriveEntryRef; expected_revision: string }; +} | { "operation": "delete"; id: DriveEntryRef; expected_mutation_id: string }; export type DriveMutationRequest = { request_id: string; @@ -133,7 +136,7 @@ export type DriveUploadQuery = { parent_id: string | null; name: string | null; id: string | null; - expected_revision: string | null; + expected_mutation_id: string | null; content_type: string; /** * Values above the file limit still decode as u32 so the service returns diff --git a/web/workspace/src/lib/generated/legacy-server-api.ts b/web/workspace/src/lib/generated/legacy-server-api.ts index 955ae4b2d..8a6f7fdee 100644 --- a/web/workspace/src/lib/generated/legacy-server-api.ts +++ b/web/workspace/src/lib/generated/legacy-server-api.ts @@ -24,7 +24,6 @@ export type WorkspaceRepositoryRecord = { provider: string | null; source: RepositorySource; default_ref: string | null; - source_revision: number; source_fingerprint: string; observed_status: RepositoryObservedStatus; observed_at: string | null; @@ -35,7 +34,6 @@ export type WorkspaceRepositoryRecord = { export type WorkspaceCreateResponse = { workspace: WorkspaceSummary; repository: WorkspaceRepositoryRecord | null; - config_revision: number; request_fingerprint: string; replayed: boolean; }; @@ -92,9 +90,9 @@ export type WorkspaceDeletionPreflightResponse = { workspace_id: string; display_name: string; /** - * Opaque persisted Workspace metadata revision used as a CAS fence. + * Observed Workspace metadata update timestamp used as a CAS fence. */ - expected_revision: string; + expected_workspace_updated_at: string; can_delete: boolean; resources: WorkspaceDeletionResourceCounts; blockers: Array; @@ -102,7 +100,7 @@ export type WorkspaceDeletionPreflightResponse = { export type WorkspaceDeletionRequest = { operation_id: string; - expected_revision: string; + expected_workspace_updated_at: string; confirmation: string; }; @@ -155,14 +153,14 @@ export type WorkspaceMetadataSettingsResponse = { workspace_id: string; display_name: string; created_at: string; - revision: string; + updated_at: string; source: string; diagnostics: Array; }; export type UpdateWorkspaceMetadataRequest = { display_name: string; - revision: string; + expected_updated_at: string; }; export type WorkspaceMetadataMutationResponse = { @@ -178,7 +176,6 @@ export type WorkspaceSigningIdentityPublic = { algorithm: string; public_key?: string; public_key_fingerprint?: string; - revision: number; state: WorkspaceSigningIdentityState; created_at: string; provisioned_at?: string; @@ -191,7 +188,6 @@ export type WorkspacePublicIdentityBundle = { algorithm: string; public_key: string; public_key_fingerprint: string; - revision: number; }; export type WorkspaceSigningIdentityResponse = { @@ -209,7 +205,6 @@ export type ConfigEntry = { }; export type ConfigTreeSnapshot = { - revision: number; digest: string; entries: { [key in string]: ConfigEntry }; }; @@ -267,7 +262,6 @@ export type WorkspaceConfigTreeResponse = { }; export type ConfigCommitRequest = { - base_revision: number; base_digest: string; changes: Array; entrypoints: Array; @@ -316,7 +310,6 @@ export type WorkspaceConfigNode = { export type WorkspaceConfigObserveResponse = { connection_id: string; validator: string; - revision: number; digest: string; /** * Canonical commit entrypoints from this exact metadata snapshot. @@ -347,7 +340,6 @@ export type WorkspaceConfigCommitRequest = { export type WorkspaceConfigCommitResponse = { validator: string; - revision: number; digest: string; }; @@ -376,8 +368,6 @@ export type WorkspaceConfigGrantResponse = { export type ProfileSettingsResponse = { workspace_id: string; - registry_revision: string; - config_revision?: number | null; tree_digest?: string | null; projection_digest?: string | null; default_profile?: string | null; @@ -406,7 +396,6 @@ export type WorkspaceProfileSourceSummary = { content_digest: string; provenance: WorkspaceProfileSourceProvenance; editable: boolean; - revision: string; size_bytes: number; diagnostics: Array; }; @@ -445,7 +434,7 @@ export type RepositorySshConnectionProbeResponse = { port: number; trust_state: RepositorySshConnectionTrustState; host_trust_id: string; - expected_host_trust_revision: number | null; + expected_host_key_fingerprint: string | null; candidates: Array; }; @@ -453,7 +442,7 @@ export type ConfirmRepositorySshHostTrustRequest = { operation_id: string; runtime_id: string; host_key: string; - expected_host_trust_revision: number | null; + expected_host_key_fingerprint: string | null; }; export type RepositoryLogResponse = { diff --git a/web/workspace/src/lib/generated/memory-api.ts b/web/workspace/src/lib/generated/memory-api.ts index e4154515b..d2ed15536 100644 --- a/web/workspace/src/lib/generated/memory-api.ts +++ b/web/workspace/src/lib/generated/memory-api.ts @@ -15,60 +15,242 @@ export const SUBJEKTIV_API_LIMITS = { export type DiagnosticSeverity = "info" | "warning" | "error"; -export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, }; +export type Diagnostic = { + code: string; + severity: DiagnosticSeverity; + message: string; +}; -export type MemoryDocumentResponse = { body_md: string, created_at: string, updated_at: string, bytes: number, record_source: string, }; +export type MemoryDocumentResponse = { + body_md: string; + created_at: string; + updated_at: string; + bytes: number; + record_source: string; +}; -export type MemoryCandidateKind = "preference" | "working_assumption" | "constraint" | "decision" | "open_question" | "lesson"; +export type MemoryCandidateKind = + | "preference" + | "working_assumption" + | "constraint" + | "decision" + | "open_question" + | "lesson"; -export type MemoryEvidenceOriginKind = "human_input" | "worker_input" | "flow_instruction" | "backend_instruction" | "model_output" | "tool_output" | "derived_summary" | "legacy_unknown"; +export type MemoryEvidenceOriginKind = + | "human_input" + | "worker_input" + | "flow_instruction" + | "backend_instruction" + | "model_output" + | "tool_output" + | "derived_summary" + | "legacy_unknown"; -export type MemoryEvidenceOrigin = { kind: MemoryEvidenceOriginKind, account_id?: string | null, workspace_id?: string | null, runtime_id?: string | null, worker_id?: string | null, flow_selector?: string | null, flow_definition_id?: string | null, flow_definition_revision?: number | null, }; +export type MemoryEvidenceOrigin = { + kind: MemoryEvidenceOriginKind; + account_id?: string | null; + workspace_id?: string | null; + runtime_id?: string | null; + worker_id?: string | null; + flow_selector?: string | null; + flow_definition_id?: string | null; + flow_definition_fingerprint?: number | null; +}; -export type MemorySourceRef = { segment_id: string, range: [number, number], }; +export type MemorySourceRef = { segment_id: string; range: [number, number] }; -export type MemoryStagingEvidence = { id: string, kind: string, entry_range: [number, number] | null, origin?: MemoryEvidenceOrigin | null, excerpt: string | null, summary: string | null, }; +export type MemoryStagingEvidence = { + id: string; + kind: string; + entry_range: [number, number] | null; + origin?: MemoryEvidenceOrigin | null; + excerpt: string | null; + summary: string | null; +}; -export type MemorySourceEvidenceRef = { session_id: string | null, segment_id: string | null, entry_range: [number, number] | null, evidence_id: string | null, origin?: MemoryEvidenceOrigin | null, evidence_kind: string | null, label: string | null, summary: string | null, }; +export type MemorySourceEvidenceRef = { + session_id: string | null; + segment_id: string | null; + entry_range: [number, number] | null; + evidence_id: string | null; + origin?: MemoryEvidenceOrigin | null; + evidence_kind: string | null; + label: string | null; + summary: string | null; +}; -export type MemoryStagingRecord = { schema_version: number, id: string, extract_run_id: string, source: MemorySourceRef, kind: MemoryCandidateKind, claim: string, why_useful: string, staleness: string | null, evidence: Array, source_refs: Array, }; +export type MemoryStagingRecord = { + schema_version: number; + id: string; + extract_run_id: string; + source: MemorySourceRef; + kind: MemoryCandidateKind; + claim: string; + why_useful: string; + staleness: string | null; + evidence: Array; + source_refs: Array; +}; -export type MemoryStagingEntry = { id: string, byte_len: number, record: MemoryStagingRecord, }; +export type MemoryStagingEntry = { + id: string; + byte_len: number; + record: MemoryStagingRecord; +}; -export type MemoryStagingListResponse = { limit: number, returned_count: number, total_valid_count: number, invalid_count: number, truncated: boolean, order: string, record_authority: string, items: Array, diagnostics: Array, }; +export type MemoryStagingListResponse = { + limit: number; + returned_count: number; + total_valid_count: number; + invalid_count: number; + truncated: boolean; + order: string; + record_authority: string; + items: Array; + diagnostics: Array; +}; export type SubjektivSubjectState = "active" | "retired"; -export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, }; +export type SubjektivSubjectCreateRequest = { + role: string; + behavior_md?: string; +}; -export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_revision: number, behavior_md: string, }; +export type SubjektivSubjectBehaviorUpdateRequest = { + expected_behavior_md: string; + behavior_md: string; +}; -export type SubjektivSubjectResponse = { id: string, role: string, behavior_md: string, behavior_revision: number, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, }; +export type SubjektivSubjectResponse = { + id: string; + role: string; + behavior_md: string; + state: SubjektivSubjectState; + memory_fingerprint: string; + created_at: string; + updated_at: string; + current_worker?: + | import("./worker-launch-api").WorkerLaunchWorkerSummary + | null; +}; -export type SubjektivSubjectListResponse = { limit: number, items: Array, next_cursor?: string | null, has_more: boolean, }; +export type SubjektivSubjectListResponse = { + limit: number; + items: Array; + next_cursor?: string | null; + has_more: boolean; +}; export type SubjektivMemoryState = "active" | "resolved" | "retracted"; -export type SubjektivMemoryRevisionRef = { memory_id: string, revision: number, }; +export type SubjektivMemoryChangeRef = { memory_id: string; change_id: string }; -export type SubjektivResidentSurfaceAvailability = "ungenerated" | "stale" | "failed" | "ready"; +export type SubjektivResidentSurfaceAvailability = + | "ungenerated" + | "stale" + | "failed" + | "ready"; -export type SubjektivResidentSurfaceSnapshot = { snapshot_id: string, body_md: string, memory_refs: Array, built_from_store_revision: number, created_at: string, }; +export type SubjektivResidentSurfaceSnapshot = { + snapshot_id: string; + body_md: string; + memory_refs: Array; + built_from_memory_fingerprint: string; + created_at: string; +}; -export type SubjektivResidentSurfaceResponse = { subject_id: string, availability: SubjektivResidentSurfaceAvailability, snapshot?: SubjektivResidentSurfaceSnapshot | null, }; +export type SubjektivResidentSurfaceResponse = { + subject_id: string; + availability: SubjektivResidentSurfaceAvailability; + snapshot?: SubjektivResidentSurfaceSnapshot | null; +}; -export type SubjektivMemoryQueryItem = { id: string, revision: number, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, excerpt: string, updated_at: string, }; +export type SubjektivMemoryQueryItem = { + id: string; + change_id: string; + kind: MemoryCandidateKind; + state: SubjektivMemoryState; + claim: string; + excerpt: string; + updated_at: string; +}; -export type SubjektivMemoryQueryResponse = { items: Array, next_cursor?: string | null, has_more: boolean, }; +export type SubjektivMemoryQueryResponse = { + items: Array; + next_cursor?: string | null; + has_more: boolean; +}; -export type SubjektivMemoryEvidence = { id: string, kind: string, entry_range?: [number, number] | null, origin?: MemoryEvidenceOrigin | null, excerpt?: string | null, summary?: string | null, }; +export type SubjektivMemoryEvidence = { + id: string; + kind: string; + entry_range?: [number, number] | null; + origin?: MemoryEvidenceOrigin | null; + excerpt?: string | null; + summary?: string | null; +}; -export type SubjektivMemorySourceEvidenceRef = { session_id?: string | null, segment_id?: string | null, entry_range?: [number, number] | null, evidence_id?: string | null, origin?: MemoryEvidenceOrigin | null, evidence_kind?: string | null, label?: string | null, summary?: string | null, }; +export type SubjektivMemorySourceEvidenceRef = { + session_id?: string | null; + segment_id?: string | null; + entry_range?: [number, number] | null; + evidence_id?: string | null; + origin?: MemoryEvidenceOrigin | null; + evidence_kind?: string | null; + label?: string | null; + summary?: string | null; +}; -export type SubjektivMemoryEvidenceCandidate = { candidate_id: string, evidence: Array, evidence_total: number, evidence_truncated: boolean, source_refs: Array, source_refs_total: number, source_refs_truncated: boolean, }; +export type SubjektivMemoryEvidenceCandidate = { + candidate_id: string; + evidence: Array; + evidence_total: number; + evidence_truncated: boolean; + source_refs: Array; + source_refs_total: number; + source_refs_truncated: boolean; +}; -export type SubjektivMemoryReadResponse = { memory_id: string, revision: number, current_revision: number, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, body_md: string, why_useful: string, staleness?: string | null, change_reason: string, created_at: string, updated_at: string, body_offset: number, body_byte_offset: number, body_next_offset?: number | null, body_next_byte_offset?: number | null, body_truncated: boolean, source_candidate_ids: Array, source_candidates: Array, derived_from: Array, evidence_next_cursor?: string | null, evidence_has_more: boolean, }; +export type SubjektivMemoryReadResponse = { + memory_id: string; + change_id: string; + current_change_id: string; + kind: MemoryCandidateKind; + state: SubjektivMemoryState; + claim: string; + body_md: string; + why_useful: string; + staleness?: string | null; + change_reason: string; + created_at: string; + updated_at: string; + body_offset: number; + body_byte_offset: number; + body_next_offset?: number | null; + body_next_byte_offset?: number | null; + body_truncated: boolean; + source_candidate_ids: Array; + source_candidates: Array; + derived_from: Array; + evidence_next_cursor?: string | null; + evidence_has_more: boolean; +}; -export type SubjektivMemoryRevisionItem = { revision: number, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, change_reason: string, updated_at: string, }; +export type SubjektivMemoryChangeItem = { + change_id: string; + kind: MemoryCandidateKind; + state: SubjektivMemoryState; + claim: string; + change_reason: string; + updated_at: string; +}; -export type SubjektivMemoryListRevisionsResponse = { memory_id: string, current_revision: number, items: Array, next_cursor?: string | null, has_more: boolean, }; +export type SubjektivMemoryListChangesResponse = { + memory_id: string; + current_change_id: string; + items: Array; + next_cursor?: string | null; + has_more: boolean; +}; diff --git a/web/workspace/src/lib/generated/protocol-validator.ts b/web/workspace/src/lib/generated/protocol-validator.ts index eb0366e1a..c9c810b09 100644 --- a/web/workspace/src/lib/generated/protocol-validator.ts +++ b/web/workspace/src/lib/generated/protocol-validator.ts @@ -21,9 +21,9 @@ const MAX_CORRELATION_ID_BYTES = 128; const MAX_RESOURCE_ID_BYTES = 256; const MAX_WORKER_IDS_PER_SELECTOR = 256; const MAX_REJECTION_MESSAGE_BYTES = 1024; -const subscriptionFrameSchema: unknown = {"$defs":{"Alert":{"additionalProperties":false,"description":"User-facing alert emitted from the Worker layer.\n\nThis is a separate channel from `tracing` (developer logs): entries\nhere are assembled explicitly by the Worker when a condition should be\nsurfaced to the person driving the client. Keep messages short and\nhuman-readable.","properties":{"level":{"$ref":"#/$defs/AlertLevel"},"message":{"type":"string"},"source":{"$ref":"#/$defs/AlertSource"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"}},"required":["level","source","message","timestamp_ms"],"type":"object"},"AlertLevel":{"enum":["warn","error"],"type":"string"},"AlertSource":{"enum":["worker","engine","compactor","agents_md"],"type":"string"},"CommandEvent":{"oneOf":[{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"kind":{"const":"started","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_call_id":{"type":["string","null"]}},"required":["kind","command_id","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"output","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stream":{"$ref":"#/$defs/CommandStream"}},"required":["kind","command_id","stream","start_offset","end_offset","content","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"kind":{"const":"terminal","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stdout_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","command_id","status","stdout_end_offset","stderr_end_offset","observed_at_ms"],"type":"object"}]},"CommandSnapshot":{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"last_output_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr":{"$ref":"#/$defs/CommandStreamSlice"},"stdout":{"$ref":"#/$defs/CommandStreamSlice"},"tool_call_id":{"type":["string","null"]}},"required":["command_id","status","started_at_ms","observed_at_ms","stdout","stderr"],"type":"object"},"CommandStatus":{"description":"Live provider-owned command status. These values are operational Console\nstate only and are never appended to Worker history.","enum":["running","completed","failed","timed_out","cancelled"],"type":"string"},"CommandStream":{"enum":["stdout","stderr"],"type":"string"},"CommandStreamSlice":{"additionalProperties":false,"properties":{"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"truncated":{"type":"boolean"}},"required":["start_offset","end_offset","content","truncated"],"type":"object"},"CompactionLifecycle":{"additionalProperties":false,"description":"Stable parent-owned lifecycle for one compaction run.","properties":{"compaction_id":{"type":"string"},"ended_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"error":{"type":["string","null"]},"internal_worker":{"anyOf":[{"$ref":"#/$defs/InternalWorkerRef"},{"type":"null"}]},"new_segment_id":{"type":["string","null"]},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"schema_version":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"description":"Milliseconds since the Unix epoch.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/CompactionLifecycleState"},"summary":{"type":["string","null"]}},"required":["schema_version","compaction_id","revision","state","started_at_ms"],"type":"object"},"CompactionLifecycleState":{"enum":["running","done","failed","interrupted"],"type":"string"},"CompactionPhase":{"enum":["preparing","summarizing","committing"],"type":"string"},"CompactionTrigger":{"enum":["manual","pre_run","request_threshold"],"type":"string"},"CompletionContext":{"additionalProperties":false,"properties":{"argument":{"type":["string","null"]},"invocation":{"$ref":"#/$defs/FeatureInvocationIdentity"}},"required":["invocation"],"type":"object"},"CompletionEntry":{"additionalProperties":false,"description":"One completion candidate for a prefix query.","properties":{"description":{"type":["string","null"]},"invocation":{"anyOf":[{"$ref":"#/$defs/FeatureInvocationDescriptor"},{"type":"null"}]},"is_dir":{"default":false,"type":"boolean"},"usage":{"type":["string","null"]},"value":{"type":"string"}},"required":["value","is_dir"],"type":"object"},"CompletionKind":{"description":"Kind of completion requested by `Method::ListCompletions`.\n\n`File` is the existing `@` path lane. `Feature` lists installed chat\ninvocations. `FeatureArgument` resolves a declared argument using the\nsource-qualified [`CompletionContext`].","enum":["file","feature","feature_argument"],"type":"string"},"ContextTokenSource":{"description":"Provenance of the current-context token count in a [`Greeting`].","oneOf":[{"const":"measured","description":"Provider-reported input occupancy from the most recent LLM request.","type":"string"},{"const":"estimated","description":"Current history projected from the Worker's token accounting records.","type":"string"}]},"ContextUsage":{"additionalProperties":false,"description":"Current prompt-context occupancy, distinct from cumulative run traffic.","properties":{"source":{"$ref":"#/$defs/ContextTokenSource"},"tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["tokens","source"],"type":"object"},"ErrorCode":{"enum":["already_running","not_running","not_paused","provider_error","tool_error","invalid_request","internal"],"type":"string"},"Event":{"oneOf":[{"additionalProperties":false,"description":"Durable Submit acceptance. A `Queued` receipt follows the durable FIFO\ncheckpoint. A `Started` receipt follows the pending activation checkpoint;\nthe corresponding [`Event::UserMessage`] marks the later history commit.\nRepeating the same request id and exact payload returns the same receipt\nwithout appending or activating twice.","properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"$ref":"#/$defs/SubmissionDisposition"},"submission_id":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","submission_id","disposition"],"type":"object"},"event":{"const":"submission_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","message"],"type":"object"},"event":{"const":"submission_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Durable notification acceptance. The request identity is persisted with\nthe notification receipt before this event is emitted. Repeating the\nsame request id and exact payload returns the same acknowledgement\nwithout appending the notification twice.","properties":{"data":{"additionalProperties":false,"properties":{"notification_request_id":{"type":"string"}},"required":["notification_request_id"],"type":"object"},"event":{"const":"notification_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated notification rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"},"event":{"const":"notification_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Revisioned FIFO replacement following enqueue, activation, cancel, or clear.","properties":{"data":{"additionalProperties":false,"properties":{"pending":{"$ref":"#/$defs/PendingSubmissionsSnapshot"}},"required":["pending"],"type":"object"},"event":{"const":"pending_submissions_changed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A user input message persisted as `LogEntry::AnnotatedUserInput` and\nactivated for a turn. Broadcast to every subscribed client so TUI / GUI\ninstances show the same user line that reconnect snapshots replay.","properties":{"data":{"additionalProperties":false,"properties":{"entry_id":{"description":"Stable durable history identity. Older mixed-version producers omit it.","type":["string","null"]},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"}},"required":["segments"],"type":"object"},"event":{"const":"user_message","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A canonical assistant/tool entry has committed to durable history.\nStreaming events remain the in-flight authority; this event supplies the\nstable identity used to reconcile that live block with paged history.","properties":{"data":{"additionalProperties":false,"properties":{"entry":{"$ref":"#/$defs/SessionSnapshotEntry"}},"required":["entry"],"type":"object"},"event":{"const":"session_entry_committed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One agent-injected system item committed to history.\n\nCarries the JSON form of `session_store::SystemItem`. Covers\n`Method::Notify` echoes, child-Worker lifecycle events from\n`Method::WorkerEvent`, `@` resolution payloads, and any future\nClients dispatch on the `kind` tag for typed rendering instead\nof parsing free-text prefixes like `[Notification] …` or\n`[File: …]`.\n\nOne event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and\nwire-side are 1:1.","properties":{"data":{"additionalProperties":false,"properties":{"entry_id":{"description":"Stable durable history identity. Synthetic compatibility events omit it.","type":["string","null"]},"item":true},"required":["item"],"type":"object"},"event":{"const":"system_item","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A new self-driving cycle has begun (IDLE → active transition).\n\nMarker event for the start of an Invoke range; the range extends\nimplicitly until the next `InvokeStart`. Fires for every accepted\n`Method::Submit` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),\n`Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other\nIDLE-breaking trigger. Mid-run interrupts (e.g. hook output,\ntyped system reminder insertion that doesn't break IDLE) do not\nemit `InvokeStart` — they appear as `SystemItem` only.\n\nCarries `kind` only; the payload (user text / notify message /\nworker event body) is delivered separately via the immediately\nfollowing `UserMessage` / `SystemItem` event.","properties":{"data":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/InvokeKind"},"timestamp_ms":{"description":"Timestamp of the persisted Invoke, not the client's attach time.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]}},"required":["kind"],"type":"object"},"event":{"const":"invoke_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One AgentTurn boundary opened. An AgentTurn is a maximal run of\nLLM generation calls whose input messages are identical (i.e.\nretries from network errors / 5xx / stream disconnects collapse\ninto the same AgentTurn). When the input changes (a new tool\nresult lands, a user interrupts, etc.), the next LLM call belongs\nto a new AgentTurn.\n\n`turn` is the AgentTurn index from `Engine::turn_count`.\n\nCurrently retry is not yet implemented (`agen-stream-continuation`)\nso AgentTurn and `LlmCall` fire 1:1, but the contract here is\nthe AgentTurn boundary; consumers that want per-LLM-call signals\nmust subscribe to `LlmCallStart` / `LlmCallEnd` instead.","properties":{"data":{"additionalProperties":false,"properties":{"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn"],"type":"object"},"event":{"const":"turn_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"AgentTurn closed.","properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/TurnResult"},"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn","result"],"type":"object"},"event":{"const":"turn_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One LLM generation call started (1 request → 1 generation, retry\nincluded). Multiple `LlmCall*` pairs may fire inside a single\n`TurnStart` / `TurnEnd` pair when a request is retried; today\nthey fire 1:1 because retry is not implemented.\n\n`llm_call` is the worker-wide running counter from\n`Engine::llm_call_count`.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"LLM generation call ended.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A transport-level LLM request retry has been scheduled.\n\nThis is operational state for clients to render while the worker is\nwaiting in backoff. It is not part of conversation history.","properties":{"data":{"additionalProperties":false,"properties":{"elapsed_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"error":{"type":"string"},"failed_attempt":{"description":"The attempt that just failed. 1 origin.","format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"status":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":["integer","null"]},"wait_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call","failed_attempt","max_attempts","wait_ms","elapsed_ms","error"],"type":"object"},"event":{"const":"llm_retry","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Stream generation was interrupted after events had begun and the worker\nis continuing with a follow-up LLM request.","properties":{"data":{"additionalProperties":false,"properties":{"attempt":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"reason":{"type":"string"}},"required":["llm_call","attempt","max_attempts","reason"],"type":"object"},"event":{"const":"llm_continuation","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A reasoning / thinking block has started.\n\nAlways paired with a `ThinkingDone`. `ThinkingDelta` is optional:\nsome providers (or some configurations) emit thinking metadata\nwithout plaintext, in which case Start → Done arrive with no\ndeltas in between. Multiple thinking blocks per turn are allowed.","properties":{"event":{"const":"thinking_start","type":"string"}},"required":["event"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Thinking block completed. `text` is the full accumulated body\n(empty string when the provider didn't emit plaintext).","properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"event":{"const":"tool_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"json":{"type":"string"}},"required":["id","json"],"type":"object"},"event":{"const":"tool_call_args_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name","arguments"],"type":"object"},"event":{"const":"tool_call_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"anyOf":[{"$ref":"#/$defs/ToolResultDisposition"},{"type":"null"}]},"id":{"type":"string"},"is_error":{"default":false,"type":"boolean"},"output":{"description":"Full tool output. Absent when the tool chose to return\nsummary-only, or when the result was pruned.","type":["string","null"]},"summary":{"description":"Short human-readable summary. Always present; used by clients\nthat only want a 1-line rendering (e.g. collapsed views).","type":"string"}},"required":["id","summary","is_error"],"type":"object"},"event":{"const":"tool_result","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Token accounting for one LLM request.\n\n`input_tokens` is the prompt prefix occupancy (cache reads /\ncache writes included), as normalised by the worker layer.\n`cache_read_input_tokens` is the cache-hit subset of that\noccupancy; subtracting it yields the \"net upload\" the client\nactually paid full price to send on this request, which is what\nthe TUI status line accumulates per turn.","properties":{"data":{"additionalProperties":false,"properties":{"cache_read_input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"timestamp_ms":{"description":"Present for usage delivered from the committed session-log lane.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]}},"type":"object"},"event":{"const":"usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Authoritative current-context occupancy after a non-request boundary.\n\nUnlike [`Event::Usage`], this is not cumulative request traffic. It lets\nconnected clients converge with reconnect snapshots after run completion,\ncompaction, rewind, and other history rewrites. `None` explicitly means\nthe current value is unavailable and stale occupancy must be cleared.","properties":{"data":{"additionalProperties":false,"properties":{"usage":{"anyOf":[{"$ref":"#/$defs/ContextUsage"},{"type":"null"}]}},"type":"object"},"event":{"const":"context_usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/RunResult"}},"required":["result"],"type":"object"},"event":{"const":"run_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/ErrorCode"},"message":{"type":"string"}},"required":["code","message"],"type":"object"},"event":{"const":"error","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Sent exactly once at the start of every client connection.\n\n`entries` is the session-log mirror at subscribe time, serialised\nas the JSON form of `session_store::LogEntry`. This is the\nbulk-reconstruction lane: clients walk the entries to seed their\nderived view.\n\n`greeting` and `status` accompany the snapshot so clients render\nworker identity and current controller state without an extra round\ntrip.\n\nLive updates after the snapshot arrive through the streaming\nevents (`TextDelta` / `ToolCall*` / `ToolResult` / etc.) plus\nrole-specific entry events (`SegmentRotated` / `SystemItem`) —\nthere is no generic \"every committed entry\" broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"greeting":{"$ref":"#/$defs/Greeting"},"in_flight":{"$ref":"#/$defs/InFlightSnapshot","description":"Unfinished model output that has already streamed in the current\nrun but is not yet represented by committed snapshot entries."},"internal_workers":{"description":"Parent-owned Internal Worker sessions visible to this client.\nService-private Internal Workers are deliberately excluded.","items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"Full revisioned live execution state. `Stopped` remains Runtime\ncatalog authority and is deliberately not represented here."}},"required":["session","greeting","state"],"type":"object"},"event":{"const":"snapshot","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A live event from a parent-owned Internal Worker. The payload reuses the\nnormal Worker event vocabulary while the wrapper carries stable origin\nidentity and a per-child revision fence.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision","event"],"type":"object"},"event":{"const":"internal_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Terminal removal fence for one parent-owned Internal Worker session.\n\nClients discard the matching child and descendants, then ignore later\nnested events for this identity until an authoritative snapshot replaces\nthe projection.","properties":{"data":{"additionalProperties":false,"properties":{"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision"],"type":"object"},"event":{"const":"internal_worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Server-side segment log rotated to a fresh `SegmentStart`.\n\nFires on compaction and on auto-fork when the store head drifts\nA compaction/fork has replaced the authoritative segment. Clients drop\ntheir derived view and reseed from the canonical committed snapshot.","properties":{"data":{"additionalProperties":false,"properties":{"session":{"$ref":"#/$defs/SessionSnapshot"}},"required":["session"],"type":"object"},"event":{"const":"segment_rotated","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Current Worker controller status. Broadcast on every controller-level\ntransition and included in `History` snapshots for late attach.","properties":{"data":{"additionalProperties":false,"properties":{"snapshot":{"$ref":"#/$defs/WorkerStateSnapshot"}},"required":["snapshot"],"type":"object"},"event":{"const":"worker_state","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"acknowledgement":{"$ref":"#/$defs/WorkerCommandAcknowledgement"}},"required":["acknowledgement"],"type":"object"},"event":{"const":"command_acknowledged","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Bounded, provider-owned command telemetry for the live Console. This is\nintentionally not a history entry and is reconstructed from\n`Snapshot.in_flight.commands` after reconnect.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/CommandEvent"}},"required":["event"],"type":"object"},"event":{"const":"command","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListCompletions`. Delivered only to the\nrequesting socket (not broadcast). `entries` is empty when no\ncandidates match or when the requested kind has no resolver\nwired up.","properties":{"data":{"additionalProperties":false,"properties":{"context":{"anyOf":[{"$ref":"#/$defs/CompletionContext"},{"type":"null"}]},"entries":{"items":{"$ref":"#/$defs/CompletionEntry"},"type":"array"},"kind":{"$ref":"#/$defs/CompletionKind"},"prefix":{"default":"","type":"string"},"request_id":{"type":["string","null"]}},"required":["kind","entries","prefix"],"type":"object"},"event":{"const":"completions","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListRewindTargets`. Clients should only open a picker\nin response to their own pending request; the event may be broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"targets":{"items":{"$ref":"#/$defs/RewindTarget"},"type":"array"}},"required":["head_entries","targets"],"type":"object"},"event":{"const":"rewind_targets","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A rewind has truncated the authoritative session. `session` is the\nretained canonical snapshot clients should use to reseed display state.","properties":{"data":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"summary":{"$ref":"#/$defs/RewindSummary"}},"required":["session","input","summary"],"type":"object"},"event":{"const":"rewind_applied","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListWorkers`. Payload is a stable JSON value so the Worker\ncrate can evolve discovery fields without introducing a protocol\ndependency on session-store.","properties":{"data":{"additionalProperties":false,"properties":{"workers":true},"required":["workers"],"type":"object"},"event":{"const":"workers_listed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RestoreWorker`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"worker_restored","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RegisterPeer`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"peer_registered","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"$ref":"#/$defs/Alert"},"event":{"const":"alert","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Latest memory extract/consolidation lifecycle event for UI observability.\n\nThis is not part of LLM history or prompt context; clients may display it\nbriefly as operational status.","properties":{"data":{"$ref":"#/$defs/MemoryWorkerEvent"},"event":{"const":"memory_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Runtime-only compaction progress. `None` clears the current status.\nThis never enters Session history and carries no operation or Segment identity.","properties":{"data":{"additionalProperties":false,"properties":{"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}]}},"type":"object"},"event":{"const":"compaction_progress","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Legacy compaction lifecycle event retained for wire read compatibility.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction completed and the session was rotated.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction failed or was cancelled. The session is unchanged.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_failed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"event":{"const":"shutdown","type":"string"}},"required":["event"],"type":"object"}]},"EventSubscriptionSelector":{"oneOf":[{"additionalProperties":false,"properties":{"topic":{"const":"runtime_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"properties":{"topic":{"const":"worker_lifecycle","type":"string"},"worker_ids":{"$ref":"#/$defs/SubscriptionWorkerIds"}},"required":["topic","worker_ids"],"type":"object"},{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"topic":{"const":"worker_protocol","type":"string"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["topic","worker_id"],"type":"object"},{"additionalProperties":false,"description":"Server-derived Workspace projection. Workspace identity comes from the\nauthenticated connection and is deliberately absent from this selector.","properties":{"topic":{"const":"workspace_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"description":"Typed extension point for the later Workdir subscription slice.","properties":{"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic"],"type":"object"}]},"FeatureInvocation":{"additionalProperties":false,"properties":{"arguments":{"items":{"$ref":"#/$defs/InvocationArgumentValue"},"type":"array"},"identity":{"$ref":"#/$defs/FeatureInvocationIdentity"},"invocation_id":{"description":"Stable client-generated id retained across queueing and transport retry.","type":"string"},"name":{"description":"Selected public name, retained for exact draft/history restoration. Host\nresolution is always by `identity`.","type":"string"}},"required":["invocation_id","identity","name","arguments"],"type":"object"},"FeatureInvocationDescriptor":{"additionalProperties":false,"properties":{"aliases":{"default":[],"items":{"type":"string"},"type":"array"},"arguments":{"default":[],"items":{"$ref":"#/$defs/InvocationArgumentDescriptor"},"type":"array"},"client_adapter":{"anyOf":[{"$ref":"#/$defs/InvocationClientAdapter"},{"type":"null"}]},"description":{"type":"string"},"display_name":{"type":"string"},"identity":{"$ref":"#/$defs/FeatureInvocationIdentity"},"name":{"type":"string"},"syntax":{"$ref":"#/$defs/FeatureInvocationSyntax"}},"required":["identity","name","display_name","description","syntax","aliases","arguments"],"type":"object"},"FeatureInvocationIdentity":{"description":"Source-qualified identity of one invocation contribution, for example\n`builtin:attachments/attach`. Display names and aliases are never authority.","type":"string"},"FeatureInvocationSyntax":{"oneOf":[{"const":"parenthesized","description":"`/name(value, key=value)` with JSON-style quoted strings and `)` as the\nexplicit boundary before following natural language.","type":"string"}]},"Greeting":{"additionalProperties":false,"description":"Worker self-description rendered by clients when a session starts empty.\n\nSnapshot metadata contains only resolved public execution information so\nclients do not need their own copy of the manifest.","properties":{"context_tokens":{"default":0,"description":"Legacy numeric mirror retained for mixed-version clients.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"context_usage":{"anyOf":[{"$ref":"#/$defs/ContextUsage"},{"type":"null"}],"description":"Typed current-context value. Older snapshots omit it; clients may fall\nback to `context_tokens` only when `context_window` is known."},"context_window":{"default":0,"description":"Resolved effective model context window in tokens. Legacy producers use\nzero when this is unavailable; clients must treat that as unknown.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"cwd":{"type":"string"},"model":{"type":"string"},"provider":{"type":"string"},"reasoning":{"anyOf":[{"$ref":"#/$defs/ReasoningConfig"},{"type":"null"}],"description":"Effective reasoning control. Absent means unspecified or unavailable."},"scope_summary":{"type":"string"},"tools":{"items":{"type":"string"},"type":"array"},"worker_name":{"type":"string"}},"required":["worker_name","cwd","provider","model","scope_summary","tools","context_tokens","context_window"],"type":"object"},"InFlightBlock":{"oneOf":[{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"thinking","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"type":"string"},"id":{"type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"state":{"$ref":"#/$defs/InFlightToolCallState"}},"required":["kind","id","name","args"],"type":"object"}]},"InFlightCompaction":{"additionalProperties":false,"properties":{"phase":{"$ref":"#/$defs/CompactionPhase"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"trigger":{"$ref":"#/$defs/CompactionTrigger"}},"required":["phase","started_at_ms","trigger"],"type":"object"},"InFlightSnapshot":{"additionalProperties":false,"description":"Unfinished model output and active command state included in\n`Event::Snapshot` for clients that attach while work is still streaming.\n\nThese blocks are presentation state only: they are reconstructed from the\nactive Worker controller and must not be treated as committed assistant\nhistory. Finalized assistant items continue to come from ordinary snapshot\nentries.","properties":{"blocks":{"items":{"$ref":"#/$defs/InFlightBlock"},"type":"array"},"commands":{"items":{"$ref":"#/$defs/CommandSnapshot"},"type":"array"},"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}],"description":"The currently running compaction, if any.\n\nThis is lifecycle progress only. Candidate history and the staged\nSegment remain private until the Segment is activated atomically."}},"type":"object"},"InFlightToolCallState":{"enum":["pending","streaming_args","done"],"type":"string"},"InternalWorkerKind":{"description":"Presentation category for an Internal Worker exposed through its parent's\nprotocol stream. Internal Workers never become independently addressable\nprotocol subjects.","oneOf":[{"enum":["sub_worker"],"type":"string"},{"additionalProperties":false,"properties":{"service":{"additionalProperties":false,"properties":{"kind":{"type":"string"}},"required":["kind"],"type":"object"}},"required":["service"],"type":"object"}]},"InternalWorkerRef":{"additionalProperties":false,"description":"Stable presentation identity for one parent-owned Internal Worker session.\n`name` is display-only; `session_id` is the identity used by clients.","properties":{"kind":{"$ref":"#/$defs/InternalWorkerKind"},"name":{"type":"string"},"parent_session_id":{"type":["string","null"]},"session_id":{"type":"string"}},"required":["session_id","name","kind"],"type":"object"},"InternalWorkerSnapshot":{"additionalProperties":false,"description":"Reconnect state for one visible Internal Worker. The revision fences live\n`Event::InternalWorker` updates that raced with parent snapshot assembly.","properties":{"error":{"type":["string","null"]},"greeting":{"anyOf":[{"$ref":"#/$defs/Greeting"},{"type":"null"}],"description":"Public execution metadata for this child. Older snapshots and service\nworkers may omit it; clients must never substitute the parent's values."},"in_flight":{"$ref":"#/$defs/InFlightSnapshot"},"internal_workers":{"items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"session":{"$ref":"#/$defs/SessionSnapshot"},"status":{"$ref":"#/$defs/WorkerStatus","default":"idle"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision","session","status"],"type":"object"},"InvocationArgumentDescriptor":{"additionalProperties":false,"properties":{"completion":{"$ref":"#/$defs/InvocationCompletion","default":{"kind":"none"}},"description":{"type":["string","null"]},"name":{"type":"string"},"position":{"description":"Zero-based positional index. `None` means named-only. A positional\nargument may also be supplied by name; this makes chip editing stable.","format":"uint16","maximum":65535.0,"minimum":0.0,"type":["integer","null"]},"required":{"default":false,"type":"boolean"},"value_type":{"$ref":"#/$defs/InvocationArgumentType"}},"required":["name","value_type","completion","required"],"type":"object"},"InvocationArgumentType":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"string","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"integer","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"boolean","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"enum","type":"string"},"values":{"items":{"type":"string"},"type":"array"}},"required":["kind","values"],"type":"object"},{"additionalProperties":false,"description":"A Worker-readable path. This does not grant filesystem authority.","properties":{"kind":{"const":"worker_file","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"description":"A resource selected and staged by the client. It must never be resolved\nas a path on the Worker host.","properties":{"kind":{"const":"client_file","type":"string"}},"required":["kind"],"type":"object"}]},"InvocationArgumentValue":{"additionalProperties":false,"properties":{"name":{"type":"string"},"value":{"$ref":"#/$defs/InvocationValue"}},"required":["name","value"],"type":"object"},"InvocationClientAdapter":{"oneOf":[{"const":"attachment","description":"The client stages a local file and replaces the draft invocation with an\n`UploadedFile` chip. Local paths are not sent to or persisted by Worker.","type":"string"}]},"InvocationCompletion":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"none","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"static","type":"string"},"values":{"items":{"type":"string"},"type":"array"}},"required":["kind","values"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"worker_file","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"client_file","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"description":"Host-owned provider selected by source-qualified id. Metadata lookup is\nside-effect free; implementations never cross this wire boundary.","properties":{"kind":{"const":"provider","type":"string"},"provider":{"type":"string"}},"required":["kind","provider"],"type":"object"}]},"InvocationValue":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"string","type":"string"},"value":{"type":"string"}},"required":["kind","value"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"integer","type":"string"},"value":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":"integer"}},"required":["kind","value"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"boolean","type":"string"},"value":{"type":"boolean"}},"required":["kind","value"],"type":"object"}]},"InvokeKind":{"description":"Kind of trigger that opened a new Invoke (IDLE → active) range.\n\nOne Invoke groups all entries from this trigger up to the next\n`Invoke` marker. The kind is the only payload — content (user text,\nnotify message, worker event body) is delivered by the immediately\nfollowing Turn entry, not by the marker itself.","oneOf":[{"const":"user_send","description":"`Method::Submit` — a user submission.","type":"string"},{"const":"notify","description":"`Method::Notify` — free-text notification injected into history.","type":"string"},{"const":"worker_event","description":"`Method::WorkerEvent` — typed lifecycle report from a child Worker.","type":"string"},{"const":"system_reminder","description":"A typed system reminder that crosses an IDLE boundary (mid-run\nreminders that don't break IDLE are SystemItem-only and do not\nopen a new Invoke).","type":"string"},{"const":"wakeup","description":"Cron / RemoteTrigger style scheduled wake-up. Reserved; no\nproducer is wired today but the variant is part of the initial\nset so future schedulers don't have to amend the wire enum.","type":"string"}]},"MemoryWorkerEvent":{"additionalProperties":false,"properties":{"message":{"description":"Human-readable compact form for actionbar rendering.","type":"string"},"reason":{"type":"string"},"run_id":{"type":"string"},"status":{"type":"string"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"},"trigger":{"type":"string"},"worker":{"type":"string"}},"required":["worker","status","run_id","trigger","reason","message","timestamp_ms"],"type":"object"},"Method":{"oneOf":[{"additionalProperties":false,"description":"Durably accept typed input for immediate activation or the session FIFO.\nRunning or Paused Workers queue Submit for a later turn; use Notify for\nadvisory information to incorporate into work already in progress.\n\n`submission_request_id` is generated by the authenticated caller and is\nused only for idempotent retry. Worker allocates the durable\n`submission_id` returned by [`Event::SubmissionAccepted`].","properties":{"method":{"const":"submit","type":"string"},"params":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","input"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Human-readable text injected into the target Worker's LLM context as a\nnon-blocking system message. Accepted notifications are delivered at the\nnext between-turn boundary, or start a coalesced notification run while\nthe Worker is idle. No side effects beyond LLM context; use\n`WorkerEvent` for typed lifecycle reports.","properties":{"method":{"const":"notify","type":"string"},"params":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Typed lifecycle report from a child Worker to its direct parent.","properties":{"method":{"const":"worker_event","type":"string"},"params":{"$ref":"#/$defs/WorkerEvent"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Return the authoritative FIFO summary without exposing queued payloads.","properties":{"method":{"const":"list_pending_submissions","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Remove one queued submission. Running or already activated submissions\nare immutable and therefore cannot be cancelled here.","properties":{"method":{"const":"cancel_pending_submission","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","expected_revision"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Remove every queued submission while preserving the active run.","properties":{"method":{"const":"clear_pending_submissions","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["expected_revision"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Activate the next queued submission while the Worker is idle. This is an\nexplicit recovery operation and never resumes a paused run implicitly.","properties":{"method":{"const":"continue_pending","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_id":{"type":"string"},"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["expected_revision","expected_head_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"resume","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"cancel","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Stop the in-flight turn and transition to `Paused`.\n\nUnlike `Cancel` (which discards and returns to `Idle`), a paused\nWorker can resume the interrupted work via `Resume`, or accept a\nfresh `Submit` (orphan `tool_use` items are closed with a\nsynthetic tool result before the new user message is appended).","properties":{"method":{"const":"pause","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request an explicit compaction while the Worker is otherwise idle.\n\nThis is a typed control method: clients must not send `compact` as a\n`Method::Submit` user message.","properties":{"method":{"const":"compact","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Ask the Worker to list valid rewind targets from its authoritative session log.","properties":{"method":{"const":"list_rewind_targets","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Truncate the current session back to the selected rewind target and\nreturn the selected user input to the client composer.","properties":{"method":{"const":"rewind_to","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"target":{"$ref":"#/$defs/RewindTargetId"}},"required":["target","expected_head_entries"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"shutdown","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request a list of completion candidates from the Worker.\n\nReply is sent on the same socket as `Event::Completions` (not\nbroadcast). The IPC server handles this directly and writes\nthe response straight back to the requesting socket.","properties":{"method":{"const":"list_completions","type":"string"},"params":{"additionalProperties":false,"properties":{"context":{"anyOf":[{"$ref":"#/$defs/CompletionContext"},{"type":"null"}]},"kind":{"$ref":"#/$defs/CompletionKind"},"prefix":{"type":"string"},"request_id":{"description":"Client query generation. Echoed verbatim so identical-prefix ABA\nresponses cannot cross edits, target switches, or permission changes.","type":["string","null"]}},"required":["kind","prefix"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"List Workers visible to this Worker from durable Worker state and the spawned-child\nregistry. This is not a host-wide Worker universe query.","properties":{"method":{"const":"list_workers","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Restore a visible stopped/restorable Worker, or report that it is already\nlive. Missing state and not-visible state are distinct errors.","properties":{"method":{"const":"restore_worker","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Register another existing Worker as a reciprocal peer of this Worker.\n\nThis is metadata/control state only: it does not ask the target's live\ncontroller for consent, and it must not grant delegated scope,\nspawned-child ownership, output cursors, or child lifecycle authority.","properties":{"method":{"const":"register_peer","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"}]},"PasteArtifactAvailability":{"enum":["available","unavailable","integrity_failed"],"type":"string"},"PasteArtifactMediaType":{"description":"One typed piece of a user submission.\n\n`Method::Submit` and `Event::UserMessage` carry `Vec`. Dumb\nclients (CLI piping, scripts) only need to produce a single\n`Segment::Text`; richer clients (TUI / GUI) construct typed atoms\n(paste chips, file refs) and\nsend them through directly so the Worker side never has to re-parse a\nflattened string.\n\nForward compat: payloads with unknown `kind` deserialize to\n`Segment::Unknown`. Worker treats this the same as known-but-unresolved\nvariants — emits an alert and inserts a `[unknown input segment]`\nplaceholder into the LLM context so neither user nor LLM is blind to\nthe dropped intent.","enum":["text_plain_utf8"],"type":"string"},"PasteArtifactRef":{"additionalProperties":false,"description":"Session-owned reference to a large pasted-input artifact.\n\nThe reference contains only bounded integrity and provenance metadata. The\nartifact body remains in session storage and is available to the model only\nthrough the scoped paste-artifact tools installed by Worker.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/PasteArtifactAvailability","description":"Availability observed when this immutable reference was committed.\nReads revalidate storage and integrity rather than trusting this field."},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"char_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"line_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"$ref":"#/$defs/PasteArtifactMediaType"},"sha256":{"type":"string"},"source_entry_id":{"type":"string"}},"required":["artifact_id","created_at_ms","media_type","availability","byte_len","char_count","line_count","sha256","source_entry_id"],"type":"object"},"PendingSubmissionSummary":{"additionalProperties":false,"description":"Bounded public projection of one pending submission. Only a short display\npreview is exposed; full payload segments and provenance remain in the log.","properties":{"accepted_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"preview":{"description":"Single-line display text, at most 240 Unicode scalars plus an ellipsis.\nAbsent on older runtimes. Never used to reconstruct the submitted input.","type":["string","null"]},"segment_count":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","accepted_at_ms","segment_count","byte_len"],"type":"object"},"PendingSubmissionsSnapshot":{"additionalProperties":false,"description":"Revisioned session-owned FIFO projection used by snapshots and live events.","properties":{"head_id":{"default":null,"type":["string","null"]},"notification_count":{"default":0,"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"notification_previews":{"description":"Ordered display-only previews of waiting notifications, each bounded like\nPendingSubmissionSummary::preview. Older runtimes omit this field.","items":{"type":"string"},"type":"array"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"submissions":{"default":[],"items":{"$ref":"#/$defs/PendingSubmissionSummary"},"type":"array"}},"required":["revision","head_id","notification_count","submissions"],"type":"object"},"Permission":{"description":"Permission lattice used by [`ScopeRule`].\n\nThe derived `Ord` instance follows declaration order, so\n`Read < Write`. Allow rules grant the stated level (and by extension\neverything below); deny rules cap the effective level **strictly\nbelow** the stated level.","enum":["read","write"],"type":"string"},"ReasoningConfig":{"description":"Public reasoning configuration resolved for the model client.\n\nThis deliberately exposes only the effective provider control, not the\nWorker manifest or provider credentials. The tagged shape prevents token\nbudgets from being presented as effort labels.","oneOf":[{"additionalProperties":false,"properties":{"effort":{"type":"string"},"kind":{"const":"effort","type":"string"}},"required":["kind","effort"],"type":"object"},{"additionalProperties":false,"properties":{"budget_tokens":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":"integer"},"kind":{"const":"budget_tokens","type":"string"}},"required":["kind","budget_tokens"],"type":"object"}]},"RewindSummary":{"additionalProperties":false,"properties":{"discarded_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_side_effect_warning":{"type":"boolean"},"truncated_to_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["truncated_to_entries","discarded_entries","tool_side_effect_warning"],"type":"object"},"RewindTarget":{"additionalProperties":false,"properties":{"disabled_reason":{"type":["string","null"]},"eligible":{"type":"boolean"},"expected_head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"id":{"$ref":"#/$defs/RewindTargetId"},"preview":{"type":"string"},"timestamp_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"truncate_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"turn_index":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"warning":{"type":["string","null"]}},"required":["id","expected_head_entries","truncate_entries","turn_index","preview","eligible"],"type":"object"},"RewindTargetId":{"additionalProperties":false,"properties":{"segment_id":{"type":"string"},"user_input_entry_index":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["segment_id","user_input_entry_index"],"type":"object"},"RunFailureKind":{"enum":["engine","compaction"],"type":"string"},"RunResult":{"oneOf":[{"enum":["finished","paused","limit_reached"],"type":"string"},{"const":"cancelled","description":"The logical Run ended because the user intentionally cancelled it after\nobservable work. Unlike `RolledBack`, committed history is retained.","type":"string"},{"const":"rolled_back","description":"The accepted Method::Submit produced no assistant/tool output before\nuser interruption, so the Worker rolled the submit-time turn state back\nto its pre-submit snapshot. Clients should treat the Worker as Idle and\nrestore the just-submitted input into the editable composer if desired.","type":"string"}]},"RunResumeSource":{"enum":["compaction","pause"],"type":"string"},"RunYieldReason":{"enum":["compaction"],"type":"string"},"ScopeRule":{"additionalProperties":false,"description":"A single allow or deny rule inside a scope configuration.","properties":{"permission":{"$ref":"#/$defs/Permission","description":"Permission level this rule grants (allow) or caps strictly below\n(deny)."},"recursive":{"default":true,"description":"When `false`, the rule only matches the target itself and its\ndirect children. Defaults to `true`.","type":"boolean"},"symlink_policy":{"$ref":"#/$defs/SymlinkPolicy","default":"resolved","description":"Which path identity an allow rule uses when symbolic links are\nencountered. Deny rules always inspect both identities."},"target":{"description":"Target path. Must be absolute by the time a `Scope` is built from\nthis rule — relative paths are resolved per-layer against the\nmanifest file's directory (cwd for overlay layers) before cascade\nmerge.","type":"string"}},"required":["target","permission","recursive","symlink_policy"],"type":"object"},"Segment":{"oneOf":[{"additionalProperties":false,"description":"Free-form text. The fallback every client can produce.","properties":{"content":{"type":"string"},"kind":{"const":"text","type":"string"}},"required":["kind","content"],"type":"object"},{"additionalProperties":false,"description":"Bracketed-paste capture from a TUI-style client. `id`, `chars`\nand `lines` carry the metadata needed to re-render a\n`[Clipboard #N | X chars, Y lines]` chip in `Event::UserMessage`\nre-broadcast.","properties":{"chars":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"content":{"type":"string"},"id":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"kind":{"const":"paste","type":"string"},"lines":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"}},"required":["kind","id","chars","lines","content"],"type":"object"},{"additionalProperties":false,"description":"Internal reference produced when Worker stores a large `Paste` before\ncommitting input. Clients may receive this in history/event projections;\nthe body is intentionally absent.","properties":{"artifact":{"$ref":"#/$defs/PasteArtifactRef"},"kind":{"const":"paste_artifact","type":"string"}},"required":["kind","artifact"],"type":"object"},{"additionalProperties":false,"description":"Client-local file uploaded into the owning Worker session before submit.\nThe Session Log stores only this immutable reference, never file bytes or\nthe client's local path.","properties":{"file":{"$ref":"#/$defs/UploadedFileRef"},"kind":{"const":"uploaded_file","type":"string"}},"required":["kind","file"],"type":"object"},{"additionalProperties":false,"description":"`@` file-system reference. Worker resolves readable files to\n`[File: ]` attachments and readable normal directories to shallow\n`[Dir: ]` listings; the flattened user text keeps the literal\n`@` placeholder either way.","properties":{"kind":{"const":"file_ref","type":"string"},"path":{"type":"string"}},"required":["kind","path"],"type":"object"},{"additionalProperties":false,"description":"Explicitly selected, source-qualified Feature invocation. The host never\npromotes free text to this variant and validates it against the installed\ndeclaration before execution.","properties":{"invocation":{"$ref":"#/$defs/FeatureInvocation"},"kind":{"const":"feature_invoke","type":"string"}},"required":["kind","invocation"],"type":"object"},{"additionalProperties":false,"description":"Source-qualified request for the host to start a Flow before committing\nthis user input. Runtime resolves it through Workspace authority and\nreplaces it with the entered state's instructions before Worker-side\ninput resolution.","properties":{"kind":{"const":"flow","type":"string"},"selector":{"type":"string"}},"required":["kind","selector"],"type":"object"},{"additionalProperties":false,"description":"Unknown variant from a newer client. Worker treats this as an\nunresolved input — surfaces an alert and inserts a placeholder.\nRound-trip is lossy: re-serializing yields `{\"kind\":\"unknown\"}`.","properties":{"kind":{"const":"unknown","type":"string"}},"required":["kind"],"type":"object"}]},"SessionContentPart":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"refusal","type":"string"},"refusal":{"type":"string"}},"required":["kind","refusal"],"type":"object"}]},"SessionEntryProvenance":{"enum":["human_input","worker_input","flow_instruction","backend_instruction","model_output","tool_output","derived_summary","legacy_unknown"],"type":"string"},"SessionMessageRole":{"enum":["user","assistant"],"type":"string"},"SessionSnapshot":{"additionalProperties":false,"description":"Canonical, storage-independent projection of committed session history.\n\nWorker protocols expose this DTO instead of append-log records. New\nstorage variants can therefore be added without teaching every client how\nto replay the durable log format.","properties":{"entries":{"items":{"$ref":"#/$defs/SessionSnapshotEntry"},"type":"array"},"pending_submissions":{"$ref":"#/$defs/PendingSubmissionsSnapshot","default":{"head_id":null,"notification_count":0,"revision":0,"submissions":[]}}},"required":["entries","pending_submissions"],"type":"object"},"SessionSnapshotEntry":{"oneOf":[{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"user_input","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","segments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"items":{"$ref":"#/$defs/SessionContentPart"},"type":"array"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"message","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"role":{"$ref":"#/$defs/SessionMessageRole"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","role","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"call_id":{"type":"string"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","name","arguments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"attachments":{"items":{"$ref":"#/$defs/SessionToolAttachment"},"type":"array"},"call_id":{"type":"string"},"content":{"type":["string","null"]},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"is_error":{"type":"boolean"},"kind":{"const":"tool_result","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"summary":{"type":"string"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","summary","is_error","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"type":"string"},"data":true,"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"item_kind":{"type":"string"},"kind":{"const":"system_item","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","item_kind","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable, non-terminal control return from one logical Run. This is not a\ncompleted run and must not be used to infer the current Worker state.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_yielded","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"reason":{"$ref":"#/$defs/RunYieldReason"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","reason","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable boundary immediately before Engine execution resumes. The source\ndistinguishes automatic compaction continuation from an intentional\nuser-pause resume; it does not prove that later model output occurred.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_resumed","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"source":{"$ref":"#/$defs/RunResumeSource"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","source","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Snapshot-only replay of persisted invocation/usage records. These are\naccounting metadata, never model context or conversation rows.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"invoke","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"trigger":{"$ref":"#/$defs/InvokeKind"}},"required":["kind","trigger","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"cache_read_input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"usage","type":"string"},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","input_tokens","cache_read_input_tokens","output_tokens","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Display-only accounting reconstructed from persisted run/usage records.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"elapsed_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_stats","type":"string"},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"requests":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"upload_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","elapsed_ms","requests","upload_tokens","output_tokens","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable terminal for an intentional cancellation. Consumers must not\npresent this lifecycle result as an execution failure.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_cancelled","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"failure":{"anyOf":[{"$ref":"#/$defs/RunFailureKind"},{"type":"null"}]},"kind":{"const":"run_error","type":"string"},"message":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","message","entry_id","timestamp","provenance"],"type":"object"}]},"SessionToolAttachment":{"additionalProperties":false,"properties":{"attachment_id":{"description":"Stable, bounded identity derived from the owning Session entry, attachment\nposition, and immutable image bytes. It is a locator, not a bearer secret.","type":"string"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"type":"string"}},"required":["attachment_id","media_type","byte_len"],"type":"object"},"SubmissionDisposition":{"description":"Durable acceptance result for one idempotent Submit request.","oneOf":[{"enum":["started"],"type":"string"},{"const":"queued","description":"Input is durably held in the session FIFO until eligible for activation.","type":"string"}]},"SubscriptionEvent":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"payload":{"$ref":"#/$defs/SubscriptionEventPayload"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","payload"],"type":"object"},"event":{"const":"event","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionTerminationCode"},"message":{"type":"string"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","code","message"],"type":"object"},"event":{"const":"subscription_closed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionEventPayload":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"worker":{"$ref":"#/$defs/SubscriptionWorker"}},"required":["worker"],"type":"object"},"event":{"const":"worker_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id"],"type":"object"},"event":{"const":"worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","event"],"type":"object"},"event":{"const":"worker_protocol","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdir":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"}},"required":["workdir"],"type":"object"},"event":{"const":"workdir_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id"],"type":"object"},"event":{"const":"workdir_removed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionId":{"type":"string"},"SubscriptionRejectionCode":{"enum":["invalid_request","unsupported_protocol_version","unsupported_selector","unauthorized","resource_not_found","capacity_exceeded","internal"],"type":"string"},"SubscriptionRequest":{"oneOf":[{"additionalProperties":false,"properties":{"method":{"const":"subscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"}},"required":["request_id","selector"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"unsubscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"}},"required":["method","params"],"type":"object"}]},"SubscriptionRequestId":{"type":"string"},"SubscriptionResponse":{"oneOf":[{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"},"snapshot":{"$ref":"#/$defs/SubscriptionSnapshot"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id","selector","snapshot"],"type":"object"},"result":{"const":"subscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"},"result":{"const":"unsubscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionRejectionCode"},"message":{"type":"string"},"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"anyOf":[{"$ref":"#/$defs/SubscriptionId"},{"type":"null"}]}},"required":["request_id","code","message"],"type":"object"},"result":{"const":"subscription_rejected","type":"string"}},"required":["result","payload"],"type":"object"}]},"SubscriptionSnapshot":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workers":{"items":{"$ref":"#/$defs/SubscriptionWorker"},"type":"array"}},"required":["workers"],"type":"object"},"topic":{"const":"workers","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"events":{"items":{"$ref":"#/$defs/Event"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","events"],"type":"object"},"topic":{"const":"worker_protocol","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdirs":{"items":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"},"type":"array"}},"required":["workdirs"],"type":"object"},"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic","data"],"type":"object"}]},"SubscriptionTerminationCode":{"enum":["lagged","resource_gone","unauthorized","server_shutdown"],"type":"string"},"SubscriptionWorkdirId":{"type":"string"},"SubscriptionWorker":{"additionalProperties":false,"properties":{"availability":{"$ref":"#/$defs/SubscriptionWorkerAvailability","default":"observed","description":"Freshness of the Runtime-backed observation carried by this projection.\n`Unavailable` preserves catalog membership without claiming that execution stopped."},"display_name":{"type":["string","null"]},"has_running_internal_workers":{"default":false,"type":"boolean"},"job":{"anyOf":[{"$ref":"#/$defs/SubscriptionWorkerJob"},{"type":"null"}],"description":"Backend-owned Job binding. Runtime producers leave this unset; the\nWorkspace projection enriches both snapshots and live updates."},"profile":{"type":["string","null"]},"resource_key":{"description":"Workspace-scoped canonical resource key. Runtime producers leave this unset;\nWorkspace-facing projections must populate it before publishing the Worker.","type":["string","null"]},"restore_observation_token":{"description":"Opaque Runtime-owned Restore observation identity, never a display-state hash.","type":["string","null"]},"runtime_id":{"description":"Set by the Workspace Server when projecting a Runtime-owned Worker to clients.\nRuntime producers leave this unset because the connection identifies the Runtime.","type":["string","null"]},"state":{"$ref":"#/$defs/SubscriptionWorkerState","description":"Runtime catalog lifecycle compatibility projection; not foreground-state authority."},"workdir_attachments":{"items":{"$ref":"#/$defs/SubscriptionWorkerWorkdirAttachment"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"},"worker_state":{"anyOf":[{"$ref":"#/$defs/WorkerStateSnapshot"},{"type":"null"}],"description":"Latest foreground state observed from the Worker. This remains\nabsent until an authoritative Worker snapshot/event has been applied."},"workspace_id":{"type":["string","null"]}},"required":["worker_id","state","availability","has_running_internal_workers"],"type":"object"},"SubscriptionWorkerAvailability":{"description":"Whether the Workspace Server has a current Runtime observation for a Worker.\n\n`Unavailable` deliberately does not imply `Stopped`: it means the last observed\nlifecycle state is retained while the Runtime observation source is unavailable.","enum":["observed","unavailable"],"type":"string"},"SubscriptionWorkerId":{"type":"string"},"SubscriptionWorkerIds":{"description":"A bounded, canonical worker-id set. Ordering on the wire and in equality/hash\nsemantics is lexical, so equivalent selectors aggregate to one upstream key.","items":{"$ref":"#/$defs/SubscriptionWorkerId"},"type":"array"},"SubscriptionWorkerJob":{"additionalProperties":false,"properties":{"attempt_id":{"type":"string"},"job_id":{"type":"string"},"purpose":{"type":"string"}},"required":["job_id","attempt_id","purpose"],"type":"object"},"SubscriptionWorkerProtocolMethod":{"additionalProperties":false,"properties":{"method":{"$ref":"#/$defs/Method"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","method"],"type":"object"},"SubscriptionWorkerState":{"enum":["idle","running","paused","stopped"],"type":"string"},"SubscriptionWorkerWorkdirAttachment":{"additionalProperties":false,"properties":{"alias":{"description":"Stable Worker-local routing alias.","type":"string"},"repository_key":{"type":["string","null"]},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["alias","working_directory_id"],"type":"object"},"SymlinkPolicy":{"description":"Symbolic-link identity used by one filesystem allow rule.\n\n`Resolved` is the least authority and the default: access is matched\nagainst the provider-resolved target. `Logical` intentionally grants the\npath as presented through the Workdir, even when it aliases another target.","enum":["resolved","logical"],"type":"string"},"ToolResultDisposition":{"enum":["success","error","interrupted","cancelled","outcome_unknown"],"type":"string"},"TurnResult":{"enum":["finished","paused"],"type":"string"},"UploadedFileAvailability":{"description":"Availability recorded for an uploaded client-local file.","enum":["available","unavailable","integrity_failed"],"type":"string"},"UploadedFileRef":{"additionalProperties":false,"description":"Session-owned immutable reference to a client-local uploaded file.\n\nUpload transports return an unbound reference. Worker fills\n`source_entry_id` immediately before the containing user input is committed;\ncommitted Session Log and public snapshot records therefore always retain\nthe durable source-entry identity without storing the file body.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/UploadedFileAvailability"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"file_name":{"type":"string"},"media_type":{"type":"string"},"sha256":{"type":"string"},"source_entry_id":{"type":["string","null"]}},"required":["artifact_id","file_name","media_type","created_at_ms","availability","byte_len","sha256"],"type":"object"},"WorkerBusyState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"run","type":"string"},"state":{"$ref":"#/$defs/WorkerRunState"}},"required":["kind","state"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"maintenance","type":"string"},"state":{"$ref":"#/$defs/WorkerMaintenanceState"}},"required":["kind","state"],"type":"object"}]},"WorkerCommandAcknowledgement":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandKind"},"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"disposition":{"$ref":"#/$defs/WorkerCommandDisposition"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"The complete authoritative state observed after command admission."}},"required":["command_id","command","disposition","state"],"type":"object"},"WorkerCommandDisposition":{"enum":["accepted","stale_command_id","conflict","invalid_state"],"type":"string"},"WorkerCommandEnvelope":{"additionalProperties":false,"description":"Caller-owned identity for one state-changing Worker command.\n\nA controller accepts command ids in strictly increasing order. Exact retries\nof an accepted id must retain the same command kind.","properties":{"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["command_id"],"type":"object"},"WorkerCommandKind":{"enum":["resume","cancel","pause","compact","shutdown"],"type":"string"},"WorkerEvent":{"description":"Typed lifecycle events sent from a child Worker to its parent.\n\nDelivered as `Method::WorkerEvent` over the parent's Unix socket. The\nparent Controller always applies variant-specific side effects\n(registry / worker-allocation updates). Agent-visible variants are also\nqueued into the notification buffer; control-plane-only variants are\nnot injected into the parent's LLM context.\n\nTransport is fire-and-forget; receivers must tolerate out-of-order\ndelivery (e.g. `TurnEnded` arriving after `ShutDown` for the same\nchild Worker).","oneOf":[{"additionalProperties":false,"description":"Child finished one turn and is back to IDLE.","properties":{"kind":{"const":"turn_ended","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Engine execution error occurred inside the child's turn.\n\nLimited to worker runtime failures (provider / tool errors) —\ndoes not include transient method-rejection responses such as\n`AlreadyRunning`.","properties":{"kind":{"const":"errored","type":"string"},"message":{"type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name","message"],"type":"object"},{"additionalProperties":false,"description":"Child has stopped (controller loop is exiting).","properties":{"kind":{"const":"shut_down","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Child SubWorker sub-delegated scope to a grandchild SubWorker via `SubWorkerSpawn`.\n\nControl-plane only: receivers apply registry side effects and\npropagate upward, but do not expose this as an agent notification.\n\nThe parent uses this to add the grandchild to its own\n`spawned_workers.json` so it can manage the grandchild directly\neven if the intermediate child dies. The parent then re-fires\nthis event upward (if it has a parent of its own) to maintain\nthe chain to root.","properties":{"kind":{"const":"scope_sub_delegated","type":"string"},"parent_worker":{"description":"Sub-delegating Worker (= the sender itself).","type":"string"},"scope":{"description":"Scope delegated to the grandchild.","items":{"$ref":"#/$defs/ScopeRule"},"type":"array"},"sub_socket":{"description":"Unix-socket path where the grandchild is reachable.","type":"string"},"sub_worker":{"description":"Name of the grandchild Worker.","type":"string"}},"required":["kind","parent_worker","sub_worker","sub_socket","scope"],"type":"object"}]},"WorkerMaintenanceState":{"enum":["compacting"],"type":"string"},"WorkerRunState":{"enum":["running","pausing","paused","cancelling"],"type":"string"},"WorkerState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"idle","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"busy","type":"string"},"state":{"$ref":"#/$defs/WorkerBusyState"}},"required":["kind","state"],"type":"object"}]},"WorkerStateSnapshot":{"additionalProperties":false,"properties":{"last_command_id":{"description":"Highest lifecycle command id observed by this controller instance.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"last_finished_submission_request_id":{"description":"Most recent Submit request whose controller Run has returned and whose\npost-Run state transition has been published. This is a teardown fence,\nnot a successful-result claim; cancelled and failed Runs may also finish.","type":["string","null"]},"state":{"$ref":"#/$defs/WorkerState"}},"required":["last_command_id","state"],"type":"object"},"WorkerStatus":{"enum":["idle","running","paused","stopped"],"type":"string"},"WorkspaceSubscriptionWorkdir":{"additionalProperties":false,"description":"Workspace-facing Workdir summary. Backend-generated Repository UUIDs never\nenter this DTO; Workspace Server must resolve the required Repository key.","properties":{"repository_key":{"type":"string"},"state":{"type":"string"},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id","repository_key","state"],"type":"object"}},"$schema":"https://json-schema.org/draft/2020-12/schema","description":"Direction is explicit at the outer envelope, while concrete message kinds\nremain typed within each lane.","oneOf":[{"additionalProperties":false,"properties":{"frame":{"const":"request","type":"string"},"message":{"$ref":"#/$defs/SubscriptionRequest"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"response","type":"string"},"message":{"$ref":"#/$defs/SubscriptionResponse"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"event","type":"string"},"message":{"$ref":"#/$defs/SubscriptionEvent"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"worker_protocol","type":"string"},"message":{"$ref":"#/$defs/SubscriptionWorkerProtocolMethod"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"}],"title":"SubscriptionFrame"}; +const subscriptionFrameSchema: unknown = {"$defs":{"Alert":{"additionalProperties":false,"description":"User-facing alert emitted from the Worker layer.\n\nThis is a separate channel from `tracing` (developer logs): entries\nhere are assembled explicitly by the Worker when a condition should be\nsurfaced to the person driving the client. Keep messages short and\nhuman-readable.","properties":{"level":{"$ref":"#/$defs/AlertLevel"},"message":{"type":"string"},"source":{"$ref":"#/$defs/AlertSource"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"}},"required":["level","source","message","timestamp_ms"],"type":"object"},"AlertLevel":{"enum":["warn","error"],"type":"string"},"AlertSource":{"enum":["worker","engine","compactor","agents_md"],"type":"string"},"CommandEvent":{"oneOf":[{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"kind":{"const":"started","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_call_id":{"type":["string","null"]}},"required":["kind","command_id","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"output","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stream":{"$ref":"#/$defs/CommandStream"}},"required":["kind","command_id","stream","start_offset","end_offset","content","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"kind":{"const":"terminal","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stdout_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","command_id","status","stdout_end_offset","stderr_end_offset","observed_at_ms"],"type":"object"}]},"CommandSnapshot":{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"last_output_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr":{"$ref":"#/$defs/CommandStreamSlice"},"stdout":{"$ref":"#/$defs/CommandStreamSlice"},"tool_call_id":{"type":["string","null"]}},"required":["command_id","status","started_at_ms","observed_at_ms","stdout","stderr"],"type":"object"},"CommandStatus":{"description":"Live provider-owned command status. These values are operational Console\nstate only and are never appended to Worker history.","enum":["running","completed","failed","timed_out","cancelled"],"type":"string"},"CommandStream":{"enum":["stdout","stderr"],"type":"string"},"CommandStreamSlice":{"additionalProperties":false,"properties":{"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"truncated":{"type":"boolean"}},"required":["start_offset","end_offset","content","truncated"],"type":"object"},"CompactionLifecycle":{"additionalProperties":false,"description":"Stable parent-owned lifecycle for one compaction run.","properties":{"compaction_id":{"type":"string"},"ended_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"error":{"type":["string","null"]},"internal_worker":{"anyOf":[{"$ref":"#/$defs/InternalWorkerRef"},{"type":"null"}]},"new_segment_id":{"type":["string","null"]},"schema_version":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"description":"Milliseconds since the Unix epoch.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/CompactionLifecycleState"},"summary":{"type":["string","null"]}},"required":["schema_version","compaction_id","state","started_at_ms"],"type":"object"},"CompactionLifecycleState":{"enum":["running","done","failed","interrupted"],"type":"string"},"CompactionPhase":{"enum":["preparing","summarizing","committing"],"type":"string"},"CompactionTrigger":{"enum":["manual","pre_run","request_threshold"],"type":"string"},"CompletionContext":{"additionalProperties":false,"properties":{"argument":{"type":["string","null"]},"invocation":{"$ref":"#/$defs/FeatureInvocationIdentity"}},"required":["invocation"],"type":"object"},"CompletionEntry":{"additionalProperties":false,"description":"One completion candidate for a prefix query.","properties":{"description":{"type":["string","null"]},"invocation":{"anyOf":[{"$ref":"#/$defs/FeatureInvocationDescriptor"},{"type":"null"}]},"is_dir":{"default":false,"type":"boolean"},"usage":{"type":["string","null"]},"value":{"type":"string"}},"required":["value","is_dir"],"type":"object"},"CompletionKind":{"description":"Kind of completion requested by `Method::ListCompletions`.\n\n`File` is the existing `@` path lane. `Feature` lists installed chat\ninvocations. `FeatureArgument` resolves a declared argument using the\nsource-qualified [`CompletionContext`].","enum":["file","feature","feature_argument"],"type":"string"},"ContextTokenSource":{"description":"Provenance of the current-context token count in a [`Greeting`].","oneOf":[{"const":"measured","description":"Provider-reported input occupancy from the most recent LLM request.","type":"string"},{"const":"estimated","description":"Current history projected from the Worker's token accounting records.","type":"string"}]},"ContextUsage":{"additionalProperties":false,"description":"Current prompt-context occupancy, distinct from cumulative run traffic.","properties":{"source":{"$ref":"#/$defs/ContextTokenSource"},"tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["tokens","source"],"type":"object"},"ErrorCode":{"enum":["already_running","not_running","not_paused","provider_error","tool_error","invalid_request","internal"],"type":"string"},"Event":{"oneOf":[{"additionalProperties":false,"description":"Durable Submit acceptance. A `Queued` receipt follows the durable FIFO\ncheckpoint. A `Started` receipt follows the pending activation checkpoint;\nthe corresponding [`Event::UserMessage`] marks the later history commit.\nRepeating the same request id and exact payload returns the same receipt\nwithout appending or activating twice.","properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"$ref":"#/$defs/SubmissionDisposition"},"submission_id":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","submission_id","disposition"],"type":"object"},"event":{"const":"submission_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","message"],"type":"object"},"event":{"const":"submission_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Durable notification acceptance. The request identity is persisted with\nthe notification receipt before this event is emitted. Repeating the\nsame request id and exact payload returns the same acknowledgement\nwithout appending the notification twice.","properties":{"data":{"additionalProperties":false,"properties":{"notification_request_id":{"type":"string"}},"required":["notification_request_id"],"type":"object"},"event":{"const":"notification_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated notification rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"},"event":{"const":"notification_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Revisioned FIFO replacement following enqueue, activation, cancel, or clear.","properties":{"data":{"additionalProperties":false,"properties":{"pending":{"$ref":"#/$defs/PendingSubmissionsSnapshot"}},"required":["pending"],"type":"object"},"event":{"const":"pending_submissions_changed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A user input message persisted as `LogEntry::AnnotatedUserInput` and\nactivated for a turn. Broadcast to every subscribed client so TUI / GUI\ninstances show the same user line that reconnect snapshots replay.","properties":{"data":{"additionalProperties":false,"properties":{"entry_id":{"description":"Stable durable history identity. Older mixed-version producers omit it.","type":["string","null"]},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"}},"required":["segments"],"type":"object"},"event":{"const":"user_message","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A canonical assistant/tool entry has committed to durable history.\nStreaming events remain the in-flight authority; this event supplies the\nstable identity used to reconcile that live block with paged history.","properties":{"data":{"additionalProperties":false,"properties":{"entry":{"$ref":"#/$defs/SessionSnapshotEntry"}},"required":["entry"],"type":"object"},"event":{"const":"session_entry_committed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One agent-injected system item committed to history.\n\nCarries the JSON form of `session_store::SystemItem`. Covers\n`Method::Notify` echoes, child-Worker lifecycle events from\n`Method::WorkerEvent`, `@` resolution payloads, and any future\nClients dispatch on the `kind` tag for typed rendering instead\nof parsing free-text prefixes like `[Notification] …` or\n`[File: …]`.\n\nOne event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and\nwire-side are 1:1.","properties":{"data":{"additionalProperties":false,"properties":{"entry_id":{"description":"Stable durable history identity. Synthetic compatibility events omit it.","type":["string","null"]},"item":true},"required":["item"],"type":"object"},"event":{"const":"system_item","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A new self-driving cycle has begun (IDLE → active transition).\n\nMarker event for the start of an Invoke range; the range extends\nimplicitly until the next `InvokeStart`. Fires for every accepted\n`Method::Submit` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),\n`Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other\nIDLE-breaking trigger. Mid-run interrupts (e.g. hook output,\ntyped system reminder insertion that doesn't break IDLE) do not\nemit `InvokeStart` — they appear as `SystemItem` only.\n\nCarries `kind` only; the payload (user text / notify message /\nworker event body) is delivered separately via the immediately\nfollowing `UserMessage` / `SystemItem` event.","properties":{"data":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/InvokeKind"},"timestamp_ms":{"description":"Timestamp of the persisted Invoke, not the client's attach time.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]}},"required":["kind"],"type":"object"},"event":{"const":"invoke_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One AgentTurn boundary opened. An AgentTurn is a maximal run of\nLLM generation calls whose input messages are identical (i.e.\nretries from network errors / 5xx / stream disconnects collapse\ninto the same AgentTurn). When the input changes (a new tool\nresult lands, a user interrupts, etc.), the next LLM call belongs\nto a new AgentTurn.\n\n`turn` is the AgentTurn index from `Engine::turn_count`.\n\nCurrently retry is not yet implemented (`agen-stream-continuation`)\nso AgentTurn and `LlmCall` fire 1:1, but the contract here is\nthe AgentTurn boundary; consumers that want per-LLM-call signals\nmust subscribe to `LlmCallStart` / `LlmCallEnd` instead.","properties":{"data":{"additionalProperties":false,"properties":{"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn"],"type":"object"},"event":{"const":"turn_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"AgentTurn closed.","properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/TurnResult"},"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn","result"],"type":"object"},"event":{"const":"turn_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One LLM generation call started (1 request → 1 generation, retry\nincluded). Multiple `LlmCall*` pairs may fire inside a single\n`TurnStart` / `TurnEnd` pair when a request is retried; today\nthey fire 1:1 because retry is not implemented.\n\n`llm_call` is the worker-wide running counter from\n`Engine::llm_call_count`.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"LLM generation call ended.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A transport-level LLM request retry has been scheduled.\n\nThis is operational state for clients to render while the worker is\nwaiting in backoff. It is not part of conversation history.","properties":{"data":{"additionalProperties":false,"properties":{"elapsed_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"error":{"type":"string"},"failed_attempt":{"description":"The attempt that just failed. 1 origin.","format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"status":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":["integer","null"]},"wait_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call","failed_attempt","max_attempts","wait_ms","elapsed_ms","error"],"type":"object"},"event":{"const":"llm_retry","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Stream generation was interrupted after events had begun and the worker\nis continuing with a follow-up LLM request.","properties":{"data":{"additionalProperties":false,"properties":{"attempt":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"reason":{"type":"string"}},"required":["llm_call","attempt","max_attempts","reason"],"type":"object"},"event":{"const":"llm_continuation","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A reasoning / thinking block has started.\n\nAlways paired with a `ThinkingDone`. `ThinkingDelta` is optional:\nsome providers (or some configurations) emit thinking metadata\nwithout plaintext, in which case Start → Done arrive with no\ndeltas in between. Multiple thinking blocks per turn are allowed.","properties":{"event":{"const":"thinking_start","type":"string"}},"required":["event"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Thinking block completed. `text` is the full accumulated body\n(empty string when the provider didn't emit plaintext).","properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"event":{"const":"tool_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"json":{"type":"string"}},"required":["id","json"],"type":"object"},"event":{"const":"tool_call_args_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name","arguments"],"type":"object"},"event":{"const":"tool_call_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"anyOf":[{"$ref":"#/$defs/ToolResultDisposition"},{"type":"null"}]},"id":{"type":"string"},"is_error":{"default":false,"type":"boolean"},"output":{"description":"Full tool output. Absent when the tool chose to return\nsummary-only, or when the result was pruned.","type":["string","null"]},"summary":{"description":"Short human-readable summary. Always present; used by clients\nthat only want a 1-line rendering (e.g. collapsed views).","type":"string"}},"required":["id","summary","is_error"],"type":"object"},"event":{"const":"tool_result","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Token accounting for one LLM request.\n\n`input_tokens` is the prompt prefix occupancy (cache reads /\ncache writes included), as normalised by the worker layer.\n`cache_read_input_tokens` is the cache-hit subset of that\noccupancy; subtracting it yields the \"net upload\" the client\nactually paid full price to send on this request, which is what\nthe TUI status line accumulates per turn.","properties":{"data":{"additionalProperties":false,"properties":{"cache_read_input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"timestamp_ms":{"description":"Present for usage delivered from the committed session-log lane.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]}},"type":"object"},"event":{"const":"usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Authoritative current-context occupancy after a non-request boundary.\n\nUnlike [`Event::Usage`], this is not cumulative request traffic. It lets\nconnected clients converge with reconnect snapshots after run completion,\ncompaction, rewind, and other history rewrites. `None` explicitly means\nthe current value is unavailable and stale occupancy must be cleared.","properties":{"data":{"additionalProperties":false,"properties":{"usage":{"anyOf":[{"$ref":"#/$defs/ContextUsage"},{"type":"null"}]}},"type":"object"},"event":{"const":"context_usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/RunResult"}},"required":["result"],"type":"object"},"event":{"const":"run_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/ErrorCode"},"message":{"type":"string"}},"required":["code","message"],"type":"object"},"event":{"const":"error","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Sent exactly once at the start of every client connection.\n\n`entries` is the session-log mirror at subscribe time, serialised\nas the JSON form of `session_store::LogEntry`. This is the\nbulk-reconstruction lane: clients walk the entries to seed their\nderived view.\n\n`greeting` and `status` accompany the snapshot so clients render\nworker identity and current controller state without an extra round\ntrip.\n\nLive updates after the snapshot arrive through the streaming\nevents (`TextDelta` / `ToolCall*` / `ToolResult` / etc.) plus\nrole-specific entry events (`SegmentRotated` / `SystemItem`) —\nthere is no generic \"every committed entry\" broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"greeting":{"$ref":"#/$defs/Greeting"},"in_flight":{"$ref":"#/$defs/InFlightSnapshot","description":"Unfinished model output that has already streamed in the current\nrun but is not yet represented by committed snapshot entries."},"internal_workers":{"description":"Parent-owned Internal Worker sessions visible to this client.\nService-private Internal Workers are deliberately excluded.","items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"Full live execution state. `Stopped` remains Runtime\ncatalog authority and is deliberately not represented here."}},"required":["session","greeting","state"],"type":"object"},"event":{"const":"snapshot","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A live event from a parent-owned Internal Worker. The payload reuses the\nnormal Worker event vocabulary while the wrapper carries stable origin\nidentity. Apply in arrival order on the parent's serialized stream; a\nreconnect snapshot replaces the projection before live events resume.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","event"],"type":"object"},"event":{"const":"internal_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Terminal removal fence for one parent-owned Internal Worker session.\n\nClients discard the matching child and descendants, then ignore later\nnested events for this identity until an authoritative snapshot replaces\nthe projection.","properties":{"data":{"additionalProperties":false,"properties":{"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker"],"type":"object"},"event":{"const":"internal_worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Server-side segment log rotated to a fresh `SegmentStart`.\n\nFires on compaction and on auto-fork when the store head drifts\nA compaction/fork has replaced the authoritative segment. Clients drop\ntheir derived view and reseed from the canonical committed snapshot.","properties":{"data":{"additionalProperties":false,"properties":{"session":{"$ref":"#/$defs/SessionSnapshot"}},"required":["session"],"type":"object"},"event":{"const":"segment_rotated","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Current Worker controller status. Broadcast on every controller-level\ntransition and included in `History` snapshots for late attach.","properties":{"data":{"additionalProperties":false,"properties":{"snapshot":{"$ref":"#/$defs/WorkerStateSnapshot"}},"required":["snapshot"],"type":"object"},"event":{"const":"worker_state","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"acknowledgement":{"$ref":"#/$defs/WorkerCommandAcknowledgement"}},"required":["acknowledgement"],"type":"object"},"event":{"const":"command_acknowledged","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Bounded, provider-owned command telemetry for the live Console. This is\nintentionally not a history entry and is reconstructed from\n`Snapshot.in_flight.commands` after reconnect.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/CommandEvent"}},"required":["event"],"type":"object"},"event":{"const":"command","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListCompletions`. Delivered only to the\nrequesting socket (not broadcast). `entries` is empty when no\ncandidates match or when the requested kind has no resolver\nwired up.","properties":{"data":{"additionalProperties":false,"properties":{"context":{"anyOf":[{"$ref":"#/$defs/CompletionContext"},{"type":"null"}]},"entries":{"items":{"$ref":"#/$defs/CompletionEntry"},"type":"array"},"kind":{"$ref":"#/$defs/CompletionKind"},"prefix":{"default":"","type":"string"},"request_id":{"type":["string","null"]}},"required":["kind","entries","prefix"],"type":"object"},"event":{"const":"completions","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListRewindTargets`. Clients should only open a picker\nin response to their own pending request; the event may be broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"targets":{"items":{"$ref":"#/$defs/RewindTarget"},"type":"array"}},"required":["head_entries","targets"],"type":"object"},"event":{"const":"rewind_targets","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A rewind has truncated the authoritative session. `session` is the\nretained canonical snapshot clients should use to reseed display state.","properties":{"data":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"summary":{"$ref":"#/$defs/RewindSummary"}},"required":["session","input","summary"],"type":"object"},"event":{"const":"rewind_applied","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListWorkers`. Payload is a stable JSON value so the Worker\ncrate can evolve discovery fields without introducing a protocol\ndependency on session-store.","properties":{"data":{"additionalProperties":false,"properties":{"workers":true},"required":["workers"],"type":"object"},"event":{"const":"workers_listed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RestoreWorker`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"worker_restored","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RegisterPeer`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"peer_registered","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"$ref":"#/$defs/Alert"},"event":{"const":"alert","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Latest memory extract/consolidation lifecycle event for UI observability.\n\nThis is not part of LLM history or prompt context; clients may display it\nbriefly as operational status.","properties":{"data":{"$ref":"#/$defs/MemoryWorkerEvent"},"event":{"const":"memory_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Runtime-only compaction progress. `None` clears the current status.\nThis never enters Session history and carries no operation or Segment identity.","properties":{"data":{"additionalProperties":false,"properties":{"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}]}},"type":"object"},"event":{"const":"compaction_progress","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Legacy compaction lifecycle event retained for wire read compatibility.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction completed and the session was rotated.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction failed or was cancelled. The session is unchanged.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_failed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"event":{"const":"shutdown","type":"string"}},"required":["event"],"type":"object"}]},"EventSubscriptionSelector":{"oneOf":[{"additionalProperties":false,"properties":{"topic":{"const":"runtime_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"properties":{"topic":{"const":"worker_lifecycle","type":"string"},"worker_ids":{"$ref":"#/$defs/SubscriptionWorkerIds"}},"required":["topic","worker_ids"],"type":"object"},{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"topic":{"const":"worker_protocol","type":"string"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["topic","worker_id"],"type":"object"},{"additionalProperties":false,"description":"Server-derived Workspace projection. Workspace identity comes from the\nauthenticated connection and is deliberately absent from this selector.","properties":{"topic":{"const":"workspace_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"description":"Typed extension point for the later Workdir subscription slice.","properties":{"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic"],"type":"object"}]},"FeatureInvocation":{"additionalProperties":false,"properties":{"arguments":{"items":{"$ref":"#/$defs/InvocationArgumentValue"},"type":"array"},"identity":{"$ref":"#/$defs/FeatureInvocationIdentity"},"invocation_id":{"description":"Stable client-generated id retained across queueing and transport retry.","type":"string"},"name":{"description":"Selected public name, retained for exact draft/history restoration. Host\nresolution is always by `identity`.","type":"string"}},"required":["invocation_id","identity","name","arguments"],"type":"object"},"FeatureInvocationDescriptor":{"additionalProperties":false,"properties":{"aliases":{"default":[],"items":{"type":"string"},"type":"array"},"arguments":{"default":[],"items":{"$ref":"#/$defs/InvocationArgumentDescriptor"},"type":"array"},"client_adapter":{"anyOf":[{"$ref":"#/$defs/InvocationClientAdapter"},{"type":"null"}]},"description":{"type":"string"},"display_name":{"type":"string"},"identity":{"$ref":"#/$defs/FeatureInvocationIdentity"},"name":{"type":"string"},"syntax":{"$ref":"#/$defs/FeatureInvocationSyntax"}},"required":["identity","name","display_name","description","syntax","aliases","arguments"],"type":"object"},"FeatureInvocationIdentity":{"description":"Source-qualified identity of one invocation contribution, for example\n`builtin:attachments/attach`. Display names and aliases are never authority.","type":"string"},"FeatureInvocationSyntax":{"oneOf":[{"const":"parenthesized","description":"`/name(value, key=value)` with JSON-style quoted strings and `)` as the\nexplicit boundary before following natural language.","type":"string"}]},"Greeting":{"additionalProperties":false,"description":"Worker self-description rendered by clients when a session starts empty.\n\nSnapshot metadata contains only resolved public execution information so\nclients do not need their own copy of the manifest.","properties":{"context_tokens":{"default":0,"description":"Legacy numeric mirror retained for mixed-version clients.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"context_usage":{"anyOf":[{"$ref":"#/$defs/ContextUsage"},{"type":"null"}],"description":"Typed current-context value. Older snapshots omit it; clients may fall\nback to `context_tokens` only when `context_window` is known."},"context_window":{"default":0,"description":"Resolved effective model context window in tokens. Legacy producers use\nzero when this is unavailable; clients must treat that as unknown.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"cwd":{"type":"string"},"model":{"type":"string"},"provider":{"type":"string"},"reasoning":{"anyOf":[{"$ref":"#/$defs/ReasoningConfig"},{"type":"null"}],"description":"Effective reasoning control. Absent means unspecified or unavailable."},"scope_summary":{"type":"string"},"tools":{"items":{"type":"string"},"type":"array"},"worker_name":{"type":"string"}},"required":["worker_name","cwd","provider","model","scope_summary","tools","context_tokens","context_window"],"type":"object"},"InFlightBlock":{"oneOf":[{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"thinking","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"type":"string"},"id":{"type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"state":{"$ref":"#/$defs/InFlightToolCallState"}},"required":["kind","id","name","args"],"type":"object"}]},"InFlightCompaction":{"additionalProperties":false,"properties":{"phase":{"$ref":"#/$defs/CompactionPhase"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"trigger":{"$ref":"#/$defs/CompactionTrigger"}},"required":["phase","started_at_ms","trigger"],"type":"object"},"InFlightSnapshot":{"additionalProperties":false,"description":"Unfinished model output and active command state included in\n`Event::Snapshot` for clients that attach while work is still streaming.\n\nThese blocks are presentation state only: they are reconstructed from the\nactive Worker controller and must not be treated as committed assistant\nhistory. Finalized assistant items continue to come from ordinary snapshot\nentries.","properties":{"blocks":{"items":{"$ref":"#/$defs/InFlightBlock"},"type":"array"},"commands":{"items":{"$ref":"#/$defs/CommandSnapshot"},"type":"array"},"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}],"description":"The currently running compaction, if any.\n\nThis is lifecycle progress only. Candidate history and the staged\nSegment remain private until the Segment is activated atomically."}},"type":"object"},"InFlightToolCallState":{"enum":["pending","streaming_args","done"],"type":"string"},"InternalWorkerKind":{"description":"Presentation category for an Internal Worker exposed through its parent's\nprotocol stream. Internal Workers never become independently addressable\nprotocol subjects.","oneOf":[{"enum":["sub_worker"],"type":"string"},{"additionalProperties":false,"properties":{"service":{"additionalProperties":false,"properties":{"kind":{"type":"string"}},"required":["kind"],"type":"object"}},"required":["service"],"type":"object"}]},"InternalWorkerRef":{"additionalProperties":false,"description":"Stable presentation identity for one parent-owned Internal Worker session.\n`name` is display-only; `session_id` is the identity used by clients.","properties":{"kind":{"$ref":"#/$defs/InternalWorkerKind"},"name":{"type":"string"},"parent_session_id":{"type":["string","null"]},"session_id":{"type":"string"}},"required":["session_id","name","kind"],"type":"object"},"InternalWorkerSnapshot":{"additionalProperties":false,"description":"Reconnect state for one visible Internal Worker. The parent serializes\nsnapshot publication and subsequent live events on the same ordered stream.","properties":{"error":{"type":["string","null"]},"greeting":{"anyOf":[{"$ref":"#/$defs/Greeting"},{"type":"null"}],"description":"Public execution metadata for this child. Older snapshots and service\nworkers may omit it; clients must never substitute the parent's values."},"in_flight":{"$ref":"#/$defs/InFlightSnapshot"},"internal_workers":{"items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"status":{"$ref":"#/$defs/WorkerStatus","default":"idle"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","session","status"],"type":"object"},"InvocationArgumentDescriptor":{"additionalProperties":false,"properties":{"completion":{"$ref":"#/$defs/InvocationCompletion","default":{"kind":"none"}},"description":{"type":["string","null"]},"name":{"type":"string"},"position":{"description":"Zero-based positional index. `None` means named-only. A positional\nargument may also be supplied by name; this makes chip editing stable.","format":"uint16","maximum":65535.0,"minimum":0.0,"type":["integer","null"]},"required":{"default":false,"type":"boolean"},"value_type":{"$ref":"#/$defs/InvocationArgumentType"}},"required":["name","value_type","completion","required"],"type":"object"},"InvocationArgumentType":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"string","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"integer","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"boolean","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"enum","type":"string"},"values":{"items":{"type":"string"},"type":"array"}},"required":["kind","values"],"type":"object"},{"additionalProperties":false,"description":"A Worker-readable path. This does not grant filesystem authority.","properties":{"kind":{"const":"worker_file","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"description":"A resource selected and staged by the client. It must never be resolved\nas a path on the Worker host.","properties":{"kind":{"const":"client_file","type":"string"}},"required":["kind"],"type":"object"}]},"InvocationArgumentValue":{"additionalProperties":false,"properties":{"name":{"type":"string"},"value":{"$ref":"#/$defs/InvocationValue"}},"required":["name","value"],"type":"object"},"InvocationClientAdapter":{"oneOf":[{"const":"attachment","description":"The client stages a local file and replaces the draft invocation with an\n`UploadedFile` chip. Local paths are not sent to or persisted by Worker.","type":"string"}]},"InvocationCompletion":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"none","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"static","type":"string"},"values":{"items":{"type":"string"},"type":"array"}},"required":["kind","values"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"worker_file","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"client_file","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"description":"Host-owned provider selected by source-qualified id. Metadata lookup is\nside-effect free; implementations never cross this wire boundary.","properties":{"kind":{"const":"provider","type":"string"},"provider":{"type":"string"}},"required":["kind","provider"],"type":"object"}]},"InvocationValue":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"string","type":"string"},"value":{"type":"string"}},"required":["kind","value"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"integer","type":"string"},"value":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":"integer"}},"required":["kind","value"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"boolean","type":"string"},"value":{"type":"boolean"}},"required":["kind","value"],"type":"object"}]},"InvokeKind":{"description":"Kind of trigger that opened a new Invoke (IDLE → active) range.\n\nOne Invoke groups all entries from this trigger up to the next\n`Invoke` marker. The kind is the only payload — content (user text,\nnotify message, worker event body) is delivered by the immediately\nfollowing Turn entry, not by the marker itself.","oneOf":[{"const":"user_send","description":"`Method::Submit` — a user submission.","type":"string"},{"const":"notify","description":"`Method::Notify` — free-text notification injected into history.","type":"string"},{"const":"worker_event","description":"`Method::WorkerEvent` — typed lifecycle report from a child Worker.","type":"string"},{"const":"system_reminder","description":"A typed system reminder that crosses an IDLE boundary (mid-run\nreminders that don't break IDLE are SystemItem-only and do not\nopen a new Invoke).","type":"string"},{"const":"wakeup","description":"Cron / RemoteTrigger style scheduled wake-up. Reserved; no\nproducer is wired today but the variant is part of the initial\nset so future schedulers don't have to amend the wire enum.","type":"string"}]},"MemoryWorkerEvent":{"additionalProperties":false,"properties":{"message":{"description":"Human-readable compact form for actionbar rendering.","type":"string"},"reason":{"type":"string"},"run_id":{"type":"string"},"status":{"type":"string"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"},"trigger":{"type":"string"},"worker":{"type":"string"}},"required":["worker","status","run_id","trigger","reason","message","timestamp_ms"],"type":"object"},"Method":{"oneOf":[{"additionalProperties":false,"description":"Durably accept typed input for immediate activation or the session FIFO.\nRunning or Paused Workers queue Submit for a later turn; use Notify for\nadvisory information to incorporate into work already in progress.\n\n`submission_request_id` is generated by the authenticated caller and is\nused only for idempotent retry. Worker allocates the durable\n`submission_id` returned by [`Event::SubmissionAccepted`].","properties":{"method":{"const":"submit","type":"string"},"params":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","input"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Human-readable text injected into the target Worker's LLM context as a\nnon-blocking system message. Accepted notifications are delivered at the\nnext between-turn boundary, or start a coalesced notification run while\nthe Worker is idle. No side effects beyond LLM context; use\n`WorkerEvent` for typed lifecycle reports.","properties":{"method":{"const":"notify","type":"string"},"params":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Typed lifecycle report from a child Worker to its direct parent.","properties":{"method":{"const":"worker_event","type":"string"},"params":{"$ref":"#/$defs/WorkerEvent"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Return the authoritative FIFO summary without exposing queued payloads.","properties":{"method":{"const":"list_pending_submissions","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Remove one queued submission. Running or already activated submissions\nare immutable and therefore cannot be cancelled here.","properties":{"method":{"const":"cancel_pending_submission","type":"string"},"params":{"additionalProperties":false,"properties":{"submission_id":{"type":"string"}},"required":["submission_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Remove exactly the observed ordered set of submissions, preserving the\nactive run. Reject if the FIFO content changed before admission.","properties":{"method":{"const":"clear_pending_submissions","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_submission_ids":{"items":{"type":"string"},"type":"array"}},"required":["expected_submission_ids"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Activate the next queued submission while the Worker is idle. This is an\nexplicit recovery operation and never resumes a paused run implicitly.","properties":{"method":{"const":"continue_pending","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_id":{"type":"string"}},"required":["expected_head_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"resume","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"cancel","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Stop the in-flight turn and transition to `Paused`.\n\nUnlike `Cancel` (which discards and returns to `Idle`), a paused\nWorker can resume the interrupted work via `Resume`, or accept a\nfresh `Submit` (orphan `tool_use` items are closed with a\nsynthetic tool result before the new user message is appended).","properties":{"method":{"const":"pause","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request an explicit compaction while the Worker is otherwise idle.\n\nThis is a typed control method: clients must not send `compact` as a\n`Method::Submit` user message.","properties":{"method":{"const":"compact","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Ask the Worker to list valid rewind targets from its authoritative session log.","properties":{"method":{"const":"list_rewind_targets","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Truncate the current session back to the selected rewind target and\nreturn the selected user input to the client composer.","properties":{"method":{"const":"rewind_to","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"target":{"$ref":"#/$defs/RewindTargetId"}},"required":["target","expected_head_entries"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"shutdown","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request a list of completion candidates from the Worker.\n\nReply is sent on the same socket as `Event::Completions` (not\nbroadcast). The IPC server handles this directly and writes\nthe response straight back to the requesting socket.","properties":{"method":{"const":"list_completions","type":"string"},"params":{"additionalProperties":false,"properties":{"context":{"anyOf":[{"$ref":"#/$defs/CompletionContext"},{"type":"null"}]},"kind":{"$ref":"#/$defs/CompletionKind"},"prefix":{"type":"string"},"request_id":{"description":"Client query identity. Echoed verbatim so identical-prefix ABA\nresponses cannot cross edits, target switches, or permission changes.","type":["string","null"]}},"required":["kind","prefix"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"List Workers visible to this Worker from durable Worker state and the spawned-child\nregistry. This is not a host-wide Worker universe query.","properties":{"method":{"const":"list_workers","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Restore a visible stopped/restorable Worker, or report that it is already\nlive. Missing state and not-visible state are distinct errors.","properties":{"method":{"const":"restore_worker","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Register another existing Worker as a reciprocal peer of this Worker.\n\nThis is metadata/control state only: it does not ask the target's live\ncontroller for consent, and it must not grant delegated scope,\nspawned-child ownership, output cursors, or child lifecycle authority.","properties":{"method":{"const":"register_peer","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"}]},"PasteArtifactAvailability":{"enum":["available","unavailable","integrity_failed"],"type":"string"},"PasteArtifactMediaType":{"description":"One typed piece of a user submission.\n\n`Method::Submit` and `Event::UserMessage` carry `Vec`. Dumb\nclients (CLI piping, scripts) only need to produce a single\n`Segment::Text`; richer clients (TUI / GUI) construct typed atoms\n(paste chips, file refs) and\nsend them through directly so the Worker side never has to re-parse a\nflattened string.\n\nForward compat: payloads with unknown `kind` deserialize to\n`Segment::Unknown`. Worker treats this the same as known-but-unresolved\nvariants — emits an alert and inserts a `[unknown input segment]`\nplaceholder into the LLM context so neither user nor LLM is blind to\nthe dropped intent.","enum":["text_plain_utf8"],"type":"string"},"PasteArtifactRef":{"additionalProperties":false,"description":"Session-owned reference to a large pasted-input artifact.\n\nThe reference contains only bounded integrity and provenance metadata. The\nartifact body remains in session storage and is available to the model only\nthrough the scoped paste-artifact tools installed by Worker.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/PasteArtifactAvailability","description":"Availability observed when this immutable reference was committed.\nReads revalidate storage and integrity rather than trusting this field."},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"char_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"line_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"$ref":"#/$defs/PasteArtifactMediaType"},"sha256":{"type":"string"},"source_entry_id":{"type":"string"}},"required":["artifact_id","created_at_ms","media_type","availability","byte_len","char_count","line_count","sha256","source_entry_id"],"type":"object"},"PendingSubmissionSummary":{"additionalProperties":false,"description":"Bounded public projection of one pending submission. Only a short display\npreview is exposed; full payload segments and provenance remain in the log.","properties":{"accepted_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"preview":{"description":"Single-line display text, at most 240 Unicode scalars plus an ellipsis.\nAbsent on older runtimes. Never used to reconstruct the submitted input.","type":["string","null"]},"segment_count":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","accepted_at_ms","segment_count","byte_len"],"type":"object"},"PendingSubmissionsSnapshot":{"additionalProperties":false,"description":"Session-owned FIFO content projection used by snapshots and live events.","properties":{"head_id":{"default":null,"type":["string","null"]},"notification_count":{"default":0,"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"notification_previews":{"description":"Ordered display-only previews of waiting notifications, each bounded like\nPendingSubmissionSummary::preview. Older runtimes omit this field.","items":{"type":"string"},"type":"array"},"submissions":{"default":[],"items":{"$ref":"#/$defs/PendingSubmissionSummary"},"type":"array"}},"required":["head_id","notification_count","submissions"],"type":"object"},"Permission":{"description":"Permission lattice used by [`ScopeRule`].\n\nThe derived `Ord` instance follows declaration order, so\n`Read < Write`. Allow rules grant the stated level (and by extension\neverything below); deny rules cap the effective level **strictly\nbelow** the stated level.","enum":["read","write"],"type":"string"},"ReasoningConfig":{"description":"Public reasoning configuration resolved for the model client.\n\nThis deliberately exposes only the effective provider control, not the\nWorker manifest or provider credentials. The tagged shape prevents token\nbudgets from being presented as effort labels.","oneOf":[{"additionalProperties":false,"properties":{"effort":{"type":"string"},"kind":{"const":"effort","type":"string"}},"required":["kind","effort"],"type":"object"},{"additionalProperties":false,"properties":{"budget_tokens":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":"integer"},"kind":{"const":"budget_tokens","type":"string"}},"required":["kind","budget_tokens"],"type":"object"}]},"RewindSummary":{"additionalProperties":false,"properties":{"discarded_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_side_effect_warning":{"type":"boolean"},"truncated_to_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["truncated_to_entries","discarded_entries","tool_side_effect_warning"],"type":"object"},"RewindTarget":{"additionalProperties":false,"properties":{"disabled_reason":{"type":["string","null"]},"eligible":{"type":"boolean"},"expected_head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"id":{"$ref":"#/$defs/RewindTargetId"},"preview":{"type":"string"},"timestamp_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"truncate_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"turn_index":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"warning":{"type":["string","null"]}},"required":["id","expected_head_entries","truncate_entries","turn_index","preview","eligible"],"type":"object"},"RewindTargetId":{"additionalProperties":false,"properties":{"segment_id":{"type":"string"},"user_input_entry_index":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["segment_id","user_input_entry_index"],"type":"object"},"RunFailureKind":{"enum":["engine","compaction"],"type":"string"},"RunResult":{"oneOf":[{"enum":["finished","paused","limit_reached"],"type":"string"},{"const":"cancelled","description":"The logical Run ended because the user intentionally cancelled it after\nobservable work. Unlike `RolledBack`, committed history is retained.","type":"string"},{"const":"rolled_back","description":"The accepted Method::Submit produced no assistant/tool output before\nuser interruption, so the Worker rolled the submit-time turn state back\nto its pre-submit snapshot. Clients should treat the Worker as Idle and\nrestore the just-submitted input into the editable composer if desired.","type":"string"}]},"RunResumeSource":{"enum":["compaction","pause"],"type":"string"},"RunYieldReason":{"enum":["compaction"],"type":"string"},"ScopeRule":{"additionalProperties":false,"description":"A single allow or deny rule inside a scope configuration.","properties":{"permission":{"$ref":"#/$defs/Permission","description":"Permission level this rule grants (allow) or caps strictly below\n(deny)."},"recursive":{"default":true,"description":"When `false`, the rule only matches the target itself and its\ndirect children. Defaults to `true`.","type":"boolean"},"symlink_policy":{"$ref":"#/$defs/SymlinkPolicy","default":"resolved","description":"Which path identity an allow rule uses when symbolic links are\nencountered. Deny rules always inspect both identities."},"target":{"description":"Target path. Must be absolute by the time a `Scope` is built from\nthis rule — relative paths are resolved per-layer against the\nmanifest file's directory (cwd for overlay layers) before cascade\nmerge.","type":"string"}},"required":["target","permission","recursive","symlink_policy"],"type":"object"},"Segment":{"oneOf":[{"additionalProperties":false,"description":"Free-form text. The fallback every client can produce.","properties":{"content":{"type":"string"},"kind":{"const":"text","type":"string"}},"required":["kind","content"],"type":"object"},{"additionalProperties":false,"description":"Bracketed-paste capture from a TUI-style client. `id`, `chars`\nand `lines` carry the metadata needed to re-render a\n`[Clipboard #N | X chars, Y lines]` chip in `Event::UserMessage`\nre-broadcast.","properties":{"chars":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"content":{"type":"string"},"id":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"kind":{"const":"paste","type":"string"},"lines":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"}},"required":["kind","id","chars","lines","content"],"type":"object"},{"additionalProperties":false,"description":"Internal reference produced when Worker stores a large `Paste` before\ncommitting input. Clients may receive this in history/event projections;\nthe body is intentionally absent.","properties":{"artifact":{"$ref":"#/$defs/PasteArtifactRef"},"kind":{"const":"paste_artifact","type":"string"}},"required":["kind","artifact"],"type":"object"},{"additionalProperties":false,"description":"Client-local file uploaded into the owning Worker session before submit.\nThe Session Log stores only this immutable reference, never file bytes or\nthe client's local path.","properties":{"file":{"$ref":"#/$defs/UploadedFileRef"},"kind":{"const":"uploaded_file","type":"string"}},"required":["kind","file"],"type":"object"},{"additionalProperties":false,"description":"`@` file-system reference. Worker resolves readable files to\n`[File: ]` attachments and readable normal directories to shallow\n`[Dir: ]` listings; the flattened user text keeps the literal\n`@` placeholder either way.","properties":{"kind":{"const":"file_ref","type":"string"},"path":{"type":"string"}},"required":["kind","path"],"type":"object"},{"additionalProperties":false,"description":"Explicitly selected, source-qualified Feature invocation. The host never\npromotes free text to this variant and validates it against the installed\ndeclaration before execution.","properties":{"invocation":{"$ref":"#/$defs/FeatureInvocation"},"kind":{"const":"feature_invoke","type":"string"}},"required":["kind","invocation"],"type":"object"},{"additionalProperties":false,"description":"Source-qualified request for the host to start a Flow before committing\nthis user input. Runtime resolves it through Workspace authority and\nreplaces it with the entered state's instructions before Worker-side\ninput resolution.","properties":{"kind":{"const":"flow","type":"string"},"selector":{"type":"string"}},"required":["kind","selector"],"type":"object"},{"additionalProperties":false,"description":"Unknown variant from a newer client. Worker treats this as an\nunresolved input — surfaces an alert and inserts a placeholder.\nRound-trip is lossy: re-serializing yields `{\"kind\":\"unknown\"}`.","properties":{"kind":{"const":"unknown","type":"string"}},"required":["kind"],"type":"object"}]},"SessionContentPart":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"refusal","type":"string"},"refusal":{"type":"string"}},"required":["kind","refusal"],"type":"object"}]},"SessionEntryProvenance":{"enum":["human_input","worker_input","flow_instruction","backend_instruction","model_output","tool_output","derived_summary","legacy_unknown"],"type":"string"},"SessionMessageRole":{"enum":["user","assistant"],"type":"string"},"SessionSnapshot":{"additionalProperties":false,"description":"Canonical, storage-independent projection of committed session history.\n\nWorker protocols expose this DTO instead of append-log records. New\nstorage variants can therefore be added without teaching every client how\nto replay the durable log format.","properties":{"entries":{"items":{"$ref":"#/$defs/SessionSnapshotEntry"},"type":"array"},"pending_submissions":{"$ref":"#/$defs/PendingSubmissionsSnapshot","default":{"head_id":null,"notification_count":0,"submissions":[]}}},"required":["entries","pending_submissions"],"type":"object"},"SessionSnapshotEntry":{"oneOf":[{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"user_input","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","segments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"items":{"$ref":"#/$defs/SessionContentPart"},"type":"array"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"message","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"role":{"$ref":"#/$defs/SessionMessageRole"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","role","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"call_id":{"type":"string"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","name","arguments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"attachments":{"items":{"$ref":"#/$defs/SessionToolAttachment"},"type":"array"},"call_id":{"type":"string"},"content":{"type":["string","null"]},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"is_error":{"type":"boolean"},"kind":{"const":"tool_result","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"summary":{"type":"string"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","summary","is_error","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"type":"string"},"data":true,"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"item_kind":{"type":"string"},"kind":{"const":"system_item","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","item_kind","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable, non-terminal control return from one logical Run. This is not a\ncompleted run and must not be used to infer the current Worker state.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_yielded","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"reason":{"$ref":"#/$defs/RunYieldReason"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","reason","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable boundary immediately before Engine execution resumes. The source\ndistinguishes automatic compaction continuation from an intentional\nuser-pause resume; it does not prove that later model output occurred.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_resumed","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"source":{"$ref":"#/$defs/RunResumeSource"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","source","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Snapshot-only replay of persisted invocation/usage records. These are\naccounting metadata, never model context or conversation rows.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"invoke","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"trigger":{"$ref":"#/$defs/InvokeKind"}},"required":["kind","trigger","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"cache_read_input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"usage","type":"string"},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","input_tokens","cache_read_input_tokens","output_tokens","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Display-only accounting reconstructed from persisted run/usage records.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"elapsed_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_stats","type":"string"},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"requests":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"upload_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","elapsed_ms","requests","upload_tokens","output_tokens","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable terminal for an intentional cancellation. Consumers must not\npresent this lifecycle result as an execution failure.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_cancelled","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"failure":{"anyOf":[{"$ref":"#/$defs/RunFailureKind"},{"type":"null"}]},"kind":{"const":"run_error","type":"string"},"message":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","message","entry_id","timestamp","provenance"],"type":"object"}]},"SessionToolAttachment":{"additionalProperties":false,"properties":{"attachment_id":{"description":"Stable, bounded identity derived from the owning Session entry, attachment\nposition, and immutable image bytes. It is a locator, not a bearer secret.","type":"string"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"type":"string"}},"required":["attachment_id","media_type","byte_len"],"type":"object"},"SubmissionDisposition":{"description":"Durable acceptance result for one idempotent Submit request.","oneOf":[{"enum":["started"],"type":"string"},{"const":"queued","description":"Input is durably held in the session FIFO until eligible for activation.","type":"string"}]},"SubscriptionEvent":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"payload":{"$ref":"#/$defs/SubscriptionEventPayload"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","payload"],"type":"object"},"event":{"const":"event","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionTerminationCode"},"message":{"type":"string"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","code","message"],"type":"object"},"event":{"const":"subscription_closed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionEventPayload":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"worker":{"$ref":"#/$defs/SubscriptionWorker"}},"required":["worker"],"type":"object"},"event":{"const":"worker_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id"],"type":"object"},"event":{"const":"worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","event"],"type":"object"},"event":{"const":"worker_protocol","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdir":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"}},"required":["workdir"],"type":"object"},"event":{"const":"workdir_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id"],"type":"object"},"event":{"const":"workdir_removed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionId":{"type":"string"},"SubscriptionRejectionCode":{"enum":["invalid_request","unsupported_protocol_version","unsupported_selector","unauthorized","resource_not_found","capacity_exceeded","internal"],"type":"string"},"SubscriptionRequest":{"oneOf":[{"additionalProperties":false,"properties":{"method":{"const":"subscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"}},"required":["request_id","selector"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"unsubscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"}},"required":["method","params"],"type":"object"}]},"SubscriptionRequestId":{"type":"string"},"SubscriptionResponse":{"oneOf":[{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"},"snapshot":{"$ref":"#/$defs/SubscriptionSnapshot"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id","selector","snapshot"],"type":"object"},"result":{"const":"subscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"},"result":{"const":"unsubscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionRejectionCode"},"message":{"type":"string"},"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"anyOf":[{"$ref":"#/$defs/SubscriptionId"},{"type":"null"}]}},"required":["request_id","code","message"],"type":"object"},"result":{"const":"subscription_rejected","type":"string"}},"required":["result","payload"],"type":"object"}]},"SubscriptionSnapshot":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workers":{"items":{"$ref":"#/$defs/SubscriptionWorker"},"type":"array"}},"required":["workers"],"type":"object"},"topic":{"const":"workers","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"events":{"items":{"$ref":"#/$defs/Event"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","events"],"type":"object"},"topic":{"const":"worker_protocol","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdirs":{"items":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"},"type":"array"}},"required":["workdirs"],"type":"object"},"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic","data"],"type":"object"}]},"SubscriptionTerminationCode":{"enum":["lagged","resource_gone","unauthorized","server_shutdown"],"type":"string"},"SubscriptionWorkdirId":{"type":"string"},"SubscriptionWorker":{"additionalProperties":false,"properties":{"availability":{"$ref":"#/$defs/SubscriptionWorkerAvailability","default":"observed","description":"Freshness of the Runtime-backed observation carried by this projection.\n`Unavailable` preserves catalog membership without claiming that execution stopped."},"display_name":{"type":["string","null"]},"has_running_internal_workers":{"default":false,"type":"boolean"},"job":{"anyOf":[{"$ref":"#/$defs/SubscriptionWorkerJob"},{"type":"null"}],"description":"Backend-owned Job binding. Runtime producers leave this unset; the\nWorkspace projection enriches both snapshots and live updates."},"profile":{"type":["string","null"]},"resource_key":{"description":"Workspace-scoped canonical resource key. Runtime producers leave this unset;\nWorkspace-facing projections must populate it before publishing the Worker.","type":["string","null"]},"restore_observation_token":{"description":"Opaque Runtime-owned Restore observation identity, never a display-state hash.","type":["string","null"]},"runtime_id":{"description":"Set by the Workspace Server when projecting a Runtime-owned Worker to clients.\nRuntime producers leave this unset because the connection identifies the Runtime.","type":["string","null"]},"state":{"$ref":"#/$defs/SubscriptionWorkerState","description":"Runtime catalog lifecycle compatibility projection; not foreground-state authority."},"workdir_attachments":{"items":{"$ref":"#/$defs/SubscriptionWorkerWorkdirAttachment"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"},"worker_state":{"anyOf":[{"$ref":"#/$defs/WorkerStateSnapshot"},{"type":"null"}],"description":"Latest foreground state observed from the Worker. This remains\nabsent until an authoritative Worker snapshot/event has been applied."},"workspace_id":{"type":["string","null"]}},"required":["worker_id","state","availability","has_running_internal_workers"],"type":"object"},"SubscriptionWorkerAvailability":{"description":"Whether the Workspace Server has a current Runtime observation for a Worker.\n\n`Unavailable` deliberately does not imply `Stopped`: it means the last observed\nlifecycle state is retained while the Runtime observation source is unavailable.","enum":["observed","unavailable"],"type":"string"},"SubscriptionWorkerId":{"type":"string"},"SubscriptionWorkerIds":{"description":"A bounded, canonical worker-id set. Ordering on the wire and in equality/hash\nsemantics is lexical, so equivalent selectors aggregate to one upstream key.","items":{"$ref":"#/$defs/SubscriptionWorkerId"},"type":"array"},"SubscriptionWorkerJob":{"additionalProperties":false,"properties":{"attempt_id":{"type":"string"},"job_id":{"type":"string"},"purpose":{"type":"string"}},"required":["job_id","attempt_id","purpose"],"type":"object"},"SubscriptionWorkerProtocolMethod":{"additionalProperties":false,"properties":{"method":{"$ref":"#/$defs/Method"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","method"],"type":"object"},"SubscriptionWorkerState":{"enum":["idle","running","paused","stopped"],"type":"string"},"SubscriptionWorkerWorkdirAttachment":{"additionalProperties":false,"properties":{"alias":{"description":"Stable Worker-local routing alias.","type":"string"},"repository_key":{"type":["string","null"]},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["alias","working_directory_id"],"type":"object"},"SymlinkPolicy":{"description":"Symbolic-link identity used by one filesystem allow rule.\n\n`Resolved` is the least authority and the default: access is matched\nagainst the provider-resolved target. `Logical` intentionally grants the\npath as presented through the Workdir, even when it aliases another target.","enum":["resolved","logical"],"type":"string"},"ToolResultDisposition":{"enum":["success","error","interrupted","cancelled","outcome_unknown"],"type":"string"},"TurnResult":{"enum":["finished","paused"],"type":"string"},"UploadedFileAvailability":{"description":"Availability recorded for an uploaded client-local file.","enum":["available","unavailable","integrity_failed"],"type":"string"},"UploadedFileRef":{"additionalProperties":false,"description":"Session-owned immutable reference to a client-local uploaded file.\n\nUpload transports return an unbound reference. Worker fills\n`source_entry_id` immediately before the containing user input is committed;\ncommitted Session Log and public snapshot records therefore always retain\nthe durable source-entry identity without storing the file body.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/UploadedFileAvailability"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"file_name":{"type":"string"},"media_type":{"type":"string"},"sha256":{"type":"string"},"source_entry_id":{"type":["string","null"]}},"required":["artifact_id","file_name","media_type","created_at_ms","availability","byte_len","sha256"],"type":"object"},"WorkerBusyState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"run","type":"string"},"state":{"$ref":"#/$defs/WorkerRunState"}},"required":["kind","state"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"maintenance","type":"string"},"state":{"$ref":"#/$defs/WorkerMaintenanceState"}},"required":["kind","state"],"type":"object"}]},"WorkerCommandAcknowledgement":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandKind"},"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"disposition":{"$ref":"#/$defs/WorkerCommandDisposition"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"The complete authoritative state observed after command admission."}},"required":["command_id","command","disposition","state"],"type":"object"},"WorkerCommandDisposition":{"enum":["accepted","stale_command_id","conflict","invalid_state"],"type":"string"},"WorkerCommandEnvelope":{"additionalProperties":false,"description":"Caller-owned identity for one state-changing Worker command.\n\nCommands operate on the controller state at admission time, not a caller's\npreviously observed Run. A controller accepts command ids in strictly\nincreasing order; retrying an accepted id cannot apply it to a later Run.\nExact retries must retain the same command kind. Removed preconditions are\nrejected at the wire boundary rather than treated as arrival-time commands.","properties":{"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["command_id"],"type":"object"},"WorkerCommandKind":{"enum":["resume","cancel","pause","compact","shutdown"],"type":"string"},"WorkerEvent":{"description":"Typed lifecycle events sent from a child Worker to its parent.\n\nDelivered as `Method::WorkerEvent` over the parent's Unix socket. The\nparent Controller always applies variant-specific side effects\n(registry / worker-allocation updates). Agent-visible variants are also\nqueued into the notification buffer; control-plane-only variants are\nnot injected into the parent's LLM context.\n\nTransport is fire-and-forget; receivers must tolerate out-of-order\ndelivery (e.g. `TurnEnded` arriving after `ShutDown` for the same\nchild Worker).","oneOf":[{"additionalProperties":false,"description":"Child finished one turn and is back to IDLE.","properties":{"kind":{"const":"turn_ended","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Engine execution error occurred inside the child's turn.\n\nLimited to worker runtime failures (provider / tool errors) —\ndoes not include transient method-rejection responses such as\n`AlreadyRunning`.","properties":{"kind":{"const":"errored","type":"string"},"message":{"type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name","message"],"type":"object"},{"additionalProperties":false,"description":"Child has stopped (controller loop is exiting).","properties":{"kind":{"const":"shut_down","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Child SubWorker sub-delegated scope to a grandchild SubWorker via `SubWorkerSpawn`.\n\nControl-plane only: receivers apply registry side effects and\npropagate upward, but do not expose this as an agent notification.\n\nThe parent uses this to add the grandchild to its own\n`spawned_workers.json` so it can manage the grandchild directly\neven if the intermediate child dies. The parent then re-fires\nthis event upward (if it has a parent of its own) to maintain\nthe chain to root.","properties":{"kind":{"const":"scope_sub_delegated","type":"string"},"parent_worker":{"description":"Sub-delegating Worker (= the sender itself).","type":"string"},"scope":{"description":"Scope delegated to the grandchild.","items":{"$ref":"#/$defs/ScopeRule"},"type":"array"},"sub_socket":{"description":"Unix-socket path where the grandchild is reachable.","type":"string"},"sub_worker":{"description":"Name of the grandchild Worker.","type":"string"}},"required":["kind","parent_worker","sub_worker","sub_socket","scope"],"type":"object"}]},"WorkerMaintenanceState":{"enum":["compacting"],"type":"string"},"WorkerRunState":{"enum":["running","pausing","paused","cancelling"],"type":"string"},"WorkerState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"idle","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"busy","type":"string"},"state":{"$ref":"#/$defs/WorkerBusyState"}},"required":["kind","state"],"type":"object"}]},"WorkerStateSnapshot":{"additionalProperties":false,"properties":{"last_command_id":{"description":"Highest lifecycle command id observed by this controller instance.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"last_finished_submission_request_id":{"description":"Most recent Submit request whose controller Run has returned and whose\npost-Run state transition has been published. This is a teardown fence,\nnot a successful-result claim; cancelled and failed Runs may also finish.","type":["string","null"]},"state":{"$ref":"#/$defs/WorkerState"}},"required":["last_command_id","state"],"type":"object"},"WorkerStatus":{"enum":["idle","running","paused","stopped"],"type":"string"},"WorkspaceSubscriptionWorkdir":{"additionalProperties":false,"description":"Workspace-facing Workdir summary. Backend-generated Repository UUIDs never\nenter this DTO; Workspace Server must resolve the required Repository key.","properties":{"repository_key":{"type":"string"},"state":{"type":"string"},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id","repository_key","state"],"type":"object"}},"$schema":"https://json-schema.org/draft/2020-12/schema","description":"Direction is explicit at the outer envelope, while concrete message kinds\nremain typed within each lane.","oneOf":[{"additionalProperties":false,"properties":{"frame":{"const":"request","type":"string"},"message":{"$ref":"#/$defs/SubscriptionRequest"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"response","type":"string"},"message":{"$ref":"#/$defs/SubscriptionResponse"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"event","type":"string"},"message":{"$ref":"#/$defs/SubscriptionEvent"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"worker_protocol","type":"string"},"message":{"$ref":"#/$defs/SubscriptionWorkerProtocolMethod"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"}],"title":"SubscriptionFrame"}; export const RUST_SERIALIZED_SUBSCRIPTION_FRAME_FIXTURES: readonly unknown[] = - [{"protocol_version":2,"frame":"response","message":{"result":"subscribed","payload":{"request_id":"fixture-request","subscription_id":"fixture-subscription","selector":{"topic":"workspace_workers"},"snapshot":{"topic":"workers","data":{"workers":[{"restore_observation_token":"fixture-restore-observation","worker_id":"fixture-worker","runtime_id":"fixture-runtime","resource_key":"fixture-worker-resource","availability":"observed","state":"idle","has_running_internal_workers":false,"workspace_id":"fixture-workspace","workdir_attachments":[{"alias":"external","working_directory_id":"fixture-external-workdir"}]}]}}}}},{"protocol_version":2,"frame":"event","message":{"event":"event","data":{"subscription_id":"fixture-subscription","payload":{"event":"worker_protocol","data":{"worker_id":"fixture-worker","event":{"event":"pending_submissions_changed","data":{"pending":{"revision":0,"notification_count":0,"head_id":null,"submissions":[]}}}}}}}},{"protocol_version":2,"frame":"event","message":{"event":"event","data":{"subscription_id":"fixture-subscription","payload":{"event":"worker_protocol","data":{"worker_id":"fixture-worker","event":{"event":"snapshot","data":{"session":{"pending_submissions":{"revision":0,"notification_count":0,"head_id":null,"submissions":[]},"entries":[]},"greeting":{"worker_name":"fixture-worker","cwd":"/fixture","provider":"openai_responses","model":"gpt-6-astra","reasoning":{"kind":"effort","effort":"high"},"scope_summary":"fixture scope","tools":["Read"],"context_window":272000,"context_tokens":142000,"context_usage":{"tokens":142000,"source":"measured"}},"state":{"last_command_id":0,"state":{"kind":"idle"}},"internal_workers":[{"worker":{"session_id":"fixture-child-session","name":"fixture-child","parent_session_id":"fixture-parent-session","kind":"sub_worker"},"revision":1,"session":{"pending_submissions":{"revision":0,"notification_count":0,"head_id":null,"submissions":[]},"entries":[]},"greeting":{"worker_name":"fixture-worker","cwd":"/fixture","provider":"openai_responses","model":"gpt-6-astra","reasoning":{"kind":"effort","effort":"high"},"scope_summary":"fixture scope","tools":["Read"],"context_window":272000,"context_tokens":142000,"context_usage":{"tokens":142000,"source":"measured"}},"status":"idle"}]}}}}}}}]; + [{"protocol_version":2,"frame":"response","message":{"result":"subscribed","payload":{"request_id":"fixture-request","subscription_id":"fixture-subscription","selector":{"topic":"workspace_workers"},"snapshot":{"topic":"workers","data":{"workers":[{"restore_observation_token":"fixture-restore-observation","worker_id":"fixture-worker","runtime_id":"fixture-runtime","resource_key":"fixture-worker-resource","availability":"observed","state":"idle","has_running_internal_workers":false,"workspace_id":"fixture-workspace","workdir_attachments":[{"alias":"external","working_directory_id":"fixture-external-workdir"}]}]}}}}},{"protocol_version":2,"frame":"event","message":{"event":"event","data":{"subscription_id":"fixture-subscription","payload":{"event":"worker_protocol","data":{"worker_id":"fixture-worker","event":{"event":"pending_submissions_changed","data":{"pending":{"notification_count":0,"head_id":null,"submissions":[]}}}}}}}},{"protocol_version":2,"frame":"event","message":{"event":"event","data":{"subscription_id":"fixture-subscription","payload":{"event":"worker_protocol","data":{"worker_id":"fixture-worker","event":{"event":"snapshot","data":{"session":{"pending_submissions":{"notification_count":0,"head_id":null,"submissions":[]},"entries":[]},"greeting":{"worker_name":"fixture-worker","cwd":"/fixture","provider":"openai_responses","model":"gpt-6-astra","reasoning":{"kind":"effort","effort":"high"},"scope_summary":"fixture scope","tools":["Read"],"context_window":272000,"context_tokens":142000,"context_usage":{"tokens":142000,"source":"measured"}},"state":{"last_command_id":0,"state":{"kind":"idle"}},"internal_workers":[{"worker":{"session_id":"fixture-child-session","name":"fixture-child","parent_session_id":"fixture-parent-session","kind":"sub_worker"},"session":{"pending_submissions":{"notification_count":0,"head_id":null,"submissions":[]},"entries":[]},"greeting":{"worker_name":"fixture-worker","cwd":"/fixture","provider":"openai_responses","model":"gpt-6-astra","reasoning":{"kind":"effort","effort":"high"},"scope_summary":"fixture scope","tools":["Read"],"context_window":272000,"context_tokens":142000,"context_usage":{"tokens":142000,"source":"measured"}},"status":"idle"}]}}}}}}}]; const utf8 = new TextEncoder(); type JsonObject = Record; diff --git a/web/workspace/src/lib/generated/protocol.ts b/web/workspace/src/lib/generated/protocol.ts index a9b86538d..8ed542ecb 100644 --- a/web/workspace/src/lib/generated/protocol.ts +++ b/web/workspace/src/lib/generated/protocol.ts @@ -115,7 +115,7 @@ export type CommandEvent = { "kind": "started", command_id: string, tool_call_id export type CompactionLifecycleState = "running" | "done" | "failed" | "interrupted"; -export type CompactionLifecycle = { schema_version: number, compaction_id: string, revision: number, internal_worker?: InternalWorkerRef | null, state: CompactionLifecycleState, +export type CompactionLifecycle = { schema_version: number, compaction_id: string, internal_worker?: InternalWorkerRef | null, state: CompactionLifecycleState, /** * Milliseconds since the Unix epoch. */ @@ -240,7 +240,7 @@ export type PendingSubmissionSummary = { submission_id: string, */ preview?: string | null, accepted_at_ms: number, segment_count: number, byte_len: number, }; -export type PendingSubmissionsSnapshot = { revision: number, notification_count: number, +export type PendingSubmissionsSnapshot = { notification_count: number, /** * Ordered display-only previews of waiting notifications, each bounded like * PendingSubmissionSummary::preview. Older runtimes omit this field. @@ -281,7 +281,7 @@ export type InternalWorkerKind = "sub_worker" | { "service": { kind: string, } } export type InternalWorkerRef = { session_id: string, name: string, parent_session_id?: string | null, kind: InternalWorkerKind, }; -export type InternalWorkerSnapshot = { worker: InternalWorkerRef, revision: number, session: SessionSnapshot, +export type InternalWorkerSnapshot = { worker: InternalWorkerRef, session: SessionSnapshot, /** * Public execution metadata for this child. Older snapshots and service * workers may omit it; clients must never substitute the parent's values. @@ -416,9 +416,9 @@ export type SubscriptionFramePayload = { "frame": "request", "message": Subscrip export type SubscriptionFrame = { protocol_version: number, } & ({ "frame": "request", "message": SubscriptionRequest } | { "frame": "response", "message": SubscriptionResponse } | { "frame": "event", "message": SubscriptionEvent } | { "frame": "worker_protocol", "message": SubscriptionWorkerProtocolMethod }); -export type Method = { "method": "submit", "params": { submission_request_id: string, input: Array, } } | { "method": "notify", "params": { notification_request_id: string, message: string, } } | { "method": "worker_event", "params": WorkerEvent } | { "method": "list_pending_submissions" } | { "method": "cancel_pending_submission", "params": { submission_id: string, expected_revision: number, } } | { "method": "clear_pending_submissions", "params": { expected_revision: number, } } | { "method": "continue_pending", "params": { expected_revision: number, expected_head_id: string, } } | { "method": "resume", "params": { command: WorkerCommandEnvelope, } } | { "method": "cancel", "params": { command: WorkerCommandEnvelope, } } | { "method": "pause", "params": { command: WorkerCommandEnvelope, } } | { "method": "compact", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_rewind_targets" } | { "method": "rewind_to", "params": { target: RewindTargetId, expected_head_entries: number, } } | { "method": "shutdown", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_completions", "params": { kind: CompletionKind, prefix: string, +export type Method = { "method": "submit", "params": { submission_request_id: string, input: Array, } } | { "method": "notify", "params": { notification_request_id: string, message: string, } } | { "method": "worker_event", "params": WorkerEvent } | { "method": "list_pending_submissions" } | { "method": "cancel_pending_submission", "params": { submission_id: string, } } | { "method": "clear_pending_submissions", "params": { expected_submission_ids: Array, } } | { "method": "continue_pending", "params": { expected_head_id: string, } } | { "method": "resume", "params": { command: WorkerCommandEnvelope, } } | { "method": "cancel", "params": { command: WorkerCommandEnvelope, } } | { "method": "pause", "params": { command: WorkerCommandEnvelope, } } | { "method": "compact", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_rewind_targets" } | { "method": "rewind_to", "params": { target: RewindTargetId, expected_head_entries: number, } } | { "method": "shutdown", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_completions", "params": { kind: CompletionKind, prefix: string, /** - * Client query generation. Echoed verbatim so identical-prefix ABA + * Client query identity. Echoed verbatim so identical-prefix ABA * responses cannot cross edits, target switches, or permission changes. */ request_id?: string | null, context?: CompletionContext | null, } } | { "method": "list_workers" } | { "method": "restore_worker", "params": { name: string, } } | { "method": "register_peer", "params": { name: string, } }; @@ -455,7 +455,7 @@ output?: string | null, disposition?: ToolResultDisposition | null, is_error: bo */ timestamp_ms?: number | null, input_tokens: number | null, output_tokens: number | null, cache_read_input_tokens?: number | null, } } | { "event": "context_usage", "data": { usage?: ContextUsage | null, } } | { "event": "run_end", "data": { result: RunResult, } } | { "event": "error", "data": { code: ErrorCode, message: string, } } | { "event": "snapshot", "data": { session: SessionSnapshot, greeting: Greeting, /** - * Full revisioned live execution state. `Stopped` remains Runtime + * Full live execution state. `Stopped` remains Runtime * catalog authority and is deliberately not represented here. */ state: WorkerStateSnapshot, @@ -468,4 +468,4 @@ in_flight?: InFlightSnapshot, * Parent-owned Internal Worker sessions visible to this client. * Service-private Internal Workers are deliberately excluded. */ -internal_workers?: Array, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, revision: number, event: Event, } } | { "event": "internal_worker_removed", "data": { worker: InternalWorkerRef, revision: number, } } | { "event": "segment_rotated", "data": { session: SessionSnapshot, } } | { "event": "worker_state", "data": { snapshot: WorkerStateSnapshot, } } | { "event": "command_acknowledged", "data": { acknowledgement: WorkerCommandAcknowledgement, } } | { "event": "command", "data": { event: CommandEvent, } } | { "event": "completions", "data": { kind: CompletionKind, prefix: string, request_id?: string | null, context?: CompletionContext | null, entries: Array, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array, } } | { "event": "rewind_applied", "data": { session: SessionSnapshot, input: Array, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compaction_progress", "data": { compaction: InFlightCompaction | null, } } | { "event": "compact_start", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_done", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_failed", "data": { lifecycle: CompactionLifecycle, } } | { "event": "shutdown" }; +internal_workers?: Array, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, event: Event, } } | { "event": "internal_worker_removed", "data": { worker: InternalWorkerRef, } } | { "event": "segment_rotated", "data": { session: SessionSnapshot, } } | { "event": "worker_state", "data": { snapshot: WorkerStateSnapshot, } } | { "event": "command_acknowledged", "data": { acknowledgement: WorkerCommandAcknowledgement, } } | { "event": "command", "data": { event: CommandEvent, } } | { "event": "completions", "data": { kind: CompletionKind, prefix: string, request_id?: string | null, context?: CompletionContext | null, entries: Array, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array, } } | { "event": "rewind_applied", "data": { session: SessionSnapshot, input: Array, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compaction_progress", "data": { compaction: InFlightCompaction | null, } } | { "event": "compact_start", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_done", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_failed", "data": { lifecycle: CompactionLifecycle, } } | { "event": "shutdown" }; diff --git a/web/workspace/src/lib/generated/repository-access-api.ts b/web/workspace/src/lib/generated/repository-access-api.ts index f84c9200e..d581b6452 100644 --- a/web/workspace/src/lib/generated/repository-access-api.ts +++ b/web/workspace/src/lib/generated/repository-access-api.ts @@ -7,7 +7,6 @@ export type RepositorySshCredential = { name: string; public_key_algorithm: string; public_key_fingerprint: string; - current_revision: number; status: string; created_at: string; rotated_at: string | null; @@ -30,7 +29,6 @@ export type GenerateRepositorySshCredentialRequest = { export type RepositorySshPublicKey = { credential_id: string; - current_revision: number; public_key_algorithm: string; public_key_fingerprint: string; public_key: string; @@ -38,14 +36,14 @@ export type RepositorySshPublicKey = { export type RotateRepositorySshCredentialRequest = { operation_id: string; - expected_revision: number; + expected_public_key_fingerprint: string; private_key: string; passphrase: string | null; }; export type DeleteRepositorySshCredentialRequest = { operation_id: string; - expected_revision: number; + expected_public_key_fingerprint: string; }; export type RepositorySshHostTrust = { @@ -56,7 +54,6 @@ export type RepositorySshHostTrust = { key_algorithm: string; host_key: string; fingerprint: string; - current_revision: number; created_at: string; updated_at: string; referenced_repositories: Array; @@ -68,12 +65,12 @@ export type PutRepositorySshHostTrustRequest = { hostname: string; port: number; host_key: string; - expected_revision: number | null; + expected_fingerprint: string | null; }; export type DeleteRepositorySshHostTrustRequest = { operation_id: string; - expected_revision: number; + expected_fingerprint: string; }; export type RepositoryAccessMode = "read_only" | "read_write"; @@ -87,7 +84,6 @@ export type RepositorySshAccessBinding = { export type RepositoryAccessProjection = { workspace_id: string; - config_revision: number; projection_digest: string; bindings: Array; }; diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index ec4dbf713..04f9e6730 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,11 +1,11 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:6dd3e9e7ffe5e653697addc29675545b5fb28ea856b14bafc580355f0453ec12) +// Input contract: 0.1.0 (sha256:07cfe53bfdd700cad76d00695a031255ffa76a6062110a05596fa8b64ae372c4) // 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 -export type ConfirmRepositorySshHostTrustRequest = { expected_host_trust_revision?: number | null; host_key: string; operation_id: string; runtime_id: string; }; +export type ConfirmRepositorySshHostTrustRequest = { expected_host_key_fingerprint?: string | null; host_key: string; operation_id: string; runtime_id: string; }; export type CreateWorkspaceRepositoryRequest = { default_ref?: string | null; repository_key: string; source: string; }; @@ -47,15 +47,15 @@ export type RepositorySourceKind = "local_path" | "file" | "ssh" | "https" | "in export type RepositorySshConnectionProbeRequest = { runtime_id: string; }; -export type RepositorySshConnectionProbeResponse = { candidates: Array; expected_host_trust_revision?: number | null; host_trust_id: string; hostname: string; port: number; repository_key: string; runtime_id: string; trust_state: RepositorySshConnectionTrustState; workspace_id: string; }; +export type RepositorySshConnectionProbeResponse = { candidates: Array; expected_host_key_fingerprint?: string | null; host_trust_id: string; hostname: string; port: number; repository_key: string; runtime_id: string; trust_state: RepositorySshConnectionTrustState; workspace_id: string; }; export type RepositorySshConnectionTrustState = "untrusted" | "verified" | "changed"; export type RepositorySshHostKeyCandidate = { algorithm: string; fingerprint: string; host_key: string; }; -export type RepositorySshHostTrust = { created_at: string; current_revision: number; fingerprint: string; host_key: string; host_trust_id: string; hostname: string; key_algorithm: string; port: number; referenced_repositories?: Array; updated_at: string; workspace_id: string; }; +export type RepositorySshHostTrust = { created_at: string; fingerprint: string; host_key: string; host_trust_id: string; hostname: string; key_algorithm: string; port: number; referenced_repositories?: Array; updated_at: string; workspace_id: string; }; -export type RepositorySummary = { default_selector?: string | null; diagnostics?: Array | null; git?: GitRepositorySummary | null; kind: string; observed_at?: string | null; observed_status: RepositoryObservedStatus; provider: string; record_authority: string; repository_key: string; source: RepositorySource; source_fingerprint: string; source_revision: number; }; +export type RepositorySummary = { default_selector?: string | null; diagnostics?: Array | null; git?: GitRepositorySummary | null; kind: string; observed_at?: string | null; observed_status: RepositoryObservedStatus; provider: string; record_authority: string; repository_key: string; source: RepositorySource; source_fingerprint: string; }; export type SubscriptionWorkerAvailability = "observed" | "unavailable"; diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index f1ee3a758..fd85dc19c 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:6dd3e9e7ffe5e653697addc29675545b5fb28ea856b14bafc580355f0453ec12) +// Input contract: 0.1.0 (sha256:07cfe53bfdd700cad76d00695a031255ffa76a6062110a05596fa8b64ae372c4) // 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 @@ -25,13 +25,13 @@ export type CompletionEntry = { description?: string | null; invocation?: Featur export type CompletionKind = "file" | "feature" | "feature_argument"; -export type CreateRemoteRuntimeRequest = { display_name?: string | null; endpoint: string; expected_revision?: number | null; public_bundle: RuntimePublicIdentityBundle; }; +export type CreateRemoteRuntimeRequest = { display_name?: string | null; endpoint: string; expected_binding_id?: string | null; public_bundle: RuntimePublicIdentityBundle; workspace_trust_id: string; }; export type Diagnostic = { code: string; message: string; severity: DiagnosticSeverity; }; export type DiagnosticSeverity = "info" | "warning" | "error"; -export type ExecuteRuntimeCleanupRequest = { confirm_dirty_discard_target_ids?: Array; expected_plan_digest: string; expected_plan_revision: string; workdir_target_ids?: Array; worker_target_ids?: Array; }; +export type ExecuteRuntimeCleanupRequest = { confirm_dirty_discard_target_ids?: Array; expected_plan_digest: string; workdir_target_ids?: Array; worker_target_ids?: Array; }; export type ExternalWorkdirPermissions = { command: boolean; read: boolean; write: boolean; }; @@ -61,17 +61,17 @@ export type PasteArtifactMediaType = "text_plain_utf8"; export type PasteArtifactRef = { artifact_id: string; availability: PasteArtifactAvailability; byte_len: number; char_count: number; created_at_ms: number; line_count: number; media_type: PasteArtifactMediaType; sha256: string; source_entry_id: string; }; -export type RemoveRuntimeRequest = { expected_binding_revision: number; operation_id: string; }; +export type RemoveRuntimeRequest = { expected_binding_id: string; operation_id: string; }; export type RepositoryApiError = { diagnostics?: Array; error: string; message: string; }; -export type RevokeRuntimeTrustKeyRequest = { expected_revision: number; }; +export type RevokeRuntimeTrustKeyRequest = { expected_binding_id: string; }; export type RuntimeCleanupExecutionResponse = { diagnostics: Array; executed_at: string; plan_after: RuntimeCleanupPlanResponse; results: Array; runtime_id: string; workspace_id: string; }; export type RuntimeCleanupExecutionResult = { action: CleanupTargetKind; message: string; status: string; target_id: string; }; -export type RuntimeCleanupPlanResponse = { diagnostics: Array; digest: string; generated_at: string; revision: string; runtime_id: string; workdirs: Array; workers: Array; workspace_id: string; }; +export type RuntimeCleanupPlanResponse = { diagnostics: Array; digest: string; generated_at: string; runtime_id: string; workdirs: Array; workers: Array; workspace_id: string; }; export type RuntimeConfigBundleResult = { availability?: unknown; diagnostics?: Array; state: WorkerOperationState; }; @@ -81,7 +81,7 @@ export type RuntimeConnectionDisplayState = "configured" | "verified" | "unavail export type RuntimeConnectionTestFailureKind = "authentication" | "authorization" | "network_unreachable" | "timeout" | "tls_or_transport" | "malformed_response" | "protocol_version_mismatch" | "runtime_identity_mismatch" | "configuration"; -export type RuntimeConnectionTestResponse = { actual_protocol_version?: number | null; binding_revision: number; checked_at: string; connection_state: RuntimeConnectionDisplayState; diagnostics?: Array; expected_protocol_version: number; failure_kind?: RuntimeConnectionTestFailureKind | null; runtime_id: string; status: RuntimeConnectionTestStatus; verification?: RuntimeVerificationEvidenceSummary | null; workspace_id: string; }; +export type RuntimeConnectionTestResponse = { actual_protocol_version?: number | null; binding_id: string; checked_at: string; connection_state: RuntimeConnectionDisplayState; diagnostics?: Array; expected_protocol_version: number; failure_kind?: RuntimeConnectionTestFailureKind | null; runtime_id: string; status: RuntimeConnectionTestStatus; verification?: RuntimeVerificationEvidenceSummary | null; workspace_id: string; }; export type RuntimeConnectionTestStatus = "compatible" | "failed"; @@ -89,7 +89,7 @@ export type RuntimeIdentityAuthority = "runtime_registry_projection" | "server_r export type RuntimeListResponse = { diagnostics?: Array; items: Array; limit: number; source: string; workspace_id: string; }; -export type RuntimeManagementApiError = { current_fingerprint?: string | null; current_revision?: number | null; diagnostics?: Array; error: string; message: string; }; +export type RuntimeManagementApiError = { current_binding_id?: string | null; current_fingerprint?: string | null; diagnostics?: Array; error: string; message: string; }; export type RuntimeManagementSummary = { binding?: WorkspaceRuntimeBindingSummary | null; built_in: boolean; config_managed: boolean; endpoint_configured: boolean; removable: boolean; token_ref_configured: boolean; }; @@ -113,15 +113,15 @@ export type RuntimeTicketWorkerRole = "intake" | "orchestrator" | "worker" | "re export type RuntimeTrustAuditAction = "created" | "replaced" | "reactivated" | "revoked"; -export type RuntimeTrustAuditEntry = { action: RuntimeTrustAuditAction; actor_account_id: string; at: string; new_fingerprint?: string | null; old_fingerprint?: string | null; revision: number; }; +export type RuntimeTrustAuditEntry = { action: RuntimeTrustAuditAction; actor_account_id: string; at: string; binding_id: string; new_fingerprint?: string | null; old_fingerprint?: string | null; }; export type RuntimeTrustKeyRevealResponse = { public_key: string; }; -export type RuntimeTrustKeyState = { created_at?: string | null; fingerprint?: string | null; revision?: number | null; revoked_at?: string | null; status: RuntimeTrustKeyStatus; updated_at?: string | null; }; +export type RuntimeTrustKeyState = { binding_id?: string | null; created_at?: string | null; fingerprint?: string | null; revoked_at?: string | null; status: RuntimeTrustKeyStatus; updated_at?: string | null; }; export type RuntimeTrustKeyStatus = "unconfigured" | "active" | "revoked"; -export type RuntimeVerificationEvidenceSummary = { binding_revision: number; last_checked_at: string; last_outcome: RuntimeVerificationOutcome; runtime_identity_revision: number; runtime_public_key_fingerprint: string; verified_at?: string | null; workspace_identity_revision: number; workspace_key_id: string; workspace_trust_generation: number; }; +export type RuntimeVerificationEvidenceSummary = { binding_id: string; last_checked_at: string; last_outcome: RuntimeVerificationOutcome; runtime_public_key_fingerprint: string; verified_at?: string | null; workspace_key_id: string; workspace_public_key_fingerprint: string; workspace_trust_id: string; }; export type RuntimeVerificationOutcome = "verified" | "challenge_issued" | "verification_failed" | "connectivity_failed"; @@ -219,7 +219,7 @@ export type WorkspaceConfigAccess = "read_only" | "read_write"; export type WorkspaceRuntimeBindingState = "configured" | "verified" | "revoked"; -export type WorkspaceRuntimeBindingSummary = { connection_state: RuntimeConnectionDisplayState; revision: number; state: WorkspaceRuntimeBindingState; verification?: RuntimeVerificationEvidenceSummary | null; workspace_key_generation?: number | null; workspace_key_id?: string | null; }; +export type WorkspaceRuntimeBindingSummary = { binding_id: string; connection_state: RuntimeConnectionDisplayState; state: WorkspaceRuntimeBindingState; verification?: RuntimeVerificationEvidenceSummary | null; workspace_key_id?: string | null; workspace_trust_id?: string | null; }; export type WorkspaceRuntimeDetail = { endpoint?: string | null; recent_audit?: Array; runtime: WorkspaceRuntimeResource; trust_key: RuntimeTrustKeyState; workspace_id: string; }; diff --git a/web/workspace/src/lib/generated/skill-api.ts b/web/workspace/src/lib/generated/skill-api.ts index f73ae0c95..98514ca09 100644 --- a/web/workspace/src/lib/generated/skill-api.ts +++ b/web/workspace/src/lib/generated/skill-api.ts @@ -33,7 +33,6 @@ export type SkillProvenance = { kind: SkillSourceKind; id: string; virtual_path?: string; - revision?: number; source_digest?: string; tree_digest?: string; }; @@ -42,10 +41,7 @@ export type SkillActivationStatus = "active" | "inactive"; export type SkillProjectionStatus = "valid" | "invalid"; -export type SkillProjectionIdentity = { - config_revision: number; - tree_digest: string; -}; +export type SkillProjectionIdentity = { tree_digest: string }; export type SkillResourceRef = { kind: string; diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index b62c197f9..1559c0419 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,13 +1,13 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:6dd3e9e7ffe5e653697addc29675545b5fb28ea856b14bafc580355f0453ec12) +// Input contract: 0.1.0 (sha256:07cfe53bfdd700cad76d00695a031255ffa76a6062110a05596fa8b64ae372c4) // 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 export type BrowserAppendTicketEventRequest = { author?: string | null; body: string; role: BrowserTicketThreadRole; }; -export type BrowserCloseTicketRequest = { expected_item_revision: string; expected_state: BrowserTicketWorkflowState; operation_key: string; resolution: string; }; +export type BrowserCloseTicketRequest = { expected_content_digest: string; expected_state: BrowserTicketWorkflowState; operation_key: string; resolution: string; }; export type BrowserEditTicketRequest = { author?: string | null; body?: string | null; new_string?: string | null; old_string?: string | null; replace_all?: boolean; target?: BrowserTicketTargetEdit | null; title?: string | null; }; @@ -19,7 +19,7 @@ export type BrowserTicketThreadRole = "comment" | "plan" | "decision" | "impleme export type BrowserTicketWorkflowState = "planning" | "ready" | "queued" | "inprogress" | "done" | "closed"; -export type BrowserTransitionTicketStateRequest = { body?: string | null; expected_item_revision: string; expected_state: BrowserTicketWorkflowState; operation_key: string; reason: string; state: BrowserTicketWorkflowState; }; +export type BrowserTransitionTicketStateRequest = { body?: string | null; expected_content_digest: string; expected_state: BrowserTicketWorkflowState; operation_key: string; reason: string; state: BrowserTicketWorkflowState; }; export type CancelTicketImplementationRequest = { assignment_id: string; operation_id: string; reason: string; }; @@ -51,7 +51,7 @@ export type MergeRequestReviewSubject = { merge_request_id: string; subject_ref: export type MergeRequestState = "open" | "merged" | "closed"; -export type MergeRequestThreadEvent = { created_at: string; event_id: string; kind: "review_requested"; requested_by: MergeRequestWorkerIdentity; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; ticket_item_revision: string; ticket_merge_request_subjects: Array; } | { body: string; created_at: string; decision: ReviewDecision; event_id: string; findings: Array; kind: "review"; request_event_id: string; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; ticket_item_revision: string; ticket_merge_request_subjects: Array; } | { created_at: string; event_id: string; kind: "review_revoked"; reason: string; review_event_id: string; revoked_by: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { created_at: string; event_id: string; kind: "review_cancelled"; reason: string; request_event_id: string; sequence: number; subject_ref: string; } | { author: MergeRequestWorkerIdentity; body: string; created_at: string; event_id: string; kind: "comment"; sequence: number; } | { approval_event_id: string; approved_source_ref: string; created_at: string; event_id: string; kind: "merge"; merged_by: MergeRequestWorkerIdentity; operation_id: string; resolution: ConflictResolution; sequence: number; strategy: MergeStrategy; target_ref_after: string; target_ref_before: string; }; +export type MergeRequestThreadEvent = { created_at: string; event_id: string; kind: "review_requested"; requested_by: MergeRequestWorkerIdentity; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; ticket_content_digest: string; ticket_merge_request_subjects: Array; } | { body: string; created_at: string; decision: ReviewDecision; event_id: string; findings: Array; kind: "review"; request_event_id: string; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; ticket_content_digest: string; ticket_merge_request_subjects: Array; } | { created_at: string; event_id: string; kind: "review_revoked"; reason: string; review_event_id: string; revoked_by: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { created_at: string; event_id: string; kind: "review_cancelled"; reason: string; request_event_id: string; sequence: number; subject_ref: string; } | { author: MergeRequestWorkerIdentity; body: string; created_at: string; event_id: string; kind: "comment"; sequence: number; } | { approval_event_id: string; approved_source_ref: string; created_at: string; event_id: string; kind: "merge"; merged_by: MergeRequestWorkerIdentity; operation_id: string; resolution: ConflictResolution; sequence: number; strategy: MergeStrategy; target_ref_after: string; target_ref_before: string; }; export type MergeRequestWorkerIdentity = { runtime_id: string; worker_id: string; }; @@ -59,7 +59,7 @@ export type MergeStrategy = "fast_forward" | "merge"; export type NewTicket = { assignee?: string | null; author?: string | null; body: MarkdownText; kind: string; labels: Array; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; risk_flags: Array; slug?: string | null; targets?: Array; title: string; workflow_state?: TicketWorkflowState | null; }; -export type ObjectiveDetail = { body: string; body_truncated: boolean; created_at?: string | null; event_page: QueryPage; events: Array; id: string; linked_ticket_summaries: Array; linked_tickets: Array; record_source: string; resource_key: string; resources: Array; revision: string; state: string; title: string; updated_at?: string | null; }; +export type ObjectiveDetail = { body: string; body_truncated: boolean; created_at?: string | null; event_page: QueryPage; events: Array; id: string; linked_ticket_summaries: Array; linked_tickets: Array; record_source: string; resource_key: string; resources: Array; state: string; title: string; updated_at?: string | null; }; export type ObjectiveEventDetail = { body?: string | null; created_at: string; event_ref: string; kind: string; }; @@ -91,7 +91,7 @@ export type TicketAssignmentRole = "orchestrator" | "worker" | "owner" | "contri export type TicketAssignmentSummary = { assignment_id: string; runtime_id: string; worker_id: string; worker_resource_key?: string | null; }; -export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array; assignment_diagnostics: Array; assignments: Array; body: string; body_truncated: boolean; created_at?: string | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array; item_revision: string; linked_objectives: Array; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array; state: string; targets: Array; title: string; updated_at?: string | null; }; +export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array; assignment_diagnostics: Array; assignments: Array; body: string; body_truncated: boolean; content_digest: string; created_at?: string | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array; linked_objectives: Array; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array; state: string; targets: Array; title: string; updated_at?: string | null; }; export type TicketDetailDerivedRelation = { at: string; author: string; forward_kind: string; inverse_kind: string; note?: string | null; source_resource_key?: string | null; source_ticket: string; }; diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index aa682fc69..82cb27e33 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-workdir-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:6dd3e9e7ffe5e653697addc29675545b5fb28ea856b14bafc580355f0453ec12) +// Input contract: 0.1.0 (sha256:07cfe53bfdd700cad76d00695a031255ffa76a6062110a05596fa8b64ae372c4) // Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/workdir-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_workdir_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 2983c1e4f..65cf310d0 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:6dd3e9e7ffe5e653697addc29675545b5fb28ea856b14bafc580355f0453ec12) +// Input contract: 0.1.0 (sha256:07cfe53bfdd700cad76d00695a031255ffa76a6062110a05596fa8b64ae372c4) // 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/api/http.test.ts b/web/workspace/src/lib/workspace/api/http.test.ts index b6c3802f9..8fd027149 100644 --- a/web/workspace/src/lib/workspace/api/http.test.ts +++ b/web/workspace/src/lib/workspace/api/http.test.ts @@ -93,7 +93,6 @@ Deno.test("loadWorkspaceSkillCatalog fetches lightweight catalog", async () => { JSON.stringify({ authority: "workspace-config-skills-v1", projection: { - config_revision: 7, tree_digest: "tree-digest", }, entries: [{ @@ -105,7 +104,6 @@ Deno.test("loadWorkspaceSkillCatalog fetches lightweight catalog", async () => { kind: "workspace", id: "workspace:triage-errors", virtual_path: "skills/triage-errors/SKILL.md", - revision: 7, source_digest: "source-digest", tree_digest: "tree-digest", }, diff --git a/web/workspace/src/lib/workspace/api/repository-access.ts b/web/workspace/src/lib/workspace/api/repository-access.ts index e18c7b224..94f3936bc 100644 --- a/web/workspace/src/lib/workspace/api/repository-access.ts +++ b/web/workspace/src/lib/workspace/api/repository-access.ts @@ -36,7 +36,6 @@ export function parseRepositorySshCredential( "name", "public_key_algorithm", "public_key_fingerprint", - "current_revision", "status", "created_at", "rotated_at", @@ -47,7 +46,6 @@ export function parseRepositorySshCredential( readString(record, "name", path); readString(record, "public_key_algorithm", path); readString(record, "public_key_fingerprint", path); - readRevision(record, "current_revision", path); readString(record, "status", path); readString(record, "created_at", path); readNullableString(record, "rotated_at", path); @@ -61,13 +59,11 @@ export function parseRepositorySshPublicKey( ): RepositorySshPublicKey { const record = readRecord(value, path, [ "credential_id", - "current_revision", "public_key_algorithm", "public_key_fingerprint", "public_key", ]); readString(record, "credential_id", path); - readRevision(record, "current_revision", path); readString(record, "public_key_algorithm", path); readString(record, "public_key_fingerprint", path); readString(record, "public_key", path); @@ -94,7 +90,6 @@ export function parseRepositorySshHostTrust( "key_algorithm", "host_key", "fingerprint", - "current_revision", "created_at", "updated_at", "referenced_repositories", @@ -112,7 +107,6 @@ export function parseRepositorySshHostTrust( readString(record, "key_algorithm", path); readString(record, "host_key", path); readString(record, "fingerprint", path); - readRevision(record, "current_revision", path); readString(record, "created_at", path); readString(record, "updated_at", path); readStringArray(record, "referenced_repositories", path); @@ -125,12 +119,10 @@ export function parseRepositoryAccessProjection( const path = "access_projection"; const record = readRecord(value, path, [ "workspace_id", - "config_revision", "projection_digest", "bindings", ]); readString(record, "workspace_id", path); - readRevision(record, "config_revision", path); readString(record, "projection_digest", path); const bindings = readArray(record.bindings, `${path}.bindings`); bindings.forEach((binding, index) => { @@ -248,18 +240,3 @@ function readInteger( } return value; } - -function readRevision( - record: Record, - key: string, - path: string, -): number { - const revision = readInteger(record, key, path); - if (revision < 0) { - throw new RepositoryAccessSchemaError( - `${path}.${key}`, - "a non-negative safe integer", - ); - } - return revision; -} diff --git a/web/workspace/src/lib/workspace/api/runtime-connection.ts b/web/workspace/src/lib/workspace/api/runtime-connection.ts index e5b0dd7c7..bde3120a8 100644 --- a/web/workspace/src/lib/workspace/api/runtime-connection.ts +++ b/web/workspace/src/lib/workspace/api/runtime-connection.ts @@ -8,7 +8,7 @@ import type { const RESPONSE_KEYS = [ "workspace_id", "runtime_id", - "binding_revision", + "binding_id", "connection_state", "verification", "checked_at", @@ -107,15 +107,15 @@ export function parseRuntimeConnectionTestResponse( ) { return null; } - const bindingRevision = value.binding_revision; + const bindingId = value.binding_id; const connectionState = parseConnectionState(value.connection_state); const verification = parseVerificationEvidence(value.verification); if ( - !isSafeRevision(bindingRevision) || + !isBoundedString(bindingId) || connectionState === null || (value.verification !== null && verification === null) || (verification !== null && - verification.binding_revision !== bindingRevision) || + verification.binding_id !== bindingId) || (connectionState === "verified" && value.status !== "compatible") || (connectionState === "verified" && verification !== null && (verification.last_outcome !== "verified" || @@ -126,7 +126,7 @@ export function parseRuntimeConnectionTestResponse( return { workspace_id: value.workspace_id, runtime_id: value.runtime_id, - binding_revision: bindingRevision, + binding_id: bindingId, connection_state: connectionState, verification, checked_at: value.checked_at, @@ -147,10 +147,6 @@ function parseConnectionState( : null; } -function isSafeRevision(value: unknown): value is number { - return Number.isSafeInteger(value) && (value as number) >= 0; -} - function parseVerificationEvidence( value: unknown, ): RuntimeVerificationEvidenceSummary | null { @@ -159,12 +155,11 @@ function parseVerificationEvidence( "verified_at", "last_checked_at", "last_outcome", - "binding_revision", + "binding_id", "workspace_key_id", - "workspace_identity_revision", - "workspace_trust_generation", + "workspace_public_key_fingerprint", + "workspace_trust_id", "runtime_public_key_fingerprint", - "runtime_identity_revision", ] as const; if (!isRecord(value) || !hasExactKeys(value, keys)) return null; if ( @@ -177,12 +172,11 @@ function parseVerificationEvidence( value.last_outcome !== "challenge_issued" && value.last_outcome !== "verification_failed" && value.last_outcome !== "connectivity_failed") || - !isSafeRevision(value.binding_revision) || + !isBoundedString(value.binding_id) || !isBoundedString(value.workspace_key_id, 128) || - !isSafeRevision(value.workspace_identity_revision) || - !isSafeRevision(value.workspace_trust_generation) || - !isBoundedString(value.runtime_public_key_fingerprint, 128) || - !isSafeRevision(value.runtime_identity_revision) + !isBoundedString(value.workspace_public_key_fingerprint) || + !isBoundedString(value.workspace_trust_id) || + !isBoundedString(value.runtime_public_key_fingerprint, 128) ) { return null; } @@ -190,12 +184,11 @@ function parseVerificationEvidence( verified_at: value.verified_at, last_checked_at: value.last_checked_at, last_outcome: value.last_outcome, - binding_revision: value.binding_revision, + binding_id: value.binding_id, workspace_key_id: value.workspace_key_id, - workspace_identity_revision: value.workspace_identity_revision, - workspace_trust_generation: value.workspace_trust_generation, + workspace_public_key_fingerprint: value.workspace_public_key_fingerprint, + workspace_trust_id: value.workspace_trust_id, runtime_public_key_fingerprint: value.runtime_public_key_fingerprint, - runtime_identity_revision: value.runtime_identity_revision, }; } diff --git a/web/workspace/src/lib/workspace/api/runtime-management.ts b/web/workspace/src/lib/workspace/api/runtime-management.ts index 391b47683..fa2809323 100644 --- a/web/workspace/src/lib/workspace/api/runtime-management.ts +++ b/web/workspace/src/lib/workspace/api/runtime-management.ts @@ -27,16 +27,20 @@ import type { } from "#lib/generated/runtime-api.ts"; import { workspaceApiPath } from "./http.ts"; -export type WorkspaceRuntimeList = Omit & { - diagnostics: Diagnostic[]; -}; -export type ValidatedWorkspaceRuntimeDetail = Omit< - WorkspaceRuntimeDetail, - "recent_audit" -> & { - recent_audit: RuntimeTrustAuditEntry[]; -}; -export type RuntimeTrustConflictKind = "stale_revision" | "fingerprint_in_use"; +export type WorkspaceRuntimeList = + & Omit + & { + diagnostics: Diagnostic[]; + }; +export type ValidatedWorkspaceRuntimeDetail = + & Omit< + WorkspaceRuntimeDetail, + "recent_audit" + > + & { + recent_audit: RuntimeTrustAuditEntry[]; + }; +export type RuntimeTrustConflictKind = "stale_binding" | "fingerprint_in_use"; export type RuntimeTrustConflictResponse = RuntimeManagementApiError & { error: RuntimeTrustConflictKind; }; @@ -83,7 +87,7 @@ const AUDIT_ACTIONS = new Set([ "revoked", ]); const CONFLICT_KINDS = new Set([ - "stale_revision", + "stale_binding", "fingerprint_in_use", ]); const BINDING_STATES = new Set([ @@ -226,8 +230,8 @@ function safeInteger(value: unknown, path: string, minimum = 0): number { return value; } -function safeRevision(value: unknown, path: string): number { - return safeInteger(value, path, 1); +function boundedIdentity(value: unknown, path: string): string { + return boundedString(value, path, LIMITS.idBytes); } function optionalNullableString( @@ -240,20 +244,20 @@ function optionalNullableString( return boundedString(value, path, maxBytes, allowEmpty); } -function optionalRevision( +function optionalIdentity( value: unknown, path: string, -): number | undefined { +): string | undefined { if (value === undefined || value === null) return undefined; - return safeRevision(value, path); + return boundedIdentity(value, path); } -function optionalNullableRevision( +function optionalNullableIdentity( value: unknown, path: string, -): number | null | undefined { +): string | null | undefined { if (value === undefined || value === null) return value; - return safeRevision(value, path); + return boundedIdentity(value, path); } function timestamp(value: unknown, path: string): string { @@ -333,12 +337,11 @@ function runtimeVerification( "verified_at", "last_checked_at", "last_outcome", - "binding_revision", + "binding_id", "workspace_key_id", - "workspace_identity_revision", - "workspace_trust_generation", + "workspace_public_key_fingerprint", + "workspace_trust_id", "runtime_public_key_fingerprint", - "runtime_identity_revision", ], [], path, @@ -366,32 +369,28 @@ function runtimeVerification( 128, ), last_outcome: lastOutcome, - binding_revision: safeRevision( - item.binding_revision, - `${path}.binding_revision`, + binding_id: boundedIdentity( + item.binding_id, + `${path}.binding_id`, ), workspace_key_id: boundedString( item.workspace_key_id, `${path}.workspace_key_id`, LIMITS.idBytes, ), - workspace_identity_revision: safeRevision( - item.workspace_identity_revision, - `${path}.workspace_identity_revision`, + workspace_public_key_fingerprint: boundedIdentity( + item.workspace_public_key_fingerprint, + `${path}.workspace_public_key_fingerprint`, ), - workspace_trust_generation: safeRevision( - item.workspace_trust_generation, - `${path}.workspace_trust_generation`, + workspace_trust_id: boundedIdentity( + item.workspace_trust_id, + `${path}.workspace_trust_id`, ), runtime_public_key_fingerprint: boundedString( item.runtime_public_key_fingerprint, `${path}.runtime_public_key_fingerprint`, LIMITS.fingerprintBytes, ), - runtime_identity_revision: safeRevision( - item.runtime_identity_revision, - `${path}.runtime_identity_revision`, - ), }; } @@ -403,8 +402,8 @@ function runtimeBinding( const item = object(value, path); exactKeys( item, - ["state", "connection_state", "revision"], - ["workspace_key_id", "workspace_key_generation", "verification"], + ["state", "connection_state", "binding_id"], + ["workspace_key_id", "workspace_trust_id", "verification"], path, ); const workspaceKeyId = optionalNullableString( @@ -412,15 +411,15 @@ function runtimeBinding( `${path}.workspace_key_id`, LIMITS.idBytes, ); - const workspaceKeyGeneration = optionalNullableRevision( - item.workspace_key_generation, - `${path}.workspace_key_generation`, + const workspaceTrustId = optionalNullableIdentity( + item.workspace_trust_id, + `${path}.workspace_trust_id`, ); const state = enumValue(item.state, `${path}.state`, BINDING_STATES); if ( requiresWorkspaceIdentity && state !== "revoked" && - (workspaceKeyId == null || workspaceKeyGeneration == null) + (workspaceKeyId == null || workspaceTrustId == null) ) { return fail(path, "requires Workspace signing key identity metadata"); } @@ -429,14 +428,14 @@ function runtimeBinding( `${path}.connection_state`, CONNECTION_STATES, ); - const revision = safeRevision(item.revision, `${path}.revision`); + const binding_id = boundedIdentity(item.binding_id, `${path}.binding_id`); const verification = item.verification === undefined ? undefined : runtimeVerification(item.verification, `${path}.verification`); if ( - verification !== undefined && verification.binding_revision !== revision + verification !== undefined && verification.binding_id !== binding_id ) { - return fail(path, "verification must match the current binding revision"); + return fail(path, "verification must match the current binding binding_id"); } if ( requiresWorkspaceIdentity && @@ -453,13 +452,13 @@ function runtimeBinding( return { state, connection_state: connectionState, - revision, + binding_id, ...(workspaceKeyId === undefined ? {} : { workspace_key_id: workspaceKeyId }), - ...(workspaceKeyGeneration === undefined + ...(workspaceTrustId === undefined ? {} - : { workspace_key_generation: workspaceKeyGeneration }), + : { workspace_trust_id: workspaceTrustId }), ...(verification === undefined ? {} : { verification }), }; } @@ -566,7 +565,7 @@ function trustKey(value: unknown, path: string): RuntimeTrustKeyState { exactKeys( item, ["status"], - ["fingerprint", "revision", "created_at", "updated_at", "revoked_at"], + ["fingerprint", "binding_id", "created_at", "updated_at", "revoked_at"], path, ); const result: RuntimeTrustKeyState = { @@ -576,7 +575,7 @@ function trustKey(value: unknown, path: string): RuntimeTrustKeyState { `${path}.fingerprint`, LIMITS.fingerprintBytes, ), - revision: optionalNullableRevision(item.revision, `${path}.revision`), + binding_id: optionalNullableIdentity(item.binding_id, `${path}.binding_id`), created_at: optionalNullableTimestamp( item.created_at, `${path}.created_at`, @@ -594,12 +593,12 @@ function trustKey(value: unknown, path: string): RuntimeTrustKeyState { const hasBinding = result.status !== "unconfigured"; if ( hasBinding && - (result.fingerprint == null || result.revision == null || + (result.fingerprint == null || result.binding_id == null || result.created_at == null || result.updated_at == null) ) { fail( path, - "must include fingerprint, revision, created_at, and updated_at", + "must include fingerprint, binding_id, created_at, and updated_at", ); } if ( @@ -623,7 +622,7 @@ function auditEntry(value: unknown, path: string): RuntimeTrustAuditEntry { const item = object(value, path); exactKeys( item, - ["action", "actor_account_id", "revision", "at"], + ["action", "actor_account_id", "binding_id", "at"], ["old_fingerprint", "new_fingerprint"], path, ); @@ -644,7 +643,7 @@ function auditEntry(value: unknown, path: string): RuntimeTrustAuditEntry { `${path}.new_fingerprint`, LIMITS.fingerprintBytes, ), - revision: safeRevision(item.revision, `${path}.revision`), + binding_id: boundedIdentity(item.binding_id, `${path}.binding_id`), at: timestamp(item.at, `${path}.at`), }; } @@ -764,7 +763,7 @@ export function parseRuntimeTrustConflict( exactKeys( response, ["error", "message"], - ["current_revision", "current_fingerprint"], + ["current_binding_id", "current_fingerprint"], "Runtime trust conflict", ); return { @@ -778,9 +777,9 @@ export function parseRuntimeTrustConflict( "Runtime trust conflict.message", LIMITS.conflictMessageBytes, ), - current_revision: optionalRevision( - response.current_revision, - "Runtime trust conflict.current_revision", + current_binding_id: optionalIdentity( + response.current_binding_id, + "Runtime trust conflict.current_binding_id", ), current_fingerprint: optionalNullableString( response.current_fingerprint, @@ -867,16 +866,6 @@ export function parseRuntimeRemovalOperationResponse( }; } -function revisionForJson(revision: number | null): number | null { - if (revision === null) return null; - if (!Number.isSafeInteger(revision) || revision < 1) { - throw new RuntimeTrustRequestError( - "Runtime trust revision is not a safe integer", - ); - } - return revision; -} - async function readBoundedJson(response: Response): Promise { const contentLength = response.headers.get("content-length"); if (contentLength !== null) { @@ -959,22 +948,25 @@ function requestErrorFrom( } } +function throwRuntimeTrustConflict(payload: unknown): never { + let conflict: RuntimeTrustConflictResponse; + try { + conflict = parseRuntimeTrustConflict(payload); + } catch { + throw new RuntimeTrustRequestError( + "Runtime trust conflict response was invalid", + ); + } + throw new RuntimeTrustConflictError(conflict); +} + async function finishMutation( response: Response, workspaceId: string, runtimeId: string, ): Promise { const payload = await readBoundedJson(response); - if (response.status === 409) { - try { - throw new RuntimeTrustConflictError(parseRuntimeTrustConflict(payload)); - } catch (error) { - if (error instanceof RuntimeTrustConflictError) throw error; - throw new RuntimeTrustRequestError( - "Runtime trust conflict response was invalid", - ); - } - } + if (response.status === 409) throwRuntimeTrustConflict(payload); if (!response.ok) throw requestErrorFrom(payload, response.status); let detail: WorkspaceRuntimeDetail; try { @@ -998,15 +990,17 @@ export async function createRemoteRuntime( request: CreateRemoteRuntimeRequest, fetchImpl: typeof fetch = fetch, ): Promise { + const enrollmentId = boundedIdentity(request.workspace_trust_id, "workspace_trust_id"); const response = await fetchImpl( workspaceApiPath(workspaceId, "/runtimes"), { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify(request), + body: JSON.stringify({ ...request, workspace_trust_id: enrollmentId }), }, ); const payload = await readBoundedJson(response); + if (response.status === 409) throwRuntimeTrustConflict(payload); if (!response.ok) throw requestErrorFrom(payload, response.status); const runtime = runtimeResource(payload, "Runtime create response"); if (runtime.runtime_id !== request.public_bundle.identity_id) { @@ -1173,7 +1167,10 @@ export async function revokeRuntimeTrustKey( method: "DELETE", headers: { "content-type": "application/json" }, body: JSON.stringify({ - expected_revision: revisionForJson(request.expected_revision), + expected_binding_id: boundedIdentity( + request.expected_binding_id, + "expected_binding_id", + ), }), }, ); diff --git a/web/workspace/src/lib/workspace/api/runtime-workers.ts b/web/workspace/src/lib/workspace/api/runtime-workers.ts index 646fb0d32..e00b83485 100644 --- a/web/workspace/src/lib/workspace/api/runtime-workers.ts +++ b/web/workspace/src/lib/workspace/api/runtime-workers.ts @@ -406,7 +406,6 @@ export function parseRuntimeCleanupPlan( "workspace_id", "runtime_id", "generated_at", - "revision", "digest", "workers", "workdirs", @@ -428,7 +427,6 @@ export function parseRuntimeCleanupPlan( item.generated_at, "Runtime cleanup plan.generated_at", ), - revision: boundedString(item.revision, "Runtime cleanup plan.revision"), digest: boundedString(item.digest, "Runtime cleanup plan.digest"), workers: array(item.workers, "Runtime cleanup plan.workers", MAX_CANDIDATES) .map( diff --git a/web/workspace/src/lib/workspace/api/ticket-browser.test.ts b/web/workspace/src/lib/workspace/api/ticket-browser.test.ts index 2333b0b43..d3b2499d3 100644 --- a/web/workspace/src/lib/workspace/api/ticket-browser.test.ts +++ b/web/workspace/src/lib/workspace/api/ticket-browser.test.ts @@ -151,7 +151,7 @@ function ticketDetailFixture() { }, id: "ticket-id", implementation_reports: [], - item_revision: "revision-1", + content_digest: "a".repeat(64), linked_objectives: [], merge_request: null, merge_requests: [], @@ -410,7 +410,7 @@ Deno.test("Ticket Browser parser validates the canonical Merge Request thread", event_id: "event-1", sequence: 1, subject_ref: "abc123", - ticket_item_revision: "ticket-revision", + ticket_content_digest: "b".repeat(64), ticket_merge_request_subjects: [ { merge_request_id: "mr-1", subject_ref: "abc123" }, ], diff --git a/web/workspace/src/lib/workspace/api/ticket-browser.ts b/web/workspace/src/lib/workspace/api/ticket-browser.ts index c96142943..da2ce4ba7 100644 --- a/web/workspace/src/lib/workspace/api/ticket-browser.ts +++ b/web/workspace/src/lib/workspace/api/ticket-browser.ts @@ -737,7 +737,7 @@ export function parseTicketDetail(value: unknown): TicketDetail { "evidence", "id", "implementation_reports", - "item_revision", + "content_digest", "linked_objectives", "merge_request", "merge_requests", @@ -792,7 +792,7 @@ export function parseTicketDetail(value: unknown): TicketDetail { `${label}.implementation_reports`, parseTicketEvidenceEvent, ), - item_revision: string(item.item_revision, `${label}.item_revision`), + content_digest: string(item.content_digest, `${label}.content_digest`), linked_objectives: array( item.linked_objectives, `${label}.linked_objectives`, @@ -986,7 +986,6 @@ export function parseObjectiveDetail(value: unknown): ObjectiveDetail { "record_source", "resource_key", "resources", - "revision", "state", "title", "updated_at", @@ -1011,7 +1010,6 @@ export function parseObjectiveDetail(value: unknown): ObjectiveDetail { `${label}.resources`, parseObjectiveResource, ), - revision: string(item.revision, `${label}.revision`), state: string(item.state, `${label}.state`), title: string(item.title, `${label}.title`), updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`), @@ -1138,7 +1136,7 @@ function parseMergeRequestThreadEvent( "requested_by", "reviewer", "subject_ref", - "ticket_item_revision", + "ticket_content_digest", "ticket_merge_request_subjects", ], label, @@ -1152,9 +1150,9 @@ function parseMergeRequestThreadEvent( ), reviewer: parseMergeRequestWorker(item.reviewer, `${label}.reviewer`), subject_ref: string(item.subject_ref, `${label}.subject_ref`), - ticket_item_revision: string( - item.ticket_item_revision, - `${label}.ticket_item_revision`, + ticket_content_digest: string( + item.ticket_content_digest, + `${label}.ticket_content_digest`, ), ticket_merge_request_subjects: array( item.ticket_merge_request_subjects, @@ -1172,7 +1170,7 @@ function parseMergeRequestThreadEvent( "request_event_id", "reviewer", "subject_ref", - "ticket_item_revision", + "ticket_content_digest", "ticket_merge_request_subjects", ], label); const decision = string(item.decision, `${label}.decision`); @@ -1191,9 +1189,9 @@ function parseMergeRequestThreadEvent( ), reviewer: parseMergeRequestWorker(item.reviewer, `${label}.reviewer`), subject_ref: string(item.subject_ref, `${label}.subject_ref`), - ticket_item_revision: string( - item.ticket_item_revision, - `${label}.ticket_item_revision`, + ticket_content_digest: string( + item.ticket_content_digest, + `${label}.ticket_content_digest`, ), ticket_merge_request_subjects: array( item.ticket_merge_request_subjects, diff --git a/web/workspace/src/lib/workspace/api/workspace-catalog.test-fixtures.ts b/web/workspace/src/lib/workspace/api/workspace-catalog.test-fixtures.ts index d2707c2d4..b5bee4679 100644 --- a/web/workspace/src/lib/workspace/api/workspace-catalog.test-fixtures.ts +++ b/web/workspace/src/lib/workspace/api/workspace-catalog.test-fixtures.ts @@ -14,7 +14,6 @@ export const currentInitialRepository = { provider: "git", source: { kind: "https", uri: "https://example.test/current.git" }, default_ref: "main", - source_revision: 2, source_fingerprint: "sha256:current", observed_status: "unverified", observed_at: null, @@ -26,7 +25,6 @@ export function creationResponse(repository: unknown = null, replayed = false) { return { workspace: createdWorkspace, repository, - config_revision: 1, request_fingerprint: "sha256:creation", replayed, }; diff --git a/web/workspace/src/lib/workspace/api/workspace-model.ts b/web/workspace/src/lib/workspace/api/workspace-model.ts index 566a7b41d..53b4a8efd 100644 --- a/web/workspace/src/lib/workspace/api/workspace-model.ts +++ b/web/workspace/src/lib/workspace/api/workspace-model.ts @@ -258,7 +258,6 @@ function repositorySummary(value: unknown, path: string): RepositorySummary { "kind", "provider", "source", - "source_revision", "source_fingerprint", "observed_status", "observed_at", @@ -291,10 +290,6 @@ function repositorySummary(value: unknown, path: string): RepositorySummary { kind: repositoryString(item.kind, `${path}.kind`), provider: repositoryString(item.provider, `${path}.provider`), source: repositorySource(item.source, `${path}.source`), - source_revision: repositorySourceRevision( - item.source_revision, - `${path}.source_revision`, - ), source_fingerprint: repositoryString( item.source_fingerprint, `${path}.source_fingerprint`, @@ -360,7 +355,6 @@ function workspaceRepositoryRecord( "provider", "source", "default_ref", - "source_revision", "source_fingerprint", "observed_status", "observed_at", @@ -383,10 +377,6 @@ function workspaceRepositoryRecord( provider: nullableString(item.provider, `${path}.provider`), source: repositorySource(item.source, `${path}.source`), default_ref: nullableString(item.default_ref, `${path}.default_ref`), - source_revision: repositorySourceRevision( - item.source_revision, - `${path}.source_revision`, - ), source_fingerprint: repositoryString( item.source_fingerprint, `${path}.source_fingerprint`, @@ -558,7 +548,6 @@ export function parseWorkspaceCreateResponse( [ "workspace", "repository", - "config_revision", "request_fingerprint", "replayed", ], @@ -573,10 +562,6 @@ export function parseWorkspaceCreateResponse( response.repository, "workspace create response.repository", ), - config_revision: integer( - response.config_revision, - "workspace create response.config_revision", - ), request_fingerprint: string( response.request_fingerprint, "workspace create response.request_fingerprint", @@ -789,7 +774,7 @@ export function parseRepositorySshConnectionProbeResponse( "port", "trust_state", "host_trust_id", - "expected_host_trust_revision", + "expected_host_key_fingerprint", "candidates", ], "repository SSH connection probe response", @@ -829,11 +814,10 @@ export function parseRepositorySshConnectionProbeResponse( response.host_trust_id, "repository SSH connection probe response.host_trust_id", ), - expected_host_trust_revision: response.expected_host_trust_revision === null - ? null - : integer( - response.expected_host_trust_revision, - "repository SSH connection probe response.expected_host_trust_revision", + expected_host_key_fingerprint: + response.expected_host_key_fingerprint === null ? null : string( + response.expected_host_key_fingerprint, + "repository SSH connection probe response.expected_host_key_fingerprint", ), candidates: array( response.candidates, @@ -980,7 +964,7 @@ export function parseWorkspaceDeletionPreflightResponse( exactKeys(item, [ "workspace_id", "display_name", - "expected_revision", + "expected_workspace_updated_at", "can_delete", "resources", "blockers", @@ -994,9 +978,9 @@ export function parseWorkspaceDeletionPreflightResponse( item.display_name, "Workspace deletion preflight.display_name", ), - expected_revision: deletionBoundedString( - item.expected_revision, - "Workspace deletion preflight.expected_revision", + expected_workspace_updated_at: deletionBoundedString( + item.expected_workspace_updated_at, + "Workspace deletion preflight.expected_workspace_updated_at", WORKSPACE_DELETION_MAX_REVISION_BYTES, ), can_delete: boolean( @@ -1116,7 +1100,8 @@ export function parseHostListResponse(value: unknown): HostListResponse { ), limit, items: repositoryArray(response.items, "host list response.items").map( - (item, index) => parseHostSummary(item, `host list response.items[${index}]`), + (item, index) => + parseHostSummary(item, `host list response.items[${index}]`), ), source: repositoryString(response.source, "host list response.source"), diagnostics: repositoryArray( diff --git a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts index 4e28bd4f9..fd5bb856f 100644 --- a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts +++ b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts @@ -10,6 +10,9 @@ const toolchain = vi.hoisted(() => ({ analyze: vi.fn(), complete: vi.fn(), close: vi.fn(), + applyChanges: vi.fn(), + changesBetween: vi.fn(), + format: vi.fn(), })); vi.mock("./toolchain.ts", () => ({ ConfigSourceToolchain: class { @@ -20,13 +23,15 @@ vi.mock("./toolchain.ts", () => ({ analyze = toolchain.analyze; complete = toolchain.complete; close = toolchain.close; + applyChanges = toolchain.applyChanges; + changesBetween = toolchain.changesBetween; + format = toolchain.format; }, })); const schemaSource = "schema body\n".repeat(6000); const tree = { snapshot: { - revision: 7, digest: "sha256:tree", entries: { "main.dcdl": { @@ -219,7 +224,6 @@ test("unmount during schema setup closes the toolchain", async () => { test("builtin diagnostics show read-only paths without adding editable workspace files", async () => { toolchain.analyze.mockResolvedValue([{ path: "$builtin/profiles/companion.dcdl", - revision: 7, tree_digest: "sha256:tree", kind: "constraint_violation", span: { start_byte: 1, end_byte: 4 }, @@ -243,7 +247,6 @@ test("builtin diagnostics show read-only paths without adding editable workspace test("unknown builtin diagnostics retain the workspace importer path", async () => { toolchain.analyze.mockResolvedValue([{ path: "main.dcdl", - revision: 7, tree_digest: "sha256:tree", kind: "import", span: { start_byte: 8, end_byte: 40 }, @@ -261,6 +264,36 @@ test("unknown builtin diagnostics retain the workspace importer path", async () expect(view.getByRole("navigation", { name: "Virtual configuration paths" }).textContent).not.toContain("$builtin/"); }); +test.each(["resolve", "reject"] as const)( + "superseded analysis %s cannot replace the current diagnostics or status", + async (settlement) => { + let resolveOld!: (value: unknown[]) => void; + let rejectOld!: (error: Error) => void; + const oldAnalysis = new Promise((resolve, reject) => { + resolveOld = resolve; + rejectOld = reject; + }); + toolchain.analyze.mockReturnValueOnce(oldAnalysis); + const view = renderEditor(); + await expectReady(view); + await waitFor(() => expect(toolchain.analyze).toHaveBeenCalledOnce()); + await fireEvent.click(view.getByRole("button", { name: "Create local source" })); + await waitFor(() => expect(toolchain.analyze).toHaveBeenCalledTimes(2)); + if (settlement === "resolve") { + resolveOld([{ + path: "main.dcdl", tree_digest: tree.snapshot.digest, kind: "import", span: { start_byte: 0, end_byte: 1 }, + message: "Stale diagnostic", labels: [], notes: [], + }]); + } else rejectOld(new Error("Stale analysis failure")); + await oldAnalysis.catch(() => {}); + await waitFor(() => { + expect(view.container.textContent).toContain("Creating local source module.dcdl"); + expect(view.container.textContent).not.toContain("Stale diagnostic"); + expect(view.container.textContent).not.toContain("Stale analysis failure"); + }); + }, +); + test("unmount during fetch does not start a toolchain", async () => { let finishFetch!: (response: Response) => void; fetcher.mockReturnValueOnce( @@ -274,3 +307,70 @@ test("unmount during fetch does not start a toolchain", async () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(toolchain.construct).not.toHaveBeenCalled(); }); + + +test("diagnostics from another tree digest are not displayed", async () => { + toolchain.analyze.mockResolvedValue([ + { + path: "main.dcdl", tree_digest: "sha256:old-tree", kind: "syntax", + span: { start_byte: 0, end_byte: 1 }, message: "Wrong tree diagnostic", + labels: [], notes: [], + }, + { + path: "main.dcdl", tree_digest: tree.snapshot.digest, kind: "syntax", + span: { start_byte: 0, end_byte: 1 }, message: "Current tree diagnostic", + labels: [], notes: [], + }, + ]); + const view = renderEditor(); + await expectReady(view); + await waitFor(() => expect(view.container.textContent).toContain("Current tree diagnostic")); + expect(view.container.textContent).not.toContain("Wrong tree diagnostic"); +}); + +test.each([409, 422])("failed commit %s preserves the local draft and authoritative base digest for retry", async (status) => { + const change = { kind: "create", path: "module.dcdl", content_type: "decodal", content: "{}\n" }; + const candidate = { + digest: "sha256:local-draft", + entries: { + ...tree.snapshot.entries, + "module.dcdl": { + path: change.path, + content_type: change.content_type, + content: change.content, + content_digest: "sha256:module", + }, + }, + }; + toolchain.applyChanges.mockResolvedValue(candidate); + toolchain.changesBetween.mockResolvedValue([change]); + toolchain.format.mockImplementation(async (source: string) => source); + const view = renderEditor(); + await expectReady(view); + await fireEvent.click(view.getByRole("button", { name: "Create local source" })); + const commit = view.getByRole("button", { name: "Commit" }); + expect(commit).toBeInstanceOf(HTMLButtonElement); + fetcher.mockResolvedValueOnce(new Response( + status === 409 ? "base content digest mismatch" : "candidate evaluation failed", + { status }, + )); + await fireEvent.click(commit); + await waitFor(() => expect(view.container.textContent).toContain( + status === 409 ? "base content digest mismatch" : "candidate evaluation failed", + )); + const expectedRequest = { + base_digest: tree.snapshot.digest, changes: [change], entrypoints: ["main.dcdl"], + }; + expect(JSON.parse(fetcher.mock.calls[1][1].body)).toEqual(expectedRequest); + expect(view.container.querySelector(".cm-content")?.textContent).toContain("{}"); + expect(commit).toHaveProperty("disabled", false); + expect(toolchain.setSnapshot).toHaveBeenCalledOnce(); + if (status === 409) expect(view.getByRole("alert").textContent).toContain("Discard local candidate"); + + fetcher.mockResolvedValueOnce(new Response(JSON.stringify({ ...tree, snapshot: candidate }))); + await fireEvent.click(commit); + await waitFor(() => expect(view.container.textContent).toContain("Committed formatted source tree")); + expect(JSON.parse(fetcher.mock.calls[2][1].body)).toEqual(expectedRequest); + expect(toolchain.setSnapshot).toHaveBeenLastCalledWith(candidate, tree.contract.schema_bundle); + expect(commit).toHaveProperty("disabled", true); +}); diff --git a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte index b1064abcd..a5c35b4ab 100644 --- a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte +++ b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte @@ -25,14 +25,13 @@ let mounted = false; let busy = $state(false); let workingChanges = $state([]); - let baseRevision = $state(0); let baseDigest = $state(""); let renamePath = $state(""); let baseSnapshot = $state.raw(null); let conflict = $state(false); let toolchain = $state.raw(null); let analysisReady = $state(false); - let analysisGeneration = 0; + let analysisRequest: AbortController | null = null; const paths = $derived( treeState ? Object.keys(treeState.snapshot.entries).toSorted() : [], @@ -48,6 +47,7 @@ void reload(); return () => { mounted = false; + analysisRequest?.abort(); toolchain?.close(); }; }); @@ -57,20 +57,23 @@ const path = selectedPath; const value = source; const ready = analysisReady; - const generation = ++analysisGeneration; + const digest = treeState?.snapshot.digest; + analysisRequest?.abort(); + const request = new AbortController(); + analysisRequest = request; diagnostics = []; if (!analyzer || !path || !ready) return; const timer = setTimeout(() => { void analyzer.analyze(path, value).then((result) => { - if (generation === analysisGeneration) diagnostics = result; + if (!request.signal.aborted) diagnostics = result.filter((diagnostic) => diagnostic.tree_digest === digest); }).catch((error) => { - if (generation === analysisGeneration) status = `Analyze failed: ${String(error)}`; + if (!request.signal.aborted) status = `Analyze failed: ${String(error)}`; }); }, 250); return () => { clearTimeout(timer); - if (generation === analysisGeneration) analysisGeneration += 1; + request.abort(); }; }); @@ -78,7 +81,7 @@ loadState = "loading"; status = "Loading source tree…"; analysisReady = false; - analysisGeneration += 1; + analysisRequest?.abort(); try { const remote = await fetchConfigTree(workspaceId); if (!mounted) return; @@ -94,14 +97,13 @@ } source = selectedPath ? treeState.snapshot.entries[selectedPath].content : ""; baseSnapshot = $state.snapshot(treeState.snapshot); - baseRevision = treeState.snapshot.revision; baseDigest = treeState.snapshot.digest; analysisReady = true; workingChanges = []; renamePath = selectedPath; diagnostics = []; conflict = false; - status = `Revision ${treeState.snapshot.revision} · ${treeState.snapshot.digest.slice(0, 20)}…`; + status = `Tree digest ${treeState.snapshot.digest.slice(0, 20)}…`; loadState = "ready"; } catch (error) { if (!mounted) return; @@ -197,7 +199,7 @@ function recordCommitError(error: unknown) { const message = String(error); - conflict = message.includes("conflict") || message.includes("base revision/digest mismatch"); + conflict = message.includes("conflict") || message.includes("base content digest mismatch"); status = conflict ? `${message} Reload the authoritative tree before editing again.` : message; } @@ -211,7 +213,6 @@ return; } treeState = await commitConfigTree(workspaceId, { - base_revision: baseRevision, base_digest: baseDigest, changes: workingChanges, entrypoints: entrypoints(), @@ -219,12 +220,11 @@ workingChanges = []; conflict = false; baseSnapshot = $state.snapshot(treeState.snapshot); - baseRevision = treeState.snapshot.revision; baseDigest = treeState.snapshot.digest; await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle); source = treeState.snapshot.entries[selectedPath]?.content ?? ""; diagnostics = []; - status = `Committed formatted revision ${treeState.snapshot.revision}.`; + status = `Committed formatted source tree · ${treeState.snapshot.digest.slice(0, 20)}…`; } catch (error) { recordCommitError(error); } finally { @@ -241,7 +241,6 @@ const localChanges = [...workingChanges]; const remote = await fetchConfigTree(workspaceId); baseSnapshot = structuredClone(remote.snapshot); - baseRevision = remote.snapshot.revision; baseDigest = remote.snapshot.digest; await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle); try { @@ -251,10 +250,10 @@ selectedPath = candidate.entries[selectedPath] ? selectedPath : Object.keys(candidate.entries).toSorted()[0] ?? ""; source = selectedPath ? candidate.entries[selectedPath].content : ""; conflict = false; - status = "Local changes reapplied to the latest revision. Commit to persist them."; + status = "Local changes reapplied to the latest source tree. Commit to persist them."; } catch (error) { conflict = true; - status = `Local changes conflict with the latest revision: ${String(error)}. Discard local changes or resolve against a fresh reload.`; + status = `Local changes conflict with the latest source tree: ${String(error)}. Discard local changes or resolve against a fresh reload.`; } } diff --git a/web/workspace/src/lib/workspace/config-source/api.ts b/web/workspace/src/lib/workspace/config-source/api.ts index 2afb76407..0e746797f 100644 --- a/web/workspace/src/lib/workspace/config-source/api.ts +++ b/web/workspace/src/lib/workspace/config-source/api.ts @@ -241,7 +241,7 @@ function parseToolchainContract(value: unknown): ToolchainContract { } export function parseConfigTreeSnapshot(value: unknown): ConfigTreeSnapshot { - const item = record(value, ["revision", "digest", "entries"]); + const item = record(value, ["digest", "entries"]); const entriesRecord = objectValue(item.entries); const entries = Object.entries(entriesRecord); if (entries.length > MAX_ENTRY_COUNT) throw schemaError(); @@ -253,7 +253,6 @@ export function parseConfigTreeSnapshot(value: unknown): ConfigTreeSnapshot { parsedEntries[boundedPath] = parsed; } return { - revision: safeInteger(item.revision), digest: boundedString(item.digest, MAX_DIGEST_BYTES), entries: parsedEntries, }; @@ -299,13 +298,13 @@ export async function fetchConfigEntry( ); } -export async function fetchConfigRevision( +export async function fetchConfigHistory( workspaceId: string, - revision: number, + contentDigest: string, fetcher: typeof fetch = fetch, ): Promise { return await readJson( - await fetcher(`${sourceTreeUrl(workspaceId)}/revisions/${revision}`, { + await fetcher(`${sourceTreeUrl(workspaceId)}/history/${encodeURIComponent(contentDigest)}`, { headers: { accept: "application/json" }, }), parseConfigTreeSnapshot, diff --git a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm index b6468fe93..3bbf09b82 100644 Binary files a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm and b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm differ diff --git a/web/workspace/src/lib/workspace/config-source/generated/types/ConfigCommitRequest.ts b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigCommitRequest.ts deleted file mode 100644 index 8c5fbd908..000000000 --- a/web/workspace/src/lib/workspace/config-source/generated/types/ConfigCommitRequest.ts +++ /dev/null @@ -1,5 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ConfigTreeChange } from "./ConfigTreeChange"; -import type { VirtualPath } from "./VirtualPath"; - -export type ConfigCommitRequest = { base_revision: number, base_digest: string, changes: Array, entrypoints: Array, }; diff --git a/web/workspace/src/lib/workspace/config-source/generated/types/ConfigDiagnostic.ts b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigDiagnostic.ts index f1de18e3f..10e02d432 100644 --- a/web/workspace/src/lib/workspace/config-source/generated/types/ConfigDiagnostic.ts +++ b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigDiagnostic.ts @@ -3,4 +3,4 @@ import type { ConfigDiagnosticLabel } from "./ConfigDiagnosticLabel"; import type { ConfigSpan } from "./ConfigSpan"; import type { VirtualPath } from "./VirtualPath"; -export type ConfigDiagnostic = { path: VirtualPath, revision: number, tree_digest: string, kind: string, span: ConfigSpan, message: string, labels: Array, notes: Array, }; +export type ConfigDiagnostic = { path: VirtualPath, tree_digest: string, kind: string, span: ConfigSpan, message: string, labels: Array, notes: Array, }; diff --git a/web/workspace/src/lib/workspace/config-source/generated/types/ConfigTreeSnapshot.ts b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigTreeSnapshot.ts index 5f723ce9d..aee8c2cf0 100644 --- a/web/workspace/src/lib/workspace/config-source/generated/types/ConfigTreeSnapshot.ts +++ b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigTreeSnapshot.ts @@ -2,4 +2,11 @@ import type { ConfigEntry } from "./ConfigEntry"; import type { VirtualPath } from "./VirtualPath"; -export type ConfigTreeSnapshot = { revision: number, digest: string, entries: { [key in VirtualPath]: ConfigEntry }, }; +export type ConfigTreeSnapshot = { + /** + * SHA-256 of ordered virtual paths, content types and complete source bytes. + * Identifies tree content, not an edit sequence or freshness counter. + */ + digest: string; + entries: { [key in VirtualPath]: ConfigEntry }; +}; diff --git a/web/workspace/src/lib/workspace/console/ConsoleLineItem.browser.spec.ts b/web/workspace/src/lib/workspace/console/ConsoleLineItem.browser.spec.ts index 3a89175d9..03d5230a1 100644 --- a/web/workspace/src/lib/workspace/console/ConsoleLineItem.browser.spec.ts +++ b/web/workspace/src/lib/workspace/console/ConsoleLineItem.browser.spec.ts @@ -15,7 +15,7 @@ function reconnectSnapshot(text: string): Event { session: { entries: [], pending_submissions: { - revision: 0, + head_id: null, notification_count: 0, submissions: [], diff --git a/web/workspace/src/lib/workspace/console/model.test.ts b/web/workspace/src/lib/workspace/console/model.test.ts index 1bf8fa1ac..8308b4173 100644 --- a/web/workspace/src/lib/workspace/console/model.test.ts +++ b/web/workspace/src/lib/workspace/console/model.test.ts @@ -1506,7 +1506,7 @@ Deno.test("legacy compaction lifecycle events are inert", () => { const running = { schema_version: 2, compaction_id: "compaction-1", - revision: 1, + internal_worker: null, state: "running", started_at_ms: 1_000, @@ -1530,7 +1530,7 @@ Deno.test("legacy compaction lifecycle events are inert", () => { data: { lifecycle: { ...running, - revision: 2, + state: "done", ended_at_ms: 4_000, summary: "accepted summary", @@ -1546,7 +1546,7 @@ Deno.test("legacy compaction lifecycle events are inert", () => { data: { lifecycle: { ...running, - revision: 3, + state: "failed", ended_at_ms: 5_000, error: "legacy failure", @@ -1580,7 +1580,7 @@ Deno.test("snapshot restores running compaction without staged content", () => { parent_session_id: "parent-session", kind: { service: { kind: "compaction" } }, }, - revision: 1, + session: canonicalSession([]), status: "running", in_flight: { @@ -1678,7 +1678,7 @@ Deno.test("snapshot does not guess between multiple compaction services", () => parent_session_id: "parent-session", kind: { service: { kind: "compaction" } }, }, - revision: 1, + session: canonicalSession([]), status: "running" as const, in_flight: { @@ -1737,7 +1737,7 @@ Deno.test("live compaction keeps its first service binding until terminal progre event: "internal_worker", data: { worker: workerA, - revision: 1, + event: { event: "tool_call_start", data: { id: "read-a", name: "ReadEntry" }, @@ -1751,7 +1751,7 @@ Deno.test("live compaction keeps its first service binding until terminal progre event: "internal_worker", data: { worker: workerB, - revision: 1, + event: { event: "tool_call_start", data: { id: "search-b", name: "SearchSessionEntries" }, @@ -1797,7 +1797,7 @@ Deno.test("compaction summary candidate stays private", () => { event: "internal_worker", data: { worker, - revision: 1, + event: { event: "tool_call_start", data: { id: "call-1", name: "write_summary" }, @@ -1811,7 +1811,7 @@ Deno.test("compaction summary candidate stays private", () => { event: "internal_worker", data: { worker, - revision: 2, + event: { event: "tool_call_done", data: { @@ -1866,7 +1866,7 @@ Deno.test("runtime compaction progress nests service activity and clears it", () event: "internal_worker", data: { worker, - revision: 1, + event: { event: "tool_call_done", data: { @@ -1883,7 +1883,7 @@ Deno.test("runtime compaction progress nests service activity and clears it", () event: "internal_worker", data: { worker, - revision: 2, + event: { event: "tool_call_done", data: { @@ -1915,7 +1915,7 @@ Deno.test("runtime compaction progress nests service activity and clears it", () eventId: "compaction-service-removed", event: { event: "internal_worker_removed", - data: { worker, revision: 3 }, + data: { worker }, } satisfies Event, }]); assertEquals(projection.internalWorkers, []); @@ -1946,7 +1946,7 @@ Deno.test("snapshot excludes storage-only compaction extension records", () => { payload: { schema_version: 2, compaction_id: "compaction-orphaned", - revision: 2, + internal_worker: { session_id: "missing-service", name: "Compaction", @@ -2023,7 +2023,7 @@ Deno.test("back-to-back compactions do not retain prior activity", () => { event: "internal_worker", data: { worker, - revision: 1, + event: { event: "tool_call_done", data: { id: "read-1", name: "ReadEntry", arguments: "{}" }, @@ -2035,7 +2035,7 @@ Deno.test("back-to-back compactions do not retain prior activity", () => { eventId: "first-removed", event: { event: "internal_worker_removed", - data: { worker, revision: 2 }, + data: { worker }, } satisfies Event, }, { @@ -3025,7 +3025,7 @@ Deno.test("projectConsole mirrors live TaskCreate and TaskUpdate calls", () => { }]); }); -Deno.test("Internal Worker output stays separate and revision-fenced", () => { +Deno.test("Internal Worker output stays separate and follows parent stream arrival order", () => { const worker = { session_id: "child-session", name: "research", @@ -3039,7 +3039,7 @@ Deno.test("Internal Worker output stays separate and revision-fenced", () => { event: "internal_worker", data: { worker, - revision: 2, + event: { event: "text_done", data: { text: "child output" } }, }, }, @@ -3057,12 +3057,12 @@ Deno.test("Internal Worker output stays separate and revision-fenced", () => { event: "internal_worker", data: { worker, - revision: 1, - event: { event: "text_done", data: { text: "stale" } }, + + event: { event: "text_done", data: { text: "next output" } }, }, }, }]); - assertEquals(projection.internalWorkers[0].console.lines.length, 1); + assertEquals(projection.internalWorkers[0].console.lines.map((line) => line.body), ["child output", "next output"]); const views = consoleWorkerViews(projection); assertEquals(views.map((view) => [view.sessionId, view.label]), [ @@ -3095,7 +3095,7 @@ Deno.test("live-created Internal Worker receives own metadata without reconnect" eventId: "child-snapshot", event: { event: "internal_worker", - data: { worker, revision: 1, event: childSnapshot }, + data: { worker, event: childSnapshot }, }, }, { @@ -3104,7 +3104,7 @@ Deno.test("live-created Internal Worker receives own metadata without reconnect" event: "internal_worker", data: { worker, - revision: 2, + event: { event: "context_usage", data: { usage: { tokens: 14_000, source: "estimated" } }, @@ -3137,7 +3137,7 @@ Deno.test("Internal Worker snapshot output continues without entering the parent }; snapshot.data.internal_workers = [{ worker, - revision: 4, + session: canonicalSession([]), greeting: { worker_name: "research", @@ -3185,7 +3185,7 @@ Deno.test("Internal Worker snapshot output continues without entering the parent event: "internal_worker", data: { worker, - revision: 5, + event: { event: "text_delta", data: { text: "ld**" } }, }, }, @@ -3216,7 +3216,7 @@ Deno.test("console Worker views expose only direct Internal Workers", () => { parent_session_id: "parent-session", kind: "sub_worker", }, - revision: 1, + event: { event: "internal_worker", data: { @@ -3226,7 +3226,7 @@ Deno.test("console Worker views expose only direct Internal Workers", () => { parent_session_id: "child-session", kind: "sub_worker", }, - revision: 1, + event: { event: "worker_state", data: { snapshot: workerState("running") }, @@ -3248,7 +3248,7 @@ Deno.test("console Worker views expose only direct Internal Workers", () => { parent_session_id: "parent-session", kind: "sub_worker", }, - revision: 1, + event: { event: "worker_state", data: { snapshot: workerState("idle") }, @@ -3298,7 +3298,7 @@ Deno.test("parent snapshot authoritatively replaces Internal Worker projections" parent_session_id: "parent-session", kind: "sub_worker", }, - revision: 4, + session: canonicalSession([{ kind: "assistant_item", ts: 1, @@ -3343,7 +3343,7 @@ Deno.test("parent snapshot authoritatively replaces Internal Worker projections" parent_session_id: "parent-session", kind: "sub_worker", }, - revision: 1, + event: { event: "worker_state", data: { snapshot: workerState("running") }, @@ -3384,12 +3384,12 @@ Deno.test("terminal Internal Worker removal drops descendants and fences late ev event: "internal_worker", data: { worker, - revision: 2, + event: { event: "internal_worker", data: { worker: nestedWorker, - revision: 1, + event: { event: "text_done", data: { text: "nested" } }, }, }, @@ -3406,7 +3406,7 @@ Deno.test("terminal Internal Worker removal drops descendants and fences late ev eventId: "removed", event: { event: "internal_worker_removed", - data: { worker, revision: 3 }, + data: { worker }, }, }, { eventId: "late", @@ -3414,7 +3414,7 @@ Deno.test("terminal Internal Worker removal drops descendants and fences late ev event: "internal_worker", data: { worker, - revision: 4, + event: { event: "text_done", data: { text: "must stay removed" } }, }, }, @@ -3427,7 +3427,7 @@ Deno.test("terminal Internal Worker removal drops descendants and fences late ev assertEquals(projection.removedInternalWorkers, {}); }); -Deno.test("stale Internal Worker removal cannot discard a newer projection", () => { +Deno.test("Internal Worker removal follows arrival order without a synthetic freshness counter", () => { const worker = { session_id: "child-session", name: "child", @@ -3441,21 +3441,21 @@ Deno.test("stale Internal Worker removal cannot discard a newer projection", () event: "internal_worker", data: { worker, - revision: 4, + event: { event: "text_done", data: { text: "current" } }, }, }, }]); const projection = projector.append([{ - eventId: "stale-removal", + eventId: "removal", event: { event: "internal_worker_removed", - data: { worker, revision: 3 }, + data: { worker }, }, }]); - assertEquals(projection.internalWorkers.length, 1); - assertEquals(projection.internalWorkers[0].revision, 4); + assertEquals(projection.internalWorkers, []); + assertEquals(projection.removedInternalWorkers, { "child-session": true }); }); Deno.test("snapshot restores TaskStore state from system history", () => { @@ -3465,7 +3465,7 @@ Deno.test("snapshot restores TaskStore state from system history", () => { if (event.event !== "snapshot") throw new Error("snapshot fixture expected"); event.data.session = { pending_submissions: { - revision: 0, + notification_count: 0, head_id: null, submissions: [], @@ -3717,7 +3717,7 @@ Deno.test("snapshot entry replay is idempotent by identity, not text, including const child = { session_id: "child-session", name: "reviewer", kind: "sub_worker" as const }; const snapshot = snapshotEvent("/repo") as SnapshotEvent; snapshot.data.internal_workers = [{ - worker: child, revision: 5, + worker: child, session: { ...snapshot.data.session, entries }, status: "idle", in_flight: { blocks: [] }, internal_workers: [], }]; @@ -3725,13 +3725,13 @@ Deno.test("snapshot entry replay is idempotent by identity, not text, including for (const [index, entry] of [user, assistant, system].entries()) { events.push({ eventId: `parent-${index}`, event: { event: "internal_worker", data: { - worker: child, revision: 6 + index, + worker: child, event: { event: "session_entry_committed", data: { entry } }, }, } }); } events.push({ eventId: "replayed-user", event: { - event: "internal_worker", data: { worker: child, revision: 9, event: { + event: "internal_worker", data: { worker: child, event: { event: "user_message", data: { entry_id: user.entry_id, segments: user.segments }, } }, } }); diff --git a/web/workspace/src/lib/workspace/console/model.ts b/web/workspace/src/lib/workspace/console/model.ts index 7f64864a0..cb2c67f6c 100644 --- a/web/workspace/src/lib/workspace/console/model.ts +++ b/web/workspace/src/lib/workspace/console/model.ts @@ -114,7 +114,6 @@ export type ConsoleLine = { export type InternalWorkerProjection = { worker: InternalWorkerRef; - revision: number; console: ConsoleProjection; }; @@ -194,7 +193,7 @@ export type ConsoleProjection = { lastEventId: string | null; internalWorkers: InternalWorkerProjection[]; /** Terminal child-session fences, reset only by an authoritative snapshot. */ - removedInternalWorkers: Record; + removedInternalWorkers: Record; }; export type WorkerTarget = { @@ -907,7 +906,7 @@ function projectInternalWorkerSnapshot( if (snapshot.in_flight?.compaction) { console = applyInFlightCompaction(console, snapshot.in_flight.compaction); } - return { worker: snapshot.worker, revision: snapshot.revision, console }; + return { worker: snapshot.worker, console }; } const RUNTIME_COMPACTION_LINE_ID = "compaction-runtime"; @@ -1247,16 +1246,13 @@ export function applyProtocolEvent( ? next.internalWorkers[existingIndex] : { worker: event.data.worker, - revision: 0, console: emptyConsoleProjection(), }; - if (event.data.revision <= existing.revision) break; const updated: InternalWorkerProjection = { worker: event.data.worker, - revision: event.data.revision, console: applyProtocolEvent(existing.console, { eventId: - `${envelope.eventId}:internal:${event.data.worker.session_id}:${event.data.revision}`, + `${envelope.eventId}:internal:${event.data.worker.session_id}`, event: event.data.event, observedAtMs: envelope.observedAtMs, }), @@ -1269,12 +1265,7 @@ export function applyProtocolEvent( const existingIndex = next.internalWorkers.findIndex((worker) => worker.worker.session_id === event.data.worker.session_id ); - const existingRevision = existingIndex >= 0 - ? next.internalWorkers[existingIndex].revision - : 0; - if (event.data.revision <= existingRevision) break; - next.removedInternalWorkers[event.data.worker.session_id] = - event.data.revision; + next.removedInternalWorkers[event.data.worker.session_id] = true; if (existingIndex >= 0) next.internalWorkers.splice(existingIndex, 1); break; } diff --git a/web/workspace/src/lib/workspace/console/run-status.test.ts b/web/workspace/src/lib/workspace/console/run-status.test.ts index e8c676f36..609b762ad 100644 --- a/web/workspace/src/lib/workspace/console/run-status.test.ts +++ b/web/workspace/src/lib/workspace/console/run-status.test.ts @@ -163,7 +163,7 @@ Deno.test("new invoke and legacy snapshot reset run activity", () => { entries: [], greeting: { text: "", profile: "" }, state: { - revision: 0, + last_command_id: 0, state: { kind: "idle" }, }, diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 33e81fa78..9180fdbd1 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -504,7 +504,7 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async () subjectIndexPage.includes(">Subjects") && subjectIndexPage.includes("Worker connection") && subjectIndexPage.includes("subject.current_worker") && - !subjectIndexPage.includes("subject.store_revision") && + !subjectIndexPage.includes("subject.memory_fingerprint") && subjectIndexPage.includes('data-memory-view="subjects"'), "Memory product entry should list explicit typed subjects", ); @@ -513,7 +513,7 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async () subjectPageLoad.includes("`${subjectPath}/memories`") && subjectPage.includes("DocumentMarkdown from") && subjectPage.includes("Resident context") && - subjectPage.includes("Subject store revision") && + subjectPage.includes("Memory fingerprint") && subjectPage.includes("Current Memories") && subjectPage.includes("surface?.availability === 'ready'") && subjectPage.includes("surface?.availability === 'ungenerated'") && @@ -524,15 +524,15 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async () "Subject page should combine strict resident-surface and current-Memory reads without a nested vertical scroller", ); assert( - memoryDetailLoad.includes("`${memoryPath}/revisions`") && + memoryDetailLoad.includes("`${memoryPath}/changes`") && memoryDetailLoad.includes("parseSubjektivMemoryReadResponse") && memoryDetailPage.includes("Candidate provenance") && memoryDetailPage.includes("Source refs") && memoryDetailPage.includes("Derived from") && - memoryDetailPage.includes("Revision history") && + memoryDetailPage.includes("Change history") && memoryDetailPage.includes("DocumentMarkdown from") && !memoryDetailPage.includes("overflow-y"), - "Committed Memory detail should preserve bounded provenance and immutable revisions without mutation controls", + "Committed Memory detail should preserve bounded provenance and immutable changes without mutation controls", ); }); diff --git a/web/workspace/src/lib/workspace/drive-ui/DrivePage.browser.spec.ts b/web/workspace/src/lib/workspace/drive-ui/DrivePage.browser.spec.ts index bb8a2790b..f50554033 100644 --- a/web/workspace/src/lib/workspace/drive-ui/DrivePage.browser.spec.ts +++ b/web/workspace/src/lib/workspace/drive-ui/DrivePage.browser.spec.ts @@ -58,8 +58,8 @@ test("A late image decode error cannot replace the newly selected image preview" expect(revoke).toHaveBeenCalledWith("blob:http://localhost/image-1"); }); -test("Refreshing metadata never silently rebases an open rename form onto another writer revision", async () => { - let revision = "1"; +test("Refreshing metadata never silently rebases an open rename form onto another writer’s saved content", async () => { + let last_mutation_id = "1"; const mutations: DriveMutationRequest[] = []; vi.stubGlobal( "fetch", @@ -71,7 +71,7 @@ test("Refreshing metadata never silently rebases an open rename form onto anothe return Response.json({ code: "conflict", classification: "not_committed", - message: "Revision conflict", + message: "Content changed", }, { status: 409 }); } if (url.pathname.endsWith("/list")) { @@ -79,7 +79,7 @@ test("Refreshing metadata never silently rebases an open rename form onto anothe } if (url.pathname.endsWith("/read-text")) { return Response.json({ - entry: entry("2", "alpha", revision), + entry: entry("2", "alpha", last_mutation_id), text: "new", truncated: false, }); @@ -88,7 +88,7 @@ test("Refreshing metadata never silently rebases an open rename form onto anothe entry( id, "alpha", - id === "1" ? "1" : revision, + id === "1" ? "1" : last_mutation_id, id === "1" ? "folder" : "file", ), ); @@ -102,7 +102,7 @@ test("Refreshing metadata never silently rebases an open rename form onto anothe await fireEvent.input(view.getByLabelText("Name"), { target: { value: "my-name.txt" }, }); - revision = "2"; + last_mutation_id = "2"; await fireEvent.click( view.getByRole("button", { name: "Refresh" }), ); @@ -113,14 +113,14 @@ test("Refreshing metadata never silently rebases an open rename form onto anothe ), ).toBe(false) ); - expect(view.container.textContent).toContain("Expected revision 1."); + expect(view.container.textContent).toContain("Changes by another writer will not be overwritten."); await fireEvent.click( view.getByRole("button", { name: "Apply rename / move" }), ); await waitFor(() => expect(mutations).toHaveLength(1)); expect(mutations[0].mutation).toMatchObject({ operation: "relocate", - expected_revision: "1", + expected_mutation_id: "1", name: "my-name.txt", id: { workspace_id: "alpha", node_id: "2" }, }); @@ -147,7 +147,7 @@ test("Folder picker blocks failed reads, filters files and self, and follows pag return Response.json({ code: "conflict", classification: "not_committed", - message: "Revision conflict", + message: "Content changed", }, { status: 409 }); } if (url.pathname.endsWith("/list")) { @@ -209,7 +209,7 @@ test("Folder picker blocks failed reads, filters files and self, and follows pag await waitFor(() => expect(mutations).toHaveLength(1)); expect(mutations[0].mutation).toMatchObject({ operation: "relocate", - expected_revision: "7", + expected_mutation_id: "7", id: { workspace_id: "alpha", node_id: "2" }, parent: { workspace_id: "alpha", node_id: "5" }, }); diff --git a/web/workspace/src/lib/workspace/drive-ui/DrivePage.svelte b/web/workspace/src/lib/workspace/drive-ui/DrivePage.svelte index a5455c109..ec978cc05 100644 --- a/web/workspace/src/lib/workspace/drive-ui/DrivePage.svelte +++ b/web/workspace/src/lib/workspace/drive-ui/DrivePage.svelte @@ -170,13 +170,13 @@ if (!file) throw new Error('Choose a file.'); if (file.size > DRIVE_FILE_MAX_BYTES) throw new Error('Upload exceeds 16 MiB.'); requestId = await controller.upload(file, selectedForm === 'replace' ? { - operation: 'update', id: current.entry, expected_revision: current.revision, content_type: file.type || 'application/octet-stream', + operation: 'update', id: current.entry, expected_mutation_id: current.last_mutation_id, content_type: file.type || 'application/octet-stream', } : { operation: 'create', parent: current.entry, name: file.name, content_type: file.type || 'application/octet-stream', }); } else if (selectedForm === 'relocate') { if (!destination || destinationBusy || destinationError) throw new Error('Choose an available destination folder.'); - requestId = await controller.mutate({ operation: 'relocate', id: current.entry, expected_revision: current.revision, parent: { workspace_id: workspaceId, node_id: parseDriveDecimal(parentId) }, name }); + requestId = await controller.mutate({ operation: 'relocate', id: current.entry, expected_mutation_id: current.last_mutation_id, parent: { workspace_id: workspaceId, node_id: parseDriveDecimal(parentId) }, name }); } else { requestId = await controller.mutate(selectedForm === 'folder' ? { operation: 'create_folder', parent: current.entry, name } : { operation: 'create_text', parent: current.entry, name, text: newText, content_type: 'text/markdown' }); } @@ -193,7 +193,7 @@ if (!current || !canWrite || !current.parent) return; if (!confirm(`Delete “${current.name}”? This cannot be undone. Folders must be empty; contents are never recursively deleted.`)) return; await action(async () => { - await controller.mutate({ operation: 'delete', id: current.entry, expected_revision: current.revision }); + await controller.mutate({ operation: 'delete', id: current.entry, expected_mutation_id: current.last_mutation_id }); // Stay on this stable URL; a subsequent lookup correctly reports NotFound. if (controller.state.receipts.at(-1)?.state === 'committed') await controller.refresh(); }); @@ -283,7 +283,7 @@ {/if} {#if entry?.parent} - {#if entry.kind === 'file'}Download{/if} + {#if entry.kind === 'file'}Download{/if} {#if canEdit}{/if}
@@ -352,17 +352,17 @@ {:else if kind === 'markdown' || kind === 'text'} {#if $controller.truncated}

Preview truncated at 64 KiB. Download the full file; partial content cannot be saved.

{/if} {#if draft?.conflict} - + {/if}
{#if editing && draft}
-
@@ -383,11 +383,11 @@ {/if}
{:else if kind === 'download'} -

No preview available

This file type is download-only. HTML, SVG and other active content are never rendered here.

Download file
+

No preview available

This file type is download-only. HTML, SVG and other active content are never rendered here.

Download file
{/if} {#if entry.parent}
Details -
Updated
{formatDate(entry.updated_at)}
By
{entry.updated_by}
Revision
{entry.revision}
+
Updated
{formatDate(entry.updated_at)}
By
{entry.updated_by}
Last change request
{entry.last_mutation_id}

Saving replaces the current content. There is no version history.

{/if} @@ -426,7 +426,7 @@ {/if} {#if form === 'text'}{/if} {/if} - {#if form === 'replace' || form === 'relocate'}

Expected revision {formEntry?.revision}. Changes by another writer will not be overwritten.

{/if} + {#if form === 'replace' || form === 'relocate'}

Changes by another writer will not be overwritten.

{/if} {#if form === 'replace'}

This replaces the current file. There is no version history.

{/if}
diff --git a/web/workspace/src/lib/workspace/drive/api.test.ts b/web/workspace/src/lib/workspace/drive/api.test.ts index d4fdfb33c..9ee0144a9 100644 --- a/web/workspace/src/lib/workspace/drive/api.test.ts +++ b/web/workspace/src/lib/workspace/drive/api.test.ts @@ -44,13 +44,13 @@ Deno.test("Drive metadata rejects cross-workspace refs and unsafe latest links", const good = entry(); const parsed = parseDriveEntry(good, "alpha"); equal(parsed.entry, good.entry); - equal(parsed.revision, good.revision); + equal(parsed.last_mutation_id, good.last_mutation_id); equal(parsed.latest_url, good.latest_url); for ( const patch of [ { entry: ref("2", "beta") }, { parent: ref("1", "beta") }, - { revision: 4 }, + { last_mutation_id: 4 }, { kind: "directory" }, { size: -1 }, { size: DRIVE_FILE_MAX_BYTES + 1 }, @@ -200,7 +200,7 @@ Deno.test("Drive client uses current routes with same-origin authentication and assert(called); equal( driveDownloadUrl("alpha", ref("9007199254740993"), "9007199254740995"), - "/api/w/alpha/drive/download?entry_workspace_id=alpha&id=9007199254740993&expected_revision=9007199254740995", + "/api/w/alpha/drive/download?entry_workspace_id=alpha&id=9007199254740993&expected_mutation_id=9007199254740995", ); }); Deno.test("Drive upload over 16 MiB is rejected before fetching", async () => { @@ -244,7 +244,7 @@ Deno.test("Drive mutation network abort or malformed reply remains unknown after client.mutate("alpha", "req", { operation: "delete", id: ref(), - expected_revision: "9007199254740993", + expected_mutation_id: "9007199254740993", }), (error) => { assert(error instanceof DriveRequestError); @@ -268,7 +268,7 @@ Deno.test("Drive typed conflict proves not committed and read responses cannot t client.mutate("alpha", "req", { operation: "delete", id: ref(), - expected_revision: "1", + expected_mutation_id: "1", }), (error) => { assert(error instanceof DriveRequestError); @@ -285,7 +285,10 @@ Deno.test("Drive typed conflict proves not committed and read responses cannot t Deno.test("Drive image preview fetches authenticated bounded binary rather than metadata URL", async () => { const image = { ...entry(), content_type: "image/png" }; const client = createDriveClient((url, init) => { - equal(String(url), driveDownloadUrl("alpha", ref(), image.revision)); + equal( + String(url), + driveDownloadUrl("alpha", ref(), image.last_mutation_id), + ); equal((init as RequestInit | undefined)?.credentials, "same-origin"); equal((init as RequestInit | undefined)?.cache, "no-store"); return Promise.resolve(new Response(new Uint8Array([0, 1, 2]))); @@ -310,7 +313,7 @@ Deno.test("Drive malformed mutation response target parent name or operation kin mutation: { operation: "update_text", id: ref(), - expected_revision: "1", + expected_mutation_id: "1", text: "abc", content_type: "text/plain", }, @@ -320,7 +323,7 @@ Deno.test("Drive malformed mutation response target parent name or operation kin mutation: { operation: "update_text", id: ref(), - expected_revision: "1", + expected_mutation_id: "1", text: "abc", content_type: "text/plain", }, @@ -330,7 +333,7 @@ Deno.test("Drive malformed mutation response target parent name or operation kin mutation: { operation: "relocate", id: ref(), - expected_revision: "1", + expected_mutation_id: "1", parent: ref("4"), name: "moved.txt", }, @@ -340,7 +343,7 @@ Deno.test("Drive malformed mutation response target parent name or operation kin mutation: { operation: "relocate", id: ref(), - expected_revision: "1", + expected_mutation_id: "1", parent: ref("4"), name: "moved.txt", }, @@ -350,7 +353,7 @@ Deno.test("Drive malformed mutation response target parent name or operation kin mutation: { operation: "relocate", id: ref(), - expected_revision: "1", + expected_mutation_id: "1", parent: ref("4"), name: "moved.txt", }, @@ -392,14 +395,14 @@ Deno.test("Drive malformed mutation response target parent name or operation kin mutation: { operation: "update_text", id: ref(), - expected_revision: "1", + expected_mutation_id: "1", text: "abc", content_type: "text/plain", }, response: null, }, { - mutation: { operation: "delete", id: ref(), expected_revision: "1" }, + mutation: { operation: "delete", id: ref(), expected_mutation_id: "1" }, response: entry(), }, ]; @@ -422,7 +425,7 @@ Deno.test("Drive malformed upload update and create identity remains unknown aft target: { operation: "update", id: ref(), - expected_revision: "1", + expected_mutation_id: "1", content_type: "text/plain", }, response: entry("3"), @@ -431,7 +434,7 @@ Deno.test("Drive malformed upload update and create identity remains unknown aft target: { operation: "update", id: ref(), - expected_revision: "1", + expected_mutation_id: "1", content_type: "text/plain", }, response: entry("2", "alpha", "2", "folder"), @@ -467,7 +470,7 @@ Deno.test("Drive malformed upload update and create identity remains unknown aft target: { operation: "update", id: ref(), - expected_revision: "1", + expected_mutation_id: "1", content_type: "text/plain", }, response: null, @@ -508,3 +511,22 @@ Deno.test("Drive bounded text roundtrip accepts worst-case JSON escaping without ); equal(result.entry.size, 64 * 1024); }); + +Deno.test("Drive committed request identity remains UTF-8 and URL encoded rather than numeric", () => { + const id = 'write/資料 ?"#&=+'; + const value = { ...entry(), last_mutation_id: id }; + equal(parseDriveEntry(value, "alpha").last_mutation_id, id); + const url = new URL( + driveDownloadUrl("alpha", value.entry, id), + "https://example.test", + ); + equal(url.searchParams.get("expected_mutation_id"), id); + equal(url.searchParams.size, 3); + equal(url.hash, ""); + for ( + const bad of ["", "\nrequest", "\x7f", "é".repeat(65), "x".repeat(129)] + ) { + throws(() => parseDriveEntry({ ...value, last_mutation_id: bad }, "alpha")); + throws(() => driveDownloadUrl("alpha", value.entry, bad)); + } +}); diff --git a/web/workspace/src/lib/workspace/drive/api.ts b/web/workspace/src/lib/workspace/drive/api.ts index 4387aab51..50d271cea 100644 --- a/web/workspace/src/lib/workspace/drive/api.ts +++ b/web/workspace/src/lib/workspace/drive/api.ts @@ -68,6 +68,14 @@ export function parseDriveDecimal(value: unknown): string { ) invalid(); return result; } +function parseMutationId(value: unknown, allowRoot = false): string { + const result = string(value, 128); + if ( + (!allowRoot && !result) || new TextEncoder().encode(result).length > 128 || + /[\x00-\x1f\x7f-\x9f]/.test(result) + ) invalid(); + return result; +} function hasControl(value: string, includeSpace = false): boolean { return Array.from(value).some((char) => char.charCodeAt(0) <= (includeSpace ? 32 : 31) @@ -158,7 +166,7 @@ export function parseDriveEntry( parent, kind: v.kind, name: string(v.name, 255), - revision: parseDriveDecimal(v.revision), + last_mutation_id: parseMutationId(v.last_mutation_id, parent === null), size: size as number | null, content_type: v.content_type === null ? null : mime(v.content_type), updated_by: string(v.updated_by), @@ -293,7 +301,7 @@ export type DriveUploadTarget = | { operation: "update"; id: DriveEntryRef; - expected_revision: string; + expected_mutation_id: string; content_type: string; }; export type DrivePageOptions = { after?: string | null; limit?: number }; @@ -349,14 +357,14 @@ export interface DriveClient { export function driveDownloadUrl( workspaceId: string, ref: DriveEntryRef, - revision?: string, + last_mutation_id?: string, ): string { return `${base(workspaceId)}/download${ query({ ...refQuery(ref, workspaceId), - expected_revision: revision === undefined + expected_mutation_id: last_mutation_id === undefined ? null - : parseDriveDecimal(revision), + : parseMutationId(last_mutation_id), }) }`; } @@ -375,7 +383,7 @@ function name(value: string): string { function validateMutation(value: DriveMutation, workspaceId: string): void { if ("id" in value) { parseDriveEntryRef(value.id, workspaceId); - parseDriveDecimal(value.expected_revision); + parseMutationId(value.expected_mutation_id); } if ("parent" in value) parseDriveEntryRef(value.parent, workspaceId); if ("name" in value) name(value.name); @@ -514,7 +522,7 @@ export function createDriveClient(fetchFn: typeof fetch = fetch): DriveClient { entry.size === null || entry.size > DRIVE_RESPONSE_MAX_BYTES ) throw new DriveRequestError("limit", "not_committed"); const res = await response( - driveDownloadUrl(ws, entry.entry, entry.revision), + driveDownloadUrl(ws, entry.entry, entry.last_mutation_id), { signal }, ); await checked(res); @@ -578,7 +586,7 @@ export function createDriveClient(fetchFn: typeof fetch = fetch): DriveClient { } : { id: parseDriveEntryRef(target.id, ws).node_id, - expected_revision: parseDriveDecimal(target.expected_revision), + expected_mutation_id: parseMutationId(target.expected_mutation_id), }; signal?.throwIfAborted(); const digest = await crypto.subtle.digest( diff --git a/web/workspace/src/lib/workspace/drive/controller.svelte.ts b/web/workspace/src/lib/workspace/drive/controller.svelte.ts index 80201da63..a1440f198 100644 --- a/web/workspace/src/lib/workspace/drive/controller.svelte.ts +++ b/web/workspace/src/lib/workspace/drive/controller.svelte.ts @@ -15,7 +15,7 @@ import { export type DriveDraft = { text: string; baseText: string; - expectedRevision: string; + expectedMutationId: string; contentType: string; conflict: boolean; }; @@ -42,7 +42,7 @@ export type DriveControllerState = { }; type ReceiptRecord = { receipt: DriveReceipt; - saved?: { text: string; revision: string }; + saved?: { text: string; last_mutation_id: string }; }; const empty = (): DriveControllerState => ({ workspaceId: null, @@ -208,7 +208,7 @@ export class DriveController { if (!this.current(generation)) return; let draft = this.drafts.get(k) ?? null; // Clean acknowledged drafts may refresh, but dirty/conflicted drafts - // and unresolved requests must keep their original CAS revision. + // and unresolved requests must keep their original CAS last_mutation_id. const unresolved = this.currentReceipts(k).some((receipt) => receipt.state === "pending" || receipt.state === "unknown" ); @@ -225,7 +225,7 @@ export class DriveController { draft = { text: result.text, baseText: result.text, - expectedRevision: result.entry.revision, + expectedMutationId: result.entry.last_mutation_id, contentType: result.entry.content_type ?? "text/plain", conflict: false, }; @@ -354,11 +354,11 @@ export class DriveController { this.client.mutate(s.ws, id, { operation: "update_text", id: s.entry.entry, - expected_revision: draft.expectedRevision, + expected_mutation_id: draft.expectedMutationId, text: draft.text, content_type: draft.contentType, }, signal), - { text: draft.text, revision: draft.expectedRevision }, + { text: draft.text, last_mutation_id: draft.expectedMutationId }, ); } async mutate(mutation: DriveMutation): Promise { @@ -453,13 +453,13 @@ export class DriveController { const draft = this.drafts.get(k); if ( record.saved && draft && entry && - draft.expectedRevision === record.saved.revision && + draft.expectedMutationId === record.saved.last_mutation_id && entry.entry.node_id === this.value.entry?.entry.node_id ) { const updated = { ...draft, baseText: record.saved.text, - expectedRevision: entry.revision, + expectedMutationId: entry.last_mutation_id, conflict: false, }; this.drafts.set(k, updated); diff --git a/web/workspace/src/lib/workspace/drive/controller.test.ts b/web/workspace/src/lib/workspace/drive/controller.test.ts index 5e2b104b1..0f87a7582 100644 --- a/web/workspace/src/lib/workspace/drive/controller.test.ts +++ b/web/workspace/src/lib/workspace/drive/controller.test.ts @@ -110,12 +110,12 @@ Deno.test("Drive A to B to A selection rejects the first A response and late rea equal(c.state.text, "fresh"); equal(c.state.error, null); }); -Deno.test("Drive drafts survive workspace and node switches without revision rebasing", async () => { - let revision = "9007199254740993"; +Deno.test("Drive drafts survive workspace and node switches without last_mutation_id rebasing", async () => { + let last_mutation_id = "9007199254740993"; const c = controller({ readText: (ws, target) => Promise.resolve({ - entry: entry(target.node_id, ws, revision), + entry: entry(target.node_id, ws, last_mutation_id), text: "server", truncated: false, }), @@ -124,22 +124,22 @@ Deno.test("Drive drafts survive workspace and node switches without revision reb c.edit("local alpha"); await c.select("beta", ref("2", "beta")); c.edit("local beta"); - revision = "9007199254740999"; + last_mutation_id = "9007199254740999"; await c.select("alpha", ref("3")); await c.select("alpha", ref()); equal(c.state.draft?.text, "local alpha"); - equal(c.state.draft?.expectedRevision, "9007199254740993"); - equal(c.state.entry?.revision, "9007199254740999"); + equal(c.state.draft?.expectedMutationId, "9007199254740993"); + equal(c.state.entry?.last_mutation_id, "9007199254740999"); await c.select("beta", ref("2", "beta")); equal(c.state.draft?.text, "local beta"); }); -Deno.test("Drive conflict keeps local text and old CAS revision across switching without retry", async () => { +Deno.test("Drive conflict keeps local text and old CAS last_mutation_id across switching without retry", async () => { let mutations = 0; const c = controller({ mutate: (_, __, mutation) => { ++mutations; - assert("expected_revision" in mutation); - equal(mutation.expected_revision, "9007199254740993"); + assert("expected_mutation_id" in mutation); + equal(mutation.expected_mutation_id, "9007199254740993"); return Promise.reject(new DriveRequestError("conflict", "not_committed")); }, }); @@ -151,7 +151,7 @@ Deno.test("Drive conflict keeps local text and old CAS revision across switching await c.select("alpha", ref("3")); await c.select("alpha", ref()); equal(c.state.draft?.conflict, true); - equal(c.state.draft?.expectedRevision, "9007199254740993"); + equal(c.state.draft?.expectedMutationId, "9007199254740993"); equal(mutations, 1); }); Deno.test("Drive acknowledged save preserves edits made while the request was in flight", async () => { @@ -168,7 +168,7 @@ Deno.test("Drive acknowledged save preserves edits made while the request was in await saving; equal(c.state.draft?.text, "new edit"); equal(c.state.draft?.baseText, "sent"); - equal(c.state.draft?.expectedRevision, "9007199254740994"); + equal(c.state.draft?.expectedMutationId, "9007199254740994"); }); Deno.test("Drive late mutation and upload success cannot affect a newly selected identity", async () => { for (const operation of ["mutation", "upload"] as const) { @@ -184,7 +184,7 @@ Deno.test("Drive late mutation and upload success cannot affect a newly selected : c.upload(new Blob(["abc"]), { operation: "update", id: ref(), - expected_revision: "9007199254740993", + expected_mutation_id: "9007199254740993", content_type: "text/plain", }); await c.select("beta", ref("2", "beta")); @@ -195,11 +195,11 @@ Deno.test("Drive late mutation and upload success cannot affect a newly selected await sending; equal(c.state.workspaceId, "beta"); equal(c.state.receipts, []); - equal(c.state.draft?.expectedRevision, "9007199254740993"); + equal(c.state.draft?.expectedMutationId, "9007199254740993"); await c.select("alpha", ref()); equal(c.state.receipts[0].state, "unknown"); equal(c.state.draft?.text, "sent"); - equal(c.state.draft?.expectedRevision, "9007199254740993"); + equal(c.state.draft?.expectedMutationId, "9007199254740993"); } }); Deno.test("Drive unknown upload reconciles by request id and never blindly retransmits", async () => { @@ -234,7 +234,7 @@ Deno.test("Drive unknown upload reconciles by request id and never blindly retra const target = { operation: "update" as const, id: ref(), - expected_revision: "9007199254740993", + expected_mutation_id: "9007199254740993", content_type: "application/octet-stream", }; const id = await c.upload(new Blob(["abc"]), target); @@ -273,7 +273,7 @@ Deno.test("Drive late status response is fenced even when the original node is r }); await checking; equal(c.state.receipts[0].state, "unknown"); - equal(c.state.draft?.expectedRevision, "9007199254740993"); + equal(c.state.draft?.expectedMutationId, "9007199254740993"); equal(c.state.draft?.text, "local"); }); Deno.test("Drive truncated text has no editable draft and disposal rejects late publication", async () => { @@ -460,7 +460,7 @@ Deno.test("Drive cancelled writes retain draft and receipt and ignore late compl : c.upload(new Blob(["abc"]), { operation: "update", id: ref(), - expected_revision: "9007199254740993", + expected_mutation_id: "9007199254740993", content_type: "text/plain", }); equal(c.state.receipts[0].state, "pending"); @@ -470,7 +470,7 @@ Deno.test("Drive cancelled writes retain draft and receipt and ignore late compl equal(c.state.receipts[0].requestId, id); equal(c.state.receipts[0].state, "unknown"); equal(c.state.draft?.text, "sent draft"); - equal(c.state.draft?.expectedRevision, "9007199254740993"); + equal(c.state.draft?.expectedMutationId, "9007199254740993"); await rejects(() => c.save()); await c.reconcile(id); assert( @@ -495,7 +495,7 @@ Deno.test("Drive cancelled writes retain draft and receipt and ignore late compl equal(await sending, id); assert(c.state === snapshot); equal(sends, 1); - equal(c.state.entry?.revision, "9007199254740994"); + equal(c.state.entry?.last_mutation_id, "9007199254740994"); } }); Deno.test("Drive cancellation does not abort or fence active search reads", async () => { @@ -555,14 +555,14 @@ Deno.test("Drive concurrent writes are blocked before transmission and receipt c c.mutate({ operation: "delete", id: ref(), - expected_revision: "9007199254740993", + expected_mutation_id: "9007199254740993", }) ); await rejects(() => c.upload(new Blob(["abc"]), { operation: "update", id: ref(), - expected_revision: "9007199254740993", + expected_mutation_id: "9007199254740993", content_type: "text/plain", }) ); @@ -574,13 +574,13 @@ Deno.test("Drive concurrent writes are blocked before transmission and receipt c }); await saving; }); -Deno.test("Drive clean draft refresh adopts latest revision unless a request remains unresolved", async () => { - let revision = "9007199254740993"; +Deno.test("Drive clean draft refresh adopts latest last_mutation_id unless a request remains unresolved", async () => { + let last_mutation_id = "9007199254740993"; let text = "abc"; const c = controller({ readText: () => Promise.resolve({ - entry: entry("2", "alpha", revision), + entry: entry("2", "alpha", last_mutation_id), text, truncated: false, }), @@ -588,19 +588,19 @@ Deno.test("Drive clean draft refresh adopts latest revision unless a request rem Promise.reject(new DriveRequestError("outcome_unknown", "unknown")), }); await c.select("alpha", ref()); - revision = "9007199254740994"; + last_mutation_id = "9007199254740994"; text = "new"; await c.refresh(); equal(c.state.draft?.text, "new"); equal(c.state.draft?.baseText, "new"); - equal(c.state.draft?.expectedRevision, revision); + equal(c.state.draft?.expectedMutationId, last_mutation_id); await c.save(); - revision = "9007199254740995"; + last_mutation_id = "9007199254740995"; text = "unknown change"; await c.refresh(); equal(c.state.text, "unknown change"); equal(c.state.draft?.text, "new"); - equal(c.state.draft?.expectedRevision, "9007199254740994"); + equal(c.state.draft?.expectedMutationId, "9007199254740994"); equal(c.state.receipts[0].state, "unknown"); await rejects(() => c.save()); }); @@ -688,13 +688,13 @@ Deno.test("Drive cancelled late success stays unknown until explicit committed s }); const id = await saving; equal(c.state.receipts[0].state, "unknown"); - equal(c.state.draft?.expectedRevision, "9007199254740993"); + equal(c.state.draft?.expectedMutationId, "9007199254740993"); equal(c.state.draft?.text, "local"); equal(statusCalls, 0); await rejects(() => c.save()); await c.reconcile(id); equal(c.state.receipts[0].state, "committed"); - equal(c.state.draft?.expectedRevision, "9007199254740994"); + equal(c.state.draft?.expectedMutationId, "9007199254740994"); equal(c.state.draft?.baseText, "local"); }); Deno.test("Drive archived dirty draft cannot enable editing after MIME changes to active bytes", async () => { @@ -723,14 +723,14 @@ Deno.test("Drive archived dirty draft cannot enable editing after MIME changes t contentType = "text/plain"; await c.refresh(); equal(c.state.draft?.text, "retain me"); - equal(c.state.draft?.expectedRevision, "9007199254740993"); + equal(c.state.draft?.expectedMutationId, "9007199254740993"); }); Deno.test("Drive conflicted clean draft stays fixed while clean truncated reread removes editor", async () => { - let revision = "9007199254740993"; + let last_mutation_id = "9007199254740993"; let truncated = false; const readText = () => Promise.resolve({ - entry: entry("2", "alpha", revision), + entry: entry("2", "alpha", last_mutation_id), text: "abc", truncated, }); @@ -741,12 +741,12 @@ Deno.test("Drive conflicted clean draft stays fixed while clean truncated reread }); await conflict.select("alpha", ref()); await conflict.save(); - revision = "9007199254740994"; + last_mutation_id = "9007199254740994"; await conflict.refresh(); equal(conflict.state.draft?.text, "abc"); equal(conflict.state.draft?.baseText, "abc"); equal(conflict.state.draft?.conflict, true); - equal(conflict.state.draft?.expectedRevision, "9007199254740993"); + equal(conflict.state.draft?.expectedMutationId, "9007199254740993"); const clean = controller({ readText }); await clean.select("alpha", ref()); assert(clean.state.draft); diff --git a/web/workspace/src/lib/workspace/drive/test-fixtures.ts b/web/workspace/src/lib/workspace/drive/test-fixtures.ts index bfca13cba..71140fbff 100644 --- a/web/workspace/src/lib/workspace/drive/test-fixtures.ts +++ b/web/workspace/src/lib/workspace/drive/test-fixtures.ts @@ -46,7 +46,7 @@ export function ref(id = "2", ws = "alpha"): DriveEntryRef { export function entry( id = "2", ws = "alpha", - revision = "9007199254740993", + last_mutation_id = "9007199254740993", kind: "file" | "folder" = "file", ): DriveEntry { return { @@ -54,7 +54,7 @@ export function entry( parent: id === "1" ? null : ref("1", ws), name: id === "1" ? "" : "note.txt", kind, - revision, + last_mutation_id, size: kind === "file" ? 3 : null, content_type: kind === "file" ? "text/plain" : null, updated_by: "user:test", diff --git a/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts b/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts index 13d0f5e66..6e5327c01 100644 --- a/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts +++ b/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts @@ -33,7 +33,7 @@ export function fixtureDetail(key = "T-101") { const { workspace_action_priority: _, ...ticket } = summary; return { ...ticket, - item_revision: "revision-1", + content_digest: "a".repeat(64), created_at: updated(1), readiness: null, body: "Implementation in progress.", @@ -155,7 +155,6 @@ export function dashboardFixture( ...detail, body: "# Objective detail\n\nHome dashboard navigation destination.", body_truncated: false, - revision: "revision-1", linked_ticket_summaries: [], resources: [], events: [], diff --git a/web/workspace/src/lib/workspace/memory/api.ts b/web/workspace/src/lib/workspace/memory/api.ts index c97dea9d4..9e0d25b3a 100644 --- a/web/workspace/src/lib/workspace/memory/api.ts +++ b/web/workspace/src/lib/workspace/memory/api.ts @@ -18,12 +18,12 @@ import type { MemoryStagingRecord, SubjektivMemoryEvidence, SubjektivMemoryEvidenceCandidate, - SubjektivMemoryListRevisionsResponse, + SubjektivMemoryListChangesResponse, SubjektivMemoryQueryItem, SubjektivMemoryQueryResponse, SubjektivMemoryReadResponse, - SubjektivMemoryRevisionItem, - SubjektivMemoryRevisionRef, + SubjektivMemoryChangeItem, + SubjektivMemoryChangeRef, SubjektivMemorySourceEvidenceRef, SubjektivMemoryState, SubjektivResidentSurfaceAvailability, @@ -79,7 +79,7 @@ const memoryStates = new Set([ ]); const MAX_SUBJECTS = 100; const MAX_CURRENT_MEMORIES = 100; -const MAX_REVISIONS = 100; +const MAX_CHANGES = 100; const MAX_MEMORY_REFS = MEMORY_API_LIMITS.maxCollectionItems; export const MEMORY_API_LOAD_POLICY = { @@ -221,15 +221,7 @@ export async function updateSubjektivSubjectBehavior( request: SubjektivSubjectBehaviorUpdateRequest, ): Promise { validateSubjectBehavior(request.behavior_md); - if ( - !Number.isSafeInteger(request.expected_behavior_revision) || - request.expected_behavior_revision < 0 - ) { - throw new SubjektivSubjectCreateError( - "validation", - "Subject behavior revision is invalid.", - ); - } + validateSubjectBehavior(request.expected_behavior_md); let response: Response; try { response = await fetchFn( @@ -494,7 +486,6 @@ function parseEvidenceOrigin(value: unknown): MemoryEvidenceOrigin { "worker_id", "flow_selector", "flow_definition_id", - "flow_definition_revision", ] as const; const record = strictRecord( value, @@ -528,14 +519,6 @@ function parseEvidenceOrigin(value: unknown): MemoryEvidenceOrigin { result[key] = text; } } - if ("flow_definition_revision" in record) { - result.flow_definition_revision = record.flow_definition_revision === null - ? null - : nonNegativeInteger( - record.flow_definition_revision, - "flow_definition_revision", - ); - } return result; } @@ -549,9 +532,8 @@ export function parseSubjektivSubjectResponse( "id", "role", "behavior_md", - "behavior_revision", "state", - "store_revision", + "memory_fingerprint", "created_at", "updated_at", "current_worker", @@ -573,9 +555,8 @@ export function parseSubjektivSubjectResponse( "behavior_md", MAX_SUBJECT_BEHAVIOR_BYTES, ), - behavior_revision: requiredNonNegativeInteger(record, "behavior_revision"), state, - store_revision: requiredNonNegativeInteger(record, "store_revision"), + memory_fingerprint: requiredIdentifier(record, "memory_fingerprint"), created_at: requiredString(record, "created_at"), updated_at: requiredString(record, "updated_at"), }; @@ -665,7 +646,7 @@ function parseResidentSurfaceSnapshot( "snapshot_id", "body_md", "memory_refs", - "built_from_store_revision", + "built_from_memory_fingerprint", "created_at", ], "Resident surface snapshot", @@ -681,10 +662,10 @@ function parseResidentSurfaceSnapshot( record.memory_refs, MAX_MEMORY_REFS, "memory_refs", - ).map(parseMemoryRevisionRef), - built_from_store_revision: requiredNonNegativeInteger( + ).map(parseMemoryChangeRef), + built_from_memory_fingerprint: requiredIdentifier( record, - "built_from_store_revision", + "built_from_memory_fingerprint", ), created_at: requiredString(record, "created_at"), }; @@ -716,12 +697,12 @@ export function parseSubjektivMemoryQueryResponse( function parseMemoryQueryItem(value: unknown): SubjektivMemoryQueryItem { const record = strictRecord( value, - ["id", "revision", "kind", "state", "claim", "excerpt", "updated_at"], + ["id", "change_id", "kind", "state", "claim", "excerpt", "updated_at"], "Current Memory item", ); return { id: requiredIdentifier(record, "id"), - revision: requiredPositiveInteger(record, "revision"), + change_id: requiredIdentifier(record, "change_id"), kind: parseCandidateKind(record.kind), state: parseMemoryState(record.state), claim: requiredString(record, "claim"), @@ -738,8 +719,8 @@ export function parseSubjektivMemoryReadResponse( value, [ "memory_id", - "revision", - "current_revision", + "change_id", + "current_change_id", "kind", "state", "claim", @@ -772,9 +753,8 @@ export function parseSubjektivMemoryReadResponse( if (expectedMemoryId !== undefined && memoryId !== expectedMemoryId) { invalid("Memory detail identity does not match the requested Memory"); } - const revision = requiredPositiveInteger(record, "revision"); - const currentRevision = requiredPositiveInteger(record, "current_revision"); - if (revision > currentRevision) invalid("revision exceeds current_revision"); + const changeId = requiredIdentifier(record, "change_id"); + const currentChangeId = requiredIdentifier(record, "current_change_id"); const bodyTruncated = requiredBoolean(record, "body_truncated"); const bodyNextOffset = optionalNonNegativeInteger(record, "body_next_offset"); const bodyNextByteOffset = optionalNonNegativeInteger( @@ -814,8 +794,8 @@ export function parseSubjektivMemoryReadResponse( assertCursorInvariant(evidenceHasMore, evidenceNextCursor, "Memory evidence"); const result: SubjektivMemoryReadResponse = { memory_id: memoryId, - revision, - current_revision: currentRevision, + change_id: changeId, + current_change_id: currentChangeId, kind: parseCandidateKind(record.kind), state: parseMemoryState(record.state), claim: requiredString(record, "claim"), @@ -837,7 +817,7 @@ export function parseSubjektivMemoryReadResponse( record.derived_from, MAX_MEMORY_REFS, "derived_from", - ).map(parseMemoryRevisionRef), + ).map(parseMemoryChangeRef), evidence_has_more: evidenceHasMore, }; if ("staleness" in record) { @@ -984,37 +964,34 @@ function parseMemorySourceEvidenceRef( return result; } -export function parseSubjektivMemoryListRevisionsResponse( +export function parseSubjektivMemoryListChangesResponse( value: unknown, expectedMemoryId?: string, -): SubjektivMemoryListRevisionsResponse { +): SubjektivMemoryListChangesResponse { const record = strictRecord( value, - ["memory_id", "current_revision", "items", "next_cursor", "has_more"], - "Memory revisions response", + ["memory_id", "current_change_id", "items", "next_cursor", "has_more"], + "Memory changes response", ["next_cursor"], ); const memoryId = requiredIdentifier(record, "memory_id"); if (expectedMemoryId !== undefined && memoryId !== expectedMemoryId) { - invalid("Memory revisions identity does not match the requested Memory"); + invalid("Memory changes identity does not match the requested Memory"); } - const currentRevision = requiredPositiveInteger(record, "current_revision"); - const items = boundedArray(record.items, MAX_REVISIONS, "items").map( - parseMemoryRevisionItem, + const currentChangeId = requiredIdentifier(record, "current_change_id"); + const items = boundedArray(record.items, MAX_CHANGES, "items").map( + parseMemoryChangeItem, ); - if (items.some((item) => item.revision > currentRevision)) { - invalid("revision history contains a future revision"); - } assertUnique( - items.map((item) => String(item.revision)), - "Memory revisions", + items.map((item) => item.change_id), + "Memory changes", ); const hasMore = requiredBoolean(record, "has_more"); const nextCursor = optionalNullableString(record, "next_cursor"); - assertCursorInvariant(hasMore, nextCursor, "Memory revisions"); - const result: SubjektivMemoryListRevisionsResponse = { + assertCursorInvariant(hasMore, nextCursor, "Memory changes"); + const result: SubjektivMemoryListChangesResponse = { memory_id: memoryId, - current_revision: currentRevision, + current_change_id: currentChangeId, items, has_more: hasMore, }; @@ -1022,14 +999,14 @@ export function parseSubjektivMemoryListRevisionsResponse( return result; } -function parseMemoryRevisionItem(value: unknown): SubjektivMemoryRevisionItem { +function parseMemoryChangeItem(value: unknown): SubjektivMemoryChangeItem { const record = strictRecord( value, - ["revision", "kind", "state", "claim", "change_reason", "updated_at"], - "Memory revision item", + ["change_id", "kind", "state", "claim", "change_reason", "updated_at"], + "Memory change item", ); return { - revision: requiredPositiveInteger(record, "revision"), + change_id: requiredIdentifier(record, "change_id"), kind: parseCandidateKind(record.kind), state: parseMemoryState(record.state), claim: requiredString(record, "claim"), @@ -1038,15 +1015,15 @@ function parseMemoryRevisionItem(value: unknown): SubjektivMemoryRevisionItem { }; } -function parseMemoryRevisionRef(value: unknown): SubjektivMemoryRevisionRef { +function parseMemoryChangeRef(value: unknown): SubjektivMemoryChangeRef { const record = strictRecord( value, - ["memory_id", "revision"], - "Memory revision ref", + ["memory_id", "change_id"], + "Memory change ref", ); return { memory_id: requiredIdentifier(record, "memory_id"), - revision: requiredPositiveInteger(record, "revision"), + change_id: requiredIdentifier(record, "change_id"), }; } @@ -1198,13 +1175,6 @@ function requiredNonNegativeInteger( return nonNegativeInteger(record[key], key); } -function requiredPositiveInteger( - record: Record, - key: string, -): number { - return positiveInteger(record[key], key); -} - function positiveInteger(value: unknown, label: string): number { const result = nonNegativeInteger(value, label); if (result === 0) invalid(`${label} must be a positive safe integer`); diff --git a/web/workspace/src/lib/workspace/protocol-validator.test.ts b/web/workspace/src/lib/workspace/protocol-validator.test.ts index 453df9808..8da2b8d27 100644 --- a/web/workspace/src/lib/workspace/protocol-validator.test.ts +++ b/web/workspace/src/lib/workspace/protocol-validator.test.ts @@ -48,7 +48,7 @@ Deno.test('pending submission previews accept new and legacy runtime payloads', snapshot: { topic: 'worker_protocol', data: { worker_id: 'worker-1', events: [{ event: 'pending_submissions_changed', data: { pending: { - revision: 1, notification_count: 0, head_id: 'queued-1', + notification_count: 0, head_id: 'queued-1', submissions: [{ submission_id: 'queued-1', accepted_at_ms: 1, segment_count: 1, byte_len: 10, ...(preview === undefined ? {} : { preview }) }], } } }], @@ -65,7 +65,7 @@ Deno.test('pending notification previews accept new and legacy runtime payloads' snapshot: { topic: 'worker_protocol', data: { worker_id: 'worker-1', events: [{ event: 'pending_submissions_changed', data: { pending: { - revision: 1, notification_count: 1, head_id: 'notification-head', submissions: [], + notification_count: 1, head_id: 'notification-head', submissions: [], ...(previews === undefined ? {} : { notification_previews: previews }), } } }], } }, diff --git a/web/workspace/src/lib/workspace/session-observation.test.ts b/web/workspace/src/lib/workspace/session-observation.test.ts index c1c277e0d..0621e295a 100644 --- a/web/workspace/src/lib/workspace/session-observation.test.ts +++ b/web/workspace/src/lib/workspace/session-observation.test.ts @@ -17,7 +17,7 @@ function assert(condition: boolean, message: string): void { const snapshot: SessionSnapshot = { pending_submissions: { - revision: 0, + notification_count: 0, head_id: null, submissions: [], diff --git a/web/workspace/src/lib/workspace/settings/WorkspaceIdentitySettings.svelte b/web/workspace/src/lib/workspace/settings/WorkspaceIdentitySettings.svelte index 19ed15a69..7e160af18 100644 --- a/web/workspace/src/lib/workspace/settings/WorkspaceIdentitySettings.svelte +++ b/web/workspace/src/lib/workspace/settings/WorkspaceIdentitySettings.svelte @@ -113,7 +113,7 @@ nameError = nameNotice = null; try { const response = await updateWorkspaceMetadata(workspaceId, { - display_name: displayNameDraft, revision: workspaceMetadata.revision, + display_name: displayNameDraft, expected_updated_at: workspaceMetadata.updated_at, }); if (disposed) return; workspaceMetadata = response.workspace; @@ -203,13 +203,13 @@ if (typeof value !== 'object' || value === null) return null; const record = value as Record; if ( - Object.keys(record).sort().join(',') !== 'confirmation,expected_revision,operation_id' || + Object.keys(record).sort().join(',') !== 'confirmation,expected_workspace_updated_at,operation_id' || typeof record.operation_id !== 'string' || record.operation_id.length === 0 || record.operation_id.length > 128 || !/^[A-Za-z0-9_-]+$/.test(record.operation_id) || - typeof record.expected_revision !== 'string' || record.expected_revision.length > 128 || + typeof record.expected_workspace_updated_at !== 'string' || record.expected_workspace_updated_at.length > 128 || typeof record.confirmation !== 'string' || record.confirmation !== workspaceName || record.confirmation.length > 256 ) return null; - return { operation_id: record.operation_id, expected_revision: record.expected_revision, confirmation: record.confirmation }; + return { operation_id: record.operation_id, expected_workspace_updated_at: record.expected_workspace_updated_at, confirmation: record.confirmation }; } catch { return null; } } onMount(() => { @@ -232,7 +232,7 @@ deletionError = null; try { const request = deletionRequest ?? { - operation_id: crypto.randomUUID(), expected_revision: deletionPreflight!.expected_revision, confirmation: deletionConfirmation, + operation_id: crypto.randomUUID(), expected_workspace_updated_at: deletionPreflight!.expected_workspace_updated_at, confirmation: deletionConfirmation, }; deletionRequest = request; sessionStorage.setItem(deletionStorageKey(), JSON.stringify(request)); @@ -285,7 +285,6 @@ Technical details
Source
{workspaceMetadata.source}
-
Revision
{workspaceMetadata.revision}
{#each diagnostics.filter((item) => item.severity === 'info') as diagnostic}

{diagnostic.message}

{/each}
@@ -316,7 +315,6 @@
Key
{signingIdentity.identity.key_id}
Fingerprint
{signingIdentity.identity.public_key_fingerprint}
-
Revision
{signingIdentity.identity.revision}
diff --git a/web/workspace/src/lib/workspace/settings/identity.test-fixtures.ts b/web/workspace/src/lib/workspace/settings/identity.test-fixtures.ts index 2ddacf317..f51dd858a 100644 --- a/web/workspace/src/lib/workspace/settings/identity.test-fixtures.ts +++ b/web/workspace/src/lib/workspace/settings/identity.test-fixtures.ts @@ -6,7 +6,7 @@ export function metadataFixture(workspaceId = "home-owner") { ? "Workspace with a long name — international documentation and distributed development" : "Workspace Settings Review", created_at: "2026-01-01T00:00:00Z", - revision: "2026-01-02T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", source: "server_db", diagnostics: [], }; @@ -16,7 +16,6 @@ export function identityFixture(workspaceId = "home-owner", pending = false) { workspace_id: workspaceId, key_id: "fixture-workspace-key", algorithm: "ed25519", - revision: 1, state: pending ? "pending_provisioning" : "active", created_at: "2026-01-01T00:00:00Z", }; @@ -37,7 +36,6 @@ export function identityFixture(workspaceId = "home-owner", pending = false) { algorithm: identity.algorithm, public_key: publicKey, public_key_fingerprint: fingerprint, - revision: 1, }, }; } @@ -45,7 +43,7 @@ export function deletionFixture(workspaceId = "home-owner") { return { workspace_id: workspaceId, display_name: metadataFixture(workspaceId).display_name, - expected_revision: metadataFixture(workspaceId).revision, + expected_workspace_updated_at: metadataFixture(workspaceId).updated_at, can_delete: true, blockers: [], resources: { @@ -70,7 +68,7 @@ export function settingsFixtureHandler() { const current = names.get(id) ?? metadataFixture(id); if (request.method === "PUT") { const body = await request.json(); - if (body.revision !== current.revision) { + if (body.expected_updated_at !== current.updated_at) { return new Response( "Workspace metadata changed. Reload the saved name.", { status: 409 }, @@ -79,7 +77,7 @@ export function settingsFixtureHandler() { const next = { ...current, display_name: String(body.display_name).trim(), - revision: String(Number.parseInt(current.revision, 10) + 1), + updated_at: new Date(Date.parse(current.updated_at) + 1).toISOString(), }; names.set(id, next); return Response.json({ workspace: next, diagnostics: [] }); @@ -115,7 +113,7 @@ export function settingsFixtureHandler() { ...(names.has(deletion[1]) ? { display_name: names.get(deletion[1])!.display_name, - expected_revision: names.get(deletion[1])!.revision, + expected_workspace_updated_at: names.get(deletion[1])!.updated_at, } : {}), }); diff --git a/web/workspace/src/lib/workspace/settings/model.ts b/web/workspace/src/lib/workspace/settings/model.ts index 7b7e95931..1e9cc3d0e 100644 --- a/web/workspace/src/lib/workspace/settings/model.ts +++ b/web/workspace/src/lib/workspace/settings/model.ts @@ -49,8 +49,8 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ "Edit the Server-owned virtual Decodal source tree through one native/WASM toolchain contract.", bullets: [ "Virtual paths and imports resolve inside the committed Workspace tree, never from browser or Server host paths.", - "Browser analysis is advisory; Server evaluation is required before an atomic revision commit.", - "Profile launch data is projected from this active revision; remaining Skill, Prompt, and Plugin consumers migrate in their follow-up cutovers.", + "Browser analysis is advisory; Server evaluation is required before an atomic content-digest CAS commit.", + "Profile, Skill, Prompt, and Plugin data are projected from the active immutable source tree.", ], }, { @@ -82,11 +82,11 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ label: "Profile Sources", status: "editable", summary: - "Inspect the Profile launch projection derived from the active Workspace configuration revision.", + "Inspect the Profile launch projection derived from the active Workspace configuration tree.", bullets: [ "Selectors are source-qualified (builtin:* or project:*); raw profile source paths, archive content, archive digests, resource handles, and runtime tokens are not exposed.", "Profile declarations and sources are edited only through the shared Workspace configuration editor and evaluate-before-commit contract.", - "Launch candidates and Profile archives carry the same active config revision, tree digest, and projection digest.", + "Launch candidates and Profile archives carry the same active tree digest and projection digest.", ], }, { diff --git a/web/workspace/src/lib/workspace/settings/profile-api.ts b/web/workspace/src/lib/workspace/settings/profile-api.ts index 3d4ece6fc..c5310fa32 100644 --- a/web/workspace/src/lib/workspace/settings/profile-api.ts +++ b/web/workspace/src/lib/workspace/settings/profile-api.ts @@ -146,7 +146,7 @@ export function parseWorkspaceMetadataSettingsResponse( "workspace_id", "display_name", "created_at", - "revision", + "updated_at", "source", "diagnostics", ], @@ -157,7 +157,7 @@ export function parseWorkspaceMetadataSettingsResponse( workspace_id: stringValue(item.workspace_id, "Workspace metadata"), display_name: stringValue(item.display_name, "Workspace metadata"), created_at: stringValue(item.created_at, "Workspace metadata"), - revision: stringValue(item.revision, "Workspace metadata"), + updated_at: stringValue(item.updated_at, "Workspace metadata"), source: stringValue(item.source, "Workspace metadata"), diagnostics: arrayValue( item.diagnostics, @@ -237,7 +237,6 @@ function parseWorkspaceProfileSourceSummary( "content_digest", "provenance", "editable", - "revision", "size_bytes", "diagnostics", ], @@ -269,7 +268,6 @@ function parseWorkspaceProfileSourceSummary( content_digest: stringValue(item.content_digest, "Profile source catalog"), provenance: provenance as WorkspaceProfileSourceProvenance, editable: booleanValue(item.editable, "Profile source catalog"), - revision: stringValue(item.revision, "Profile source catalog"), size_bytes: sizeBytes, diagnostics: arrayValue( item.diagnostics, @@ -285,14 +283,12 @@ export function parseProfileSettingsResponse( const item = record(value, "Profile settings"); exactKeys( item, - ["workspace_id", "registry_revision", "profiles", "sources", "diagnostics"], - ["config_revision", "tree_digest", "projection_digest", "default_profile"], + ["workspace_id", "profiles", "sources", "diagnostics"], + ["tree_digest", "projection_digest", "default_profile"], "Profile settings", ); return { workspace_id: stringValue(item.workspace_id, "Profile settings"), - registry_revision: stringValue(item.registry_revision, "Profile settings"), - config_revision: optionalRevision(item.config_revision, "Profile settings"), tree_digest: optionalString(item.tree_digest, "Profile settings"), projection_digest: optionalString( item.projection_digest, @@ -330,7 +326,7 @@ export function parseWorkspaceSigningIdentityResponse( const identityItem = record(item.identity, "Workspace signing identity"); exactKeys( identityItem, - ["workspace_id", "key_id", "algorithm", "revision", "state", "created_at"], + ["workspace_id", "key_id", "algorithm", "state", "created_at"], ["public_key", "public_key_fingerprint", "provisioned_at"], "Workspace signing identity", ); @@ -345,16 +341,6 @@ export function parseWorkspaceSigningIdentityResponse( 502, ); } - const revision = optionalRevision( - identityItem.revision, - "Workspace signing identity", - ); - if (revision === undefined || revision === null || revision < 1) { - throw new ProfileApiError( - "Workspace signing identity returned an invalid response.", - 502, - ); - } const publicKey = optionalBoundedString( identityItem.public_key, "Workspace signing identity", @@ -392,7 +378,6 @@ export function parseWorkspaceSigningIdentityResponse( ...(fingerprint === undefined || fingerprint === null ? {} : { public_key_fingerprint: fingerprint }), - revision, state: state as WorkspaceSigningIdentityState, created_at: boundedStringValue( identityItem.created_at, @@ -419,24 +404,10 @@ export function parseWorkspaceSigningIdentityResponse( "algorithm", "public_key", "public_key_fingerprint", - "revision", ], [], "Workspace public identity bundle", ); - const bundleRevision = optionalRevision( - bundle.revision, - "Workspace public identity bundle", - ); - if ( - bundleRevision === undefined || bundleRevision === null || - bundleRevision < 1 - ) { - throw new ProfileApiError( - "Workspace public identity bundle returned an invalid response.", - 502, - ); - } publicBundle = { workspace_id: boundedStringValue( bundle.workspace_id, @@ -468,7 +439,6 @@ export function parseWorkspaceSigningIdentityResponse( "Workspace public identity bundle", 128, ), - revision: bundleRevision, }; } if ( @@ -478,8 +448,7 @@ export function parseWorkspaceSigningIdentityResponse( publicBundle.key_id !== identity.key_id || publicBundle.algorithm !== identity.algorithm || publicBundle.public_key !== identity.public_key || - publicBundle.public_key_fingerprint !== identity.public_key_fingerprint || - publicBundle.revision !== identity.revision + publicBundle.public_key_fingerprint !== identity.public_key_fingerprint ) ) { throw new ProfileApiError( diff --git a/web/workspace/src/lib/workspace/settings/workdir-revision.test.ts b/web/workspace/src/lib/workspace/settings/workdir-checkout.test.ts similarity index 89% rename from web/workspace/src/lib/workspace/settings/workdir-revision.test.ts rename to web/workspace/src/lib/workspace/settings/workdir-checkout.test.ts index d26299d57..0262296fc 100644 --- a/web/workspace/src/lib/workspace/settings/workdir-revision.test.ts +++ b/web/workspace/src/lib/workspace/settings/workdir-checkout.test.ts @@ -1,5 +1,5 @@ import type { WorkingDirectorySummary } from "../sidebar/types.ts"; -import { formatCurrentWorkdirRevision } from "./workdir-revision.ts"; +import { formatCurrentWorkdirCheckout } from "./workdir-checkout.ts"; declare const Deno: { test(name: string, fn: () => Promise | void): void; @@ -32,7 +32,7 @@ function workdir( Deno.test("Git detached Workdir shows only its current ref", () => { assertEquals( - formatCurrentWorkdirRevision( + formatCurrentWorkdirCheckout( workdir(null, "0123456789abcdef0123456789abcdef01234567"), "git", ), @@ -42,7 +42,7 @@ Deno.test("Git detached Workdir shows only its current ref", () => { Deno.test("Git Workdir with a selector shows selector at current ref", () => { assertEquals( - formatCurrentWorkdirRevision( + formatCurrentWorkdirCheckout( workdir("feature/current", "fedcba9876543210fedcba9876543210fedcba98"), "git", ), @@ -52,7 +52,7 @@ Deno.test("Git Workdir with a selector shows selector at current ref", () => { Deno.test("non-Git Workdir does not receive Git hash formatting", () => { assertEquals( - formatCurrentWorkdirRevision( + formatCurrentWorkdirCheckout( workdir("snapshot", "revision-value"), "archive", ), diff --git a/web/workspace/src/lib/workspace/settings/workdir-revision.ts b/web/workspace/src/lib/workspace/settings/workdir-checkout.ts similarity index 94% rename from web/workspace/src/lib/workspace/settings/workdir-revision.ts rename to web/workspace/src/lib/workspace/settings/workdir-checkout.ts index 8c4a43e99..be02e0596 100644 --- a/web/workspace/src/lib/workspace/settings/workdir-revision.ts +++ b/web/workspace/src/lib/workspace/settings/workdir-checkout.ts @@ -1,6 +1,6 @@ import type { WorkingDirectorySummary } from "../sidebar/types.ts"; -export function formatCurrentWorkdirRevision( +export function formatCurrentWorkdirCheckout( workdir: WorkingDirectorySummary, repositoryProvider: string | null | undefined, ): string { diff --git a/web/workspace/src/lib/workspace/sidebar/worker-actions.ts b/web/workspace/src/lib/workspace/sidebar/worker-actions.ts index fedba2790..26bb28bc9 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-actions.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-actions.ts @@ -239,7 +239,7 @@ export async function deleteSidebarWorker( method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ - expected_plan_revision: plan.revision, + expected_plan_digest: plan.digest, worker_target_ids: [candidate.target_id], workdir_target_ids: [], diff --git a/web/workspace/src/lib/workspace/sidebar/worker-restore-store.browser.spec.ts b/web/workspace/src/lib/workspace/sidebar/worker-restore-store.browser.spec.ts index 4ed94105d..d0a30d095 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-restore-store.browser.spec.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-restore-store.browser.spec.ts @@ -138,10 +138,11 @@ describe("shared catalog observation refresh", () => { latest = state ); mock.handlers.onFrame(snapshot()); - const before = latest.observationVersion; + const before = latest.catalogRequest; const old = pending.at(-1)!; mock.handlers.onFrame(snapshot("idle", "generation-2")); - expect(latest.observationVersion).toBeGreaterThan(before); + expect(latest.catalogRequest).not.toBe(before); + expect(before?.aborted).toBe(true); expect(latest.workers[0].lifecycleState).toBe("idle"); pending.at(-1)!(response("idle", "generation-2")); await settle(); @@ -163,9 +164,10 @@ describe("shared catalog observation refresh", () => { ); mock.handlers.onFrame(snapshot()); await settle(); - const before = latest.observationVersion; + const before = latest.catalogRequest; await refreshWorkspaceWorkers("team"); - expect(latest.observationVersion).toBeGreaterThan(before); + expect(latest.catalogRequest).not.toBe(before); + expect(before?.aborted).toBe(true); expect(fetchMock.mock.calls.every(([url]) => !url.endsWith("/restore"))) .toBe(true); }); diff --git a/web/workspace/src/lib/workspace/sidebar/worker-stop.browser.spec.ts b/web/workspace/src/lib/workspace/sidebar/worker-stop.browser.spec.ts index c12878e54..901bdf7d6 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-stop.browser.spec.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-stop.browser.spec.ts @@ -369,7 +369,7 @@ test("sidebar Delete cannot proceed to execution when its cleanup plan arrives a workspace_id: "stop-test", runtime_id: "runtime-a", generated_at: "2026-10-06T00:00:00Z", - revision: "old-plan", + digest: "old-digest", workdirs: [], diagnostics: [], diff --git a/web/workspace/src/lib/workspace/sidebar/worker-subscription.ts b/web/workspace/src/lib/workspace/sidebar/worker-subscription.ts index 430846039..d7c587dce 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-subscription.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-subscription.ts @@ -34,8 +34,8 @@ export type WorkspaceWorkersState = { workers: SidebarWorker[]; /** Full catalog metadata; null until fetched. Never substitutes sidebar display state. */ catalogWorkers: Worker[] | null; - /** Changes immediately when an observation invalidates dependent cleanup plans. */ - observationVersion: number; + /** Actual catalog GET identity; changes before dependent cleanup plans can settle. */ + catalogRequest: AbortSignal | null; catalogRefreshing: boolean; }; @@ -108,7 +108,7 @@ export function workspaceWorkersStore( loading: true, workers: [], catalogWorkers: null, - observationVersion: 0, + catalogRequest: null, catalogRefreshing: true, }, (set) => { @@ -117,7 +117,7 @@ export function workspaceWorkersStore( loading: false, workers: [], catalogWorkers: [], - observationVersion: 0, + catalogRequest: null, catalogRefreshing: false, }); return; @@ -130,9 +130,7 @@ export function workspaceWorkersStore( let loading = true; let disposed = false; let catalogWorkers: Worker[] | null = null; - let observationVersion = 0; let catalogRefreshing = true; - let refreshSequence = 0; let catalogAbort: AbortController | null = null; const publish = (): boolean => { if (disposed) return false; @@ -144,7 +142,7 @@ export function workspaceWorkersStore( loading, workers, catalogWorkers, - observationVersion, + catalogRequest: catalogAbort?.signal ?? null, catalogRefreshing, }); return true; @@ -160,7 +158,7 @@ export function workspaceWorkersStore( loading: false, workers, catalogWorkers, - observationVersion, + catalogRequest: catalogAbort?.signal ?? null, catalogRefreshing, }); return false; @@ -168,8 +166,6 @@ export function workspaceWorkersStore( }; const refreshCatalog = async () => { if (disposed) return; - const sequence = ++refreshSequence; - const version = ++observationVersion; catalogAbort?.abort(); const abort = new AbortController(); catalogAbort = abort; @@ -183,8 +179,7 @@ export function workspaceWorkersStore( { diagnosticLabel: "Worker API", maxResponseBytes: 8 * 1024 * 1024 }, ); if ( - disposed || sequence !== refreshSequence || - version !== observationVersion + disposed || abort.signal.aborted || catalogAbort !== abort ) return; if (!result.data || result.data.workspace_id !== workspaceId) { reportWorkerFailure( diff --git a/web/workspace/src/lib/workspace/skills/api.ts b/web/workspace/src/lib/workspace/skills/api.ts index 255cc7150..48ca307cc 100644 --- a/web/workspace/src/lib/workspace/skills/api.ts +++ b/web/workspace/src/lib/workspace/skills/api.ts @@ -167,14 +167,10 @@ function parseCatalogEntry( function parseProjection(value: unknown): SkillProjectionIdentity { const record = strictObject( value, - ["config_revision", "tree_digest"], + ["tree_digest"], "Skill projection identity", ); return { - config_revision: safeInteger( - record.config_revision, - "Skill config revision", - ), tree_digest: boundedString( record.tree_digest, "Skill tree digest", @@ -205,14 +201,12 @@ function parseProvenance( "kind", "id", "virtual_path", - "revision", "source_digest", "tree_digest", ], "Skill provenance", [ "virtual_path", - "revision", "source_digest", "tree_digest", ], @@ -239,9 +233,6 @@ function parseProvenance( "Skill provenance tree digest", SKILL_API_LIMITS.maxDigestBytes, ); - const revision = record.revision === undefined - ? undefined - : safeInteger(record.revision, "Skill provenance revision"); if ( !id.startsWith(`${kind}:`) || virtualPath === undefined || @@ -250,17 +241,14 @@ function parseProvenance( throw contractError("invalid Skill provenance"); } if (kind === "builtin") { - if (revision !== undefined || treeDigest !== undefined) { + if (treeDigest !== undefined) { throw contractError("invalid built-in Skill provenance"); } } else { - if (revision === undefined || treeDigest === undefined) { + if (treeDigest === undefined) { throw contractError("incomplete Workspace Skill provenance"); } - if ( - revision !== projection.config_revision || - treeDigest !== projection.tree_digest - ) { + if (treeDigest !== projection.tree_digest) { throw contractError("stale Workspace Skill projection"); } } @@ -269,7 +257,6 @@ function parseProvenance( kind, id, virtual_path: virtualPath, - revision, source_digest: sourceDigest, tree_digest: treeDigest, }; diff --git a/web/workspace/src/routes/design-lab/workspace-web-ux/+page.svelte b/web/workspace/src/routes/design-lab/workspace-web-ux/+page.svelte index ff4f44981..fa7008ad7 100644 --- a/web/workspace/src/routes/design-lab/workspace-web-ux/+page.svelte +++ b/web/workspace/src/routes/design-lab/workspace-web-ux/+page.svelte @@ -228,7 +228,7 @@
Technical details
-
Revision
rev_01J8WZ7C8AV6P3Y2N0M4KQ9B1F
+
Observed
Digest
sha256:2f8d9bb168afb7b19f447463f3fbe8b4728d8e87a64eaf1afbea1ca4d4c41f21
diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/+page.svelte index 48a6bd335..5658d9a70 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/+page.svelte @@ -45,7 +45,7 @@ const workspaceId = subjectWorkspaceId; const subjectId = subject.id; const request = { - expected_behavior_revision: subject.behavior_revision, + expected_behavior_md: subject.behavior_md, behavior_md: behaviorDraft, }; const isCurrent = () => generation === routeGeneration && @@ -94,11 +94,12 @@ return workspaceRoute(data.workspaceId, '/memory'); } - function memoryHref(memoryId: string): string { - return workspaceRoute( + function memoryHref(memoryId: string, changeId?: string): string { + const path = workspaceRoute( data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}/${encodeURIComponent(memoryId)}`, ); + return changeId ? `${path}?change_id=${encodeURIComponent(changeId)}` : path; } function memoryPageHref(cursor?: string | null): string { @@ -192,8 +193,8 @@
Subject ID
{subject.id}
-
Subject store revision
-
{subject.store_revision}Internal change number for committed Memories; not a Memory count or content-quality score.
+
Memory fingerprint
+
{subject.memory_fingerprint}Content condition for current committed Memories; not a Memory count or content-quality score.
Last changed
@@ -218,7 +219,7 @@ {/if} -

The Host injects this text verbatim for connected Workers, separately from generated Memory. Saving confirms storage only. Connected Workers check for the latest revision before later model requests; this page does not report that application.

+

The Host injects this text verbatim for connected Workers, separately from generated Memory. Saving confirms storage only. Connected Workers check for the latest text before later model requests; this page does not report that application.

{#if editingBehavior}
{ event.preventDefault(); void saveBehavior(); }}>
+ {#if runtimeFingerprint}
@@ -321,7 +330,7 @@
{:else}
+ + +

Copy the Runtime-issued workspace_trust_id from trust-workspace add/show for the current enrollment. Do not use a key fingerprint or a revoked enrollment ID.