feat: replace revision counters with domain state contracts

Use content digests, key fingerprints, binding and trust identities, immutable mutation receipts, and observation tokens instead of state-update ordinals across API, storage, Runtime, and Web contracts.

Preserve historical replay and secret material through frozen migrations; preflight uncertain removals before cutover, resume provisioning receipts, validate encrypted migration candidates, and align fresh/upgraded schemas. Include reviewed regression fixes, generated artifacts, and validation evidence.
This commit is contained in:
2026-10-10 15:55:20 +09:00
parent 72ce817de4
commit c78f7f7d80
358 changed files with 25965 additions and 12069 deletions
Generated
+2
View File
@@ -2754,6 +2754,7 @@ dependencies = [
"serde_json", "serde_json",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"ticket",
"uuid", "uuid",
] ]
@@ -4788,6 +4789,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"server-api", "server-api",
"sha2 0.11.0",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"uuid", "uuid",
+1 -1
View File
@@ -172,5 +172,5 @@ webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation
[workspace.metadata.wip-upstream] [workspace.metadata.wip-upstream]
repository = "https://gitea.hareworks.net/Hare/wip-rs" repository = "https://gitea.hareworks.net/Hare/wip-rs"
version = "0.2.0" version = "0.2.0"
reference-revision = "6086f3c5ef10aa1464ce9c824750d7f667217bfd" reference-commit = "6086f3c5ef10aa1464ce9c824750d7f667217bfd"
source = "crates.io" source = "crates.io"
+19 -19
View File
@@ -3,11 +3,11 @@ use server_api::{
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse, CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest, ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest,
RuntimeTrustKeyRevealResponse, SubjektivMemoryDetailQuery, SubjektivMemoryListQuery, RuntimeTrustKeyRevealResponse, SubjektivMemoryChangesQuery, SubjektivMemoryDetailQuery,
SubjektivMemoryListRevisionsResponse, SubjektivMemoryQueryResponse, SubjektivMemoryListChangesResponse, SubjektivMemoryListQuery, SubjektivMemoryQueryResponse,
SubjektivMemoryReadResponse, SubjektivMemoryRevisionsQuery, SubjektivResidentSurfaceResponse, SubjektivMemoryReadResponse, SubjektivResidentSurfaceResponse, SubjektivSubjectListQuery,
SubjektivSubjectListQuery, SubjektivSubjectListResponse, SubjektivSubjectResponse, SubjektivSubjectListResponse, SubjektivSubjectResponse, WorkerLaunchOptionsResponse,
WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource, WorkspaceRuntimeDetail, WorkspaceRuntimeResource,
}; };
use ticket::{ use ticket::{
MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation,
@@ -168,7 +168,7 @@ impl BackendWorkspaceProductClient {
}; };
Ok(ticket::TicketStateUpdate { Ok(ticket::TicketStateUpdate {
operation_key: uuid::Uuid::now_v7().to_string(), operation_key: uuid::Uuid::now_v7().to_string(),
expected_item_revision: ticket::ticket_item_revision(&current), expected_content_digest: ticket::ticket_content_digest(&current),
expected_state: parse(&change.from)?, expected_state: parse(&change.from)?,
state: parse(&change.to)?, state: parse(&change.to)?,
reason: if change.body.as_str().is_empty() { reason: if change.body.as_str().is_empty() {
@@ -208,7 +208,7 @@ impl BackendWorkspaceProductClient {
let current = self.show_ticket(id)?; let current = self.show_ticket(id)?;
let request = ticket::TicketCompletion { let request = ticket::TicketCompletion {
operation_key: uuid::Uuid::now_v7().to_string(), operation_key: uuid::Uuid::now_v7().to_string(),
expected_item_revision: ticket::ticket_item_revision(&current), expected_content_digest: ticket::ticket_content_digest(&current),
expected_state: current.meta.workflow_state, expected_state: current.meta.workflow_state,
reason: resolution.as_str().to_string(), reason: resolution.as_str().to_string(),
references: Vec::new(), references: Vec::new(),
@@ -521,19 +521,19 @@ impl BackendWorkspaceProductClient {
}) })
} }
pub fn list_subjektiv_memory_revisions( pub fn list_subjektiv_memory_changes(
&self, &self,
subject_id: &str, subject_id: &str,
memory_id: &str, memory_id: &str,
query: &SubjektivMemoryRevisionsQuery, query: &SubjektivMemoryChangesQuery,
) -> Result<SubjektivMemoryListRevisionsResponse, BackendWorkspaceClientError> { ) -> Result<SubjektivMemoryListChangesResponse, BackendWorkspaceClientError> {
let workspace_id = self.workspace_id.clone(); let workspace_id = self.workspace_id.clone();
let subject_id = subject_id.to_string(); let subject_id = subject_id.to_string();
let memory_id = memory_id.to_string(); let memory_id = memory_id.to_string();
let query = query.clone(); let query = query.clone();
self.generated(move |client| async move { self.generated(move |client| async move {
client client
.subjektiv_memory_revisions(workspace_id, subject_id, memory_id, query) .subjektiv_memory_changes(workspace_id, subject_id, memory_id, query)
.await .await
}) })
} }
@@ -997,7 +997,7 @@ mod tests {
} }
#[test] #[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 temp = tempfile::tempdir().unwrap();
let backend = let backend =
ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a") ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a")
@@ -1021,8 +1021,8 @@ mod tests {
let body: serde_json::Value = let body: serde_json::Value =
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap(); serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
assert_eq!( assert_eq!(
body["expected_item_revision"], body["expected_content_digest"],
ticket::ticket_item_revision(&snapshot) ticket::ticket_content_digest(&snapshot)
); );
assert_eq!(body["expected_state"], "ready"); assert_eq!(body["expected_state"], "ready");
assert_eq!(body["state"], "done"); assert_eq!(body["state"], "done");
@@ -1057,8 +1057,8 @@ mod tests {
let body: serde_json::Value = let body: serde_json::Value =
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap(); serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
assert_eq!( assert_eq!(
body["expected_item_revision"], body["expected_content_digest"],
ticket::ticket_item_revision(&snapshot) ticket::ticket_content_digest(&snapshot)
); );
assert_eq!(body["expected_state"], "planning"); assert_eq!(body["expected_state"], "planning");
assert_eq!(body["reason"], "The request was withdrawn"); assert_eq!(body["reason"], "The request was withdrawn");
@@ -1070,7 +1070,7 @@ mod tests {
fn ticket_backend_completion_forwards_cas_judgment_and_backend_conflicts() { fn ticket_backend_completion_forwards_cas_judgment_and_backend_conflicts() {
let (base_url, request, handle) = one_response_server( let (base_url, request, handle) = one_response_server(
"409 Conflict", "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( let client = BackendWorkspaceProductClient::new_with_access_token(
base_url, base_url,
@@ -1083,7 +1083,7 @@ mod tests {
"T-718", "T-718",
ticket::TicketCompletion { ticket::TicketCompletion {
operation_key: "research-done".into(), operation_key: "research-done".into(),
expected_item_revision: "ticket:1".into(), expected_content_digest: "ticket:1".into(),
expected_state: ticket::TicketWorkflowState::Planning, expected_state: ticket::TicketWorkflowState::Planning,
reason: "Research findings recorded".into(), reason: "Research findings recorded".into(),
references: Vec::new(), references: Vec::new(),
@@ -1100,7 +1100,7 @@ mod tests {
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap(); serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
assert_eq!(body["state"], "done"); assert_eq!(body["state"], "done");
assert_eq!(body["operation_key"], "research-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["expected_state"], "planning");
assert_eq!(body["reason"], "Research findings recorded"); assert_eq!(body["reason"], "Research findings recorded");
assert!(body.get("merge_request_ids").is_none()); assert!(body.get("merge_request_ids").is_none());
+42 -19
View File
@@ -230,27 +230,51 @@ mod tests {
use config_source::{ConfigContentType, ConfigEntry}; use config_source::{ConfigContentType, ConfigEntry};
fn snapshot() -> ConfigTreeSnapshot { fn snapshot() -> ConfigTreeSnapshot {
ConfigTreeSnapshot::from_entries( ConfigTreeSnapshot::from_entries([
7, ConfigEntry::new(
[ VirtualPath::parse("main.dcdl").unwrap(),
ConfigEntry::new( ConfigContentType::Decodal,
VirtualPath::parse("main.dcdl").unwrap(), "{}",
ConfigContentType::Decodal, )
"{}", .unwrap(),
) // A same-suffix workspace file must not be an unknown builtin fallback.
.unwrap(), ConfigEntry::new(
// A same-suffix workspace file must not be an unknown builtin fallback. VirtualPath::parse("profiles/missing.dcdl").unwrap(),
ConfigEntry::new( ConfigContentType::Decodal,
VirtualPath::parse("profiles/missing.dcdl").unwrap(), "{}",
ConfigContentType::Decodal, )
"{}", .unwrap(),
) ])
.unwrap(),
],
)
.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] #[test]
fn wasm_completion_boundary_preserves_read_only_builtin_detail() { fn wasm_completion_boundary_preserves_read_only_builtin_detail() {
let snapshot = snapshot(); let snapshot = snapshot();
@@ -301,7 +325,6 @@ mod tests {
}) })
.unwrap(); .unwrap();
assert_eq!(diagnostic.path, path); assert_eq!(diagnostic.path, path);
assert_eq!(diagnostic.revision, 7);
assert_eq!(diagnostic.tree_digest, snapshot.digest); assert_eq!(diagnostic.tree_digest, snapshot.digest);
assert!( assert!(
diagnostic diagnostic
@@ -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 }, };
@@ -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, };
@@ -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, };
@@ -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;
@@ -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<ConfigSchemaContribution>, source: string, fingerprint: string, };
+344 -169
View File
@@ -27,9 +27,16 @@ pub const MAX_TOTAL_BYTES: usize = 4 * 1024 * 1024;
pub const MAX_PATH_BYTES: usize = 512; pub const MAX_PATH_BYTES: usize = 512;
pub const MAX_IMPORT_DEPTH: usize = 32; 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); pub struct VirtualPath(String);
impl<'de> Deserialize<'de> for VirtualPath {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
Self::parse(value).map_err(serde::de::Error::custom)
}
}
impl VirtualPath { impl VirtualPath {
pub fn parse(value: impl AsRef<str>) -> Result<Self, ConfigTreeError> { pub fn parse(value: impl AsRef<str>) -> Result<Self, ConfigTreeError> {
let value = value.as_ref(); 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 { pub struct ConfigTreeSnapshot {
#[ts(type = "number")] /// SHA-256 of ordered virtual paths, content types and complete source bytes.
pub revision: u64, /// Identifies tree content, not an edit sequence or freshness counter.
pub digest: String, pub digest: String,
pub entries: BTreeMap<VirtualPath, ConfigEntry>, pub entries: BTreeMap<VirtualPath, ConfigEntry>,
} }
impl<'de> Deserialize<'de> for ConfigTreeSnapshot {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
struct SnapshotContent {
digest: String,
entries: BTreeMap<VirtualPath, ConfigEntry>,
}
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 { impl ConfigTreeSnapshot {
pub fn empty() -> Self { 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( pub fn from_entries(
revision: u64,
entries: impl IntoIterator<Item = ConfigEntry>, entries: impl IntoIterator<Item = ConfigEntry>,
) -> Result<Self, ConfigTreeError> { ) -> Result<Self, ConfigTreeError> {
let mut ordered = BTreeMap::new(); let mut ordered = BTreeMap::new();
let mut total = 0usize; let mut total = 0usize;
for entry in entries { for entry in entries {
validate_workspace_path(&entry.path)?; 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 { if entry.content.len() > MAX_ENTRY_BYTES {
return Err(ConfigTreeError::LimitExceeded("entry bytes")); return Err(ConfigTreeError::LimitExceeded("entry bytes"));
} }
@@ -306,12 +332,25 @@ impl ConfigTreeSnapshot {
} }
let digest = snapshot_digest(&ordered); let digest = snapshot_digest(&ordered);
Ok(Self { Ok(Self {
revision,
digest, digest,
entries: ordered, 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> { pub fn list_prefix(&self, prefix: Option<&VirtualPath>) -> Vec<&ConfigEntry> {
self.entries self.entries
.values() .values()
@@ -364,7 +403,11 @@ impl ConfigTreeSnapshot {
changes 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, ConfigTreeError> { pub fn apply(&self, changes: &[ConfigTreeChange]) -> Result<Self, ConfigTreeError> {
self.validate()?;
if changes.len() > MAX_CHANGE_COUNT { if changes.len() > MAX_CHANGE_COUNT {
return Err(ConfigTreeError::LimitExceeded("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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
pub struct ConfigDiagnostic { pub struct ConfigDiagnostic {
pub path: VirtualPath, pub path: VirtualPath,
#[ts(type = "number")]
pub revision: u64,
pub tree_digest: String, pub tree_digest: String,
pub kind: String, pub kind: String,
pub span: ConfigSpan, pub span: ConfigSpan,
@@ -1436,7 +1477,6 @@ impl SnapshotEnvironment {
) -> ConfigDiagnostic { ) -> ConfigDiagnostic {
ConfigDiagnostic { ConfigDiagnostic {
path, path,
revision: self.snapshot.revision,
tree_digest: self.snapshot.digest.clone(), tree_digest: self.snapshot.digest.clone(),
kind: kind.into(), kind: kind.into(),
span: ConfigSpan { span: ConfigSpan {
@@ -1572,13 +1612,39 @@ impl SnapshotImportLoader {
import_edges: BTreeMap::new(), 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<VirtualPath, ConfigTreeError> {
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( pub fn resolve(
&self, &self,
current_key: Option<&str>, current_key: Option<&str>,
specifier: &str, specifier: &str,
) -> Result<VirtualPath, ConfigTreeError> { ) -> Result<VirtualPath, ConfigTreeError> {
let current = current_key let current = current_key
.map(VirtualPath::parse) .map(|key| self.source_path(key))
.transpose()? .transpose()?
.ok_or_else(|| ConfigTreeError::InvalidImport(specifier.into()))?; .ok_or_else(|| ConfigTreeError::InvalidImport(specifier.into()))?;
resolve_import(&current, specifier) resolve_import(&current, specifier)
@@ -1594,11 +1660,10 @@ impl ImportLoader for SnapshotImportLoader {
let path = self.resolve(current_key, specifier).map_err(import_error)?; let path = self.resolve(current_key, specifier).map_err(import_error)?;
// Guard the import graph before returning a module to Decodal. In particular, // Guard the import graph before returning a module to Decodal. In particular,
// cycles hidden inside lazy object fields must not recurse during materialization. // cycles hidden inside lazy object fields must not recurse during materialization.
let current = current_key let current = self
.expect("resolve requires a source context") .source_path(current_key.expect("resolve requires a source context"))
.split("@sha256:") .map_err(import_error)?
.next() .to_string();
.unwrap();
if self if self
.import_edges .import_edges
.entry(current.to_string()) .entry(current.to_string())
@@ -1614,7 +1679,7 @@ impl ImportLoader for SnapshotImportLoader {
// A language service can keep using its loader after a diagnostic. // A language service can keep using its loader after a diagnostic.
// Only successful edges may be cached as already validated. // Only successful edges may be cached as already validated.
self.import_edges self.import_edges
.get_mut(current) .get_mut(&current)
.unwrap() .unwrap()
.remove(path.as_str()); .remove(path.as_str());
return Err(diagnostic); return Err(diagnostic);
@@ -1651,7 +1716,10 @@ impl ImportLoader for SnapshotImportLoader {
return Err(import_error(ConfigTreeError::LimitExceeded("total bytes"))); return Err(import_error(ConfigTreeError::LimitExceeded("total bytes")));
} }
return Ok(LoadedImport::source( 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(), path.as_str(),
resource.source, resource.source,
)); ));
@@ -1747,9 +1815,16 @@ fn validate_import_graph(
fn snapshot_import_cache_key(entry: &ConfigEntry) -> String { fn snapshot_import_cache_key(entry: &ConfigEntry) -> String {
// The source id remains the virtual path for diagnostics and relative-import // The source id remains the virtual path for diagnostics and relative-import
// resolution. The cache identity also includes immutable source content so // resolution. Cache identity binds path, import interpretation and source bytes,
// equal paths from different revisions cannot alias in an Engine cache. // so changed content or content type cannot alias in an Engine cache.
format!("{}@{}", entry.path, entry.content_digest) // 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 { fn is_builtin_path(path: &VirtualPath) -> bool {
@@ -1881,7 +1956,6 @@ fn project_engine_diagnostic(
.unwrap_or(fallback_path); .unwrap_or(fallback_path);
ConfigDiagnostic { ConfigDiagnostic {
path, path,
revision: snapshot.revision,
tree_digest: snapshot.digest.clone(), tree_digest: snapshot.digest.clone(),
span: ConfigSpan { span: ConfigSpan {
start_byte: diagnostic.span.start, start_byte: diagnostic.span.start,
@@ -1911,7 +1985,6 @@ fn project_diagnostic(
) -> ConfigDiagnostic { ) -> ConfigDiagnostic {
ConfigDiagnostic { ConfigDiagnostic {
path: fallback_path, path: fallback_path,
revision: snapshot.revision,
tree_digest: snapshot.digest.clone(), tree_digest: snapshot.digest.clone(),
kind: diagnostic_kind(diagnostic.kind).to_string(), kind: diagnostic_kind(diagnostic.kind).to_string(),
span: ConfigSpan { span: ConfigSpan {
@@ -2174,6 +2247,12 @@ pub enum ConfigTreeError {
NotFound(VirtualPath), NotFound(VirtualPath),
#[error("virtual config entry changed: {0}")] #[error("virtual config entry changed: {0}")]
EntryConflict(VirtualPath), 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}")] #[error("virtual config path changed more than once in one candidate: {0}")]
PathChangedMoreThanOnce(VirtualPath), PathChangedMoreThanOnce(VirtualPath),
#[error("duplicate virtual config path")] #[error("duplicate virtual config path")]
@@ -2254,13 +2333,10 @@ mod tests {
#[test] #[test]
fn candidate_changes_are_atomic_ordered_and_conflict_checked() { fn candidate_changes_are_atomic_ordered_and_conflict_checked() {
let base = ConfigTreeSnapshot::from_entries( let base = ConfigTreeSnapshot::from_entries([
7, entry("profiles/a.dcdl", "{ a = 1; }"),
[ entry("shared.dcdl", "{}"),
entry("profiles/a.dcdl", "{ a = 1; }"), ])
entry("shared.dcdl", "{}"),
],
)
.unwrap(); .unwrap();
let updated = base let updated = base
.apply(&[ .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::<Vec<_>>(),
["digest", "entries"]
);
assert_eq!(
serde_json::from_value::<ConfigTreeSnapshot>(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::<ConfigTreeSnapshot>(wire).is_err(),
"{mutation}"
);
}
}
#[test] #[test]
fn snapshot_digest_is_deterministic() { fn snapshot_digest_is_deterministic() {
let left = ConfigTreeSnapshot::from_entries( let left = ConfigTreeSnapshot::from_entries([
1, entry("z.dcdl", "{}"),
[entry("z.dcdl", "{}"), entry("a.dcdl", "{ x = 1; }")], entry("a.dcdl", "{ x = 1; }"),
) ])
.unwrap(); .unwrap();
let right = ConfigTreeSnapshot::from_entries( let right = ConfigTreeSnapshot::from_entries([
99, entry("a.dcdl", "{ x = 1; }"),
[entry("a.dcdl", "{ x = 1; }"), entry("z.dcdl", "{}")], entry("z.dcdl", "{}"),
) ])
.unwrap(); .unwrap();
assert_eq!(left.digest, right.digest); assert_eq!(left.digest, right.digest);
} }
#[test] #[test]
fn entrypoint_can_assert_empty_workspace_schema_global() { fn entrypoint_can_assert_empty_workspace_schema_global() {
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries([entry(
1, "main.dcdl",
[entry( "{ answer = 42; } as WorkspaceConfigSchema",
"main.dcdl", )])
"{ answer = 42; } as WorkspaceConfigSchema",
)],
)
.unwrap(); .unwrap();
let contract = ToolchainContract::new(1, vec![path("main.dcdl")], 1); let contract = ToolchainContract::new(1, vec![path("main.dcdl")], 1);
SnapshotEnvironment::new(snapshot) SnapshotEnvironment::new(snapshot)
@@ -2373,7 +2515,7 @@ mod tests {
#[test] #[test]
fn completion_projects_workspace_schema_fields_into_config_objects() { 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( let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"builtin:profile", "builtin:profile",
"profile", "profile",
@@ -2458,7 +2600,7 @@ mod tests {
#[test] #[test]
fn completion_projects_array_element_and_nested_profile_fields() { 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) let environment = SnapshotEnvironment::new(snapshot)
.with_schema_bundle(profile_entries_completion_schema()); .with_schema_bundle(profile_entries_completion_schema());
for (marked_source, expected, prefix_len) in [ for (marked_source, expected, prefix_len) in [
@@ -2544,7 +2686,7 @@ mod tests {
#[test] #[test]
fn completion_tracks_fixed_array_positions_and_nested_array_ranges() { 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( let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"builtin:profile", "builtin:profile",
"profile", "profile",
@@ -2617,7 +2759,7 @@ mod tests {
#[test] #[test]
fn completion_preserves_general_schema_member_behavior() { 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) let environment = SnapshotEnvironment::new(snapshot)
.with_schema_bundle(profile_entries_completion_schema()); .with_schema_bundle(profile_entries_completion_schema());
for source in [ for source in [
@@ -2642,7 +2784,7 @@ mod tests {
#[test] #[test]
fn completion_array_schema_retains_nested_profile_diagnostics() { 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 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) let environment = SnapshotEnvironment::new(snapshot)
.with_schema_bundle(profile_entries_completion_schema()); .with_schema_bundle(profile_entries_completion_schema());
assert!(environment.analyze(&path("main.dcdl"), None).is_empty()); assert!(environment.analyze(&path("main.dcdl"), None).is_empty());
@@ -2663,7 +2805,7 @@ mod tests {
diagnostics.iter().any(|diagnostic| { diagnostics.iter().any(|diagnostic| {
diagnostic.kind == "constraint_violation" diagnostic.kind == "constraint_violation"
&& diagnostic.path == path("main.dcdl") && diagnostic.path == path("main.dcdl")
&& diagnostic.revision == 7 && diagnostic.tree_digest == environment.snapshot().digest
&& diagnostic.span.end_byte > diagnostic.span.start_byte && diagnostic.span.end_byte > diagnostic.span.start_byte
&& (diagnostic.message.contains(field) && (diagnostic.message.contains(field)
|| diagnostic || diagnostic
@@ -2690,7 +2832,7 @@ mod tests {
)]) )])
.unwrap(); .unwrap();
let source = "{ profile = { defaults = {}; entries = [{ selector = \"default\"; profile = {}; }]; }; } as WorkspaceConfigSchema"; 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()); let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema.clone());
assert!(environment.analyze(&path("main.dcdl"), None).is_empty()); assert!(environment.analyze(&path("main.dcdl"), None).is_empty());
let contract = ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, schema); 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() { fn actual_profile_schema_diagnoses_nested_values_without_materializing_omissions() {
let schema = actual_profile_schema(); let schema = actual_profile_schema();
let source = "{ profile = { entries = [{ selector = \"project:alpha\"; profile = {}; }]; }; } as WorkspaceConfigSchema"; let source = "{ profile = { entries = [{ selector = \"project:alpha\"; profile = {}; }]; }; } as WorkspaceConfigSchema";
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries([
8, entry("main.dcdl", source),
[ entry("recipe.dcdl", "{ worker = { mode = 42; }; }"),
entry("main.dcdl", source), ])
entry("recipe.dcdl", "{ worker = { mode = 42; }; }"),
],
)
.unwrap(); .unwrap();
let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema.clone()); let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema.clone());
let contract = ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, schema); let contract = ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, schema);
@@ -2814,7 +2953,7 @@ mod tests {
"{diagnostic:?}" "{diagnostic:?}"
); );
assert_eq!(diagnostic.path, path(expected_path), "{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" { let diagnostic_source = if expected_path == "main.dcdl" {
&source &source
} else { } else {
@@ -2874,10 +3013,10 @@ mod tests {
let source = format!( let source = format!(
r#"{{ profile.entries = [{{ selector = "project:scoped"; profile = {form}; }}]; }} as WorkspaceConfigSchema"# r#"{{ profile.entries = [{{ selector = "project:scoped"; profile = {form}; }}]; }} as WorkspaceConfigSchema"#
); );
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries([
9, entry("main.dcdl", &source),
[entry("main.dcdl", &source), entry("scoped.dcdl", recipe)], entry("scoped.dcdl", recipe),
) ])
.unwrap(); .unwrap();
let schema = actual_profile_schema(); let schema = actual_profile_schema();
let environment = let environment =
@@ -2967,10 +3106,10 @@ mod tests {
let source = format!( let source = format!(
r#"{{ profile.entries = [{{ selector = "project:corpus"; profile = {form}; }}]; }} as WorkspaceConfigSchema"# r#"{{ profile.entries = [{{ selector = "project:corpus"; profile = {form}; }}]; }} as WorkspaceConfigSchema"#
); );
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries([
10, entry("main.dcdl", &source),
[entry("main.dcdl", &source), entry("recipe.dcdl", &recipe)], entry("recipe.dcdl", &recipe),
) ])
.unwrap(); .unwrap();
let schema = actual_profile_schema(); let schema = actual_profile_schema();
let environment = let environment =
@@ -3001,7 +3140,7 @@ mod tests {
} }
let source = "{} as WorkspaceConfigSchema"; let source = "{} as WorkspaceConfigSchema";
let environment = SnapshotEnvironment::new( 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()); .with_schema_bundle(actual_profile_schema());
for (profile_path, prefix, label) in [ for (profile_path, prefix, label) in [
@@ -3051,7 +3190,7 @@ mod tests {
.unwrap(), .unwrap(),
]) ])
.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); let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema);
for (marked_source, expected) in [ for (marked_source, expected) in [
("{ ed| } as WorkspaceConfigSchema", "editor"), ("{ ed| } as WorkspaceConfigSchema", "editor"),
@@ -3148,14 +3287,11 @@ mod tests {
#[test] #[test]
fn relative_imports_and_completion_share_the_snapshot_namespace() { fn relative_imports_and_completion_share_the_snapshot_namespace() {
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries([
1, entry("profiles/main.dcdl", r#"import "../shared/value.dcdl""#),
[ entry("shared/value.dcdl", "{ answer = 42; }"),
entry("profiles/main.dcdl", r#"import "../shared/value.dcdl""#), entry("other.dcdl", "{}"),
entry("shared/value.dcdl", "{ answer = 42; }"), ])
entry("other.dcdl", "{}"),
],
)
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
resolve_import(&path("profiles/main.dcdl"), "../shared/value.dcdl").unwrap(), resolve_import(&path("profiles/main.dcdl"), "../shared/value.dcdl").unwrap(),
@@ -3207,7 +3343,7 @@ mod tests {
#[test] #[test]
fn workspace_schema_applies_defaults_with_asymmetric_decodal_validation() { fn workspace_schema_applies_defaults_with_asymmetric_decodal_validation() {
let snapshot = 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( let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"builtin:web", "builtin:web",
"web", "web",
@@ -3239,8 +3375,7 @@ mod tests {
("{ web = {}; custom = 42; }", "custom"), ("{ web = {}; custom = 42; }", "custom"),
("{ web = { typo = true; }; }", "typo"), ("{ web = { typo = true; }; }", "typo"),
] { ] {
let snapshot = let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", source)]).unwrap();
ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", source)]).unwrap();
let diagnostics = SnapshotEnvironment::new(snapshot) let diagnostics = SnapshotEnvironment::new(snapshot)
.evaluate_contract(&ToolchainContract::with_schema_bundle( .evaluate_contract(&ToolchainContract::with_schema_bundle(
1, 1,
@@ -3258,13 +3393,10 @@ mod tests {
#[test] #[test]
fn workspace_schema_supports_typed_associative_collections() { fn workspace_schema_supports_typed_associative_collections() {
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries([entry(
1, "main.dcdl",
[entry( "{ features = { web = { enabled = true; }; tickets = { enabled = false; }; }; }",
"main.dcdl", )])
"{ features = { web = { enabled = true; }; tickets = { enabled = false; }; }; }",
)],
)
.unwrap(); .unwrap();
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"builtin:features", "builtin:features",
@@ -3315,8 +3447,7 @@ mod tests {
"constraintviolation", "constraintviolation",
), ),
] { ] {
let snapshot = let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", source)]).unwrap();
ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", source)]).unwrap();
let diagnostics = SnapshotEnvironment::new(snapshot) let diagnostics = SnapshotEnvironment::new(snapshot)
.evaluate_contract(&ToolchainContract::with_schema_bundle( .evaluate_contract(&ToolchainContract::with_schema_bundle(
1, 1,
@@ -3335,7 +3466,7 @@ mod tests {
fn language_service_and_formatter_accept_decodal_0_4_schema_syntax() { fn language_service_and_formatter_accept_decodal_0_4_schema_syntax() {
let source = let source =
"{} as { features = {...{ enabled = Bool; }}; web = { enabled = Bool; ...Unknown }; }"; "{} 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 environment = SnapshotEnvironment::new(snapshot);
let diagnostics = environment.analyze(&path("schema.dcdl"), None); let diagnostics = environment.analyze(&path("schema.dcdl"), None);
assert!( assert!(
@@ -3370,13 +3501,10 @@ mod tests {
#[test] #[test]
fn workspace_schema_preserves_fields_only_where_rest_is_explicit() { fn workspace_schema_preserves_fields_only_where_rest_is_explicit() {
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries([entry(
1, "main.dcdl",
[entry( "{ web = { enabled = true; extension_value = 42; }; }",
"main.dcdl", )])
"{ web = { enabled = true; extension_value = 42; }; }",
)],
)
.unwrap(); .unwrap();
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"builtin:web", "builtin:web",
@@ -3402,8 +3530,7 @@ mod tests {
#[test] #[test]
fn unresolved_unknown_cannot_be_materialized() { fn unresolved_unknown_cannot_be_materialized() {
let snapshot = let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", "Unknown")]).unwrap();
ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "Unknown")]).unwrap();
let diagnostics = SnapshotEnvironment::new(snapshot) let diagnostics = SnapshotEnvironment::new(snapshot)
.evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1)) .evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1))
.unwrap_err(); .unwrap_err();
@@ -3413,11 +3540,9 @@ mod tests {
#[test] #[test]
fn workspace_schema_type_mismatch_is_a_decodal_diagnostic() { fn workspace_schema_type_mismatch_is_a_decodal_diagnostic() {
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot =
1, ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{ web = { enabled = 1; }; }")])
[entry("main.dcdl", "{ web = { enabled = 1; }; }")], .unwrap();
)
.unwrap();
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"builtin:web", "builtin:web",
"web", "web",
@@ -3459,9 +3584,82 @@ mod tests {
let first = text_entry("skills/debug-rust/SKILL.md", "first"); let first = text_entry("skills/debug-rust/SKILL.md", "first");
let second = text_entry("skills/debug-rust/SKILL.md", "second"); let second = text_entry("skills/debug-rust/SKILL.md", "second");
let first_key = snapshot_import_cache_key(&first); 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!(first_key.ends_with(&first.content_digest));
assert_ne!(first_key, snapshot_import_cache_key(&second)); 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::<ConfigTreeSnapshot>(&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::<VirtualPath>(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] #[test]
@@ -3476,9 +3674,7 @@ mod tests {
"---\n", "---\n",
"# Debug Rust\n", "# Debug Rust\n",
); );
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries([
3,
[
entry( entry(
"main.dcdl", "main.dcdl",
r#"{ skill = import "./skills/debug-rust/SKILL.md" as { frontmatter = { name = String; description = String; ...Unknown }; content = String; }; }"#, 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(), content: source.to_string(),
} }
); );
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries([
1, entry("main.dcdl", r#"import "./skills/plain/SKILL.md""#),
[ text_entry("skills/plain/SKILL.md", source),
entry("main.dcdl", r#"import "./skills/plain/SKILL.md""#), ])
text_entry("skills/plain/SKILL.md", source),
],
)
.unwrap(); .unwrap();
let result = SnapshotEnvironment::new(snapshot) let result = SnapshotEnvironment::new(snapshot)
.evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1)) .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(), project_markdown_document("---\nname: missing-close\nbody\n").unwrap_err(),
"opening YAML frontmatter delimiter has no closing delimiter" "opening YAML frontmatter delimiter has no closing delimiter"
); );
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries([
1, entry("main.dcdl", r#"import "./skills/broken/SKILL.md""#),
[ text_entry(
entry("main.dcdl", r#"import "./skills/broken/SKILL.md""#), "skills/broken/SKILL.md",
text_entry( "---\nname: [unterminated\n---\nbody\n",
"skills/broken/SKILL.md", ),
"---\nname: [unterminated\n---\nbody\n", ])
),
],
)
.unwrap(); .unwrap();
let diagnostics = SnapshotEnvironment::new(snapshot) let diagnostics = SnapshotEnvironment::new(snapshot)
.evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1)) .evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1))
@@ -3557,7 +3747,7 @@ mod tests {
#[test] #[test]
fn builtin_imports_are_ordinary_values_with_confined_transitive_sources() { 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("main.dcdl", r#"import "$builtin/profiles/companion.dcdl" // { worker = { mode = "wip"; }; feature = { workspace_config = { enabled = true; }; }; }"#),
entry("profiles/base.dcdl", "{ slug = \"workspace-shadow\"; }"), entry("profiles/base.dcdl", "{ slug = \"workspace-shadow\"; }"),
]).unwrap(); ]).unwrap();
@@ -3583,13 +3773,10 @@ mod tests {
); );
// Intersections conflict on explicit builtin values, exactly like any other import. // Intersections conflict on explicit builtin values, exactly like any other import.
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries([entry(
1, "main.dcdl",
[entry( r#"import "$builtin/profiles/companion.dcdl" & { slug = "different"; }"#,
"main.dcdl", )])
r#"import "$builtin/profiles/companion.dcdl" & { slug = "different"; }"#,
)],
)
.unwrap(); .unwrap();
assert!( assert!(
SnapshotEnvironment::new(snapshot) SnapshotEnvironment::new(snapshot)
@@ -3612,13 +3799,10 @@ mod tests {
"builtin:companion", "builtin:companion",
] { ] {
let source = format!("import {specifier:?}"); let source = format!("import {specifier:?}");
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries([
1, entry("main.dcdl", &source),
[ entry("profiles/base.dcdl", "{ spoof = true; }"),
entry("main.dcdl", &source), ])
entry("profiles/base.dcdl", "{ spoof = true; }"),
],
)
.unwrap(); .unwrap();
let diagnostics = SnapshotEnvironment::new(snapshot) let diagnostics = SnapshotEnvironment::new(snapshot)
.evaluate_contract(&contract) .evaluate_contract(&contract)
@@ -3684,7 +3868,7 @@ mod tests {
#[test] #[test]
fn builtin_values_do_not_bypass_cycle_or_path_limits() { 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("main.dcdl", r#"{ builtin = import "$builtin/profiles/companion.dcdl"; cycle = import "./cycle.dcdl"; }"#),
entry("cycle.dcdl", r#"import "./main.dcdl""#), entry("cycle.dcdl", r#"import "./main.dcdl""#),
]).unwrap(); ]).unwrap();
@@ -3718,7 +3902,7 @@ mod tests {
#[test] #[test]
fn rejected_import_graph_edges_stay_rejected_after_diagnostics() { fn rejected_import_graph_edges_stay_rejected_after_diagnostics() {
let mut loader = SnapshotImportLoader::new( 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(), .unwrap(),
); );
loader.load(Some("a.dcdl"), "./b.dcdl").unwrap(); loader.load(Some("a.dcdl"), "./b.dcdl").unwrap();
@@ -3760,7 +3944,7 @@ mod tests {
entries.push(entry(&format!("{index}.dcdl"), &source)); entries.push(entry(&format!("{index}.dcdl"), &source));
} }
let diagnostics = 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)) .evaluate_contract(&ToolchainContract::new(1, vec![path("0.dcdl")], 1))
.unwrap_err(); .unwrap_err();
assert!( assert!(
@@ -3781,7 +3965,7 @@ mod tests {
entries.push(text_entry(&format!("unused/{i}.txt"), "")); entries.push(text_entry(&format!("unused/{i}.txt"), ""));
} }
let environment = 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(); let diagnostics = environment.evaluate_contract(&contract).unwrap_err();
assert!(diagnostics[0].message.contains("entry count")); assert!(diagnostics[0].message.contains("entry count"));
let mut entries = vec![entry( let mut entries = vec![entry(
@@ -3798,20 +3982,17 @@ mod tests {
entries.push(text_entry(&format!("unused/{i}.txt"), &"a".repeat(bytes))); entries.push(text_entry(&format!("unused/{i}.txt"), &"a".repeat(bytes)));
} }
let environment = 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(); let diagnostics = environment.evaluate_contract(&contract).unwrap_err();
assert!(diagnostics[0].message.contains("total bytes")); assert!(diagnostics[0].message.contains("total bytes"));
} }
#[test] #[test]
fn host_environment_evaluation_uses_only_snapshot_imports() { fn host_environment_evaluation_uses_only_snapshot_imports() {
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries([
3, entry("profiles/main.dcdl", r#"import "./shared.dcdl""#),
[ entry("profiles/shared.dcdl", "{ answer = 42; }"),
entry("profiles/main.dcdl", r#"import "./shared.dcdl""#), ])
entry("profiles/shared.dcdl", "{ answer = 42; }"),
],
)
.unwrap(); .unwrap();
let contract = ToolchainContract::new( let contract = ToolchainContract::new(
DEFAULT_SCHEMA_VERSION, DEFAULT_SCHEMA_VERSION,
@@ -3826,13 +4007,10 @@ mod tests {
#[test] #[test]
fn candidate_evaluation_rejects_invalid_unreferenced_decodal_source() { fn candidate_evaluation_rejects_invalid_unreferenced_decodal_source() {
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries([
1, entry("workspace.dcdl", "{ answer = 42; }"),
[ entry("unused.dcdl", "{ broken = ; }"),
entry("workspace.dcdl", "{ answer = 42; }"), ])
entry("unused.dcdl", "{ broken = ; }"),
],
)
.unwrap(); .unwrap();
let diagnostics = SnapshotEnvironment::new(snapshot) let diagnostics = SnapshotEnvironment::new(snapshot)
.evaluate_contract(&ToolchainContract::new(1, vec![path("workspace.dcdl")], 1)) .evaluate_contract(&ToolchainContract::new(1, vec![path("workspace.dcdl")], 1))
@@ -3844,7 +4022,7 @@ mod tests {
#[test] #[test]
fn missing_import_and_cycles_are_structured_failures() { fn missing_import_and_cycles_are_structured_failures() {
let missing = let missing =
ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", r#"import "./missing.dcdl""#)]) ConfigTreeSnapshot::from_entries([entry("main.dcdl", r#"import "./missing.dcdl""#)])
.unwrap(); .unwrap();
let contract = ToolchainContract::new(1, vec![path("main.dcdl")], 1); let contract = ToolchainContract::new(1, vec![path("main.dcdl")], 1);
let diagnostics = SnapshotEnvironment::new(missing) let diagnostics = SnapshotEnvironment::new(missing)
@@ -3852,13 +4030,10 @@ mod tests {
.unwrap_err(); .unwrap_err();
assert_eq!(diagnostics[0].kind, "import"); assert_eq!(diagnostics[0].kind, "import");
let cycle = ConfigTreeSnapshot::from_entries( let cycle = ConfigTreeSnapshot::from_entries([
1, entry("a.dcdl", r#"import "./b.dcdl""#),
[ entry("b.dcdl", r#"import "./a.dcdl""#),
entry("a.dcdl", r#"import "./b.dcdl""#), ])
entry("b.dcdl", r#"import "./a.dcdl""#),
],
)
.unwrap(); .unwrap();
let diagnostics = SnapshotEnvironment::new(cycle) let diagnostics = SnapshotEnvironment::new(cycle)
.evaluate_contract(&ToolchainContract::new(1, vec![path("a.dcdl")], 1)) .evaluate_contract(&ToolchainContract::new(1, vec![path("a.dcdl")], 1))
+2 -11
View File
@@ -7,9 +7,6 @@ const CODER_REVIEW_FLOW_SOURCE: &str = include_str!("../../../resources/flows/co
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BuiltinFlowSource { pub struct BuiltinFlowSource {
pub slug: &'static str, 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 path: &'static str,
pub content: &'static str, pub content: &'static str,
} }
@@ -24,7 +21,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
match slug { match slug {
CODER_REVIEW_FLOW_SLUG => Some(BuiltinFlowSource { CODER_REVIEW_FLOW_SLUG => Some(BuiltinFlowSource {
slug: CODER_REVIEW_FLOW_SLUG, slug: CODER_REVIEW_FLOW_SLUG,
revision: 5,
path: "builtin/flows/coder-review.dcdl", path: "builtin/flows/coder-review.dcdl",
content: CODER_REVIEW_FLOW_SOURCE, content: CODER_REVIEW_FLOW_SOURCE,
}), }),
@@ -35,7 +32,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] { pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] {
const SOURCES: &[BuiltinFlowSource] = &[BuiltinFlowSource { const SOURCES: &[BuiltinFlowSource] = &[BuiltinFlowSource {
slug: CODER_REVIEW_FLOW_SLUG, slug: CODER_REVIEW_FLOW_SLUG,
revision: 5,
path: "builtin/flows/coder-review.dcdl", path: "builtin/flows/coder-review.dcdl",
content: CODER_REVIEW_FLOW_SOURCE, content: CODER_REVIEW_FLOW_SOURCE,
}]; }];
@@ -64,7 +61,6 @@ mod tests {
"MergeRequestShow", "MergeRequestShow",
"MergeRequestReview", "MergeRequestReview",
"MergeRequestComplete", "MergeRequestComplete",
"new immutable revision",
] { ] {
assert!(!source.content.contains(stale), "stale contract {stale}"); assert!(!source.content.contains(stale), "stale contract {stale}");
} }
@@ -74,10 +70,6 @@ mod tests {
fn every_builtin_flow_compiles_and_matches_catalog_identity() { fn every_builtin_flow_compiles_and_matches_catalog_identity() {
assert!(!builtin_flow_sources().is_empty()); assert!(!builtin_flow_sources().is_empty());
for source in builtin_flow_sources() { 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| { let definition = source.compile().unwrap_or_else(|error| {
panic!( panic!(
"builtin Flow {} failed to compile: {:?}", "builtin Flow {} failed to compile: {:?}",
@@ -88,7 +80,6 @@ mod tests {
let selected = let selected =
builtin_flow_source(source.slug).expect("builtin Flow must be selectable"); builtin_flow_source(source.slug).expect("builtin Flow must be selectable");
assert_eq!(selected.content, source.content); assert_eq!(selected.content, source.content);
assert_eq!(selected.revision, source.revision);
} }
} }
} }
+126 -56
View File
@@ -21,10 +21,8 @@ pub enum FlowInstanceStatus {
pub struct FlowInstance { pub struct FlowInstance {
pub instance_id: String, pub instance_id: String,
pub definition_id: String, pub definition_id: String,
pub definition_revision: u64,
pub definition_digest: String, pub definition_digest: String,
pub current_state: StateId, pub current_state: StateId,
pub state_revision: u64,
pub status: FlowInstanceStatus, pub status: FlowInstanceStatus,
pub active_attempt_id: Option<String>, pub active_attempt_id: Option<String>,
} }
@@ -33,7 +31,6 @@ impl FlowInstance {
pub fn start( pub fn start(
instance_id: impl Into<String>, instance_id: impl Into<String>,
definition_id: impl Into<String>, definition_id: impl Into<String>,
definition_revision: u64,
definition: &CompiledFlowDefinition, definition: &CompiledFlowDefinition,
) -> Result<Self, FlowTransitionError> { ) -> Result<Self, FlowTransitionError> {
let instance_id = instance_id.into(); let instance_id = instance_id.into();
@@ -41,11 +38,6 @@ impl FlowInstance {
ensure_non_empty("instance_id", &instance_id) ensure_non_empty("instance_id", &instance_id)
.and_then(|_| ensure_non_empty("definition_id", &definition_id)) .and_then(|_| ensure_non_empty("definition_id", &definition_id))
.map_err(FlowTransitionError::InvalidRequest)?; .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(|| { let initial_state = definition.state(&definition.initial).ok_or_else(|| {
FlowTransitionError::Invariant("compiled initial state is missing".to_string()) FlowTransitionError::Invariant("compiled initial state is missing".to_string())
})?; })?;
@@ -57,10 +49,8 @@ impl FlowInstance {
Ok(Self { Ok(Self {
instance_id, instance_id,
definition_id, definition_id,
definition_revision,
definition_digest: definition.content_digest.clone(), definition_digest: definition.content_digest.clone(),
current_state: definition.initial.clone(), current_state: definition.initial.clone(),
state_revision: 0,
status, status,
active_attempt_id: None, active_attempt_id: None,
}) })
@@ -77,9 +67,7 @@ pub struct FlowTransitionRequest {
pub struct FlowTransitionAttempt { pub struct FlowTransitionAttempt {
pub attempt_id: String, pub attempt_id: String,
pub instance_id: String, pub instance_id: String,
pub definition_revision: u64,
pub definition_digest: String, pub definition_digest: String,
pub checked_state_revision: u64,
pub from_state: StateId, pub from_state: StateId,
pub reason: String, pub reason: String,
pub transitions: Vec<TransitionCheckSnapshot>, pub transitions: Vec<TransitionCheckSnapshot>,
@@ -174,7 +162,6 @@ pub enum FlowEventKind {
TransitionRequested { TransitionRequested {
attempt_id: String, attempt_id: String,
state_id: StateId, state_id: StateId,
state_revision: u64,
reason: String, reason: String,
transitions: Vec<TransitionCheckSnapshot>, transitions: Vec<TransitionCheckSnapshot>,
}, },
@@ -201,7 +188,6 @@ pub enum FlowEventKind {
StateEntered { StateEntered {
attempt_id: String, attempt_id: String,
state_id: StateId, state_id: StateId,
state_revision: u64,
status: FlowInstanceStatus, status: FlowInstanceStatus,
}, },
} }
@@ -214,7 +200,7 @@ pub struct FlowRuntimeEvent {
/// Durable Flow authority owned by one Runtime Worker. /// 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 /// this value. Runtime persists the complete snapshot with the Worker session
/// and replaces it only after the corresponding session-log write succeeds. /// and replaces it only after the corresponding session-log write succeeds.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -240,12 +226,8 @@ impl FlowRuntimeState {
"resolved Flow selector does not match compiled definition name".to_string(), "resolved Flow selector does not match compiled definition name".to_string(),
)); ));
} }
let instance = FlowInstance::start( let instance =
instance_id, FlowInstance::start(instance_id, source.flow_id.clone(), &source.definition)?;
source.flow_id.clone(),
source.revision,
&source.definition,
)?;
let initial = source let initial = source
.definition .definition
.state(&source.definition.initial) .state(&source.definition.initial)
@@ -257,7 +239,6 @@ impl FlowRuntimeState {
event: FlowEventKind::StateEntered { event: FlowEventKind::StateEntered {
attempt_id: String::new(), attempt_id: String::new(),
state_id: instance.current_state.clone(), state_id: instance.current_state.clone(),
state_revision: instance.state_revision,
status: instance.status, status: instance.status,
}, },
}; };
@@ -282,8 +263,22 @@ impl FlowRuntimeState {
if let Some(attempt) = &self.active_attempt { if let Some(attempt) = &self.active_attempt {
return Ok(attempt.clone()); 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 { let request = FlowTransitionRequest {
attempt_id: attempt_id.into(), attempt_id,
reason: reason.into(), reason: reason.into(),
}; };
let (attempt, events) = begin_transition(&mut self.instance, &self.definition, request)?; let (attempt, events) = begin_transition(&mut self.instance, &self.definition, request)?;
@@ -329,7 +324,7 @@ pub enum FlowTransitionError {
NotActive, NotActive,
#[error("another transition attempt is already active")] #[error("another transition attempt is already active")]
AttemptInProgress, 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, DefinitionMismatch,
#[error("invalid transition request: {0}")] #[error("invalid transition request: {0}")]
InvalidRequest(String), InvalidRequest(String),
@@ -337,7 +332,7 @@ pub enum FlowTransitionError {
Invariant(String), Invariant(String),
} }
pub fn begin_transition( fn begin_transition(
instance: &mut FlowInstance, instance: &mut FlowInstance,
definition: &CompiledFlowDefinition, definition: &CompiledFlowDefinition,
request: FlowTransitionRequest, request: FlowTransitionRequest,
@@ -374,9 +369,7 @@ pub fn begin_transition(
let attempt = FlowTransitionAttempt { let attempt = FlowTransitionAttempt {
attempt_id: request.attempt_id, attempt_id: request.attempt_id,
instance_id: instance.instance_id.clone(), instance_id: instance.instance_id.clone(),
definition_revision: instance.definition_revision,
definition_digest: instance.definition_digest.clone(), definition_digest: instance.definition_digest.clone(),
checked_state_revision: instance.state_revision,
from_state: instance.current_state.clone(), from_state: instance.current_state.clone(),
reason: request.reason, reason: request.reason,
transitions, transitions,
@@ -387,7 +380,6 @@ pub fn begin_transition(
FlowEventKind::TransitionRequested { FlowEventKind::TransitionRequested {
attempt_id: attempt.attempt_id.clone(), attempt_id: attempt.attempt_id.clone(),
state_id: attempt.from_state.clone(), state_id: attempt.from_state.clone(),
state_revision: attempt.checked_state_revision,
reason: attempt.reason.clone(), reason: attempt.reason.clone(),
transitions: attempt.transitions.clone(), transitions: attempt.transitions.clone(),
}, },
@@ -398,20 +390,23 @@ pub fn begin_transition(
Ok((attempt, events)) Ok((attempt, events))
} }
pub fn resolve_transition( fn resolve_transition(
instance: &mut FlowInstance, instance: &mut FlowInstance,
definition: &CompiledFlowDefinition, definition: &CompiledFlowDefinition,
mut attempt: FlowTransitionAttempt, mut attempt: FlowTransitionAttempt,
outcome: FlowVerifierOutcome, outcome: FlowVerifierOutcome,
) -> Result<FlowTransitionResolution, FlowTransitionError> { ) -> Result<FlowTransitionResolution, FlowTransitionError> {
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( return Err(FlowTransitionError::InvalidRequest(
"attempt is not the active/latest attempt for this Flow instance".to_string(), "attempt is not the active/latest attempt for this Flow instance".to_string(),
)); ));
} }
if definition.content_digest != instance.definition_digest if definition.content_digest != instance.definition_digest
|| attempt.definition_digest != instance.definition_digest || attempt.definition_digest != instance.definition_digest
|| attempt.definition_revision != instance.definition_revision
{ {
return Err(FlowTransitionError::DefinitionMismatch); return Err(FlowTransitionError::DefinitionMismatch);
} }
@@ -447,7 +442,7 @@ pub fn resolve_transition(
) )
} }
FlowVerifierOutcome::Completed { results } => { FlowVerifierOutcome::Completed { results } => {
if instance.state_revision != attempt.checked_state_revision if instance.status != FlowInstanceStatus::Active
|| instance.current_state != attempt.from_state || instance.current_state != attempt.from_state
{ {
attempt.status = FlowAttemptStatus::Rejected; attempt.status = FlowAttemptStatus::Rejected;
@@ -604,9 +599,6 @@ fn resolve_completed_results(
results, results,
}); });
instance.current_state = transition.target.clone(); 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 { instance.status = if transition.target.as_str() == CANCELLED_STATE_ID {
FlowInstanceStatus::Cancelled FlowInstanceStatus::Cancelled
} else if target_state.terminal { } else if target_state.terminal {
@@ -617,7 +609,6 @@ fn resolve_completed_results(
events.push(FlowEventKind::StateEntered { events.push(FlowEventKind::StateEntered {
attempt_id: attempt.attempt_id.clone(), attempt_id: attempt.attempt_id.clone(),
state_id: instance.current_state.clone(), state_id: instance.current_state.clone(),
state_revision: instance.state_revision,
status: instance.status, status: instance.status,
}); });
Ok(FlowTransitionResolution { Ok(FlowTransitionResolution {
@@ -735,16 +726,109 @@ mod tests {
attempt 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] #[test]
fn exactly_one_met_transition_enters_terminal_state() { fn exactly_one_met_transition_enters_terminal_state() {
let definition = definition(); let definition = definition();
let mut instance = FlowInstance { let mut instance = FlowInstance {
instance_id: "instance-1".to_string(), instance_id: "instance-1".to_string(),
definition_id: "definition-1".to_string(), definition_id: "definition-1".to_string(),
definition_revision: 1,
definition_digest: definition.content_digest.clone(), definition_digest: definition.content_digest.clone(),
current_state: definition.initial.clone(), current_state: definition.initial.clone(),
state_revision: 0,
status: FlowInstanceStatus::Active, status: FlowInstanceStatus::Active,
active_attempt_id: None, active_attempt_id: None,
}; };
@@ -770,7 +854,7 @@ mod tests {
) )
.unwrap(); .unwrap();
assert_eq!(instance.current_state.as_str(), "done"); assert_eq!(instance.current_state.as_str(), "done");
assert_eq!(instance.state_revision, 1);
assert_eq!(instance.status, FlowInstanceStatus::Completed); assert_eq!(instance.status, FlowInstanceStatus::Completed);
assert!(resolution.rejection.is_none()); assert!(resolution.rejection.is_none());
assert!(matches!( assert!(matches!(
@@ -788,10 +872,8 @@ mod tests {
let mut instance = FlowInstance { let mut instance = FlowInstance {
instance_id: "instance-1".to_string(), instance_id: "instance-1".to_string(),
definition_id: "definition-1".to_string(), definition_id: "definition-1".to_string(),
definition_revision: 1,
definition_digest: definition.content_digest.clone(), definition_digest: definition.content_digest.clone(),
current_state: definition.initial.clone(), current_state: definition.initial.clone(),
state_revision: 0,
status: FlowInstanceStatus::Active, status: FlowInstanceStatus::Active,
active_attempt_id: None, active_attempt_id: None,
}; };
@@ -827,10 +909,8 @@ mod tests {
let mut instance = FlowInstance { let mut instance = FlowInstance {
instance_id: "instance-1".to_string(), instance_id: "instance-1".to_string(),
definition_id: "definition-1".to_string(), definition_id: "definition-1".to_string(),
definition_revision: 1,
definition_digest: definition.content_digest.clone(), definition_digest: definition.content_digest.clone(),
current_state: definition.initial.clone(), current_state: definition.initial.clone(),
state_revision: 0,
status: FlowInstanceStatus::Active, status: FlowInstanceStatus::Active,
active_attempt_id: None, active_attempt_id: None,
}; };
@@ -852,7 +932,7 @@ mod tests {
) )
.unwrap(); .unwrap();
assert_eq!(instance.current_state.as_str(), "work"); assert_eq!(instance.current_state.as_str(), "work");
assert_eq!(instance.state_revision, 0);
assert_eq!(instance.active_attempt_id, None); assert_eq!(instance.active_attempt_id, None);
assert_eq!( assert_eq!(
resolution.rejection.unwrap().code, resolution.rejection.unwrap().code,
@@ -866,10 +946,8 @@ mod tests {
let mut instance = FlowInstance { let mut instance = FlowInstance {
instance_id: "instance-1".to_string(), instance_id: "instance-1".to_string(),
definition_id: "definition-1".to_string(), definition_id: "definition-1".to_string(),
definition_revision: 1,
definition_digest: definition.content_digest.clone(), definition_digest: definition.content_digest.clone(),
current_state: definition.initial.clone(), current_state: definition.initial.clone(),
state_revision: 0,
status: FlowInstanceStatus::Active, status: FlowInstanceStatus::Active,
active_attempt_id: None, active_attempt_id: None,
}; };
@@ -901,15 +979,13 @@ mod tests {
let mut instance = FlowInstance { let mut instance = FlowInstance {
instance_id: "instance-1".to_string(), instance_id: "instance-1".to_string(),
definition_id: "definition-1".to_string(), definition_id: "definition-1".to_string(),
definition_revision: 1,
definition_digest: definition.content_digest.clone(), definition_digest: definition.content_digest.clone(),
current_state: definition.initial.clone(), current_state: definition.initial.clone(),
state_revision: 0,
status: FlowInstanceStatus::Active, status: FlowInstanceStatus::Active,
active_attempt_id: None, active_attempt_id: None,
}; };
let attempt = begin(&mut instance); let attempt = begin(&mut instance);
instance.state_revision = 1; instance.current_state = StateId::new("done").unwrap();
let results = attempt let results = attempt
.transitions .transitions
.iter() .iter()
@@ -938,10 +1014,8 @@ mod tests {
let mut instance = FlowInstance { let mut instance = FlowInstance {
instance_id: "instance-1".to_string(), instance_id: "instance-1".to_string(),
definition_id: "definition-1".to_string(), definition_id: "definition-1".to_string(),
definition_revision: 1,
definition_digest: definition.content_digest.clone(), definition_digest: definition.content_digest.clone(),
current_state: definition.initial.clone(), current_state: definition.initial.clone(),
state_revision: 0,
status: FlowInstanceStatus::Active, status: FlowInstanceStatus::Active,
active_attempt_id: None, active_attempt_id: None,
}; };
@@ -967,7 +1041,7 @@ mod tests {
FlowRejectionCode::MultipleConditionsMet FlowRejectionCode::MultipleConditionsMet
); );
assert_eq!(instance.current_state.as_str(), "work"); assert_eq!(instance.current_state.as_str(), "work");
assert_eq!(instance.state_revision, 0);
assert!( assert!(
resolution resolution
.events .events
@@ -982,10 +1056,8 @@ mod tests {
let mut instance = FlowInstance { let mut instance = FlowInstance {
instance_id: "instance-1".to_string(), instance_id: "instance-1".to_string(),
definition_id: "definition-1".to_string(), definition_id: "definition-1".to_string(),
definition_revision: 1,
definition_digest: definition.content_digest.clone(), definition_digest: definition.content_digest.clone(),
current_state: definition.initial.clone(), current_state: definition.initial.clone(),
state_revision: 0,
status: FlowInstanceStatus::Active, status: FlowInstanceStatus::Active,
active_attempt_id: None, active_attempt_id: None,
}; };
@@ -1015,10 +1087,8 @@ mod tests {
let mut instance = FlowInstance { let mut instance = FlowInstance {
instance_id: "instance-1".to_string(), instance_id: "instance-1".to_string(),
definition_id: "definition-1".to_string(), definition_id: "definition-1".to_string(),
definition_revision: 1,
definition_digest: definition.content_digest.clone(), definition_digest: definition.content_digest.clone(),
current_state: definition.initial.clone(), current_state: definition.initial.clone(),
state_revision: 0,
status: FlowInstanceStatus::Active, status: FlowInstanceStatus::Active,
active_attempt_id: None, active_attempt_id: None,
}; };
-1
View File
@@ -124,7 +124,6 @@ pub struct ResolvedFlowSource {
pub selector: FlowSelector, pub selector: FlowSelector,
pub workspace_id: String, pub workspace_id: String,
pub flow_id: String, pub flow_id: String,
pub revision: u64,
pub content_digest: String, pub content_digest: String,
pub definition: crate::CompiledFlowDefinition, pub definition: crate::CompiledFlowDefinition,
} }
+13 -7
View File
@@ -100,8 +100,6 @@ pub struct JobRequest {
/// Stable caller-selected idempotency key within the host's namespace. /// Stable caller-selected idempotency key within the host's namespace.
pub job_id: String, pub job_id: String,
pub purpose: String, pub purpose: String,
/// Revision fenced by the structured result.
pub input_revision: String,
pub input_ref: String, pub input_ref: String,
/// Bounded immutable snapshot; must not contain credentials. /// Bounded immutable snapshot; must not contain credentials.
pub input: Value, pub input: Value,
@@ -126,7 +124,6 @@ impl JobRequest {
pub fn validate_fields(&self) -> Result<()> { pub fn validate_fields(&self) -> Result<()> {
validate_identifier("job_id", &self.job_id, 256)?; validate_identifier("job_id", &self.job_id, 256)?;
validate_identifier("purpose", &self.purpose, MAX_JOB_PURPOSE_BYTES)?; 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("input_ref", &self.input_ref, MAX_JOB_REFERENCE_BYTES)?;
validate_bounded_text("instruction", &self.instruction, MAX_JOB_INSTRUCTION_BYTES)?; validate_bounded_text("instruction", &self.instruction, MAX_JOB_INSTRUCTION_BYTES)?;
self.profile_selector()?; self.profile_selector()?;
@@ -157,6 +154,13 @@ impl JobRequest {
fingerprint(self) 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<String> {
self.validate_input()?;
fingerprint(&self.input)
}
pub fn worker_input(&self, attempt_id: &str) -> Result<String> { pub fn worker_input(&self, attempt_id: &str) -> Result<String> {
worker_input(self, attempt_id) worker_input(self, attempt_id)
} }
@@ -167,7 +171,8 @@ impl JobRequest {
pub struct JobResultSubmission { pub struct JobResultSubmission {
pub job_id: String, pub job_id: String,
pub attempt_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, pub result: Value,
} }
@@ -202,7 +207,8 @@ pub enum JobAttemptState {
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct JobOutcome { pub struct JobOutcome {
pub job_id: String, 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_id: String,
pub attempt: u8, pub attempt: u8,
pub state: JobState, pub state: JobState,
@@ -263,11 +269,11 @@ pub fn worker_input_with_label(
let input = serde_json::to_string(&request.input) let input = serde_json::to_string(&request.input)
.map_err(|error| JobError::InvalidInput(format!("serialize Job input: {error}")))?; .map_err(|error| JobError::InvalidInput(format!("serialize Job input: {error}")))?;
Ok(format!( 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.instruction,
request.job_id, request.job_id,
attempt_id, attempt_id,
request.input_revision, request.input_digest()?,
request.input_ref, request.input_ref,
input input
)) ))
+26 -10
View File
@@ -5,7 +5,6 @@ fn request() -> JobRequest {
JobRequest { JobRequest {
job_id: "check:r7".into(), job_id: "check:r7".into(),
purpose: "snapshot_check".into(), purpose: "snapshot_check".into(),
input_revision: "7".into(),
input_ref: "snapshot://7".into(), input_ref: "snapshot://7".into(),
input: json!({"title": "Check me"}), input: json!({"title": "Check me"}),
instruction: "Check this immutable snapshot.".into(), 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::<JobRequest>(old).is_err());
}
#[test] #[test]
fn shared_lifecycle_enums_round_trip_all_host_states() { fn shared_lifecycle_enums_round_trip_all_host_states() {
for (state, name) in [ 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() { fn outcome_is_a_neutral_read_snapshot_with_optional_result_and_failure() {
let mut outcome = JobOutcome { let mut outcome = JobOutcome {
job_id: "job-1".into(), job_id: "job-1".into(),
input_revision: "7".into(), input_digest: request().input_digest().unwrap(),
attempt_id: "job-1:attempt:2".into(), attempt_id: "job-1:attempt:2".into(),
attempt: 2, attempt: 2,
state: JobState::Pending, state: JobState::Pending,
@@ -66,7 +84,7 @@ fn outcome_is_a_neutral_read_snapshot_with_optional_result_and_failure() {
assert_eq!( assert_eq!(
encoded, encoded,
json!({ 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", "attempt": 2, "state": "pending", "attempt_state": "dispatching",
"result": null, "failure_category": null, "failure_detail": null "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() { fn neutral_request_and_result_have_strict_round_trip_contracts() {
let request = request(); let request = request();
let encoded = serde_json::to_value(&request).unwrap(); 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!(encoded.get("serialization_key").is_none());
assert_eq!( assert_eq!(
serde_json::from_value::<JobRequest>(encoded.clone()).unwrap(), serde_json::from_value::<JobRequest>(encoded.clone()).unwrap(),
@@ -137,10 +155,11 @@ fn neutral_request_and_result_have_strict_round_trip_contracts() {
with_provenance["source_worker"] = json!({}); with_provenance["source_worker"] = json!({});
assert!(serde_json::from_value::<JobRequest>(with_provenance).is_err()); assert!(serde_json::from_value::<JobRequest>(with_provenance).is_err());
let input_digest = request.input_digest().unwrap();
let submission = JobResultSubmission { let submission = JobResultSubmission {
job_id: request.job_id, job_id: request.job_id,
attempt_id: "check:r7:attempt:1".into(), attempt_id: "check:r7:attempt:1".into(),
input_revision: request.input_revision, input_digest,
result: json!({"ok": true}), result: json!({"ok": true}),
}; };
let mut encoded = serde_json::to_value(&submission).unwrap(); 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 [ for mutate in [
|r: &mut JobRequest| r.job_id.push('x'), |r: &mut JobRequest| r.job_id.push('x'),
|r: &mut JobRequest| r.purpose.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_ref.push('x'),
|r: &mut JobRequest| r.input = json!({"title": "changed"}), |r: &mut JobRequest| r.input = json!({"title": "changed"}),
|r: &mut JobRequest| r.instruction.push('x'), |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() { fn validation_enforces_utf8_bytes_and_metadata_bounds() {
let mut r = request(); let mut r = request();
r.job_id = "x".repeat(256); r.job_id = "x".repeat(256);
r.input_revision = "x".repeat(256);
r.purpose = "x".repeat(MAX_JOB_PURPOSE_BYTES); r.purpose = "x".repeat(MAX_JOB_PURPOSE_BYTES);
r.input_ref = "x".repeat(MAX_JOB_REFERENCE_BYTES); r.input_ref = "x".repeat(MAX_JOB_REFERENCE_BYTES);
r.instruction = "x".repeat(MAX_JOB_INSTRUCTION_BYTES); r.instruction = "x".repeat(MAX_JOB_INSTRUCTION_BYTES);
@@ -225,7 +242,6 @@ fn validation_enforces_utf8_bytes_and_metadata_bounds() {
r.validate().unwrap(); r.validate().unwrap();
for mutate in [ for mutate in [
|r: &mut JobRequest| r.job_id.push('x'), |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.purpose.push('x'),
|r: &mut JobRequest| r.input_ref.push('x'), |r: &mut JobRequest| r.input_ref.push('x'),
|r: &mut JobRequest| r.instruction.push('x'), |r: &mut JobRequest| r.instruction.push('x'),
@@ -287,7 +303,7 @@ fn result_digest_and_failure_detail_bound_exact_utf8_encoding() {
} }
#[test] #[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 request = request();
let attempt = attempt_id(&request.job_id, 2); let attempt = attempt_id(&request.job_id, 2);
assert_eq!(attempt, "check:r7:attempt: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 envelope (immutable):",
"job_id: check:r7", "job_id: check:r7",
"attempt_id: check:r7:attempt:2", "attempt_id: check:r7:attempt:2",
"input_revision: 7", "input_digest: sha256:",
"input_ref: snapshot://7", "input_ref: snapshot://7",
r#"input_json: {"title":"Check me"}"#, r#"input_json: {"title":"Check me"}"#,
"structured Job result capability", "structured Job result capability",
+113 -14
View File
@@ -391,8 +391,7 @@ impl ResolvedMemoryFeatureConfig {
return Err("disabled Memory feature must not carry Workspace settings"); return Err("disabled Memory feature must not carry Workspace settings");
} }
if let Some(settings) = &self.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"); 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"); return Err("disabled subjektiv feature must not carry Workspace settings");
} }
if let Some(settings) = &self.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"); 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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkspaceMemorySettingsSnapshot { pub struct WorkspaceMemorySettingsSnapshot {
/// Workspace that owns the settings revision. /// Workspace that owns these settings.
pub workspace_id: String, pub workspace_id: String,
/// Monotonic Workspace Memory settings revision.
pub settings_revision: u64,
/// Normalized language used for Memory extraction and consolidation output. /// Normalized language used for Memory extraction and consolidation output.
pub language: String, pub language: String,
} }
@@ -1155,7 +1151,8 @@ struct LegacyMemoryConfig {
consolidation_threshold_bytes: Option<u64>, consolidation_threshold_bytes: Option<u64>,
} }
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; const PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION: u64 = 2;
/// Serialize a resolved Worker Manifest for durable Worker-specific storage. /// 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 if version != RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION
&& version != MEMORY_COUNTER_MANIFEST_SNAPSHOT_SCHEMA_VERSION
&& version != PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION && version != PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION
{ {
return Err(serde_json::Error::io(std::io::Error::new( 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 { if version == PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION {
migrate_legacy_manifest_authority(&mut manifest)?; 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)?); return validate_persisted_worker_manifest(serde_json::from_value(manifest)?);
} }
@@ -1240,6 +1241,32 @@ fn validate_persisted_worker_manifest(
Ok(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( fn migrate_legacy_manifest_authority(
manifest: &mut serde_json::Value, manifest: &mut serde_json::Value,
) -> Result<(), serde_json::Error> { ) -> Result<(), serde_json::Error> {
@@ -1385,6 +1412,7 @@ fn migrate_legacy_resolved_manifest_snapshot(
} }
feature.insert("memory".to_string(), resolved); feature.insert("memory".to_string(), resolved);
migrate_legacy_manifest_authority(&mut snapshot)?; migrate_legacy_manifest_authority(&mut snapshot)?;
migrate_legacy_memory_settings_counter(&mut snapshot)?;
validate_persisted_worker_manifest(serde_json::from_value(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\ worker_max_turns = 2\n\n\
[feature.memory.workspace_settings]\n\ [feature.memory.workspace_settings]\n\
workspace_id = \"workspace-1\"\n\ workspace_id = \"workspace-1\"\n\
settings_revision = 7\n\
language = \"日本語\"\n" language = \"日本語\"\n"
); );
let manifest = WorkerManifest::from_toml(&toml).unwrap(); let manifest = WorkerManifest::from_toml(&toml).unwrap();
@@ -1774,7 +1801,6 @@ model_id = "claude-sonnet-4-20250514"
fn resolved_memory_execution_validation_fails_closed() { fn resolved_memory_execution_validation_fails_closed() {
let snapshot = WorkspaceMemorySettingsSnapshot { let snapshot = WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".to_string(), workspace_id: "workspace-1".to_string(),
settings_revision: 1,
language: "English".to_string(), language: "English".to_string(),
}; };
let mut enabled = ResolvedMemoryFeatureConfig::default(); let mut enabled = ResolvedMemoryFeatureConfig::default();
@@ -1793,7 +1819,6 @@ model_id = "claude-sonnet-4-20250514"
fn resolved_subjektiv_policy_is_inert_until_trusted_attachment() { fn resolved_subjektiv_policy_is_inert_until_trusted_attachment() {
let snapshot = WorkspaceMemorySettingsSnapshot { let snapshot = WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".to_string(), workspace_id: "workspace-1".to_string(),
settings_revision: 1,
language: "English".to_string(), language: "English".to_string(),
}; };
let mut enabled = ResolvedSubjektivFeatureConfig::default(); let mut enabled = ResolvedSubjektivFeatureConfig::default();
@@ -1816,6 +1841,80 @@ model_id = "claude-sonnet-4-20250514"
assert!(WorkerManifest::from_toml(&toml).is_err()); 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::<WorkspaceMemorySettingsSnapshot>(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] #[test]
fn persisted_manifest_adapter_migrates_legacy_memory_authority() { fn persisted_manifest_adapter_migrates_legacy_memory_authority() {
let mut manifest = let mut manifest =
@@ -1863,7 +1962,7 @@ model_id = "claude-sonnet-4-20250514"
"Français" "Français"
); );
let current = write_persisted_worker_manifest_snapshot(&migrated).unwrap(); 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()); assert!(current["manifest"].get("memory").is_none());
let mut disabled = let mut disabled =
@@ -1905,7 +2004,7 @@ model_id = "claude-sonnet-4-20250514"
let restored = read_persisted_worker_manifest_snapshot(versioned).unwrap(); let restored = read_persisted_worker_manifest_snapshot(versioned).unwrap();
let current = write_persisted_worker_manifest_snapshot(&restored).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"].get("plugins").is_none());
assert!(current["manifest"]["feature"].get("plugins").is_none()); assert!(current["manifest"]["feature"].get("plugins").is_none());
assert!(current["manifest"]["feature"].get("workers").is_none()); assert!(current["manifest"]["feature"].get("workers").is_none());
@@ -1976,7 +2075,7 @@ model_id = "claude-sonnet-4-20250514"
assert!( assert!(
read_persisted_worker_manifest_snapshot(serde_json::json!({ read_persisted_worker_manifest_snapshot(serde_json::json!({
"schema_version": 4, "schema_version": 5,
"manifest": manifest, "manifest": manifest,
})) }))
.is_err() .is_err()
-1
View File
@@ -1489,7 +1489,6 @@ mod tests {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
for (field, value) in [ for (field, value) in [
("workspace_id", serde_json::json!("workspace-a")), ("workspace_id", serde_json::json!("workspace-a")),
("settings_revision", serde_json::json!(2)),
("language", serde_json::json!("Japanese")), ("language", serde_json::json!("Japanese")),
] { ] {
let artifact = serde_json::json!({ let artifact = serde_json::json!({
-2
View File
@@ -184,8 +184,6 @@ impl OperationCounts {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct MemorySettingsAudit { pub struct MemorySettingsAudit {
pub workspace_id: String, pub workspace_id: String,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub settings_revision: u64,
pub language: String, pub language: String,
} }
+30 -2
View File
@@ -108,8 +108,9 @@ pub struct EvidenceOrigin {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub flow_definition_id: Option<String>, pub flow_definition_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] /// SHA-256 of the exact pinned Flow definition content, when available.
pub flow_definition_revision: Option<u64>, /// Historical origins without captured definition content leave this absent.
pub flow_definition_fingerprint: Option<String>,
} }
/// Host-resolved source/evidence metadata for an individual staging claim. /// 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 { mod tests {
use super::*; 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] #[test]
fn splits_simple() { fn splits_simple() {
let doc = "---\nfoo: 1\n---\nbody here\n"; let doc = "---\nfoo: 1\n---\nbody here\n";
+1
View File
@@ -11,6 +11,7 @@ schemars = { workspace = true, features = ["chrono04"] }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true serde_json.workspace = true
thiserror.workspace = true thiserror.workspace = true
ticket = { path = "../ticket" }
uuid = { workspace = true, features = ["v7"] } uuid = { workspace = true, features = ["v7"] }
[dev-dependencies] [dev-dependencies]
+109 -58
View File
@@ -8,7 +8,7 @@ use std::{
use thiserror::Error; use thiserror::Error;
use uuid::Uuid; use uuid::Uuid;
const SCHEMA_VERSION: i64 = 12; const SCHEMA_VERSION: i64 = 13;
const MAX_BODY_BYTES: usize = 16 * 1024; const MAX_BODY_BYTES: usize = 16 * 1024;
const DOMAIN_TABLES: [&str; 5] = [ const DOMAIN_TABLES: [&str; 5] = [
"merge_requests", "merge_requests",
@@ -103,7 +103,7 @@ pub struct ReviewRequestedEvent {
pub sequence: u64, pub sequence: u64,
pub subject_ref: String, pub subject_ref: String,
#[serde(default)] #[serde(default)]
pub ticket_item_revision: String, pub ticket_content_digest: String,
#[serde(default)] #[serde(default)]
pub ticket_merge_request_subjects: Vec<MergeRequestReviewSubject>, pub ticket_merge_request_subjects: Vec<MergeRequestReviewSubject>,
pub requested_by: WorkerIdentity, pub requested_by: WorkerIdentity,
@@ -118,7 +118,7 @@ pub struct ReviewEvent {
pub request_event_id: String, pub request_event_id: String,
pub subject_ref: String, pub subject_ref: String,
#[serde(default)] #[serde(default)]
pub ticket_item_revision: String, pub ticket_content_digest: String,
#[serde(default)] #[serde(default)]
pub ticket_merge_request_subjects: Vec<MergeRequestReviewSubject>, pub ticket_merge_request_subjects: Vec<MergeRequestReviewSubject>,
pub decision: ReviewDecision, pub decision: ReviewDecision,
@@ -249,7 +249,7 @@ pub enum MergeRequestEvidenceError {
ReviewRequestMissing, ReviewRequestMissing,
ReviewRequestMismatch, ReviewRequestMismatch,
ApprovalNotEffective, ApprovalNotEffective,
ItemRevisionMismatch, TicketContentMismatch,
SourceSnapshotMismatch, SourceSnapshotMismatch,
RequirementApprovalMissing, RequirementApprovalMissing,
} }
@@ -268,7 +268,7 @@ impl MergeRequestEvidenceError {
Self::ReviewRequestMissing => "review_request_missing", Self::ReviewRequestMissing => "review_request_missing",
Self::ReviewRequestMismatch => "review_request_mismatch", Self::ReviewRequestMismatch => "review_request_mismatch",
Self::ApprovalNotEffective => "approval_not_effective", Self::ApprovalNotEffective => "approval_not_effective",
Self::ItemRevisionMismatch => "item_revision_mismatch", Self::TicketContentMismatch => "ticket_content_mismatch",
Self::SourceSnapshotMismatch => "source_snapshot_mismatch", Self::SourceSnapshotMismatch => "source_snapshot_mismatch",
Self::RequirementApprovalMissing => "requirement_approval_missing", Self::RequirementApprovalMissing => "requirement_approval_missing",
} }
@@ -290,14 +290,14 @@ impl MergeRequestEvidenceError {
"Reviewer approval does not match linked ReviewRequested evidence" "Reviewer approval does not match linked ReviewRequested evidence"
} }
Self::ApprovalNotEffective => "Reviewer approval is not the effective review", Self::ApprovalNotEffective => "Reviewer approval is not the effective review",
Self::ItemRevisionMismatch => { Self::TicketContentMismatch => {
"Reviewer approval does not attest the current Ticket revision" "Reviewer approval does not attest the current Ticket digest"
} }
Self::SourceSnapshotMismatch => { Self::SourceSnapshotMismatch => {
"Reviewer approval does not attest the exact linked source set" "Reviewer approval does not attest the exact linked source set"
} }
Self::RequirementApprovalMissing => { 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)?; .ok_or(MergeRequestEvidenceError::ReviewRequestMissing)?;
if requested.subject_ref != review.subject_ref 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.ticket_merge_request_subjects != review.ticket_merge_request_subjects
|| requested.reviewer != review.reviewer || requested.reviewer != review.reviewer
|| requested.sequence >= review.sequence || 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 /// exact linked source snapshot. Callers supply all linked requests and resolved
/// source refs (recorded merged refs for completion); this function does not read /// source refs (recorded merged refs for completion); this function does not read
/// authority, resolve selectors, require merged state, or mutate lifecycle state. /// 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. /// attestation. Snapshot ordering is immaterial, but duplicate ids are rejected.
pub fn requirement_approval<'a>( pub fn requirement_approval<'a>(
requests: &'a [MergeRequest], requests: &'a [MergeRequest],
item_revision: &str, content_digest: &str,
subjects: &[MergeRequestReviewSubject], subjects: &[MergeRequestReviewSubject],
event_id: Option<&str>, event_id: Option<&str>,
) -> Result<&'a ReviewEvent, MergeRequestEvidenceError> { ) -> Result<&'a ReviewEvent, MergeRequestEvidenceError> {
@@ -436,8 +436,10 @@ pub fn requirement_approval<'a>(
{ {
return Err(MergeRequestEvidenceError::ApprovalNotEffective); return Err(MergeRequestEvidenceError::ApprovalNotEffective);
} }
if review.ticket_item_revision != item_revision || item_revision.trim().is_empty() { if review.ticket_content_digest != content_digest
return Err(MergeRequestEvidenceError::ItemRevisionMismatch); || content_digest.trim().is_empty()
{
return Err(MergeRequestEvidenceError::TicketContentMismatch);
} }
if !same_source_snapshot(&review.ticket_merge_request_subjects, subjects) { if !same_source_snapshot(&review.ticket_merge_request_subjects, subjects) {
return Err(MergeRequestEvidenceError::SourceSnapshotMismatch); return Err(MergeRequestEvidenceError::SourceSnapshotMismatch);
@@ -508,7 +510,7 @@ pub struct OpenMergeRequest {
pub struct RequestMergeRequestReview { pub struct RequestMergeRequestReview {
pub merge_request_id: String, pub merge_request_id: String,
pub ticket_id: String, pub ticket_id: String,
pub ticket_item_revision: String, pub ticket_content_digest: String,
pub ticket_merge_request_subjects: Vec<MergeRequestReviewSubject>, pub ticket_merge_request_subjects: Vec<MergeRequestReviewSubject>,
pub subject_ref: String, pub subject_ref: String,
pub child_session_id: String, pub child_session_id: String,
@@ -604,6 +606,7 @@ pub struct CompleteMergeRequest {
pub struct TicketCompletionEvent { pub struct TicketCompletionEvent {
pub operation_id: String, pub operation_id: String,
pub ticket_id: String, pub ticket_id: String,
/// Frozen historical payload only; not a content attestation or live API.
pub item_revision: String, pub item_revision: String,
pub merge_request_ids: Vec<String>, pub merge_request_ids: Vec<String>,
pub requirement_approval_event_id: String, pub requirement_approval_event_id: String,
@@ -805,11 +808,11 @@ impl MergeRequestStore {
} }
let mut c = self.lock()?; let mut c = self.lock()?;
let t = c.transaction()?; 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)?; .ok_or(MergeRequestError::NotFound)?;
if current_revision != i.ticket_item_revision { if current_digest != i.ticket_content_digest {
return Err(MergeRequestError::Conflict( 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)? 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(), event_id: Uuid::now_v7().to_string(),
sequence: next_seq(&t, &mr.workspace_id, &mr.merge_request_id)?, sequence: next_seq(&t, &mr.workspace_id, &mr.merge_request_id)?,
subject_ref: i.subject_ref, 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, ticket_merge_request_subjects: i.ticket_merge_request_subjects,
requested_by: WorkerIdentity { requested_by: WorkerIdentity {
runtime_id: i.auth.runtime_id, runtime_id: i.auth.runtime_id,
@@ -944,12 +947,12 @@ impl MergeRequestStore {
"review grant invalid".into(), "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| { .and_then(|request| {
request.thread.into_iter().find_map(|event| match event { request.thread.into_iter().find_map(|event| match event {
MergeRequestThreadEvent::ReviewRequested(event) if event.event_id == req => { MergeRequestThreadEvent::ReviewRequested(event) if event.event_id == req => {
Some(( Some((
event.ticket_item_revision, event.ticket_content_digest,
event.ticket_merge_request_subjects, event.ticket_merge_request_subjects,
)) ))
} }
@@ -982,7 +985,7 @@ impl MergeRequestStore {
sequence: next_seq(&t, &ws, &mr)?, sequence: next_seq(&t, &ws, &mr)?,
request_event_id: req, request_event_id: req,
subject_ref: subject, subject_ref: subject,
ticket_item_revision, ticket_content_digest,
ticket_merge_request_subjects, ticket_merge_request_subjects,
decision: i.decision, decision: i.decision,
body: i.body, body: i.body,
@@ -1056,13 +1059,12 @@ impl MergeRequestStore {
} }
if let Some(review) = &review { if let Some(review) = &review {
let connection = self.lock()?; let connection = self.lock()?;
let current_revision = let current_digest =
current_ticket_revision(&connection, &i.auth.workspace_id, &i.ticket_id)? current_ticket_content_digest(&connection, &i.auth.workspace_id, &i.ticket_id)?
.ok_or(MergeRequestError::NotFound)?; .ok_or(MergeRequestError::NotFound)?;
if review.ticket_item_revision != current_revision { if review.ticket_content_digest != current_digest {
b.push( b.push(
"approval predates the current Ticket revision; fresh review is required" "approval predates the current Ticket digest; fresh review is required".into(),
.into(),
); );
} }
} }
@@ -1147,12 +1149,12 @@ impl MergeRequestStore {
)); ));
} }
let connection = self.lock()?; let connection = self.lock()?;
let current_revision = let current_digest =
current_ticket_revision(&connection, &mr.workspace_id, &i.ticket_id)? current_ticket_content_digest(&connection, &mr.workspace_id, &i.ticket_id)?
.ok_or(MergeRequestError::NotFound)?; .ok_or(MergeRequestError::NotFound)?;
if review.ticket_item_revision != current_revision { if review.ticket_content_digest != current_digest {
return Err(MergeRequestError::NotReady( 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<String> = connection let state: Option<String> = connection
@@ -1227,12 +1229,12 @@ impl MergeRequestStore {
"current effective review does not approve the source ref".into(), "current effective review does not approve the source ref".into(),
)); ));
} }
let current_revision = let current_digest =
current_ticket_revision(&transaction, &mr.workspace_id, &i.ticket_id)? current_ticket_content_digest(&transaction, &mr.workspace_id, &i.ticket_id)?
.ok_or(MergeRequestError::NotFound)?; .ok_or(MergeRequestError::NotFound)?;
if review.ticket_item_revision != current_revision { if review.ticket_content_digest != current_digest {
return Err(MergeRequestError::NotReady( 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<String> = transaction let state: Option<String> = transaction
@@ -1655,34 +1657,13 @@ fn linked_merge_request_ids(
.map_err(Into::into) .map_err(Into::into)
} }
fn current_ticket_revision( fn current_ticket_content_digest(
connection: &Connection, connection: &Connection,
workspace_id: &str, workspace_id: &str,
ticket_id: &str, ticket_id: &str,
) -> Result<Option<String>, MergeRequestError> { ) -> Result<Option<String>, MergeRequestError> {
let event_index = connection ticket::sqlite_ticket_content_digest(connection, workspace_id, ticket_id)
.query_row( .map_err(|error| MergeRequestError::Operation(error.to_string()))
"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)
} }
fn load_mr(c: &Connection, w: &str, m: &str) -> Result<Option<MergeRequest>, MergeRequestError> { fn load_mr(c: &Connection, w: &str, m: &str) -> Result<Option<MergeRequest>, MergeRequestError> {
@@ -1773,12 +1754,82 @@ pub fn migrate(c: &Connection) -> Result<(), MergeRequestError> {
match schema_state(c)? { match schema_state(c)? {
SchemaState::Fresh => fresh(c), SchemaState::Fresh => fresh(c),
SchemaState::Current(SCHEMA_VERSION) => verify(c), SchemaState::Current(SCHEMA_VERSION) => verify(c),
SchemaState::Current(12) => migrate_v12_content_attestations(c),
SchemaState::Current(v) => Err(MergeRequestError::Operation(format!( SchemaState::Current(v) => Err(MergeRequestError::Operation(format!(
"unsupported schema {v}" "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::<Result<Vec<_>, _>>()?
};
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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SchemaState { enum SchemaState {
Fresh, Fresh,
+37 -62
View File
@@ -24,7 +24,7 @@ fn subjects() -> Vec<MergeRequestReviewSubject> {
fn add_review( fn add_review(
request: &mut MergeRequest, request: &mut MergeRequest,
event_id: &str, event_id: &str,
revision: &str, digest: &str,
snapshot: &[MergeRequestReviewSubject], snapshot: &[MergeRequestReviewSubject],
decision: ReviewDecision, decision: ReviewDecision,
) { ) {
@@ -37,7 +37,7 @@ fn add_review(
event_id: format!("request-{event_id}"), event_id: format!("request-{event_id}"),
sequence, sequence,
subject_ref: subject.subject_ref.clone(), subject_ref: subject.subject_ref.clone(),
ticket_item_revision: revision.into(), ticket_content_digest: digest.into(),
ticket_merge_request_subjects: snapshot.to_vec(), ticket_merge_request_subjects: snapshot.to_vec(),
requested_by: actor(), requested_by: actor(),
reviewer: actor(), reviewer: actor(),
@@ -48,7 +48,7 @@ fn add_review(
sequence: sequence + 1, sequence: sequence + 1,
request_event_id: requested.event_id.clone(), request_event_id: requested.event_id.clone(),
subject_ref: requested.subject_ref.clone(), subject_ref: requested.subject_ref.clone(),
ticket_item_revision: revision.into(), ticket_content_digest: digest.into(),
ticket_merge_request_subjects: snapshot.to_vec(), ticket_merge_request_subjects: snapshot.to_vec(),
decision, decision,
body: String::new(), body: String::new(),
@@ -84,7 +84,7 @@ fn merged_requests() -> Vec<MergeRequest> {
add_review( add_review(
&mut request, &mut request,
&approval_id, &approval_id,
"revision-one", "digest-one",
&snapshot, &snapshot,
ReviewDecision::Approve, ReviewDecision::Approve,
); );
@@ -109,22 +109,17 @@ fn merged_requests() -> Vec<MergeRequest> {
} }
#[test] #[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 mut requests = merged_requests();
let snapshot = subjects(); let snapshot = subjects();
assert_eq!( assert_eq!(
requirement_approval( requirement_approval(&requests, "digest-two", &snapshot, Some("integration-MR-1")),
&requests, Err(MergeRequestEvidenceError::TicketContentMismatch)
"revision-two",
&snapshot,
Some("integration-MR-1")
),
Err(MergeRequestEvidenceError::ItemRevisionMismatch)
); );
add_review( add_review(
&mut requests[0], &mut requests[0],
"postmerge", "postmerge",
"revision-two", "digest-two",
&snapshot, &snapshot,
ReviewDecision::Approve, ReviewDecision::Approve,
); );
@@ -134,28 +129,23 @@ fn postmerge_latest_approval_attests_the_current_revision_without_replacing_inte
"integration-MR-1" "integration-MR-1"
); );
assert_eq!( assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, None) requirement_approval(&requests, "digest-two", &snapshot, None)
.unwrap() .unwrap()
.event_id, .event_id,
"postmerge" "postmerge"
); );
assert_eq!( assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, Some("postmerge")) requirement_approval(&requests, "digest-two", &snapshot, Some("postmerge"))
.unwrap() .unwrap()
.event_id, .event_id,
"postmerge" "postmerge"
); );
assert_eq!( assert_eq!(
requirement_approval( requirement_approval(&requests, "digest-one", &snapshot, Some("integration-MR-1")),
&requests,
"revision-one",
&snapshot,
Some("integration-MR-1")
),
Err(MergeRequestEvidenceError::ApprovalNotEffective) Err(MergeRequestEvidenceError::ApprovalNotEffective)
); );
assert_eq!( assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, Some("unknown")), requirement_approval(&requests, "digest-two", &snapshot, Some("unknown")),
Err(MergeRequestEvidenceError::RequirementApprovalMissing) Err(MergeRequestEvidenceError::RequirementApprovalMissing)
); );
} }
@@ -167,80 +157,70 @@ fn later_request_changes_prevents_older_requirement_approval_from_being_effectiv
add_review( add_review(
&mut requests[0], &mut requests[0],
"approved", "approved",
"revision-two", "digest-two",
&snapshot, &snapshot,
ReviewDecision::Approve, ReviewDecision::Approve,
); );
add_review( add_review(
&mut requests[0], &mut requests[0],
"changes", "changes",
"revision-two", "digest-two",
&snapshot, &snapshot,
ReviewDecision::RequestChanges, ReviewDecision::RequestChanges,
); );
assert_eq!( assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, None), requirement_approval(&requests, "digest-two", &snapshot, None),
Err(MergeRequestEvidenceError::RequirementApprovalMissing) Err(MergeRequestEvidenceError::RequirementApprovalMissing)
); );
assert_eq!( assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, Some("approved")), requirement_approval(&requests, "digest-two", &snapshot, Some("approved")),
Err(MergeRequestEvidenceError::ApprovalNotEffective) Err(MergeRequestEvidenceError::ApprovalNotEffective)
); );
assert_eq!( assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, Some("changes")), requirement_approval(&requests, "digest-two", &snapshot, Some("changes")),
Err(MergeRequestEvidenceError::ApprovalNotApproved) Err(MergeRequestEvidenceError::ApprovalNotApproved)
); );
assert!(requests[0].integration_approval().is_ok()); assert!(requests[0].integration_approval().is_ok());
} }
#[test] #[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 requests = merged_requests();
let original = subjects(); 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!( assert_eq!(
requirement_approval( requirement_approval(&requests, "digest-two", &original, Some("integration-MR-1")),
&requests, Err(MergeRequestEvidenceError::TicketContentMismatch)
"revision-two",
&original,
Some("integration-MR-1")
),
Err(MergeRequestEvidenceError::ItemRevisionMismatch)
); );
let mut changed = original.clone(); let mut changed = original.clone();
changed[1].subject_ref = "changed-source-two".into(); changed[1].subject_ref = "changed-source-two".into();
assert_eq!( assert_eq!(
requirement_approval( requirement_approval(&requests, "digest-one", &changed, Some("integration-MR-1")),
&requests,
"revision-one",
&changed,
Some("integration-MR-1")
),
Err(MergeRequestEvidenceError::SourceSnapshotMismatch) Err(MergeRequestEvidenceError::SourceSnapshotMismatch)
); );
let mut reversed = original.clone(); let mut reversed = original.clone();
reversed.reverse(); 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 // Even after the caller refreshes the linked request collection, an old
// attestation cannot authorize completion against a smaller linked set. // attestation cannot authorize completion against a smaller linked set.
assert_eq!( assert_eq!(
requirement_approval( requirement_approval(
&requests[..1], &requests[..1],
"revision-one", "digest-one",
&original[..1], &original[..1],
Some("integration-MR-1") Some("integration-MR-1")
), ),
Err(MergeRequestEvidenceError::SourceSnapshotMismatch) Err(MergeRequestEvidenceError::SourceSnapshotMismatch)
); );
assert_eq!( assert_eq!(
requirement_approval(&requests, "revision-one", &original[..1], None), requirement_approval(&requests, "digest-one", &original[..1], None),
Err(MergeRequestEvidenceError::SourceSnapshotMismatch) Err(MergeRequestEvidenceError::SourceSnapshotMismatch)
); );
assert_eq!( assert_eq!(
requirement_approval( requirement_approval(
&requests, &requests,
"revision-one", "digest-one",
&[original[0].clone(), original[0].clone()], &[original[0].clone(), original[0].clone()],
None None
), ),
@@ -249,24 +229,24 @@ fn changed_revision_or_any_linked_source_invalidates_a_multiple_mr_attestation()
} }
#[test] #[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 requests = merged_requests();
let mut snapshot = subjects(); let mut snapshot = subjects();
snapshot.pop(); snapshot.pop();
requests.pop(); requests.pop();
assert_eq!( assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, None), requirement_approval(&requests, "digest-two", &snapshot, None),
Err(MergeRequestEvidenceError::RequirementApprovalMissing) Err(MergeRequestEvidenceError::RequirementApprovalMissing)
); );
add_review( add_review(
&mut requests[0], &mut requests[0],
"refreshed-set", "refreshed-set",
"revision-two", "digest-two",
&snapshot, &snapshot,
ReviewDecision::Approve, ReviewDecision::Approve,
); );
assert_eq!( assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, None) requirement_approval(&requests, "digest-two", &snapshot, None)
.unwrap() .unwrap()
.event_id, .event_id,
"refreshed-set" "refreshed-set"
@@ -282,7 +262,7 @@ fn requirement_approval_must_bind_its_own_mr_source_and_corresponding_request()
let original = subjects(); let original = subjects();
for (case, expected) in [ for (case, expected) in [
("missing", MergeRequestEvidenceError::ReviewRequestMissing), ("missing", MergeRequestEvidenceError::ReviewRequestMissing),
("revision", MergeRequestEvidenceError::ReviewRequestMismatch), ("digest", MergeRequestEvidenceError::ReviewRequestMismatch),
("snapshot", MergeRequestEvidenceError::ReviewRequestMismatch), ("snapshot", MergeRequestEvidenceError::ReviewRequestMismatch),
("reviewer", MergeRequestEvidenceError::ReviewRequestMismatch), ("reviewer", MergeRequestEvidenceError::ReviewRequestMismatch),
("source", MergeRequestEvidenceError::ApprovalSourceMismatch), ("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(); review.request_event_id = "absent-request".into();
} }
MergeRequestThreadEvent::ReviewRequested(requested) => match case { MergeRequestThreadEvent::ReviewRequested(requested) => match case {
"revision" => requested.ticket_item_revision = "other-revision".into(), "digest" => requested.ticket_content_digest = "other-digest".into(),
"snapshot" => { "snapshot" => {
requested.ticket_merge_request_subjects.pop(); requested.ticket_merge_request_subjects.pop();
} }
@@ -309,12 +289,7 @@ fn requirement_approval_must_bind_its_own_mr_source_and_corresponding_request()
} }
} }
assert_eq!( assert_eq!(
requirement_approval( requirement_approval(&requests, "digest-one", &original, Some("integration-MR-1")),
&requests,
"revision-one",
&original,
Some("integration-MR-1")
),
Err(expected), Err(expected),
"{case}" "{case}"
); );
@@ -339,14 +314,14 @@ fn revoked_requirement_approval_is_rejected_and_discovery_uses_another_valid_mr(
assert_eq!( assert_eq!(
requirement_approval( requirement_approval(
&requests, &requests,
"revision-one", "digest-one",
&subjects(), &subjects(),
Some("integration-MR-1") Some("integration-MR-1")
), ),
Err(MergeRequestEvidenceError::ApprovalRevoked) Err(MergeRequestEvidenceError::ApprovalRevoked)
); );
assert_eq!( assert_eq!(
requirement_approval(&requests, "revision-one", &subjects(), None) requirement_approval(&requests, "digest-one", &subjects(), None)
.unwrap() .unwrap()
.event_id, .event_id,
"integration-MR-2" "integration-MR-2"
@@ -441,8 +416,8 @@ fn evidence_error_codes_are_stable_machine_diagnostics_separate_from_messages()
"approval_not_effective", "approval_not_effective",
), ),
( (
MergeRequestEvidenceError::ItemRevisionMismatch, MergeRequestEvidenceError::TicketContentMismatch,
"item_revision_mismatch", "ticket_content_mismatch",
), ),
( (
MergeRequestEvidenceError::SourceSnapshotMismatch, MergeRequestEvidenceError::SourceSnapshotMismatch,
+254 -24
View File
@@ -35,11 +35,24 @@ fn auth_repo(repository_id: &str) -> MergeRequestAuth {
..auth() ..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) { fn fixture() -> (tempfile::TempDir, MergeRequestStore) {
let d = tempfile::tempdir().unwrap(); let d = tempfile::tempdir().unwrap();
let p = d.path().join("db"); let p = d.path().join("db");
let c = Connection::open(&p).unwrap(); 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); drop(c);
let a = Assignments(Arc::new(Mutex::new(CurrentAssignment { let a = Assignments(Arc::new(Mutex::new(CurrentAssignment {
assignment_id: "A".into(), 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(); let s = MergeRequestStore::open(&p, Arc::new(a), Arc::new(Repositories)).unwrap();
(d, s) (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::<Result<Vec<_>, _>>()
.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::<Result<Vec<_>, _>>()
.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::<Result<Vec<_>, _>>()
.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<String>)> = 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::<Result<_, _>>().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) { fn open_for(s: &MergeRequestStore, merge_request_id: &str, repository_id: &str) {
s.open_merge_request(OpenMergeRequest { s.open_merge_request(OpenMergeRequest {
merge_request_id: merge_request_id.into(), merge_request_id: merge_request_id.into(),
@@ -108,7 +336,7 @@ fn request_for(
.collect(); .collect();
s.request_review(RequestMergeRequestReview { s.request_review(RequestMergeRequestReview {
merge_request_id: merge_request_id.into(), merge_request_id: merge_request_id.into(),
ticket_item_revision: "t".into(), ticket_content_digest: content_digest("Title"),
ticket_merge_request_subjects, ticket_merge_request_subjects,
ticket_id: "T".into(), ticket_id: "T".into(),
subject_ref: subject.into(), subject_ref: subject.into(),
@@ -236,13 +464,13 @@ fn integration_preserves_selectors_ticket_state_and_assignment_and_replays_its_m
assert!(active); assert!(active);
let json = serde_json::to_string(&mr).unwrap(); let json = serde_json::to_string(&mr).unwrap();
for banned in [ for banned in [
"revision_id", "digest_id",
"attempt_id", "attempt_id",
"base_commit", "base_commit",
"head_commit", "head_commit",
"source_commit", "source_commit",
"result_commit", "result_commit",
"current_revision", "current_digest",
] { ] {
assert!(!json.contains(banned), "{banned} in {json}") assert!(!json.contains(banned), "{banned} in {json}")
} }
@@ -375,7 +603,7 @@ fn review_revocation_invalidates_readiness() {
} }
#[test] #[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(); let c = Connection::open_in_memory().unwrap();
c.execute_batch( 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));", "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) r.get::<_, i64>(0)
}) })
.unwrap(), .unwrap(),
12 13
); );
merge_request::migrate(&c).unwrap(); 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 stale = approve(&store, "subject", "token-stale");
let connection = Connection::open(dir.path().join("db")).unwrap(); let connection = Connection::open(dir.path().join("db")).unwrap();
connection connection
.execute( .execute_batch(
"INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)", "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(); .unwrap();
connection connection
@@ -750,7 +977,7 @@ fn ticket_rescope_requires_fresh_review_before_merge_and_remains_recoverable() {
readiness readiness
.blockers .blockers
.iter() .iter()
.any(|blocker| blocker.contains("Ticket revision")) .any(|blocker| blocker.contains("Ticket digest"))
); );
let stale_completion = CompleteMergeRequest { let stale_completion = CompleteMergeRequest {
@@ -789,7 +1016,7 @@ fn ticket_rescope_requires_fresh_review_before_merge_and_remains_recoverable() {
.request_review(RequestMergeRequestReview { .request_review(RequestMergeRequestReview {
merge_request_id: "MR".into(), merge_request_id: "MR".into(),
ticket_id: "T".into(), ticket_id: "T".into(),
ticket_item_revision: "T:1".into(), ticket_content_digest: content_digest("Rescoped"),
ticket_merge_request_subjects: vec![MergeRequestReviewSubject { ticket_merge_request_subjects: vec![MergeRequestReviewSubject {
merge_request_id: "MR".into(), merge_request_id: "MR".into(),
subject_ref: "subject".into(), subject_ref: "subject".into(),
@@ -852,9 +1079,8 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
.unwrap(); .unwrap();
let connection = Connection::open(dir.path().join("db")).unwrap(); let connection = Connection::open(dir.path().join("db")).unwrap();
connection connection
.execute( .execute_batch(
"INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)", "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(); .unwrap();
connection connection
@@ -873,11 +1099,11 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
assert_eq!( assert_eq!(
requirement_approval( requirement_approval(
&before_refresh, &before_refresh,
"T:1", &content_digest("Rescoped"),
&snapshot, &snapshot,
Some(&integration_approval.event_id), Some(&integration_approval.event_id),
), ),
Err(MergeRequestEvidenceError::ItemRevisionMismatch) Err(MergeRequestEvidenceError::TicketContentMismatch)
); );
assert_eq!(before_refresh[0].merged_result().unwrap(), &merge); assert_eq!(before_refresh[0].merged_result().unwrap(), &merge);
assert_eq!( assert_eq!(
@@ -899,7 +1125,7 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
.request_review(RequestMergeRequestReview { .request_review(RequestMergeRequestReview {
merge_request_id: "MR".into(), merge_request_id: "MR".into(),
ticket_id: "T".into(), ticket_id: "T".into(),
ticket_item_revision: "T:1".into(), ticket_content_digest: content_digest("Rescoped"),
ticket_merge_request_subjects: vec![MergeRequestReviewSubject { ticket_merge_request_subjects: vec![MergeRequestReviewSubject {
merge_request_id: "MR".into(), merge_request_id: "MR".into(),
subject_ref: "subject".into(), subject_ref: "subject".into(),
@@ -927,7 +1153,7 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
assert_eq!( assert_eq!(
requirement_approval( requirement_approval(
&refreshed, &refreshed,
"T:1", &content_digest("Rescoped"),
&snapshot, &snapshot,
Some(&requirement_review.event_id), Some(&requirement_review.event_id),
) )
@@ -935,7 +1161,7 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
&requirement_review &requirement_review
); );
assert_eq!( assert_eq!(
requirement_approval(&refreshed, "T:1", &snapshot, None).unwrap(), requirement_approval(&refreshed, &content_digest("Rescoped"), &snapshot, None).unwrap(),
&requirement_review &requirement_review
); );
assert_eq!(refreshed[0].merged_result().unwrap(), &merge); 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 { let result = store.request_review(RequestMergeRequestReview {
merge_request_id: "MR".into(), merge_request_id: "MR".into(),
ticket_id: "T".into(), ticket_id: "T".into(),
ticket_item_revision: "t".into(), ticket_content_digest: content_digest("Title"),
ticket_merge_request_subjects: vec![MergeRequestReviewSubject { ticket_merge_request_subjects: vec![MergeRequestReviewSubject {
merge_request_id: "MR".into(), merge_request_id: "MR".into(),
subject_ref: "subject-one".into(), subject_ref: "subject-one".into(),
@@ -1058,7 +1284,13 @@ fn partial_and_full_integration_retain_ticket_state_and_active_assignment() {
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert_eq!( 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 &second
); );
let first_request = requests let first_request = requests
@@ -1217,9 +1449,7 @@ fn persisted_integration_evidence_rejects_missing_mismatched_revoked_or_late_app
match case { match case {
"source-mismatch" => altered.subject_ref = "different-source".into(), "source-mismatch" => altered.subject_ref = "different-source".into(),
"not-approved" => altered.decision = ReviewDecision::RequestChanges, "not-approved" => altered.decision = ReviewDecision::RequestChanges,
"request-mismatch" => { "request-mismatch" => altered.ticket_content_digest = "different-digest".into(),
altered.ticket_item_revision = "different-revision".into()
}
_ => unreachable!(), _ => unreachable!(),
} }
connection.execute( connection.execute(
+45 -30
View File
@@ -103,11 +103,15 @@ impl AuthenticatedInputSource {
/// Caller-owned identity for one state-changing Worker command. /// Caller-owned identity for one state-changing Worker command.
/// ///
/// A controller accepts command ids in strictly increasing order. Exact retries /// Commands operate on the controller state at admission time, not a caller's
/// of an accepted id must retain the same command kind. /// 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct WorkerCommandEnvelope { pub struct WorkerCommandEnvelope {
pub command_id: u64, pub command_id: u64,
} }
@@ -312,16 +316,15 @@ pub enum Method {
/// are immutable and therefore cannot be cancelled here. /// are immutable and therefore cannot be cancelled here.
CancelPendingSubmission { CancelPendingSubmission {
submission_id: String, 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 { ClearPendingSubmissions {
expected_revision: u64, expected_submission_ids: Vec<String>,
}, },
/// Activate the next queued submission while the Worker is idle. This is an /// Activate the next queued submission while the Worker is idle. This is an
/// explicit recovery operation and never resumes a paused run implicitly. /// explicit recovery operation and never resumes a paused run implicitly.
ContinuePending { ContinuePending {
expected_revision: u64,
expected_head_id: String, expected_head_id: String,
}, },
Resume { Resume {
@@ -365,7 +368,7 @@ pub enum Method {
ListCompletions { ListCompletions {
kind: CompletionKind, kind: CompletionKind,
prefix: String, 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. /// responses cannot cross edits, target switches, or permission changes.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
request_id: Option<String>, request_id: Option<String>,
@@ -766,7 +769,6 @@ pub enum InternalWorkerKind {
pub struct CompactionLifecycle { pub struct CompactionLifecycle {
pub schema_version: u32, pub schema_version: u32,
pub compaction_id: String, pub compaction_id: String,
pub revision: u64,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub internal_worker: Option<InternalWorkerRef>, pub internal_worker: Option<InternalWorkerRef>,
pub state: CompactionLifecycleState, pub state: CompactionLifecycleState,
@@ -806,14 +808,13 @@ pub struct InternalWorkerRef {
pub kind: InternalWorkerKind, pub kind: InternalWorkerKind,
} }
/// Reconnect state for one visible Internal Worker. The revision fences live /// Reconnect state for one visible Internal Worker. The parent serializes
/// `Event::InternalWorker` updates that raced with parent snapshot assembly. /// snapshot publication and subsequent live events on the same ordered stream.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct InternalWorkerSnapshot { pub struct InternalWorkerSnapshot {
pub worker: InternalWorkerRef, pub worker: InternalWorkerRef,
pub revision: u64,
pub session: SessionSnapshot, pub session: SessionSnapshot,
/// Public execution metadata for this child. Older snapshots and service /// Public execution metadata for this child. Older snapshots and service
/// workers may omit it; clients must never substitute the parent's values. /// workers may omit it; clients must never substitute the parent's values.
@@ -869,12 +870,11 @@ pub struct PendingSubmissionSummary {
pub byte_len: u64, 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)] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct PendingSubmissionsSnapshot { pub struct PendingSubmissionsSnapshot {
pub revision: u64,
#[serde(default)] #[serde(default)]
pub notification_count: u32, pub notification_count: u32,
/// Ordered display-only previews of waiting notifications, each bounded like /// Ordered display-only previews of waiting notifications, each bounded like
@@ -1356,7 +1356,7 @@ pub enum Event {
Snapshot { Snapshot {
session: SessionSnapshot, session: SessionSnapshot,
greeting: Greeting, 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. /// catalog authority and is deliberately not represented here.
state: WorkerStateSnapshot, state: WorkerStateSnapshot,
/// Unfinished model output that has already streamed in the current /// 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 /// A live event from a parent-owned Internal Worker. The payload reuses the
/// normal Worker event vocabulary while the wrapper carries stable origin /// 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 { InternalWorker {
worker: InternalWorkerRef, worker: InternalWorkerRef,
revision: u64,
event: Box<Event>, event: Box<Event>,
}, },
/// Terminal removal fence for one parent-owned Internal Worker session. /// Terminal removal fence for one parent-owned Internal Worker session.
@@ -1383,7 +1383,6 @@ pub enum Event {
/// the projection. /// the projection.
InternalWorkerRemoved { InternalWorkerRemoved {
worker: InternalWorkerRef, worker: InternalWorkerRef,
revision: u64,
}, },
/// Server-side segment log rotated to a fresh `SegmentStart`. /// Server-side segment log rotated to a fresh `SegmentStart`.
/// ///
@@ -2276,6 +2275,28 @@ mod tests {
assert!(error.to_string().contains("params")); 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::<Method>(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::<Method>(old_command)
.expect_err("removed command preconditions must not be silently ignored");
assert!(error.to_string().contains(field), "{method}: {error}");
}
}
}
#[test] #[test]
fn lifecycle_methods_roundtrip_with_command_identity() { fn lifecycle_methods_roundtrip_with_command_identity() {
for method in [ for method in [
@@ -3048,7 +3069,6 @@ mod tests {
CompactionLifecycle { CompactionLifecycle {
schema_version: 2, schema_version: 2,
compaction_id: "0192f0e8-4d84-7d6e-a000-000000000000".into(), compaction_id: "0192f0e8-4d84-7d6e-a000-000000000000".into(),
revision: 1,
internal_worker: None, internal_worker: None,
state, state,
started_at_ms: 1_700_000_000_000, started_at_ms: 1_700_000_000_000,
@@ -3069,7 +3089,9 @@ mod tests {
assert_eq!(parsed["event"], "compact_start"); assert_eq!(parsed["event"], "compact_start");
assert_eq!(parsed["data"]["lifecycle"]["state"], "running"); assert_eq!(parsed["data"]["lifecycle"]["state"], "running");
let decoded: Event = serde_json::from_str(&json).unwrap(); 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] #[test]
@@ -3292,7 +3314,6 @@ mod tests {
parent_session_id: Some("parent-session".into()), parent_session_id: Some("parent-session".into()),
kind: InternalWorkerKind::SubWorker, kind: InternalWorkerKind::SubWorker,
}, },
revision: 7,
event: Box::new(Event::TextDone { event: Box::new(Event::TextDone {
text: "result".into(), text: "result".into(),
}), }),
@@ -3300,14 +3321,9 @@ mod tests {
let json = serde_json::to_string(&event).unwrap(); let json = serde_json::to_string(&event).unwrap();
let decoded: Event = serde_json::from_str(&json).unwrap(); let decoded: Event = serde_json::from_str(&json).unwrap();
match decoded { match decoded {
Event::InternalWorker { Event::InternalWorker { worker, event } => {
worker,
revision,
event,
} => {
assert_eq!(worker.session_id, "session-1"); assert_eq!(worker.session_id, "session-1");
assert_eq!(worker.parent_session_id.as_deref(), Some("parent-session")); 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")); assert!(matches!(*event, Event::TextDone { ref text } if text == "result"));
} }
other => panic!("expected internal Worker event, got {other:?}"), other => panic!("expected internal Worker event, got {other:?}"),
@@ -3323,21 +3339,20 @@ mod tests {
parent_session_id: Some("parent-session".into()), parent_session_id: Some("parent-session".into()),
kind: InternalWorkerKind::SubWorker, kind: InternalWorkerKind::SubWorker,
}, },
revision: 8,
}; };
let json = serde_json::to_string(&event).unwrap(); let json = serde_json::to_string(&event).unwrap();
let decoded: Event = serde_json::from_str(&json).unwrap(); let decoded: Event = serde_json::from_str(&json).unwrap();
assert!(matches!( assert!(matches!(
decoded, decoded,
Event::InternalWorkerRemoved { worker, revision } Event::InternalWorkerRemoved { worker }
if worker.session_id == "session-1" && revision == 8 if worker.session_id == "session-1"
)); ));
} }
#[test] #[test]
fn pending_notification_previews_are_optional_and_roundtrip() { fn pending_notification_previews_are_optional_and_roundtrip() {
let legacy = serde_json::json!({ let legacy = serde_json::json!({
"revision": 1, "notification_count": 1, "notification_count": 1,
"head_id": "notification-head", "submissions": [] "head_id": "notification-head", "submissions": []
}); });
let mut snapshot: PendingSubmissionsSnapshot = let mut snapshot: PendingSubmissionsSnapshot =
-1
View File
@@ -322,7 +322,6 @@ fn rust_serialized_subscription_frame_fixtures() -> Vec<SubscriptionFrame> {
parent_session_id: Some("fixture-parent-session".into()), parent_session_id: Some("fixture-parent-session".into()),
kind: InternalWorkerKind::SubWorker, kind: InternalWorkerKind::SubWorker,
}, },
revision: 1,
session: SessionSnapshot { session: SessionSnapshot {
pending_submissions: PendingSubmissionsSnapshot::default(), pending_submissions: PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
+3 -12
View File
@@ -250,7 +250,6 @@ pub struct WorkingDirectoryRepository {
pub id: String, pub id: String,
pub provider: String, pub provider: String,
pub source: RepositorySource, pub source: RepositorySource,
pub source_revision: u64,
pub source_fingerprint: String, pub source_fingerprint: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<RepositorySelector>, pub selector: Option<RepositorySelector>,
@@ -291,9 +290,6 @@ pub struct BackendResourceHandle {
pub operation: BackendResourceOperation, pub operation: BackendResourceOperation,
pub expires_at_unix_seconds: i64, pub expires_at_unix_seconds: i64,
pub nonce: String, pub nonce: String,
pub revision: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generation: Option<u64>,
pub max_bytes: u64, pub max_bytes: u64,
pub content_type: String, pub content_type: String,
pub redaction: ResourceRedactionPolicy, pub redaction: ResourceRedactionPolicy,
@@ -305,14 +301,14 @@ pub struct BackendResourceHandle {
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct RepositorySshCredentialCandidate { pub struct RepositorySshCredentialCandidate {
pub credential_id: String, pub credential_id: String,
pub credential_revision: u64, pub public_key_fingerprint: String,
} }
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct RepositorySshMaterializationAccess { pub struct RepositorySshMaterializationAccess {
pub credential_candidates: Vec<RepositorySshCredentialCandidate>, pub credential_candidates: Vec<RepositorySshCredentialCandidate>,
pub host_trust_id: String, pub host_trust_id: String,
pub host_trust_revision: u64, pub host_key_fingerprint: String,
pub access: RepositoryAccessMode, pub access: RepositoryAccessMode,
pub expires_at_epoch_seconds: u64, pub expires_at_epoch_seconds: u64,
pub repository_id: String, pub repository_id: String,
@@ -326,7 +322,6 @@ pub struct RepositoryMaterializationContext {
pub workspace_id: String, pub workspace_id: String,
pub runtime_id: String, pub runtime_id: String,
pub operation_id: String, pub operation_id: String,
pub config_revision: u64,
pub config_projection_digest: String, pub config_projection_digest: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh: Option<RepositorySshMaterializationAccess>, pub ssh: Option<RepositorySshMaterializationAccess>,
@@ -427,7 +422,7 @@ pub struct BackendJobExecutionBinding {
pub job_id: String, pub job_id: String,
pub attempt_id: String, pub attempt_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub input_revision: Option<String>, pub input_digest: Option<String>,
#[serde(default)] #[serde(default)]
pub subjektiv_consolidation: bool, pub subjektiv_consolidation: bool,
} }
@@ -689,13 +684,11 @@ pub struct WorkerRetentionExecutionRequest {
pub workspace_id: String, pub workspace_id: String,
pub source_runtime_id: String, pub source_runtime_id: String,
pub worker_id: WorkerId, pub worker_id: WorkerId,
pub expected_worker_revision: String,
pub source_created_at: String, pub source_created_at: String,
pub removed_at: String, pub removed_at: String,
pub effective_profile: Option<String>, pub effective_profile: Option<String>,
pub retention_class: Option<String>, pub retention_class: Option<String>,
pub policy_id: String, pub policy_id: String,
pub policy_revision: u64,
pub session_disposition: SessionDisposition, pub session_disposition: SessionDisposition,
pub diagnostics_disposition: DiagnosticsDisposition, pub diagnostics_disposition: DiagnosticsDisposition,
} }
@@ -718,7 +711,6 @@ pub struct WorkerSessionArchiveManifest {
pub content_bytes: u64, pub content_bytes: u64,
pub content_file_count: u64, pub content_file_count: u64,
pub policy_id: String, pub policy_id: String,
pub policy_revision: u64,
pub operation_id: String, pub operation_id: String,
pub input_fingerprint: String, pub input_fingerprint: String,
} }
@@ -727,7 +719,6 @@ pub struct WorkerSessionArchiveManifest {
pub struct WorkerRetentionExecutionResult { pub struct WorkerRetentionExecutionResult {
pub operation_id: String, pub operation_id: String,
pub input_fingerprint: String, pub input_fingerprint: String,
pub expected_worker_revision: String,
pub worker_id: WorkerId, pub worker_id: WorkerId,
pub session_disposition: SessionDisposition, pub session_disposition: SessionDisposition,
pub diagnostics_disposition: DiagnosticsDisposition, pub diagnostics_disposition: DiagnosticsDisposition,
+79 -33
View File
@@ -41,6 +41,39 @@ fn canonical_positive_decimal(value: &str) -> bool {
&& value.bytes().all(|byte| byte.is_ascii_digit()) && value.bytes().all(|byte| byte.is_ascii_digit())
&& value.parse::<i64>().is_ok_and(|value| value > 0) && value.parse::<i64>().is_ok_and(|value| value > 0)
} }
fn node_mutation_id<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
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<String, D::Error> {
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<Option<String>, D::Error> {
Option::<String>::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<String, D::Error> { fn bounded_text<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
let value = String::deserialize(deserializer)?; let value = String::deserialize(deserializer)?;
if value.len() > DRIVE_TEXT_MAX_BYTES { if value.len() > DRIVE_TEXT_MAX_BYTES {
@@ -110,9 +143,10 @@ pub struct DriveEntry {
pub parent: Option<DriveEntryRef>, pub parent: Option<DriveEntryRef>,
pub name: String, pub name: String,
pub kind: DriveEntryKind, pub kind: DriveEntryKind,
#[serde(deserialize_with = "decimal")] #[serde(deserialize_with = "node_mutation_id")]
#[schemars(regex(pattern = "^[1-9][0-9]*$"))] #[schemars(length(max = 128))]
pub revision: String, /// 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. /// File length in bytes; absent for folders.
#[schemars(range(min = 0, max = 16_777_216))] #[schemars(range(min = 0, max = 16_777_216))]
pub size: Option<u32>, pub size: Option<u32>,
@@ -201,10 +235,10 @@ pub struct DriveReadChunkQuery {
#[serde(deserialize_with = "decimal")] #[serde(deserialize_with = "decimal")]
#[schemars(regex(pattern = "^[1-9][0-9]*$"))] #[schemars(regex(pattern = "^[1-9][0-9]*$"))]
pub id: String, pub id: String,
/// Bind a multi-chunk read to the same file revision. /// Require each chunk to match the same committed mutation observed in metadata.
#[serde(deserialize_with = "decimal")] #[serde(deserialize_with = "mutation_id")]
#[schemars(regex(pattern = "^[1-9][0-9]*$"))] #[schemars(length(max = 128))]
pub expected_revision: String, pub expected_mutation_id: String,
#[schemars(range(min = 0, max = 16_777_216))] #[schemars(range(min = 0, max = 16_777_216))]
pub offset: u32, pub offset: u32,
#[serde(default = "default_read_size", deserialize_with = "bounded_chunk")] #[serde(default = "default_read_size", deserialize_with = "bounded_chunk")]
@@ -219,8 +253,8 @@ pub struct DriveDownloadQuery {
#[serde(deserialize_with = "decimal")] #[serde(deserialize_with = "decimal")]
#[schemars(regex(pattern = "^[1-9][0-9]*$"))] #[schemars(regex(pattern = "^[1-9][0-9]*$"))]
pub id: String, pub id: String,
#[serde(default, deserialize_with = "optional_decimal")] #[serde(default, deserialize_with = "optional_mutation_id")]
pub expected_revision: Option<String>, pub expected_mutation_id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -248,26 +282,26 @@ pub enum DriveMutation {
}, },
UpdateText { UpdateText {
id: DriveEntryRef, id: DriveEntryRef,
#[serde(deserialize_with = "decimal")] #[serde(deserialize_with = "mutation_id")]
#[schemars(regex(pattern = "^[1-9][0-9]*$"))] #[schemars(length(max = 128))]
expected_revision: String, expected_mutation_id: String,
#[serde(deserialize_with = "bounded_text")] #[serde(deserialize_with = "bounded_text")]
text: String, text: String,
content_type: String, content_type: String,
}, },
Relocate { Relocate {
id: DriveEntryRef, id: DriveEntryRef,
#[serde(deserialize_with = "decimal")] #[serde(deserialize_with = "mutation_id")]
#[schemars(regex(pattern = "^[1-9][0-9]*$"))] #[schemars(length(max = 128))]
expected_revision: String, expected_mutation_id: String,
parent: DriveEntryRef, parent: DriveEntryRef,
name: String, name: String,
}, },
Delete { Delete {
id: DriveEntryRef, id: DriveEntryRef,
#[serde(deserialize_with = "decimal")] #[serde(deserialize_with = "mutation_id")]
#[schemars(regex(pattern = "^[1-9][0-9]*$"))] #[schemars(length(max = 128))]
expected_revision: String, expected_mutation_id: String,
}, },
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -287,8 +321,8 @@ pub enum DriveUploadOperation {
Update, Update,
} }
/// Flat query metadata accompanies the binary body. The service rejects invalid /// Flat query metadata accompanies the binary body. The service rejects invalid
/// combinations: create requires parent_id/name and forbids id/expected_revision; /// combinations: create requires parent_id/name and forbids id/expected_mutation_id;
/// update requires id/expected_revision and forbids parent_id/name. Both targets /// 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. /// are bound by entry_workspace_id; declared size and SHA256 must match the body.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
@@ -303,8 +337,8 @@ pub struct DriveUploadQuery {
pub name: Option<String>, pub name: Option<String>,
#[serde(default, deserialize_with = "optional_decimal")] #[serde(default, deserialize_with = "optional_decimal")]
pub id: Option<String>, pub id: Option<String>,
#[serde(default, deserialize_with = "optional_decimal")] #[serde(default, deserialize_with = "optional_mutation_id")]
pub expected_revision: Option<String>, pub expected_mutation_id: Option<String>,
pub content_type: String, pub content_type: String,
/// Values above the file limit still decode as u32 so the service returns /// Values above the file limit still decode as u32 so the service returns
/// typed Limit rather than a generic request-decoding error. /// typed Limit rather than a generic request-decoding error.
@@ -422,7 +456,7 @@ impl DriveApiError {
message: match code { message: match code {
Denied => "Drive access denied", Denied => "Drive access denied",
NotFound => "Drive resource not found", NotFound => "Drive resource not found",
Conflict => "Drive revision or name conflict", Conflict => "Drive content, location, or name conflict",
Invalid => "Invalid Drive request", Invalid => "Invalid Drive request",
Limit => "Drive request exceeds a limit", Limit => "Drive request exceeds a limit",
StorageUnavailable => "Drive storage unavailable", StorageUnavailable => "Drive storage unavailable",
@@ -614,7 +648,7 @@ mod tests {
let value = json!({ let value = json!({
"entry":{"workspace_id":"a","node_id":"7"}, "entry":{"workspace_id":"a","node_id":"7"},
"parent":{"workspace_id":"a","node_id":"1"}, "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", "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" "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() { fn drive_json_mutations_accept_only_bounded_text_and_workspace_bound_cas() {
let valid = json!({"request_id":"request-a", "mutation":{ let valid = json!({"request_id":"request-a", "mutation":{
"operation":"update_text", "id":{"workspace_id":"a","node_id":"7"}, "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(); let request: DriveMutationRequest = serde_json::from_value(valid.clone()).unwrap();
assert_eq!(serde_json::to_value(request).unwrap(), valid); assert_eq!(serde_json::to_value(request).unwrap(), valid);
@@ -675,9 +709,16 @@ mod tests {
let mut invalid = valid.clone(); let mut invalid = valid.clone();
invalid["mutation"]["id"] = json!("7"); invalid["mutation"]["id"] = json!("7");
assert!(serde_json::from_value::<DriveMutationRequest>(invalid).is_err()); assert!(serde_json::from_value::<DriveMutationRequest>(invalid).is_err());
let mut invalid = valid.clone(); for bad in [
invalid["mutation"]["expected_revision"] = json!("01"); "".to_string(),
assert!(serde_json::from_value::<DriveMutationRequest>(invalid).is_err()); "\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::<DriveMutationRequest>(invalid).is_err());
}
// UTF-8 byte count, not character count, determines the text bound. // UTF-8 byte count, not character count, determines the text bound.
let mut boundary = valid; let mut boundary = valid;
boundary["mutation"]["text"] = json!("é".repeat(DRIVE_TEXT_MAX_BYTES / 2)); boundary["mutation"]["text"] = json!("é".repeat(DRIVE_TEXT_MAX_BYTES / 2));
@@ -695,18 +736,18 @@ mod tests {
) )
.is_err() .is_err()
); );
assert!(serde_json::from_value::<DriveReadChunkQuery>(json!({"entry_workspace_id":"a","id":"7","expected_revision":"1","offset":0,"length":size})).is_err()); assert!(serde_json::from_value::<DriveReadChunkQuery>(json!({"entry_workspace_id":"a","id":"7","expected_mutation_id":"1","offset":0,"length":size})).is_err());
} }
let default: DriveReadTextQuery = let default: DriveReadTextQuery =
serde_json::from_value(json!({"entry_workspace_id":"a","id":"7"})).unwrap(); serde_json::from_value(json!({"entry_workspace_id":"a","id":"7"})).unwrap();
assert_eq!(default.max_bytes, 65536); 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); assert_eq!(chunk.length, 65536);
} }
#[test] #[test]
fn drive_upload_requires_binding_declared_integrity_and_request_identity() { 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::<DriveUploadQuery>(valid.clone()).is_ok()); assert!(serde_json::from_value::<DriveUploadQuery>(valid.clone()).is_ok());
for field in ["entry_workspace_id", "size", "sha256", "request_id"] { for field in ["entry_workspace_id", "size", "sha256", "request_id"] {
let mut missing = valid.clone(); let mut missing = valid.clone();
@@ -725,7 +766,7 @@ mod tests {
"service must receive oversized declared sizes for typed Limit classification" "service must receive oversized declared sizes for typed Limit classification"
); );
} }
assert!(serde_json::from_value::<DriveReadChunkQuery>(json!({"entry_workspace_id":"a","id":"7","expected_revision":"1","offset":DRIVE_FILE_MAX_BYTES,"length":1})).is_ok()); assert!(serde_json::from_value::<DriveReadChunkQuery>(json!({"entry_workspace_id":"a","id":"7","expected_mutation_id":"1","offset":DRIVE_FILE_MAX_BYTES,"length":1})).is_ok());
let mut invalid = valid; let mut invalid = valid;
invalid["bytes"] = json!([0, 255]); invalid["bytes"] = json!([0, 255]);
assert!(serde_json::from_value::<DriveUploadQuery>(invalid).is_err()); assert!(serde_json::from_value::<DriveUploadQuery>(invalid).is_err());
@@ -763,7 +804,12 @@ mod tests {
for (code, wire, status, message) in [ for (code, wire, status, message) in [
(Denied, "denied", 403, "Drive access denied"), (Denied, "denied", 403, "Drive access denied"),
(NotFound, "not_found", 404, "Drive resource not found"), (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"), (Invalid, "invalid", 400, "Invalid Drive request"),
(Limit, "limit", 413, "Drive request exceeds a limit"), (Limit, "limit", 413, "Drive request exceeds a limit"),
( (
File diff suppressed because it is too large Load Diff
@@ -1996,33 +1996,39 @@ mod tests {
#[test] #[test]
fn generator_rejects_lossy_safe_integers_and_preserves_bounded_ranges() { fn generator_rejects_lossy_safe_integers_and_preserves_bounded_ranges() {
let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); let mut fixture: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["maximum"] = 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); serde_json::json!(9_007_199_254_740_992_u64);
let error = generate_repository_typescript(&document.to_string()).unwrap_err(); let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(error.to_string().contains("safe-number")); assert!(error.to_string().contains("safe-number"));
let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); let mut document = fixture.clone();
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["minimum"] = document["components"]["schemas"]["RepositorySummary"]["properties"]["size_bytes"]["minimum"] =
serde_json::json!(-1); serde_json::json!(-1);
let error = generate_repository_typescript(&document.to_string()).unwrap_err(); let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(error.to_string().contains("safe-number")); assert!(error.to_string().contains("safe-number"));
let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); let mut document = fixture.clone();
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["maximum"] = document["components"]["schemas"]["RepositorySummary"]["properties"]["size_bytes"]["maximum"] =
serde_json::json!(9_007_199_254_740_990_u64); serde_json::json!(9_007_199_254_740_990_u64);
let output = generate_repository_typescript(&document.to_string()).unwrap(); let output = generate_repository_typescript(&document.to_string()).unwrap();
assert!(output.contains( 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(); let mut document = fixture.clone();
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["format"] = document["components"]["schemas"]["RepositorySummary"]["properties"]["size_bytes"]["format"] =
serde_json::json!("int64"); serde_json::json!("int64");
let error = generate_repository_typescript(&document.to_string()).unwrap_err(); let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(error.to_string().contains("unsupported integer format")); 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"] document["components"]["schemas"]["RepositorySummary"]["properties"]["git"]["anyOf"]
.as_array_mut() .as_array_mut()
.unwrap() .unwrap()
+15 -8
View File
@@ -82,9 +82,6 @@ pub struct WorkspaceConfigNode {
pub struct WorkspaceConfigObserveResponse { pub struct WorkspaceConfigObserveResponse {
pub connection_id: String, pub connection_id: String,
pub validator: 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, pub digest: String,
/// Canonical commit entrypoints from this exact metadata snapshot. /// Canonical commit entrypoints from this exact metadata snapshot.
pub entrypoints: Vec<String>, pub entrypoints: Vec<String>,
@@ -121,9 +118,6 @@ pub struct WorkspaceConfigCommitRequest {
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkspaceConfigCommitResponse { pub struct WorkspaceConfigCommitResponse {
pub validator: 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, pub digest: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -178,11 +172,11 @@ mod tests {
#[test] #[test]
fn workspace_config_observation_is_metadata_only_and_has_same_snapshot_cas_inputs() { 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 = let response: WorkspaceConfigObserveResponse =
serde_json::from_value(value.clone()).unwrap(); serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(response).unwrap(), value); 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(); let mut missing = value.clone();
missing.as_object_mut().unwrap().remove(field); missing.as_object_mut().unwrap().remove(field);
assert!(serde_json::from_value::<WorkspaceConfigObserveResponse>(missing).is_err()); assert!(serde_json::from_value::<WorkspaceConfigObserveResponse>(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::<WorkspaceConfigCommitResponse>(missing).is_err());
}
}
#[test] #[test]
fn workspace_config_failure_classification_roundtrips_and_rejections_are_pre_effects() { fn workspace_config_failure_classification_roundtrips_and_rejections_are_pre_effects() {
for classification in [ for classification in [
+3 -1
View File
@@ -51,7 +51,9 @@ pub enum LoggedSessionHistoryOrigin {
FlowInstruction { FlowInstruction {
selector: String, selector: String,
definition_id: 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<String>,
instance_id: String, instance_id: String,
state_id: String, state_id: String,
}, },
+2 -2
View File
@@ -3662,7 +3662,7 @@ mod tests {
LoggedSessionHistoryOrigin::FlowInstruction { LoggedSessionHistoryOrigin::FlowInstruction {
selector: "builtin:coder-review".into(), selector: "builtin:coder-review".into(),
definition_id: "flow-definition".into(), definition_id: "flow-definition".into(),
definition_revision: 7, definition_digest: Some("sha256:flow-source".into()),
instance_id: "flow-instance".into(), instance_id: "flow-instance".into(),
state_id: "implement".into(), state_id: "implement".into(),
}, },
@@ -3695,7 +3695,7 @@ mod tests {
origin: LoggedSessionHistoryOrigin::FlowInstruction { origin: LoggedSessionHistoryOrigin::FlowInstruction {
selector: "builtin:coder-review".into(), selector: "builtin:coder-review".into(),
definition_id: "flow-definition".into(), definition_id: "flow-definition".into(),
definition_revision: 7, definition_digest: Some("sha256:flow-source".into()),
instance_id: "flow-instance".into(), instance_id: "flow-instance".into(),
state_id: "implement".into(), state_id: "implement".into(),
}, },
+1 -1
View File
@@ -1156,7 +1156,7 @@ mod tests {
)))], )))],
extensions: vec![SessionExtension::new( extensions: vec![SessionExtension::new(
"flow.runtime.v1", "flow.runtime.v1",
serde_json::json!({ "state": "implement", "revision": 0 }), serde_json::json!({ "state": "implement" }),
)], )],
}; };
let json = serde_json::to_string(&entry).unwrap(); let json = serde_json::to_string(&entry).unwrap();
+24 -12
View File
@@ -77,7 +77,6 @@ impl SystemReminder {
pub struct PromptRenderProvenance { pub struct PromptRenderProvenance {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>, pub workspace_id: Option<String>,
pub config_revision: u64,
pub source_digest: String, pub source_digest: String,
pub projection_digest: String, pub projection_digest: String,
pub logical_name: String, pub logical_name: String,
@@ -176,10 +175,14 @@ pub enum SystemItem {
}, },
/// A Host-fetched change to user-managed Subject behavior. Each changed /// 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 { SubjectBehaviorRefresh {
body: String, 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")] #[serde(default, skip_serializing_if = "Option::is_none")]
prompt_provenance: Option<PromptRenderProvenance>, prompt_provenance: Option<PromptRenderProvenance>,
}, },
@@ -292,22 +295,31 @@ mod tests {
} }
#[test] #[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 { let item = SystemItem::SubjectBehaviorRefresh {
body: "current Subject context".to_string(), body: "current Subject context".to_string(),
behavior_revision: 7, behavior_md: "exact behavior".to_string(),
prompt_provenance: None, prompt_provenance: None,
}; };
let raw = serde_json::to_string(&item).unwrap(); let raw = serde_json::to_string(&item).unwrap();
let parsed: SystemItem = serde_json::from_str(&raw).unwrap(); let parsed: SystemItem = serde_json::from_str(&raw).unwrap();
assert_eq!(parsed.history_text(), "current Subject context"); assert_eq!(parsed.history_text(), "current Subject context");
assert!(matches!( assert!(
parsed, matches!(parsed, SystemItem::SubjectBehaviorRefresh { behavior_md, .. } if behavior_md == "exact behavior")
SystemItem::SubjectBehaviorRefresh { );
behavior_revision: 7, }
..
} #[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] #[test]
+36 -24
View File
@@ -13,7 +13,10 @@ use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use thiserror::Error; 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@@ -69,7 +72,10 @@ impl JobSnapshot {
pub fn outcome(&self) -> job::JobOutcome { pub fn outcome(&self) -> job::JobOutcome {
job::JobOutcome { job::JobOutcome {
job_id: self.request.job_id.clone(), 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_id: self.attempt.attempt_id.clone(),
attempt: self.attempt.number, attempt: self.attempt.number,
state: match self.state { state: match self.state {
@@ -135,8 +141,8 @@ pub enum JobStoreError {
ConcurrentLimit, ConcurrentLimit,
#[error("result or lifecycle operation is not bound to the current Job attempt")] #[error("result or lifecycle operation is not bound to the current Job attempt")]
AttemptMismatch, AttemptMismatch,
#[error("result input revision does not match the immutable request")] #[error("result input digest does not match the immutable request")]
RevisionMismatch, DigestMismatch,
#[error("accepted Job result cannot be changed")] #[error("accepted Job result cannot be changed")]
ResultConflict, ResultConflict,
#[error("invalid Job transition: {0}")] #[error("invalid Job transition: {0}")]
@@ -176,8 +182,11 @@ impl JobStore {
tx.execute_batch(GRANT_SCHEMA)?; tx.execute_batch(GRANT_SCHEMA)?;
tx.pragma_update(None, "user_version", SCHEMA_VERSION)?; tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
} }
1 => { 1 | 2 => {
tx.execute_batch(GRANT_SCHEMA)?; if version == 1 {
tx.execute_batch(GRANT_SCHEMA)?;
}
legacy_migrations::migrate_legacy_jobs(&tx)?;
tx.pragma_update(None, "user_version", SCHEMA_VERSION)?; tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
} }
SCHEMA_VERSION => {} SCHEMA_VERSION => {}
@@ -370,8 +379,8 @@ impl JobStore {
.transaction_with_behavior(TransactionBehavior::Immediate)?; .transaction_with_behavior(TransactionBehavior::Immediate)?;
let snapshot = require_snapshot(&tx, &bound.job_id)?; let snapshot = require_snapshot(&tx, &bound.job_id)?;
check_attempt(&snapshot, &bound.attempt_id)?; check_attempt(&snapshot, &bound.attempt_id)?;
if snapshot.request.input_revision != bound.input_revision { if snapshot.request.input_digest()? != bound.input_digest {
return Err(JobStoreError::RevisionMismatch); return Err(JobStoreError::DigestMismatch);
} }
let (digest, encoded) = let (digest, encoded) =
job::result_digest(&bound.result, snapshot.request.limits.max_result_bytes)?; job::result_digest(&bound.result, snapshot.request.limits.max_result_bytes)?;
@@ -527,13 +536,13 @@ fn insert_attempt(
number: u8, number: u8,
) -> Result<(), JobStoreError> { ) -> Result<(), JobStoreError> {
conn.execute( 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')", VALUES (?1, ?2, ?3, ?4, 'reserved')",
params![ params![
request.job_id, request.job_id,
number, number,
job::attempt_id(&request.job_id, number), job::attempt_id(&request.job_id, number),
request.input_revision request.input_digest()?
], ],
)?; )?;
Ok(()) Ok(())
@@ -669,7 +678,7 @@ CREATE TABLE job_attempts (
job_id TEXT NOT NULL REFERENCES job_intents(job_id), job_id TEXT NOT NULL REFERENCES job_intents(job_id),
number INTEGER NOT NULL CHECK (number BETWEEN 1 AND 255), number INTEGER NOT NULL CHECK (number BETWEEN 1 AND 255),
attempt_id TEXT NOT NULL UNIQUE, 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')), state TEXT NOT NULL CHECK (state IN ('reserved', 'dispatched', 'completed', 'failed', 'unknown', 'cancelled')),
result_json TEXT, result_json TEXT,
result_digest 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 job_id, request_json, fingerprint, serialization_key, max_concurrent ON job_intents
BEGIN SELECT RAISE(ABORT, 'immutable Job intent'); END; BEGIN SELECT RAISE(ABORT, 'immutable Job intent'); END;
CREATE TRIGGER job_attempt_immutable BEFORE UPDATE OF 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; BEGIN SELECT RAISE(ABORT, 'immutable Job attempt identity'); END;
CREATE TRIGGER job_result_immutable BEFORE UPDATE OF result_json, result_digest ON job_attempts CREATE TRIGGER job_result_immutable BEFORE UPDATE OF result_json, result_digest ON job_attempts
WHEN OLD.result_json IS NOT NULL AND WHEN OLD.result_json IS NOT NULL AND
@@ -705,7 +714,6 @@ mod tests {
JobRequest { JobRequest {
job_id: id.into(), job_id: id.into(),
purpose: "fixture".into(), purpose: "fixture".into(),
input_revision: "revision-1".into(),
input_ref: "fixture:input".into(), input_ref: "fixture:input".into(),
input: json!({"immutable": [1, 2, 3]}), input: json!({"immutable": [1, 2, 3]}),
instruction: "Produce a structured result".into(), instruction: "Produce a structured result".into(),
@@ -725,7 +733,10 @@ mod tests {
JobResultSubmission { JobResultSubmission {
job_id: snapshot.request.job_id.clone(), job_id: snapshot.request.job_id.clone(),
attempt_id: snapshot.attempt.attempt_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, result,
} }
} }
@@ -762,10 +773,10 @@ mod tests {
for sql in [ for sql in [
"UPDATE job_intents SET request_json = '{}' WHERE job_id = 'identity'", "UPDATE job_intents SET request_json = '{}' WHERE job_id = 'identity'",
"UPDATE job_intents SET fingerprint = 'changed' 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_attempts SET attempt_id = 'changed' WHERE job_id = 'identity'",
"UPDATE job_intents SET current_attempt = 2 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')", VALUES ('absent', 1, 'absent:attempt:1', 'r', 'reserved')",
] { ] {
assert!( assert!(
@@ -818,9 +829,6 @@ mod tests {
assert_eq!(store.reserve(original.clone()).unwrap(), snapshot); assert_eq!(store.reserve(original.clone()).unwrap(), snapshot);
let mut variants = Vec::new(); let mut variants = Vec::new();
let mut changed = original.clone(); let mut changed = original.clone();
changed.input_revision = "revision-2".into();
variants.push(changed);
let mut changed = original.clone();
changed.input = json!({"changed": true}); changed.input = json!({"changed": true});
variants.push(changed); variants.push(changed);
let mut changed = original.clone(); let mut changed = original.clone();
@@ -917,12 +925,16 @@ mod tests {
Err(JobStoreError::AttemptMismatch) Err(JobStoreError::AttemptMismatch)
)); ));
let prior: (String, String, String) = store.conn.query_row( 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)?)), [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
).unwrap(); ).unwrap();
assert_eq!( assert_eq!(
prior, prior,
("failed".into(), "timeout".into(), "revision-1".into()) (
"failed".into(),
"timeout".into(),
original.input_digest().unwrap()
)
); );
store store
.finish( .finish(
@@ -960,10 +972,10 @@ mod tests {
Err(JobStoreError::AttemptMismatch) Err(JobStoreError::AttemptMismatch)
)); ));
let mut invalid = valid.clone(); let mut invalid = valid.clone();
invalid.input_revision = "revision-2".into(); invalid.input_digest = "wrong-digest".into();
assert!(matches!( assert!(matches!(
store.accept(&invalid), store.accept(&invalid),
Err(JobStoreError::RevisionMismatch) Err(JobStoreError::DigestMismatch)
)); ));
let mut invalid = valid.clone(); let mut invalid = valid.clone();
invalid.result = json!("x".repeat(32)); invalid.result = json!("x".repeat(32));
@@ -1537,7 +1549,7 @@ mod tests {
let absent = JobResultSubmission { let absent = JobResultSubmission {
job_id: "missing".into(), job_id: "missing".into(),
attempt_id: "attempt".into(), attempt_id: "attempt".into(),
input_revision: "r".into(), input_digest: request("missing").input_digest().unwrap(),
result: json!({}), result: json!({}),
}; };
assert!(matches!( assert!(matches!(
@@ -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::<Result<Vec<_>, _>>()?;
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<String>>(4)?, "result_digest":r.get::<_, Option<String>>(5)?,
"failure":r.get::<_, Option<String>>(6)?
})))?.collect::<Result<Vec<_>, _>>()?;
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::<Value>(&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<String> = 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::<Result<_, _>>()
.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()
);
}
}
+12 -6
View File
@@ -219,7 +219,10 @@ impl StandaloneJobs {
store: self.store.clone(), store: self.store.clone(),
job_id: job_id.into(), job_id: job_id.into(),
attempt_id: snapshot.attempt.attempt_id.clone(), 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, deadline,
}); });
let grant = match ( let grant = match (
@@ -295,7 +298,10 @@ impl StandaloneJobs {
store: store.clone(), store: store.clone(),
job_id: id.clone(), job_id: id.clone(),
attempt_id: attempt_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, deadline,
}); });
let (cancel, mut cancellation) = watch::channel(false); let (cancel, mut cancellation) = watch::channel(false);
@@ -451,14 +457,14 @@ struct BoundResultSink {
store: Arc<Mutex<Option<JobStore>>>, store: Arc<Mutex<Option<JobStore>>>,
job_id: String, job_id: String,
attempt_id: String, attempt_id: String,
input_revision: String, input_digest: String,
deadline: tokio::time::Instant, deadline: tokio::time::Instant,
} }
impl worker::job::JobResultSink for BoundResultSink { impl worker::job::JobResultSink for BoundResultSink {
fn submit(&self, submission: JobResultSubmission) -> Result<(), String> { fn submit(&self, submission: JobResultSubmission) -> Result<(), String> {
if submission.job_id != self.job_id if submission.job_id != self.job_id
|| submission.attempt_id != self.attempt_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()); return Err("Job result capability binding mismatch".into());
} }
@@ -482,7 +488,7 @@ struct BoundAttemptFence {
store: Arc<Mutex<Option<JobStore>>>, store: Arc<Mutex<Option<JobStore>>>,
job_id: String, job_id: String,
attempt_id: String, attempt_id: String,
input_revision: String, input_digest: String,
deadline: tokio::time::Instant, deadline: tokio::time::Instant,
} }
impl JobAttemptFence for BoundAttemptFence { impl JobAttemptFence for BoundAttemptFence {
@@ -499,7 +505,7 @@ impl JobAttemptFence for BoundAttemptFence {
if snapshot.state != JobState::Pending if snapshot.state != JobState::Pending
|| snapshot.attempt.state != JobAttemptState::Dispatched || snapshot.attempt.state != JobAttemptState::Dispatched
|| snapshot.attempt.attempt_id != self.attempt_id || 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()); return Err("Job domain grant is not bound to the current live attempt".into());
} }
+9 -10
View File
@@ -11,7 +11,6 @@ fn request(id: &str) -> JobRequest {
JobRequest { JobRequest {
job_id: id.into(), job_id: id.into(),
purpose: "test".into(), purpose: "test".into(),
input_revision: "r1".into(),
input_ref: "fixture:input".into(), input_ref: "fixture:input".into(),
input: serde_json::json!({"data":42}), input: serde_json::json!({"data":42}),
instruction: "Return a structured result".into(), instruction: "Return a structured result".into(),
@@ -25,7 +24,7 @@ fn sink(jobs: &StandaloneJobs, id: &str, attempt: &str) -> BoundResultSink {
store: jobs.store.clone(), store: jobs.store.clone(),
job_id: id.into(), job_id: id.into(),
attempt_id: attempt.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), deadline: tokio::time::Instant::now() + Duration::from_secs(10),
} }
} }
@@ -33,7 +32,7 @@ fn result(id: &str, attempt: &str) -> JobResultSubmission {
JobResultSubmission { JobResultSubmission {
job_id: id.into(), job_id: id.into(),
attempt_id: attempt.into(), attempt_id: attempt.into(),
input_revision: "r1".into(), input_digest: request(id).input_digest().unwrap(),
result: serde_json::json!({"valid":true}), result: serde_json::json!({"valid":true}),
} }
} }
@@ -99,7 +98,7 @@ type CapturedDomainAttempt = (JobSnapshot, Arc<dyn JobAttemptFence>);
#[derive(Default)] #[derive(Default)]
struct RecordingDomainProvider(Mutex<Vec<CapturedDomainAttempt>>); struct RecordingDomainProvider(Mutex<Vec<CapturedDomainAttempt>>);
fn domain_grant(id: &str) -> Value { 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 { fn domain_request(id: &str) -> JobRequest {
let mut intent = request(id); let mut intent = request(id);
@@ -244,7 +243,7 @@ async fn persisted_grant_requires_provider_and_missing_grant_cannot_be_derived_f
} }
#[tokio::test] #[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 temp = tempfile::tempdir().unwrap();
let jobs = let jobs =
StandaloneJobs::open(&temp.path().join("jobs.sqlite3"), temp.path().to_path_buf()).unwrap(); 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 [ for (id, grant) in [
("foreign-job", domain_grant("another-job")), ("foreign-job", domain_grant("another-job")),
( (
"foreign-revision", "foreign-digest",
json!({"consumer":"test-domain","job_id":"foreign-revision","input_revision":"r0"}), json!({"consumer":"test-domain","job_id":"foreign-digest","input_digest":"r0"}),
), ),
( (
"foreign-consumer", "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(); 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] #[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 temp = tempfile::tempdir().unwrap();
let jobs = let jobs =
StandaloneJobs::open(&temp.path().join("jobs.sqlite3"), temp.path().to_path_buf()).unwrap(); 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()); assert!(bound.submit(result("first", "first:attempt:2")).is_err());
let mut stale = result("first", &first.attempt.attempt_id); 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()); assert!(bound.submit(stale).is_err());
let mut expired = sink(&jobs, "first", &first.attempt.attempt_id); let mut expired = sink(&jobs, "first", &first.attempt.attempt_id);
expired.deadline = tokio::time::Instant::now() - Duration::from_secs(1); expired.deadline = tokio::time::Instant::now() - Duration::from_secs(1);
+146 -26
View File
@@ -17,7 +17,7 @@ const LEASE_FILE: &str = "lease.json";
const LEASE_LOCK_FILE: &str = "lease.lock"; const LEASE_LOCK_FILE: &str = "lease.lock";
const SESSIONS_DIR: &str = "sessions"; const SESSIONS_DIR: &str = "sessions";
const WORKER_DIR: &str = "worker"; const WORKER_DIR: &str = "worker";
const SCHEMA_VERSION: u32 = 1; const SCHEMA_VERSION: u32 = 2;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StandaloneCwdIdentity { pub struct StandaloneCwdIdentity {
@@ -79,7 +79,6 @@ pub enum StandaloneShutdownReason {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StandaloneWorkerRecord { pub struct StandaloneWorkerRecord {
pub schema_version: u32, pub schema_version: u32,
pub revision: u64,
pub worker_id: WorkerId, pub worker_id: WorkerId,
/// User-facing Worker name resolved from the profile. /// User-facing Worker name resolved from the profile.
pub worker_name: String, pub worker_name: String,
@@ -194,7 +193,6 @@ impl StandaloneWorkerStore {
let now = now_unix_ms()?; let now = now_unix_ms()?;
let record = StandaloneWorkerRecord { let record = StandaloneWorkerRecord {
schema_version: SCHEMA_VERSION, schema_version: SCHEMA_VERSION,
revision: 1,
worker_id: allocation.worker_id, worker_id: allocation.worker_id,
worker_name: manifest.worker.name.clone(), worker_name: manifest.worker.name.clone(),
storage_key, storage_key,
@@ -326,13 +324,12 @@ impl StandaloneWorkerStore {
active_segment_id: Option<SegmentId>, active_segment_id: Option<SegmentId>,
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> { ) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
let mut next = record.clone(); let mut next = record.clone();
next.revision = next.revision.saturating_add(1);
next.updated_at_unix_ms = now_unix_ms()?; next.updated_at_unix_ms = now_unix_ms()?;
next.active_session_id = active_session_id; next.active_session_id = active_session_id;
next.active_segment_id = active_segment_id; next.active_segment_id = active_segment_id;
next.status = StandaloneWorkerStatus::Active; next.status = StandaloneWorkerStatus::Active;
next.shutdown_reason = None; next.shutdown_reason = None;
self.commit_record(Some(record.revision), &next)?; self.commit_record(Some(record), &next)?;
Ok(next) Ok(next)
} }
@@ -344,13 +341,12 @@ impl StandaloneWorkerStore {
reason: StandaloneShutdownReason, reason: StandaloneShutdownReason,
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> { ) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
let mut next = record.clone(); let mut next = record.clone();
next.revision = next.revision.saturating_add(1);
next.updated_at_unix_ms = now_unix_ms()?; next.updated_at_unix_ms = now_unix_ms()?;
next.active_session_id = active_session_id; next.active_session_id = active_session_id;
next.active_segment_id = active_segment_id; next.active_segment_id = active_segment_id;
next.status = StandaloneWorkerStatus::Stopped; next.status = StandaloneWorkerStatus::Stopped;
next.shutdown_reason = Some(reason); next.shutdown_reason = Some(reason);
self.commit_record(Some(record.revision), &next)?; self.commit_record(Some(record), &next)?;
Ok(next) Ok(next)
} }
@@ -405,7 +401,7 @@ impl StandaloneWorkerStore {
fn commit_record( fn commit_record(
&self, &self,
expected_revision: Option<u64>, expected_record: Option<&StandaloneWorkerRecord>,
next: &StandaloneWorkerRecord, next: &StandaloneWorkerRecord,
) -> Result<(), StandaloneStoreError> { ) -> Result<(), StandaloneStoreError> {
let dir = self.worker_dir(next.worker_id); let dir = self.worker_dir(next.worker_id);
@@ -421,19 +417,21 @@ impl StandaloneWorkerStore {
StandaloneStoreError::Io(error) 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)?; marker_file.sync_all().map_err(StandaloneStoreError::Io)?;
sync_directory(&dir)?; 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)?; let current = self.load_record_while_committing(next.worker_id)?;
if current.revision != expected { // The create-new commit marker excludes other writers across this
let _ = fs::remove_file(&marker); // comparison and replacement. Compare the observed metadata itself,
return Err(StandaloneStoreError::RevisionConflict { // including its manifest, pointer and lifecycle state, not a counter.
id: next.worker_id, if serde_json::to_value(&current).map_err(StandaloneStoreError::Json)?
expected, != serde_json::to_value(expected).map_err(StandaloneStoreError::Json)?
found: current.revision, {
}); 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", "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(|| { let persisted_manifest = object.remove("manifest").ok_or_else(|| {
serde_json::Error::io(io::Error::new( serde_json::Error::io(io::Error::new(
io::ErrorKind::InvalidData, io::ErrorKind::InvalidData,
@@ -770,14 +781,8 @@ pub enum StandaloneStoreError {
LeaseOwnershipLost, LeaseOwnershipLost,
#[error("standalone Worker {0} must be stopped before deletion")] #[error("standalone Worker {0} must be stopped before deletion")]
DeleteActive(WorkerId), DeleteActive(WorkerId),
#[error( #[error("standalone Worker {0} metadata changed since it was read")]
"standalone Worker {id} metadata revision changed (expected {expected}, found {found})" MetadataChanged(WorkerId),
)]
RevisionConflict {
id: WorkerId,
expected: u64,
found: u64,
},
#[error("system clock is before the Unix epoch or out of range")] #[error("system clock is before the Unix epoch or out of range")]
Clock, Clock,
#[error("standalone metadata serialization failed")] #[error("standalone metadata serialization failed")]
@@ -816,7 +821,6 @@ permission = "write"
let manifest = test_manifest(); let manifest = test_manifest();
let record = StandaloneWorkerRecord { let record = StandaloneWorkerRecord {
schema_version: SCHEMA_VERSION, schema_version: SCHEMA_VERSION,
revision: 6,
worker_id, worker_id,
worker_name: manifest.worker.name.clone(), worker_name: manifest.worker.name.clone(),
storage_key: "standalone-test".to_string(), 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] #[test]
fn lease_liveness_requires_positive_live_or_stale_evidence() { fn lease_liveness_requires_positive_live_or_stale_evidence() {
assert_eq!( assert_eq!(
+4 -5
View File
@@ -352,7 +352,7 @@ fn operation_error(error: subjektiv::api::OperationError) -> SubjektivHostError
} else if message.contains("candidate_decision_conflict") { } else if message.contains("candidate_decision_conflict") {
"candidate_decision_conflict" "candidate_decision_conflict"
} else { } else {
"revision_conflict" "change_conflict"
} }
.into(), .into(),
message, message,
@@ -655,7 +655,7 @@ impl LocalSubjectHost {
worker_id: Some(self.worker_id.to_string()), worker_id: Some(self.worker_id.to_string()),
flow_selector: None, flow_selector: None,
flow_definition_id: None, flow_definition_id: None,
flow_definition_revision: None, flow_definition_fingerprint: None,
}; };
anchor.origin = Some(origin.clone()); anchor.origin = Some(origin.clone());
source.origin = Some(origin); source.origin = Some(origin);
@@ -835,8 +835,7 @@ impl SubjektivHost for LocalSubjectHost {
job::DEFAULT_MAX_RESULT_BYTES, job::DEFAULT_MAX_RESULT_BYTES,
) )
.map_err(operation_error)?; .map_err(operation_error)?;
let subject = self self.catalog
.catalog
.store .store
.subject(&self.binding.subject_id) .subject(&self.binding.subject_id)
.map_err(domain_error)? .map_err(domain_error)?
@@ -847,7 +846,7 @@ impl SubjektivHost for LocalSubjectHost {
}; };
let request=job::JobRequest { let request=job::JobRequest {
job_id:format!("subjektiv-consolidation:{}",uuid::Uuid::now_v7()),purpose:"subjektiv_consolidation".into(), 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}), 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(), 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)), profile:"builtin:standalone-subjektiv-consolidation".into(),serialization_key:Some(format!("subjektiv:{}",self.binding.subject_id)),
+1 -1
View File
@@ -487,7 +487,7 @@ async fn standalone_restore_recovers_only_a_proven_stale_lease() -> TestResult {
source: manifest::ProfileRegistrySource::User, source: manifest::ProfileRegistrySource::User,
name: "user-standalone".to_string(), name: "user-standalone".to_string(),
path: None, path: None,
provenance: Some("user-config-revision-7".to_string()), provenance: Some("user-config-digest".to_string()),
}, },
profile: Some(manifest::ProfileMetadata { profile: Some(manifest::ProfileMetadata {
name: Some("User standalone".to_string()), name: Some("User standalone".to_string()),
-1
View File
@@ -91,7 +91,6 @@ fn request(id: &str) -> JobRequest {
JobRequest { JobRequest {
job_id: id.into(), job_id: id.into(),
purpose: "generic_test".into(), purpose: "generic_test".into(),
input_revision: "r1".into(),
input_ref: "test:immutable".into(), input_ref: "test:immutable".into(),
input: serde_json::json!({"test_input":42}), input: serde_json::json!({"test_input":42}),
instruction: "Return a structured validity result".into(), instruction: "Return a structured validity result".into(),
+7 -7
View File
@@ -273,7 +273,7 @@ impl ScriptedClient {
"SubmitMemorySurface", "SubmitMemorySurface",
json!({"points":[{ json!({"points":[{
"body_md":format!("- {}", material["body_md"].as_str().unwrap()), "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(); let memory = store.list_memories(&subject.id).unwrap().pop().unwrap();
assert_eq!(memory.claim, claim); 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()]); assert_eq!(memory.source_candidate_ids, vec![ids[0].as_str().unwrap()]);
let surface = store.resident_surface(&subject.id).unwrap(); let surface = store.resident_surface(&subject.id).unwrap();
assert_eq!(surface.availability, subjektiv::SurfaceAvailability::Ready); 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!(snapshot.body_md.contains(claim));
assert_eq!( assert_eq!(
snapshot.memory_refs, snapshot.memory_refs,
vec![subjektiv::MemoryRevisionRef { vec![subjektiv::MemoryChangeRef {
memory_id: memory.id.clone(), memory_id: memory.id.clone(),
revision: 1 change_id: memory.change_id.clone()
}] }]
); );
assert_eq!( assert_eq!(
snapshot.built_from_store_revision, snapshot.built_from_memory_fingerprint,
accepted["surface"]["store_revision"].as_u64().unwrap() accepted["surface"]["memory_fingerprint"].as_str().unwrap()
); );
memory 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(); let memory = store.list_memories(&subject.id).unwrap();
assert_eq!(memory.len(), 1); assert_eq!(memory.len(), 1);
assert_eq!(memory[0].claim, claim); assert_eq!(memory[0].claim, claim);
assert_eq!(memory[0].revision, 1); assert!(!memory[0].change_id.is_empty());
assert_ne!( assert_ne!(
store.resident_surface(&subject.id).unwrap().availability, store.resident_surface(&subject.id).unwrap().availability,
subjektiv::SurfaceAvailability::Ready subjektiv::SurfaceAvailability::Ready
+1
View File
@@ -13,6 +13,7 @@ manifest.workspace = true
rusqlite.workspace = true rusqlite.workspace = true
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true serde_json.workspace = true
sha2.workspace = true
server-api.workspace = true server-api.workspace = true
thiserror.workspace = true thiserror.workspace = true
uuid = { workspace = true, features = ["v7"] } uuid = { workspace = true, features = ["v7"] }
+24 -3
View File
@@ -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 Subject IDs are independently issued random domain identities. An ID is a lookup
key, never an authorization credential. 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 `store_scope.workspace_id` still stores the physical storage scope, under its
historical column name. Existing Backend placement and snapshot manifest keys historical column name. Existing Backend placement and snapshot manifest keys
are unchanged. Optional historical `EvidenceOrigin.workspace_id` values continue 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 `api::execute(store, &HostOperationContext, operation)` is the common dispatcher
used by Backend and local Hosts. It owns query/cursor rules, bounded body and 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 explicit staging/receipt rules, resident context, candidate decisions and
surface generation/publication/failure projections. 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 `require_job_surface_generation`, and `validate_job_surface_outcome` are public
trusted-domain methods for both Backend and local Hosts. 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`, 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`.
+113 -114
View File
@@ -24,7 +24,7 @@ pub const SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES: usize = 40 * 1024;
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct SubjektivQueryCursor { struct SubjektivQueryCursor {
subject_id: String, subject_id: String,
store_revision: u64, memory_fingerprint: String,
query: Option<String>, query: Option<String>,
kinds: Vec<String>, kinds: Vec<String>,
states: Vec<String>, states: Vec<String>,
@@ -33,10 +33,10 @@ struct SubjektivQueryCursor {
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct SubjektivRevisionCursor { struct SubjektivChangeCursor {
subject_id: String, subject_id: String,
memory_id: String, memory_id: String,
max_revision: u64, head_change_id: String,
offset: usize, offset: usize,
} }
@@ -45,8 +45,8 @@ struct SubjektivRevisionCursor {
struct SubjektivEvidenceCursor { struct SubjektivEvidenceCursor {
subject_id: String, subject_id: String,
memory_id: String, memory_id: String,
revision: u64, change_id: String,
/// Candidate/derivation reference offset within the immutable revision. /// Candidate/derivation reference offset within the immutable change_id.
offset: usize, offset: usize,
/// Nested offsets keep compatible historical candidates with more anchors /// Nested offsets keep compatible historical candidates with more anchors
/// resumable without admitting new over-limit records. /// resumable without admitting new over-limit records.
@@ -92,14 +92,14 @@ pub fn subjektiv_surface_prepare_response(
server_api::SubjektivSurfacePrepareResponse { server_api::SubjektivSurfacePrepareResponse {
generation_id: generation.id, generation_id: generation.id,
current_snapshot_id: None, current_snapshot_id: None,
store_revision: generation.store_revision, memory_fingerprint: generation.memory_fingerprint,
active_memory_count: generation.active_memory_count, active_memory_count: generation.active_memory_count,
materials: generation materials: generation
.materials .materials
.into_iter() .into_iter()
.map(|material| server_api::SubjektivSurfaceMaterial { .map(|material| server_api::SubjektivSurfaceMaterial {
memory_id: material.memory_id, memory_id: material.memory_id,
revision: material.revision, change_id: material.change_id.clone(),
kind: material.kind, kind: material.kind,
body_md: material.body_md, body_md: material.body_md,
why_useful: material.why_useful, why_useful: material.why_useful,
@@ -120,7 +120,7 @@ pub fn subjektiv_candidate_summary(
candidate_id: candidate.id, candidate_id: candidate.id,
kind: candidate.kind, kind: candidate.kind,
claim: candidate.claim, 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, created_at: candidate.created_at,
} }
} }
@@ -137,30 +137,26 @@ pub fn subjektiv_candidate(
source: candidate.source, source: candidate.source,
evidence: candidate.evidence, evidence: candidate.evidence,
source_refs: candidate.source_refs, 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, created_at: candidate.created_at,
} }
} }
pub fn subjektiv_proposal_metadata( pub fn subjektiv_proposal_metadata(
proposal: crate::RevisionProposal, proposal: crate::ChangeProposal,
) -> server_api::SubjektivMemoryRevisionProposalMetadata { ) -> server_api::SubjektivMemoryChangeProposalMetadata {
server_api::SubjektivMemoryRevisionProposalMetadata { server_api::SubjektivMemoryChangeProposalMetadata {
memory_id: proposal.memory_id, memory_id: proposal.memory_id,
expected_revision: proposal.expected_revision, expected_change_id: proposal.expected_change_id.clone(),
intent: match proposal.intent { intent: match proposal.intent {
crate::RevisionProposalIntent::Revise => { crate::ChangeProposalIntent::Revise => server_api::SubjektivMemoryChangeIntent::Revise,
server_api::SubjektivMemoryRevisionIntent::Revise crate::ChangeProposalIntent::Resolve => {
server_api::SubjektivMemoryChangeIntent::Resolve
} }
crate::RevisionProposalIntent::Resolve => { crate::ChangeProposalIntent::Retract => {
server_api::SubjektivMemoryRevisionIntent::Resolve server_api::SubjektivMemoryChangeIntent::Retract
}
crate::RevisionProposalIntent::Retract => {
server_api::SubjektivMemoryRevisionIntent::Retract
}
crate::RevisionProposalIntent::Reopen => {
server_api::SubjektivMemoryRevisionIntent::Reopen
} }
crate::ChangeProposalIntent::Reopen => server_api::SubjektivMemoryChangeIntent::Reopen,
}, },
change_reason: proposal.change_reason, change_reason: proposal.change_reason,
} }
@@ -172,15 +168,13 @@ pub fn subjektiv_candidate_decision(
let decision = match input.decision { let decision = match input.decision {
server_api::SubjektivMemoryCandidateDecision::Apply { target, memory } => { server_api::SubjektivMemoryCandidateDecision::Apply { target, memory } => {
let target = match target { let target = match target {
server_api::SubjektivMemoryApplyTarget::Create => { server_api::SubjektivMemoryApplyTarget::Create => crate::MemoryChangeTarget::Create,
crate::MemoryRevisionTarget::Create
}
server_api::SubjektivMemoryApplyTarget::Revise { server_api::SubjektivMemoryApplyTarget::Revise {
memory_id, memory_id,
expected_revision, expected_change_id,
} => crate::MemoryRevisionTarget::Revise { } => crate::MemoryChangeTarget::Revise {
memory_id, memory_id,
expected_revision, expected_change_id,
}, },
}; };
crate::CandidateDecision::Apply { crate::CandidateDecision::Apply {
@@ -196,9 +190,9 @@ pub fn subjektiv_candidate_decision(
derived_from: memory derived_from: memory
.derived_from .derived_from
.into_iter() .into_iter()
.map(|reference| crate::MemoryRevisionRef { .map(|reference| crate::MemoryChangeRef {
memory_id: reference.memory_id, memory_id: reference.memory_id,
revision: reference.revision, change_id: reference.change_id.clone(),
}) })
.collect(), .collect(),
change_reason: memory.change_reason, change_reason: memory.change_reason,
@@ -225,9 +219,9 @@ pub fn subjektiv_candidate_decision(
}, },
affected_memory: affected_memory affected_memory: affected_memory
.into_iter() .into_iter()
.map(|reference| crate::MemoryRevisionRef { .map(|reference| crate::MemoryChangeRef {
memory_id: reference.memory_id, memory_id: reference.memory_id,
revision: reference.revision, change_id: reference.change_id.clone(),
}) })
.collect(), .collect(),
}, },
@@ -281,17 +275,17 @@ pub fn subjektiv_candidate_decision_response(
.into_iter() .into_iter()
.map(|reference| server_api::SubjektivMemoryAffectedRef { .map(|reference| server_api::SubjektivMemoryAffectedRef {
memory_id: reference.memory_id, memory_id: reference.memory_id,
revision: reference.revision, change_id: reference.change_id.clone(),
operation, operation,
}) })
.collect(), .collect(),
memory: receipt memory: receipt
.memory .memory
.map(|memory| server_api::SubjektivMemoryRevisionRef { .map(|memory| server_api::SubjektivMemoryChangeRef {
memory_id: memory.id, 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, surface_dirty: receipt.surface_dirty,
} }
} }
@@ -316,10 +310,19 @@ pub fn subjektiv_memory_query(
.filter(|value| !value.is_empty()); .filter(|value| !value.is_empty());
let kinds = canonical_subjektiv_kinds(input.kinds)?; let kinds = canonical_subjektiv_kinds(input.kinds)?;
let states = canonical_subjektiv_states(input.states)?; let states = canonical_subjektiv_states(input.states)?;
let subject = store let _subject = store
.subject(subject_id) .subject(subject_id)
.map_err(subjektiv_store_error)? .map_err(subjektiv_store_error)?
.ok_or_else(|| OperationError::SubjectNotFound(subject_id.to_string()))?; .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::<Vec<_>>(),
);
let mut offset = 0; let mut offset = 0;
if let Some(cursor) = input.cursor { if let Some(cursor) = input.cursor {
let cursor: SubjektivQueryCursor = decode_subjektiv_cursor("query", &cursor)?; let cursor: SubjektivQueryCursor = decode_subjektiv_cursor("query", &cursor)?;
@@ -333,7 +336,7 @@ pub fn subjektiv_memory_query(
) )
.into()); .into());
} }
if cursor.store_revision != subject.store_revision { if cursor.memory_fingerprint != memory_fingerprint {
return Err(OperationError::Conflict( return Err(OperationError::Conflict(
"stale_cursor: subjektiv query cursor is stale because Memory changed; restart the query".into(), "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::<HashSet<_>>(); let kind_filter = kinds.iter().cloned().collect::<HashSet<_>>();
let state_filter = states.iter().cloned().collect::<HashSet<_>>(); let state_filter = states.iter().cloned().collect::<HashSet<_>>();
let records = store let records = all_records
.list_memories(subject_id)
.map_err(subjektiv_store_error)?
.into_iter() .into_iter()
.filter(|record| { .filter(|record| {
kind_filter.contains(record.kind.as_str()) kind_filter.contains(record.kind.as_str())
@@ -369,7 +370,7 @@ pub fn subjektiv_memory_query(
.iter() .iter()
.map(|record| server_api::SubjektivMemoryQueryItem { .map(|record| server_api::SubjektivMemoryQueryItem {
id: record.id.clone(), id: record.id.clone(),
revision: record.revision, change_id: record.change_id.clone(),
kind: record.kind.clone(), kind: record.kind.clone(),
state: api_memory_state(record.state), state: api_memory_state(record.state),
claim: record.claim.clone(), claim: record.claim.clone(),
@@ -383,7 +384,7 @@ pub fn subjektiv_memory_query(
"query", "query",
&SubjektivQueryCursor { &SubjektivQueryCursor {
subject_id: subject_id.to_string(), subject_id: subject_id.to_string(),
store_revision: subject.store_revision, memory_fingerprint: memory_fingerprint,
query, query,
kinds, kinds,
states, states,
@@ -407,18 +408,18 @@ pub fn subjektiv_memory_read(
let current = store let current = store
.scoped_memory(subject_id, &input.memory_id) .scoped_memory(subject_id, &input.memory_id)
.map_err(subjektiv_store_error)?; .map_err(subjektiv_store_error)?;
let record = match input.revision { let record = match input.change_id.clone() {
Some(0) => { Some(id) if id.trim().is_empty() => {
return Err( return Err(OperationError::InvalidInput(
OperationError::InvalidInput("revision must be a positive integer".into()).into(), "change_id must be nonempty".into(),
); ));
} }
Some(revision) => store Some(change_id) => store
.scoped_memory_revision(subject_id, &input.memory_id, revision) .scoped_memory_change(subject_id, &input.memory_id, change_id.clone())
.map_err(subjektiv_store_error)? .map_err(subjektiv_store_error)?
.ok_or_else(|| { .ok_or_else(|| {
OperationError::InvalidInput(format!( 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 input.memory_id
)) ))
})?, })?,
@@ -433,9 +434,9 @@ pub fn subjektiv_memory_read(
} }
let body_offset = input.offset.unwrap_or(0); let body_offset = input.offset.unwrap_or(0);
let body_byte_offset = input.byte_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( 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()); .into());
} }
@@ -477,10 +478,10 @@ pub fn subjektiv_memory_read(
let cursor: SubjektivEvidenceCursor = decode_subjektiv_cursor("evidence", &cursor)?; let cursor: SubjektivEvidenceCursor = decode_subjektiv_cursor("evidence", &cursor)?;
if cursor.subject_id != subject_id if cursor.subject_id != subject_id
|| cursor.memory_id != record.id || cursor.memory_id != record.id
|| cursor.revision != record.revision || cursor.change_id != record.change_id
{ {
return Err(OperationError::InvalidInput( 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()); .into());
} }
@@ -570,9 +571,9 @@ pub fn subjektiv_memory_read(
} }
let derived_index = evidence_offset - record.source_candidate_ids.len(); let derived_index = evidence_offset - record.source_candidate_ids.len();
let reference = &record.derived_from[derived_index]; 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(), memory_id: reference.memory_id.clone(),
revision: reference.revision, change_id: reference.change_id.clone(),
}); });
next_evidence_offset += 1; next_evidence_offset += 1;
} else if nested_evidence_offset != 0 || nested_source_ref_offset != 0 { } else if nested_evidence_offset != 0 || nested_source_ref_offset != 0 {
@@ -592,7 +593,7 @@ pub fn subjektiv_memory_read(
&SubjektivEvidenceCursor { &SubjektivEvidenceCursor {
subject_id: subject_id.to_string(), subject_id: subject_id.to_string(),
memory_id: record.id.clone(), memory_id: record.id.clone(),
revision: record.revision, change_id: record.change_id.clone(),
offset: next_evidence_offset, offset: next_evidence_offset,
evidence_offset: next_nested_evidence_offset, evidence_offset: next_nested_evidence_offset,
source_ref_offset: next_nested_source_ref_offset, source_ref_offset: next_nested_source_ref_offset,
@@ -603,8 +604,8 @@ pub fn subjektiv_memory_read(
let mut response = server_api::SubjektivMemoryReadResponse { let mut response = server_api::SubjektivMemoryReadResponse {
memory_id: record.id, memory_id: record.id,
revision: record.revision, change_id: record.change_id.clone(),
current_revision: current.revision, current_change_id: current.change_id.clone(),
kind: record.kind, kind: record.kind,
state: api_memory_state(record.state), state: api_memory_state(record.state),
claim: record.claim, claim: record.claim,
@@ -635,36 +636,41 @@ pub fn subjektiv_memory_read(
Ok(response) Ok(response)
} }
pub fn subjektiv_memory_list_revisions( pub fn subjektiv_memory_list_changes(
store: &crate::SubjektivStore, store: &crate::SubjektivStore,
subject_id: &str, subject_id: &str,
input: server_api::SubjektivMemoryListRevisionsRequest, input: server_api::SubjektivMemoryListChangesRequest,
) -> OperationResult<server_api::SubjektivMemoryListRevisionsResponse> { ) -> OperationResult<server_api::SubjektivMemoryListChangesResponse> {
let limit = bounded_subjektiv_limit(input.limit, SUBJEKTIV_QUERY_DEFAULT_LIMIT)?; let limit = bounded_subjektiv_limit(input.limit, SUBJEKTIV_QUERY_DEFAULT_LIMIT)?;
let current = store let current = store
.scoped_memory(subject_id, &input.memory_id) .scoped_memory(subject_id, &input.memory_id)
.map_err(subjektiv_store_error)?; .map_err(subjektiv_store_error)?;
let (max_revision, offset) = if let Some(cursor) = input.cursor { let (head_change_id, offset) = if let Some(cursor) = input.cursor {
let cursor: SubjektivRevisionCursor = decode_subjektiv_cursor("revisions", &cursor)?; let cursor: SubjektivChangeCursor = decode_subjektiv_cursor("changes", &cursor)?;
if cursor.subject_id != subject_id || cursor.memory_id != input.memory_id { if cursor.subject_id != subject_id || cursor.memory_id != input.memory_id {
return Err(OperationError::InvalidInput( 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()); .into());
} }
(cursor.max_revision, cursor.offset) (cursor.head_change_id, cursor.offset)
} else { } else {
(current.revision, 0) (current.change_id.clone(), 0)
}; };
let records = store let records = store
.list_memory_revisions(subject_id, &input.memory_id) .list_memory_changes(subject_id, &input.memory_id)
.map_err(subjektiv_store_error)? .map_err(subjektiv_store_error)?
.into_iter() .into_iter()
.filter(|record| record.revision <= max_revision) .skip_while(|record| record.change_id != head_change_id)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if records.is_empty() {
return Err(OperationError::InvalidInput(
"subjektiv history cursor refers to an unknown change".into(),
));
}
if offset > records.len() { if offset > records.len() {
return Err(OperationError::InvalidInput( return Err(OperationError::InvalidInput(
"subjektiv revision cursor offset is invalid".into(), "subjektiv change_id cursor offset is invalid".into(),
) )
.into()); .into());
} }
@@ -672,8 +678,8 @@ pub fn subjektiv_memory_list_revisions(
let has_more = end < records.len(); let has_more = end < records.len();
let items = records[offset..end] let items = records[offset..end]
.iter() .iter()
.map(|record| server_api::SubjektivMemoryRevisionItem { .map(|record| server_api::SubjektivMemoryChangeItem {
revision: record.revision, change_id: record.change_id.clone(),
kind: record.kind.clone(), kind: record.kind.clone(),
state: api_memory_state(record.state), state: api_memory_state(record.state),
claim: record.claim.clone(), claim: record.claim.clone(),
@@ -684,19 +690,19 @@ pub fn subjektiv_memory_list_revisions(
let next_cursor = has_more let next_cursor = has_more
.then(|| { .then(|| {
encode_subjektiv_cursor( encode_subjektiv_cursor(
"revisions", "changes",
&SubjektivRevisionCursor { &SubjektivChangeCursor {
subject_id: subject_id.to_string(), subject_id: subject_id.to_string(),
memory_id: input.memory_id.clone(), memory_id: input.memory_id.clone(),
max_revision, head_change_id,
offset: end, offset: end,
}, },
) )
}) })
.transpose()?; .transpose()?;
Ok(server_api::SubjektivMemoryListRevisionsResponse { Ok(server_api::SubjektivMemoryListChangesResponse {
memory_id: input.memory_id, memory_id: input.memory_id,
current_revision: current.revision, current_change_id: current.change_id.clone(),
items, items,
next_cursor, next_cursor,
has_more, has_more,
@@ -708,25 +714,25 @@ pub fn subjektiv_memory_validate_proposal(
subject_id: &str, subject_id: &str,
input: server_api::SubjektivMemoryValidateProposalRequest, input: server_api::SubjektivMemoryValidateProposalRequest,
) -> OperationResult<server_api::SubjektivMemoryProposalValidationResponse> { ) -> OperationResult<server_api::SubjektivMemoryProposalValidationResponse> {
if input.expected_revision == 0 { if input.expected_change_id.trim().is_empty() {
return Err( return Err(
OperationError::InvalidInput("expected_revision must be positive".into()).into(), OperationError::InvalidInput("expected_change_id must be nonempty".into()).into(),
); );
} }
let current = store let current = store
.scoped_memory(subject_id, &input.memory_id) .scoped_memory(subject_id, &input.memory_id)
.map_err(subjektiv_store_error)?; .map_err(subjektiv_store_error)?;
if current.revision != input.expected_revision { if current.change_id != input.expected_change_id {
return Err(OperationError::Conflict(format!( return Err(OperationError::Conflict(format!(
"revision_conflict: Memory `{}` expected {}, current {}", "change_conflict: Memory `{}` expected {}, current {}",
input.memory_id, input.expected_revision, current.revision input.memory_id, input.expected_change_id, current.change_id
)) ))
.into()); .into());
} }
validate_subjektiv_proposal_transition(current.state, input.intent)?; validate_subjektiv_proposal_transition(current.state, input.intent)?;
Ok(server_api::SubjektivMemoryProposalValidationResponse { Ok(server_api::SubjektivMemoryProposalValidationResponse {
memory_id: input.memory_id, memory_id: input.memory_id,
current_revision: current.revision, current_change_id: current.change_id.clone(),
kind: current.kind, kind: current.kind,
state: api_memory_state(current.state), state: api_memory_state(current.state),
intent: input.intent, intent: input.intent,
@@ -821,10 +827,10 @@ pub fn subjektiv_memory_stage_explicit(
.proposal .proposal
.as_ref() .as_ref()
.map(|proposal| { .map(|proposal| {
crate::RevisionProposal::new( crate::ChangeProposal::new(
domain_proposal_intent(proposal.intent), domain_proposal_intent(proposal.intent),
proposal.memory_id.clone(), proposal.memory_id.clone(),
proposal.expected_revision, proposal.expected_change_id.clone(),
proposal.change_reason.clone(), proposal.change_reason.clone(),
) )
}) })
@@ -836,7 +842,7 @@ pub fn subjektiv_memory_stage_explicit(
.map_err(subjektiv_store_error)?; .map_err(subjektiv_store_error)?;
if current.kind != input.kind { if current.kind != input.kind {
return Err(OperationError::InvalidInput( 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()); .into());
} }
@@ -859,7 +865,7 @@ pub fn subjektiv_memory_stage_explicit(
let mut staging = crate::SubjectStagingRecord::attach(&attribution.subject_id, staging); let mut staging = crate::SubjectStagingRecord::attach(&attribution.subject_id, staging);
if let Some(proposal) = proposal.clone() { if let Some(proposal) = proposal.clone() {
staging staging
.attach_revision_proposal(proposal) .attach_change_proposal(proposal)
.map_err(subjektiv_store_error)?; .map_err(subjektiv_store_error)?;
} }
let (staged, _) = store let (staged, _) = store
@@ -873,7 +879,7 @@ pub fn subjektiv_memory_stage_explicit(
.as_ref() .as_ref()
.map(|proposal| server_api::SubjektivMemoryProposalTarget { .map(|proposal| server_api::SubjektivMemoryProposalTarget {
memory_id: proposal.memory_id.clone(), memory_id: proposal.memory_id.clone(),
expected_revision: proposal.expected_revision, expected_change_id: proposal.expected_change_id.clone(),
}), }),
intent: proposal intent: proposal
.as_ref() .as_ref()
@@ -883,10 +889,10 @@ pub fn subjektiv_memory_stage_explicit(
pub fn validate_subjektiv_proposal_transition( pub fn validate_subjektiv_proposal_transition(
state: crate::MemoryState, state: crate::MemoryState,
intent: server_api::SubjektivMemoryRevisionIntent, intent: server_api::SubjektivMemoryChangeIntent,
) -> OperationResult<()> { ) -> OperationResult<()> {
use crate::MemoryState::{Active, Resolved}; 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 { let valid = match intent {
Revise => matches!(state, Active | Resolved), Revise => matches!(state, Active | Resolved),
Resolve => state == Active, Resolve => state == Active,
@@ -979,32 +985,24 @@ pub fn subjektiv_memory_state_name(state: crate::MemoryState) -> &'static str {
} }
pub fn domain_proposal_intent( pub fn domain_proposal_intent(
intent: server_api::SubjektivMemoryRevisionIntent, intent: server_api::SubjektivMemoryChangeIntent,
) -> crate::RevisionProposalIntent { ) -> crate::ChangeProposalIntent {
match intent { match intent {
server_api::SubjektivMemoryRevisionIntent::Revise => crate::RevisionProposalIntent::Revise, server_api::SubjektivMemoryChangeIntent::Revise => crate::ChangeProposalIntent::Revise,
server_api::SubjektivMemoryRevisionIntent::Resolve => { server_api::SubjektivMemoryChangeIntent::Resolve => crate::ChangeProposalIntent::Resolve,
crate::RevisionProposalIntent::Resolve server_api::SubjektivMemoryChangeIntent::Retract => crate::ChangeProposalIntent::Retract,
} server_api::SubjektivMemoryChangeIntent::Reopen => crate::ChangeProposalIntent::Reopen,
server_api::SubjektivMemoryRevisionIntent::Retract => {
crate::RevisionProposalIntent::Retract
}
server_api::SubjektivMemoryRevisionIntent::Reopen => crate::RevisionProposalIntent::Reopen,
} }
} }
pub fn api_proposal_intent( pub fn api_proposal_intent(
intent: crate::RevisionProposalIntent, intent: crate::ChangeProposalIntent,
) -> server_api::SubjektivMemoryRevisionIntent { ) -> server_api::SubjektivMemoryChangeIntent {
match intent { match intent {
crate::RevisionProposalIntent::Revise => server_api::SubjektivMemoryRevisionIntent::Revise, crate::ChangeProposalIntent::Revise => server_api::SubjektivMemoryChangeIntent::Revise,
crate::RevisionProposalIntent::Resolve => { crate::ChangeProposalIntent::Resolve => server_api::SubjektivMemoryChangeIntent::Resolve,
server_api::SubjektivMemoryRevisionIntent::Resolve crate::ChangeProposalIntent::Retract => server_api::SubjektivMemoryChangeIntent::Retract,
} crate::ChangeProposalIntent::Reopen => server_api::SubjektivMemoryChangeIntent::Reopen,
crate::RevisionProposalIntent::Retract => {
server_api::SubjektivMemoryRevisionIntent::Retract
}
crate::RevisionProposalIntent::Reopen => server_api::SubjektivMemoryRevisionIntent::Reopen,
} }
} }
@@ -1189,6 +1187,7 @@ pub fn bounded_subjektiv_evidence_origin(
&mut origin.worker_id, &mut origin.worker_id,
&mut origin.flow_selector, &mut origin.flow_selector,
&mut origin.flow_definition_id, &mut origin.flow_definition_id,
&mut origin.flow_definition_fingerprint,
] { ] {
if let Some(value) = field.take() { if let Some(value) = field.take() {
*field = Some(bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES)); *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 { .. } => { crate::SubjektivError::SubjectScopeMismatch { .. } => {
OperationError::PermissionDenied(format!("subject_scope_mismatch: {error}")) OperationError::PermissionDenied(format!("subject_scope_mismatch: {error}"))
} }
crate::SubjektivError::RevisionConflict { .. } crate::SubjektivError::MemoryChangeConflict { .. }
| crate::SubjektivError::SubjectBehaviorConflict { .. } | crate::SubjektivError::SubjectBehaviorConflict { .. }
| crate::SubjektivError::SurfaceGenerationConflict(_) => { | crate::SubjektivError::SurfaceGenerationConflict(_) => {
OperationError::Conflict(format!("revision_conflict: {error}")) OperationError::Conflict(format!("change_conflict: {error}"))
} }
crate::SubjektivError::CandidateResolved(_) crate::SubjektivError::CandidateResolved(_)
| crate::SubjektivError::DecisionRequestConflict(_) => { | crate::SubjektivError::DecisionRequestConflict(_) => {
+9 -10
View File
@@ -28,14 +28,13 @@ pub fn execute(
.map_err(subjektiv_store_error)?; .map_err(subjektiv_store_error)?;
Response::ResidentContext(server_api::SubjektivResidentContextOutput { Response::ResidentContext(server_api::SubjektivResidentContextOutput {
behavior_md: resident.subject.behavior_md, behavior_md: resident.subject.behavior_md,
behavior_revision: resident.subject.behavior_revision,
memory_surface: resident_summary_output(resident.surface), memory_surface: resident_summary_output(resident.surface),
}) })
} }
Op::Query(input) => Response::Query(subjektiv_memory_query(store, subject_id, input)?), 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::Read(input) => Response::Read(subjektiv_memory_read(store, subject_id, input)?),
Op::ListRevisions(input) => { Op::ListChanges(input) => {
Response::ListRevisions(subjektiv_memory_list_revisions(store, subject_id, input)?) Response::ListChanges(subjektiv_memory_list_changes(store, subject_id, input)?)
} }
Op::ValidateProposal(input) => { Op::ValidateProposal(input) => {
context.body()?; context.body()?;
@@ -169,7 +168,7 @@ pub fn execute(
if resident.availability == crate::SurfaceAvailability::Ready { if resident.availability == crate::SurfaceAvailability::Ready {
response.current_snapshot_id = resident response.current_snapshot_id = resident
.snapshot .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); .map(|s| s.id);
} }
Response::SurfacePrepared(response) Response::SurfacePrepared(response)
@@ -194,9 +193,9 @@ pub fn execute(
memory_refs: point memory_refs: point
.memory_refs .memory_refs
.into_iter() .into_iter()
.map(|r| crate::MemoryRevisionRef { .map(|r| crate::MemoryChangeRef {
memory_id: r.memory_id, memory_id: r.memory_id,
revision: r.revision, change_id: r.change_id,
}) })
.collect(), .collect(),
}) })
@@ -206,7 +205,7 @@ pub fn execute(
.map_err(subjektiv_store_error)?; .map_err(subjektiv_store_error)?;
Response::SurfacePublished(server_api::SubjektivSurfacePublishResponse { Response::SurfacePublished(server_api::SubjektivSurfacePublishResponse {
snapshot_id: snapshot.id, 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(), empty: snapshot.body_md.is_empty(),
}) })
} }
@@ -222,7 +221,7 @@ pub fn execute(
) )
.map_err(subjektiv_store_error)?; .map_err(subjektiv_store_error)?;
} }
let revision = store let memory_fingerprint = store
.fail_surface_generation(subject_id, &input.generation_id, &input.reason_code) .fail_surface_generation(subject_id, &input.generation_id, &input.reason_code)
.map_err(subjektiv_store_error)?; .map_err(subjektiv_store_error)?;
let status = if let Some(job) = job { let status = if let Some(job) = job {
@@ -230,7 +229,7 @@ pub fn execute(
.validate_job_surface_outcome( .validate_job_surface_outcome(
subject_id, subject_id,
&input.generation_id, &input.generation_id,
revision, memory_fingerprint.clone(),
"failed", "failed",
None, None,
Some(&input.reason_code), Some(&input.reason_code),
@@ -247,7 +246,7 @@ pub fn execute(
"failed" "failed"
}; };
Response::SurfaceFailed(server_api::SubjektivSurfaceFailureResponse { Response::SurfaceFailed(server_api::SubjektivSurfaceFailureResponse {
store_revision: revision, memory_fingerprint,
status: status.into(), status: status.into(),
}) })
} }
+117 -6
View File
@@ -23,10 +23,15 @@ fn query(cursor: Option<String>) -> Op {
}) })
} }
fn read(id: &str, revision: Option<u64>, offset: Option<usize>, byte_offset: Option<usize>) -> Op { fn read(
id: &str,
change_id: Option<String>,
offset: Option<usize>,
byte_offset: Option<usize>,
) -> Op {
Op::Read(server_api::SubjektivMemoryReadRequest { Op::Read(server_api::SubjektivMemoryReadRequest {
memory_id: id.into(), memory_id: id.into(),
revision, change_id,
offset, offset,
byte_offset, byte_offset,
limit: Some(1), limit: Some(1),
@@ -86,7 +91,7 @@ fn neutral_scope_reopen_and_context_attenuation() {
} }
#[test] #[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 temp = tempfile::tempdir().unwrap();
let (_manager, store) = open(temp.path(), "local-random-scope"); let (_manager, store) = open(temp.path(), "local-random-scope");
let subject = store let subject = store
@@ -148,7 +153,12 @@ fn shared_query_cursor_and_utf8_fixed_revision_projection() {
let Response::Read(page) = execute( let Response::Read(page) = execute(
&store, &store,
&context, &context,
read(&first.id, Some(1), offset, byte_offset), read(
&first.id,
Some(first.change_id.clone()),
offset,
byte_offset,
),
) )
.unwrap() else { .unwrap() else {
panic!() panic!()
@@ -247,7 +257,7 @@ fn explicit_receipts_candidate_batch_and_decision_retries_share_logic() {
entry_range: Some([1, 1]), entry_range: Some([1, 1]),
kind: memory::schema::EvidenceKind::new("model_output"), kind: memory::schema::EvidenceKind::new("model_output"),
origin: None, origin: None,
excerpt: Some("Use immutable revisions".into()), excerpt: Some("Use immutable changes".into()),
summary: None, summary: None,
}], }],
source_refs: vec![memory::schema::SourceEvidenceRef { source_refs: vec![memory::schema::SourceEvidenceRef {
@@ -319,7 +329,7 @@ fn explicit_receipts_candidate_batch_and_decision_retries_share_logic() {
kind: CandidateKind::Lesson, kind: CandidateKind::Lesson,
state: server_api::SubjektivMemoryState::Active, state: server_api::SubjektivMemoryState::Active,
claim: "Preserve correction history".into(), claim: "Preserve correction history".into(),
body_md: "Use immutable revisions".into(), body_md: "Use immutable changes".into(),
why_useful: "Allows auditing".into(), why_useful: "Allows auditing".into(),
staleness: None, staleness: None,
derived_from: vec![], derived_from: vec![],
@@ -341,3 +351,104 @@ fn explicit_receipts_candidate_batch_and_decision_retries_share_logic() {
) )
.unwrap(); .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(_))
));
}
+3 -3
View File
@@ -65,7 +65,7 @@ struct ConsolidationResult {
struct SurfaceResult { struct SurfaceResult {
availability: String, availability: String,
generation_id: String, generation_id: String,
store_revision: u64, memory_fingerprint: String,
#[serde(default)] #[serde(default)]
snapshot_id: Option<String>, snapshot_id: Option<String>,
#[serde(default)] #[serde(default)]
@@ -105,7 +105,7 @@ pub fn validate_result(
.validate_job_surface_outcome( .validate_job_surface_outcome(
subject_id, subject_id,
&result.surface.generation_id, &result.surface.generation_id,
result.surface.store_revision, result.surface.memory_fingerprint,
&result.surface.availability, &result.surface.availability,
result.surface.snapshot_id.as_deref(), result.surface.snapshot_id.as_deref(),
result.surface.reason_code.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 /// 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. /// deterministic projection neither applies candidates nor creates a new ledger.
pub fn with_dispositions( pub fn with_dispositions(
store: &SubjektivStore, store: &SubjektivStore,
+4 -4
View File
@@ -22,7 +22,7 @@ fn escaped_ids_use_exact_t704_complete_result_budget() {
.collect(); .collect();
let result = serde_json::json!({ let result = serde_json::json!({
"subject_id": "\\\"".repeat(128), "candidate_ids": batch, "candidate_dispositions": dispositions, "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!(serde_json::to_vec(&result).unwrap().len() <= MAX_RESULT_BYTES as usize);
assert!( assert!(
@@ -119,7 +119,7 @@ fn result_checks_durable_dispositions_and_exact_job_surface_outcome() {
.publish_surface_generation(&subject.id, &generation.id, vec![]) .publish_surface_generation(&subject.id, &generation.id, vec![])
.unwrap(); .unwrap();
let ready = serde_json::json!({"subject_id": subject.id, "candidate_ids": batch, 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!( assert!(
validate_result(&store, &subject.id, &batch, "job", "attempt", &ready) validate_result(&store, &subject.id, &batch, "job", "attempt", &ready)
.unwrap_err() .unwrap_err()
@@ -170,11 +170,11 @@ fn result_checks_durable_dispositions_and_exact_job_surface_outcome() {
let failure = store let failure = store
.prepare_job_surface_generation(&subject.id, Some(("job", "attempt"))) .prepare_job_surface_generation(&subject.id, Some(("job", "attempt")))
.unwrap(); .unwrap();
let revision = store let memory_fingerprint = store
.fail_surface_generation(&subject.id, &failure.id, "model_failure") .fail_surface_generation(&subject.id, &failure.id, "model_failure")
.unwrap(); .unwrap();
let failed = serde_json::json!({"subject_id": subject.id, "candidate_ids": batch, 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(); validate_result(&store, &subject.id, &batch, "job", "attempt", &failed).unwrap();
assert!(validate_result(&store, &subject.id, &batch, "job", "attempt", &ready).is_err()); assert!(validate_result(&store, &subject.id, &batch, "job", "attempt", &ready).is_err());
let mut false_reason = failed; let mut false_reason = failed;
File diff suppressed because it is too large Load Diff
+711 -1332
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -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(())
}
+390
View File
@@ -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;
+175 -55
View File
@@ -88,8 +88,8 @@ pub enum TicketError {
InvalidReadWriteTargetCount(usize), InvalidReadWriteTargetCount(usize),
#[error("ticket target authority is unavailable")] #[error("ticket target authority is unavailable")]
TargetAuthorityUnavailable, TargetAuthorityUnavailable,
#[error("stale ticket item revision: expected `{expected}`, found `{actual}`")] #[error("stale ticket content: expected `{expected}`, found `{actual}`")]
StaleItemRevision { expected: String, actual: String }, StaleContent { expected: String, actual: String },
#[error("stale ticket workflow state: expected `{expected}`, found `{actual}`")] #[error("stale ticket workflow state: expected `{expected}`, found `{actual}`")]
StaleWorkflowState { expected: String, actual: String }, StaleWorkflowState { expected: String, actual: String },
#[error("invalid ticket workflow transition `{from}` -> `{to}`")] #[error("invalid ticket workflow transition `{from}` -> `{to}`")]
@@ -473,7 +473,7 @@ impl TicketStateChange {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct TicketCompletion { pub struct TicketCompletion {
pub expected_item_revision: String, pub expected_content_digest: String,
pub expected_state: TicketWorkflowState, pub expected_state: TicketWorkflowState,
pub operation_key: String, pub operation_key: String,
pub reason: String, pub reason: String,
@@ -483,7 +483,7 @@ pub struct TicketCompletion {
pub author: Option<String>, pub author: Option<String>,
} }
/// 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 /// Exact replay returns the current authoritative Ticket without a new event or
/// reapplying the recorded state, even after later edits or reopening. /// reapplying the recorded state, even after later edits or reopening.
/// Setting `state` to Closed also stores `reason` as the resolution; 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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct TicketStateUpdate { pub struct TicketStateUpdate {
pub expected_item_revision: String, pub expected_content_digest: String,
pub expected_state: TicketWorkflowState, pub expected_state: TicketWorkflowState,
pub state: TicketWorkflowState, pub state: TicketWorkflowState,
pub operation_key: String, pub operation_key: String,
@@ -505,7 +505,7 @@ pub struct TicketStateUpdate {
impl From<TicketCompletion> for TicketStateUpdate { impl From<TicketCompletion> for TicketStateUpdate {
fn from(request: TicketCompletion) -> Self { fn from(request: TicketCompletion) -> Self {
Self { Self {
expected_item_revision: request.expected_item_revision, expected_content_digest: request.expected_content_digest,
expected_state: request.expected_state, expected_state: request.expected_state,
state: TicketWorkflowState::Done, state: TicketWorkflowState::Done,
operation_key: request.operation_key, operation_key: request.operation_key,
@@ -516,17 +516,69 @@ impl From<TicketCompletion> for TicketStateUpdate {
} }
} }
/// Canonical item revision shared by Ticket authority, item checkers, and Merge /// SHA-256 of the actual editable Ticket requirements: identity, title, body,
/// Request review records. Only persisted item_edit event indexes advance it. /// and ordered repository targets. Comments, timestamps, workflow state, and
pub fn ticket_item_revision(ticket: &Ticket) -> String { /// event order are not requirements. Restoring identical content restores its
let index = ticket /// digest; state CAS and operation receipts are independent preconditions.
.events 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() .iter()
.filter(|event| event.kind == TicketEventKind::Other("item_edit".to_owned())) .map(|byte| format!("{byte:02x}"))
.filter_map(|event| event.attributes.get("event_sequence")?.parse::<i64>().ok()) .collect()
.max() }
.unwrap_or(0);
format!("{}:{index}", ticket.meta.id) /// 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<Option<String>> {
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::<std::result::Result<Vec<_>, _>>()
.map_err(sqlite_err)?;
Ok(Some(content_digest(ticket_id, &title, &body, &targets)))
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
@@ -2402,7 +2454,7 @@ impl SqliteTicketBackend {
] ]
.map(|key| self.event_attributes.get(key)); .map(|key| self.event_attributes.get(key));
let mut digest = Sha256::new(); let mut digest = Sha256::new();
digest.update(b"ticket.state-update.v1\0"); digest.update(b"ticket.state-update.v2\0");
digest.update( digest.update(
serde_json::to_vec(&(ticket_id, request, body, source)) serde_json::to_vec(&(ticket_id, request, body, source))
.map_err(|error| TicketError::Conflict(error.to_string()))?, .map_err(|error| TicketError::Conflict(error.to_string()))?,
@@ -2437,7 +2489,7 @@ impl SqliteTicketBackend {
body: MarkdownText, body: MarkdownText,
) -> Result<Ticket> { ) -> Result<Ticket> {
validate_required_event_value("operation_key", &request.operation_key)?; 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 { validate_state_change(&TicketStateChange {
from: request.expected_state.to_string(), from: request.expected_state.to_string(),
to: request.state.to_string(), to: request.state.to_string(),
@@ -2457,7 +2509,7 @@ impl SqliteTicketBackend {
.attributes .attributes
.get("fingerprint_version") .get("fingerprint_version")
.map(String::as_str) .map(String::as_str)
!= Some("state-update-v1") != Some("state-update-v2")
|| event.attributes.get("request_fingerprint") != Some(&fingerprint) || event.attributes.get("request_fingerprint") != Some(&fingerprint)
{ {
return Err(TicketError::OperationFingerprintMismatch { return Err(TicketError::OperationFingerprintMismatch {
@@ -2471,10 +2523,10 @@ impl SqliteTicketBackend {
// Exact recorded replay is independent of today's live role/assignment. // Exact recorded replay is independent of today's live role/assignment.
// Parent transport authenticates identity before restoring receipt context. // Parent transport authenticates identity before restoring receipt context.
self.require_active_source_assignment(conn, ticket_id)?; self.require_active_source_assignment(conn, ticket_id)?;
let actual = ticket_item_revision(&previous); let actual = ticket_content_digest(&previous);
if actual != request.expected_item_revision { if actual != request.expected_content_digest {
return Err(TicketError::StaleItemRevision { return Err(TicketError::StaleContent {
expected: request.expected_item_revision.clone(), expected: request.expected_content_digest.clone(),
actual, actual,
}); });
} }
@@ -2526,11 +2578,11 @@ impl SqliteTicketBackend {
("request_fingerprint".to_owned(), fingerprint), ("request_fingerprint".to_owned(), fingerprint),
( (
"fingerprint_version".to_owned(), "fingerprint_version".to_owned(),
"state-update-v1".to_owned(), "state-update-v2".to_owned(),
), ),
( (
"expected_item_revision".to_owned(), "expected_content_digest".to_owned(),
request.expected_item_revision.clone(), request.expected_content_digest.clone(),
), ),
]), ]),
}, },
@@ -3449,7 +3501,7 @@ impl TicketBackend for SqliteTicketBackend {
let ticket_id = self.resolve_ticket_id(conn, id)?; let ticket_id = self.resolve_ticket_id(conn, id)?;
let previous = self.load_ticket(conn, &ticket_id)?; let previous = self.load_ticket(conn, &ticket_id)?;
let request = TicketStateUpdate { let request = TicketStateUpdate {
expected_item_revision: ticket_item_revision(&previous), expected_content_digest: ticket_content_digest(&previous),
expected_state: from, expected_state: from,
state: to, state: to,
operation_key: self.next_legacy_state_operation_key(conn, &ticket_id)?, 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 ticket_id = self.resolve_ticket_id(conn, id)?;
let previous = self.load_ticket(conn, &ticket_id)?; let previous = self.load_ticket(conn, &ticket_id)?;
let request = TicketStateUpdate { let request = TicketStateUpdate {
expected_item_revision: ticket_item_revision(&previous), expected_content_digest: ticket_content_digest(&previous),
expected_state: previous.meta.workflow_state, expected_state: previous.meta.workflow_state,
state: TicketWorkflowState::Closed, state: TicketWorkflowState::Closed,
operation_key: self.next_legacy_state_operation_key(conn, &ticket_id)?, operation_key: self.next_legacy_state_operation_key(conn, &ticket_id)?,
@@ -5626,13 +5678,22 @@ mod tests {
} }
#[test] #[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 tmp = TempDir::new().unwrap();
let backend = backend(&tmp); let backend = backend(&tmp);
let (reference, ticket) = backend let (reference, ticket) = backend
.create_with_snapshot(NewTicket::new("Revision format")) .create_with_snapshot(NewTicket::new("Content digest"))
.unwrap(); .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 backend
.add_event( .add_event(
reference.id.clone().into(), reference.id.clone().into(),
@@ -5648,7 +5709,10 @@ mod tests {
}, },
) )
.unwrap(); .unwrap();
assert_eq!(ticket_item_revision(&first), format!("{}:2", reference.id)); assert_ne!(
ticket_content_digest(&first),
ticket_content_digest(&ticket)
);
backend backend
.add_event( .add_event(
reference.id.clone().into(), reference.id.clone().into(),
@@ -5664,13 +5728,23 @@ mod tests {
}, },
) )
.unwrap(); .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; let mut reordered = second;
reordered.events.reverse(); reordered.events.reverse();
assert_eq!( assert_eq!(ticket_content_digest(&reordered), digest);
ticket_item_revision(&reordered),
format!("{}:4", reference.id)
);
} }
#[test] #[test]
@@ -5983,18 +6057,18 @@ mod tests {
assert_eq!(event.from.as_deref(), Some("planning")); assert_eq!(event.from.as_deref(), Some("planning"));
assert_eq!(event.reason.as_deref(), Some("finished")); assert_eq!(event.reason.as_deref(), Some("finished"));
assert_eq!( assert_eq!(
event.attributes.get("expected_item_revision"), event.attributes.get("expected_content_digest"),
Some(&ticket_item_revision(&ticket)) Some(&ticket_content_digest(&ticket))
); );
assert_eq!( assert_eq!(
event event
.attributes .attributes
.get("fingerprint_version") .get("fingerprint_version")
.map(String::as_str), .map(String::as_str),
Some("state-update-v1") Some("state-update-v2")
); );
let request = TicketStateUpdate { let request = TicketStateUpdate {
expected_item_revision: ticket_item_revision(&ticket), expected_content_digest: ticket_content_digest(&ticket),
expected_state: TicketWorkflowState::Planning, expected_state: TicketWorkflowState::Planning,
state: TicketWorkflowState::Done, state: TicketWorkflowState::Done,
operation_key: event.attributes["operation_key"].clone(), operation_key: event.attributes["operation_key"].clone(),
@@ -6023,8 +6097,8 @@ mod tests {
assert_eq!(close.body, resolution); assert_eq!(close.body, resolution);
assert_eq!(closed.resolution, Some(resolution)); assert_eq!(closed.resolution, Some(resolution));
assert_eq!( assert_eq!(
close.attributes.get("expected_item_revision"), close.attributes.get("expected_content_digest"),
Some(&ticket_item_revision(&done)) Some(&ticket_content_digest(&done))
); );
assert_ne!( assert_ne!(
close.attributes["operation_key"], close.attributes["operation_key"],
@@ -6175,7 +6249,7 @@ mod tests {
fn completion_request(ticket: &Ticket, key: &str) -> TicketCompletion { fn completion_request(ticket: &Ticket, key: &str) -> TicketCompletion {
TicketCompletion { TicketCompletion {
expected_item_revision: ticket_item_revision(ticket), expected_content_digest: ticket_content_digest(ticket),
expected_state: ticket.meta.workflow_state, expected_state: ticket.meta.workflow_state,
operation_key: key.to_owned(), operation_key: key.to_owned(),
reason: "Acceptance criteria met".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] #[test]
fn completion_from_all_states_needs_no_git_targets_or_merge_request() { fn completion_from_all_states_needs_no_git_targets_or_merge_request() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
@@ -6316,7 +6436,7 @@ mod tests {
.unwrap(); .unwrap();
assert!(matches!( assert!(matches!(
backend.complete(&reference.id, request), backend.complete(&reference.id, request),
Err(TicketError::StaleItemRevision { .. }) Err(TicketError::StaleContent { .. })
)); ));
assert_eq!(backend.show(reference.id.clone().into()).unwrap(), edited); assert_eq!(backend.show(reference.id.clone().into()).unwrap(), edited);
let mut request = completion_request(&edited, "complete"); let mut request = completion_request(&edited, "complete");
@@ -6440,7 +6560,7 @@ mod tests {
..request.clone() ..request.clone()
}, },
TicketCompletion { TicketCompletion {
expected_item_revision: "other-revision".to_owned(), expected_content_digest: "other-content".to_owned(),
..request.clone() ..request.clone()
}, },
TicketCompletion { TicketCompletion {
@@ -6562,13 +6682,13 @@ mod tests {
} }
#[test] #[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 tmp = TempDir::new().unwrap();
let backend = backend(&tmp); let backend = backend(&tmp);
let (reference, ticket) = backend let (reference, ticket) = backend
.create_with_snapshot(NewTicket::new("Revision")) .create_with_snapshot(NewTicket::new("Content"))
.unwrap(); .unwrap();
let revision = ticket_item_revision(&ticket); let digest = ticket_content_digest(&ticket);
backend backend
.add_event( .add_event(
reference.id.clone().into(), reference.id.clone().into(),
@@ -6576,11 +6696,11 @@ mod tests {
) )
.unwrap(); .unwrap();
let commented = backend.show(reference.id.clone().into()).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 let completed = backend
.complete(&reference.id, completion_request(&ticket, "complete")) .complete(&reference.id, completion_request(&ticket, "complete"))
.unwrap(); .unwrap();
assert_eq!(ticket_item_revision(&completed), revision); assert_eq!(ticket_content_digest(&completed), digest);
backend backend
.edit_item( .edit_item(
reference.id.clone().into(), reference.id.clone().into(),
@@ -6601,7 +6721,7 @@ mod tests {
}, },
) )
.unwrap(); .unwrap();
assert_ne!(ticket_item_revision(&restored), revision); assert_eq!(ticket_content_digest(&restored), digest);
} }
#[test] #[test]
@@ -6618,7 +6738,7 @@ mod tests {
..request.clone() ..request.clone()
}, },
TicketCompletion { TicketCompletion {
expected_item_revision: "".to_owned(), expected_content_digest: "".to_owned(),
..request.clone() ..request.clone()
}, },
TicketCompletion { TicketCompletion {
@@ -7081,8 +7201,8 @@ mod tests {
assert_eq!(close.from.as_deref(), Some("planning")); assert_eq!(close.from.as_deref(), Some("planning"));
assert_eq!(close.to.as_deref(), Some("closed")); assert_eq!(close.to.as_deref(), Some("closed"));
assert_eq!( assert_eq!(
close.attributes.get("expected_item_revision"), close.attributes.get("expected_content_digest"),
Some(&format!("{}:0", record.meta.id)) Some(&ticket_content_digest(&record))
); );
assert!(close.attributes.contains_key("operation_key")); assert!(close.attributes.contains_key("operation_key"));
} }
+6 -5
View File
@@ -40,11 +40,12 @@ arguments; Backspace/Delete removes a chip atomically. Typed chips survive local
history, draft restoration, transport retry, and rewind. history, draft restoration, transport retry, and rewind.
Every completion and chip-re-edit query carries a fresh `request_id`. Replies 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 must echo that ID and match the active query context and Worker target. The first
revision, authority snapshot generation, and Worker target. Identical-prefix ABA input/cursor edit cancels the pending input watch; returning to identical text
edits, cancelled/reopened queries, permission snapshots, and Worker/view switches cannot revive it. Authority snapshots and Worker/view switches discard pending
cannot reuse older replies or candidates. Uncorrelated replies are ignored, even queries and visible candidates, rather than advancing another counter.
for File queries where the wire field remains optional for legacy callers. 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 The declared attachment adapter stages a client-local file in place, replacing
its staging chip with an `UploadedFile` reference when ready. Local file argument its staging chip with an `UploadedFile` reference when ready. Local file argument
+107 -138
View File
@@ -35,14 +35,22 @@ pub enum CommandCompletionApply {
/// typing inside a `@` / `#` / `/` token. Cleared whenever the trigger /// typing inside a `@` / `#` / `/` token. Cleared whenever the trigger
/// is invalidated (cursor moved out, whitespace landed inside the /// is invalidated (cursor moved out, whitespace landed inside the
/// token, the sigil was deleted, or the candidate was confirmed). /// token, the sigil was deleted, or the candidate was confirmed).
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone)]
struct CompletionSnapshot { struct CompletionSnapshot {
input_revision: u64, input: crate::input::CompletionInputWatch,
generation: u64,
target: String, target: String,
worker_view: Option<String>, worker_view: Option<String>,
} }
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 { struct PendingFeatureEdit {
invocation: protocol::FeatureInvocation, invocation: protocol::FeatureInvocation,
request_id: String, request_id: String,
@@ -229,7 +237,6 @@ impl ActionbarNotice {
pub struct InternalWorkerView { pub struct InternalWorkerView {
pub worker: InternalWorkerRef, pub worker: InternalWorkerRef,
pub revision: u64,
pub app: Box<App>, pub app: Box<App>,
} }
@@ -242,7 +249,7 @@ pub struct WorkerViewTab {
pub struct App { pub struct App {
pub worker_name: String, pub worker_name: String,
pub connected: bool, pub connected: bool,
/// Latest authoritative revisioned live execution state. /// Latest authoritative live execution state.
pub worker_state: WorkerStateSnapshot, pub worker_state: WorkerStateSnapshot,
next_command_id: u64, next_command_id: u64,
/// Derived Runtime-catalog compatibility projection used by existing UI. /// 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 /// Turn/protocol errors retained when a real `SegmentStart` replaces the
/// replayable conversation rows during segment rotation. /// replayable conversation rows during segment rotation.
run_error_messages: Vec<String>, run_error_messages: Vec<String>,
/// Current compaction identity/revision used to fence snapshot/live updates. /// Current compaction operation; terminal IDs cannot reopen on delayed events.
active_compaction: Option<(String, u64)>, active_compaction: Option<String>,
terminal_compactions: std::collections::HashSet<String>,
pub compaction_progress: Option<protocol::InFlightCompaction>, pub compaction_progress: Option<protocol::InFlightCompaction>,
/// Presentation-only Internal Worker projections keyed by session identity. /// Presentation-only Internal Worker projections keyed by session identity.
/// They are rendered in separate selectable views and never mixed into `blocks`. /// 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. /// view; the stable session identity survives projection reordering.
selected_internal_worker_session_id: Option<String>, selected_internal_worker_session_id: Option<String>,
/// Terminal child-session fences, reset only by an authoritative snapshot. /// Terminal child-session fences, reset only by an authoritative snapshot.
removed_internal_workers: HashMap<String, u64>, removed_internal_workers: std::collections::HashSet<String>,
pub scroll: Scroll, pub scroll: Scroll,
pub mode: Mode, pub mode: Mode,
pub cache: FileCache, pub cache: FileCache,
@@ -323,7 +331,6 @@ pub struct App {
/// typed FeatureInvoke chip. /// typed FeatureInvoke chip.
selected_feature_invocations: HashMap<String, protocol::FeatureInvocationDescriptor>, selected_feature_invocations: HashMap<String, protocol::FeatureInvocationDescriptor>,
pending_feature_edit: Option<PendingFeatureEdit>, pending_feature_edit: Option<PendingFeatureEdit>,
completion_generation: u64,
completion_target: String, completion_target: String,
/// Dedicated main-view rewind picker state. /// Dedicated main-view rewind picker state.
pub rewind_picker: Option<RewindPickerState>, pub rewind_picker: Option<RewindPickerState>,
@@ -397,11 +404,12 @@ impl App {
shutdown_confirm: None, shutdown_confirm: None,
blocks: Vec::new(), blocks: Vec::new(),
active_compaction: None, active_compaction: None,
terminal_compactions: std::collections::HashSet::new(),
compaction_progress: None, compaction_progress: None,
run_error_messages: Vec::new(), run_error_messages: Vec::new(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
selected_internal_worker_session_id: None, selected_internal_worker_session_id: None,
removed_internal_workers: HashMap::new(), removed_internal_workers: std::collections::HashSet::new(),
scroll: Scroll::default(), scroll: Scroll::default(),
mode: Mode::Normal, mode: Mode::Normal,
cache: FileCache::new(), cache: FileCache::new(),
@@ -409,7 +417,6 @@ impl App {
completion: None, completion: None,
selected_feature_invocations: HashMap::new(), selected_feature_invocations: HashMap::new(),
pending_feature_edit: None, pending_feature_edit: None,
completion_generation: 0,
completion_target: protocol::new_submission_request_id(), completion_target: protocol::new_submission_request_id(),
rewind_picker: None, rewind_picker: None,
rewind_request_pending: false, rewind_request_pending: false,
@@ -542,7 +549,7 @@ impl App {
/// Cycle the presentation-only transcript/task view. Input and control /// Cycle the presentation-only transcript/task view. Input and control
/// methods continue to target the parent Worker regardless of selection. /// methods continue to target the parent Worker regardless of selection.
pub fn cycle_worker_view(&mut self) -> bool { pub fn cycle_worker_view(&mut self) -> bool {
self.invalidate_completion_generation(); self.cancel_completion_requests();
if self.internal_workers.is_empty() { if self.internal_workers.is_empty() {
self.selected_internal_worker_session_id = None; self.selected_internal_worker_session_id = None;
return false; return false;
@@ -604,8 +611,7 @@ impl App {
fn completion_snapshot(&self) -> CompletionSnapshot { fn completion_snapshot(&self) -> CompletionSnapshot {
CompletionSnapshot { CompletionSnapshot {
input_revision: self.input.revision(), input: self.input.watch_completion_input(),
generation: self.completion_generation,
target: self.completion_target.clone(), target: self.completion_target.clone(),
worker_view: self.selected_internal_worker_session_id.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) { pub(crate) fn set_completion_target(&mut self, target: String) {
if self.completion_target != target { if self.completion_target != target {
self.completion_target = target; self.completion_target = target;
self.invalidate_completion_generation(); self.cancel_completion_requests();
self.selected_feature_invocations.clear(); self.selected_feature_invocations.clear();
} }
} }
/// Authority/snapshot/target changes revoke in-flight queries and visible candidates. /// Authority/snapshot/target changes revoke in-flight queries and visible candidates.
fn invalidate_completion_generation(&mut self) { fn cancel_completion_requests(&mut self) {
self.completion_generation = self.completion_generation.wrapping_add(1);
self.completion = None; self.completion = None;
self.pending_feature_edit = None; self.pending_feature_edit = None;
} }
@@ -672,7 +677,7 @@ impl App {
&& state.prefix_start == start && state.prefix_start == start
&& state.prefix == prefix && state.prefix == prefix
&& state.context == context && state.context == context
&& state.snapshot == snapshot && state.snapshot.same_context(&snapshot)
}) { }) {
return None; return None;
} }
@@ -722,7 +727,7 @@ impl App {
} }
pub fn cancel_completion(&mut self) { 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 /// Tab path: insert the popup-selected entry's value (with a
@@ -736,7 +741,7 @@ impl App {
if self if self
.completion .completion
.as_ref() .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(); self.cancel_completion();
return None; return None;
@@ -885,7 +890,7 @@ impl App {
if self if self
.completion .completion
.as_ref() .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(); self.cancel_completion();
return false; return false;
@@ -942,7 +947,7 @@ impl App {
if self if self
.completion .completion
.as_ref() .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(); self.cancel_completion();
return false; return false;
@@ -1248,22 +1253,23 @@ impl App {
pub fn continue_pending_method(&self) -> Option<Method> { pub fn continue_pending_method(&self) -> Option<Method> {
Some(Method::ContinuePending { Some(Method::ContinuePending {
expected_revision: self.pending_submissions.revision,
expected_head_id: self.pending_submissions.head_id.clone()?, expected_head_id: self.pending_submissions.head_id.clone()?,
}) })
} }
pub fn clear_pending_method(&self) -> Method { pub fn clear_pending_method(&self) -> Method {
Method::ClearPendingSubmissions { 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 { pub fn cancel_pending_method(&self, submission_id: String) -> Method {
Method::CancelPendingSubmission { Method::CancelPendingSubmission { submission_id }
submission_id,
expected_revision: self.pending_submissions.revision,
}
} }
pub fn next_queued_input_preview(&self) -> Option<&str> { pub fn next_queued_input_preview(&self) -> Option<&str> {
@@ -1802,14 +1808,13 @@ impl App {
}); });
} }
Event::CompactStart { lifecycle } => { Event::CompactStart { lifecycle } => {
let should_apply = match &self.active_compaction { let should_apply = !self.terminal_compactions.contains(&lifecycle.compaction_id)
None => true, && self
Some((id, revision)) => { .active_compaction
id == &lifecycle.compaction_id && lifecycle.revision > *revision .as_ref()
} .is_none_or(|id| id == &lifecycle.compaction_id);
};
if should_apply { 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() { if self.last_streaming_compact_mut().is_none() {
self.blocks.push(Block::Compact(CompactEvent::Streaming { self.blocks.push(Block::Compact(CompactEvent::Streaming {
started_at: Instant::now(), started_at: Instant::now(),
@@ -1818,15 +1823,16 @@ impl App {
} }
} }
Event::CompactDone { lifecycle } => { Event::CompactDone { lifecycle } => {
let should_apply = match &self.active_compaction { let should_apply = !self.terminal_compactions.contains(&lifecycle.compaction_id)
None => true, && self
Some((id, revision)) => { .active_compaction
id == &lifecycle.compaction_id && lifecycle.revision > *revision .as_ref()
} .is_none_or(|id| id == &lifecycle.compaction_id);
};
if !should_apply { if !should_apply {
return None; return None;
} }
self.terminal_compactions
.insert(lifecycle.compaction_id.clone());
self.active_compaction = None; self.active_compaction = None;
let new_segment_id = lifecycle let new_segment_id = lifecycle
.new_segment_id .new_segment_id
@@ -1852,15 +1858,16 @@ impl App {
} }
} }
Event::CompactFailed { lifecycle } => { Event::CompactFailed { lifecycle } => {
let should_apply = match &self.active_compaction { let should_apply = !self.terminal_compactions.contains(&lifecycle.compaction_id)
None => true, && self
Some((id, revision)) => { .active_compaction
id == &lifecycle.compaction_id && lifecycle.revision > *revision .as_ref()
} .is_none_or(|id| id == &lifecycle.compaction_id);
};
if !should_apply { if !should_apply {
return None; return None;
} }
self.terminal_compactions
.insert(lifecycle.compaction_id.clone());
self.active_compaction = None; self.active_compaction = None;
let error = lifecycle let error = lifecycle
.error .error
@@ -1915,14 +1922,10 @@ impl App {
self.replace_internal_worker_snapshots(internal_workers); self.replace_internal_worker_snapshots(internal_workers);
return self.refresh_completion(); return self.refresh_completion();
} }
Event::InternalWorker { Event::InternalWorker { worker, event } => {
worker, self.apply_internal_worker_event(worker, *event)
revision,
event,
} => self.apply_internal_worker_event(worker, revision, *event),
Event::InternalWorkerRemoved { worker, revision } => {
self.remove_internal_worker(worker, revision)
} }
Event::InternalWorkerRemoved { worker } => self.remove_internal_worker(worker),
Event::WorkerState { snapshot } => { Event::WorkerState { snapshot } => {
self.rewind_refresh_fence = false; self.rewind_refresh_fence = false;
self.apply_worker_state_snapshot(&snapshot); self.apply_worker_state_snapshot(&snapshot);
@@ -1956,7 +1959,7 @@ impl App {
&& self.pending_feature_edit.as_ref().is_some_and(|edit| { && self.pending_feature_edit.as_ref().is_some_and(|edit| {
edit.invocation.name == prefix edit.invocation.name == prefix
&& request_id.as_ref() == Some(&edit.request_id) && 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; let invocation = self.pending_feature_edit.take().unwrap().invocation;
@@ -1981,7 +1984,7 @@ impl App {
// stale argument/provider replies cannot cross invocation scope. // stale argument/provider replies cannot cross invocation scope.
if let Some(state) = self.completion.as_mut() if let Some(state) = self.completion.as_mut()
&& request_id.as_ref() == Some(&state.request_id) && request_id.as_ref() == Some(&state.request_id)
&& state.snapshot == snapshot && state.snapshot.same_context(&snapshot)
&& state.kind == kind && state.kind == kind
&& state.prefix == prefix && state.prefix == prefix
&& state.context == context && state.context == context
@@ -2100,7 +2103,7 @@ impl App {
); );
} }
Event::Shutdown => { Event::Shutdown => {
self.invalidate_completion_generation(); self.cancel_completion_requests();
self.mark_orphan_compacts_incomplete(); self.mark_orphan_compacts_incomplete();
self.quit = true; self.quit = true;
} }
@@ -2309,7 +2312,7 @@ impl App {
} }
pub fn enter_command_mode(&mut self) { pub fn enter_command_mode(&mut self) {
self.invalidate_completion_generation(); self.cancel_completion_requests();
self.input_mode = CommandInputMode::Command; self.input_mode = CommandInputMode::Command;
self.completion = None; self.completion = None;
self.command_completion_selected = None; self.command_completion_selected = None;
@@ -2317,7 +2320,7 @@ impl App {
} }
pub fn exit_command_mode(&mut self) { pub fn exit_command_mode(&mut self) {
self.invalidate_completion_generation(); self.cancel_completion_requests();
self.input_mode = CommandInputMode::Composer; self.input_mode = CommandInputMode::Composer;
self.command_input.clear(); self.command_input.clear();
self.command_completion_selected = None; self.command_completion_selected = None;
@@ -2445,7 +2448,7 @@ impl App {
} }
pub fn request_rewind_picker(&mut self) -> Option<Method> { pub fn request_rewind_picker(&mut self) -> Option<Method> {
self.invalidate_completion_generation(); self.cancel_completion_requests();
// Rewind is a parent Worker control surface. Bring the parent transcript // Rewind is a parent Worker control surface. Bring the parent transcript
// back into view before presenting diagnostics or the picker. // back into view before presenting diagnostics or the picker.
self.selected_internal_worker_session_id = None; self.selected_internal_worker_session_id = None;
@@ -2791,21 +2794,12 @@ impl App {
app.replace_internal_worker_snapshots(snapshot.internal_workers); app.replace_internal_worker_snapshots(snapshot.internal_workers);
InternalWorkerView { InternalWorkerView {
worker: snapshot.worker, worker: snapshot.worker,
revision: snapshot.revision,
app: Box::new(app), app: Box::new(app),
} }
} }
fn apply_internal_worker_event( fn apply_internal_worker_event(&mut self, worker: InternalWorkerRef, event: Event) {
&mut self, if self.removed_internal_workers.contains(&worker.session_id) {
worker: InternalWorkerRef,
revision: u64,
event: Event,
) {
if self
.removed_internal_workers
.contains_key(&worker.session_id)
{
return; return;
} }
let index = self let index = self
@@ -2819,21 +2813,16 @@ impl App {
app.mode = self.mode; app.mode = self.mode;
self.internal_workers.push(InternalWorkerView { self.internal_workers.push(InternalWorkerView {
worker: worker.clone(), worker: worker.clone(),
revision: 0,
app: Box::new(app), app: Box::new(app),
}); });
self.internal_workers.last_mut().unwrap() self.internal_workers.last_mut().unwrap()
}; };
if revision <= target.revision {
return;
}
target.worker = worker; target.worker = worker;
target.revision = revision;
let _ = target.app.handle_worker_event(event); let _ = target.app.handle_worker_event(event);
} }
fn remove_internal_worker(&mut self, worker: InternalWorkerRef, revision: u64) { fn remove_internal_worker(&mut self, worker: InternalWorkerRef) {
self.invalidate_completion_generation(); self.cancel_completion_requests();
let session_id = worker.session_id; let session_id = worker.session_id;
let Some(index) = self let Some(index) = self
.internal_workers .internal_workers
@@ -2843,20 +2832,14 @@ impl App {
if self.selected_internal_worker_session_id.as_deref() == Some(session_id.as_str()) { if self.selected_internal_worker_session_id.as_deref() == Some(session_id.as_str()) {
self.selected_internal_worker_session_id = None; self.selected_internal_worker_session_id = None;
} }
self.removed_internal_workers self.removed_internal_workers.insert(session_id);
.entry(session_id)
.and_modify(|current| *current = (*current).max(revision))
.or_insert(revision);
return; return;
}; };
if revision <= self.internal_workers[index].revision {
return;
}
self.internal_workers.remove(index); self.internal_workers.remove(index);
if self.selected_internal_worker_session_id.as_deref() == Some(session_id.as_str()) { if self.selected_internal_worker_session_id.as_deref() == Some(session_id.as_str()) {
self.selected_internal_worker_session_id = None; 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( fn restore_snapshot(
@@ -2918,7 +2901,7 @@ impl App {
session: &protocol::SessionSnapshot, session: &protocol::SessionSnapshot,
greeting: Option<protocol::Greeting>, greeting: Option<protocol::Greeting>,
) { ) {
self.invalidate_completion_generation(); self.cancel_completion_requests();
self.run_error_messages.clear(); self.run_error_messages.clear();
self.turn_index = 0; self.turn_index = 0;
self.blocks.clear(); self.blocks.clear();
@@ -4102,7 +4085,6 @@ mod completion_flow_tests {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.handle_worker_event(Event::PendingSubmissionsChanged { app.handle_worker_event(Event::PendingSubmissionsChanged {
pending: protocol::PendingSubmissionsSnapshot { pending: protocol::PendingSubmissionsSnapshot {
revision: 3,
notification_count: 0, notification_count: 0,
notification_previews: vec![], notification_previews: vec![],
head_id: Some("submission-1".into()), head_id: Some("submission-1".into()),
@@ -4497,34 +4479,25 @@ mod completion_flow_tests {
}; };
app.handle_worker_event(Event::InternalWorker { app.handle_worker_event(Event::InternalWorker {
worker: worker.clone(), worker: worker.clone(),
revision: 2,
event: Box::new(Event::TextDelta { event: Box::new(Event::TextDelta {
text: "child output".into(), text: "child output".into(),
}), }),
}); });
app.handle_worker_event(Event::InternalWorker { app.handle_worker_event(Event::InternalWorker {
worker, worker,
revision: 1,
event: Box::new(Event::TextDelta { event: Box::new(Event::TextDelta {
text: "stale".into(), text: " next".into(),
}), }),
}); });
assert!(app.blocks.is_empty()); assert!(app.blocks.is_empty());
assert_eq!(app.internal_workers.len(), 1); assert_eq!(app.internal_workers.len(), 1);
assert_eq!(app.internal_workers[0].revision, 2); assert!(app.internal_workers[0].app.blocks.iter().any(
assert!( |block| matches!(block, Block::AssistantText { text } if text == "child output next")
app.internal_workers[0].app.blocks.iter().any( ));
|block| matches!(block, Block::AssistantText { text } if text == "child output")
)
);
} }
fn test_internal_worker_snapshot( fn test_internal_worker_snapshot(session_id: &str, name: &str) -> InternalWorkerSnapshot {
session_id: &str,
name: &str,
revision: u64,
) -> InternalWorkerSnapshot {
InternalWorkerSnapshot { InternalWorkerSnapshot {
worker: InternalWorkerRef { worker: InternalWorkerRef {
session_id: session_id.into(), session_id: session_id.into(),
@@ -4532,7 +4505,6 @@ mod completion_flow_tests {
parent_session_id: Some("parent".into()), parent_session_id: Some("parent".into()),
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision,
status: WorkerStatus::Idle, status: WorkerStatus::Idle,
greeting: None, greeting: None,
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
@@ -4548,7 +4520,7 @@ mod completion_flow_tests {
#[test] #[test]
fn internal_worker_snapshot_uses_child_greeting_metadata() { fn internal_worker_snapshot_uses_child_greeting_metadata() {
let mut app = App::new("parent".into()); 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(); let mut greeting = test_greeting();
greeting.worker_name = "research".into(); greeting.worker_name = "research".into();
greeting.model = "child-model".into(); greeting.model = "child-model".into();
@@ -4570,7 +4542,7 @@ mod completion_flow_tests {
#[test] #[test]
fn live_internal_worker_snapshot_installs_child_metadata_before_context_updates() { fn live_internal_worker_snapshot_installs_child_metadata_before_context_updates() {
let mut app = App::new("parent".into()); 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(); let mut greeting = test_greeting();
greeting.worker_name = "research".into(); greeting.worker_name = "research".into();
greeting.model = "child-model".into(); greeting.model = "child-model".into();
@@ -4582,7 +4554,6 @@ mod completion_flow_tests {
app.handle_worker_event(Event::InternalWorker { app.handle_worker_event(Event::InternalWorker {
worker: worker.clone(), worker: worker.clone(),
revision: 1,
event: Box::new(Event::Snapshot { event: Box::new(Event::Snapshot {
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(), pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
@@ -4596,7 +4567,6 @@ mod completion_flow_tests {
}); });
app.handle_worker_event(Event::InternalWorker { app.handle_worker_event(Event::InternalWorker {
worker, worker,
revision: 2,
event: Box::new(Event::ContextUsage { event: Box::new(Event::ContextUsage {
usage: Some(protocol::ContextUsage { usage: Some(protocol::ContextUsage {
tokens: 14_000, tokens: 14_000,
@@ -4626,7 +4596,6 @@ mod completion_flow_tests {
parent_session_id: Some("parent".into()), parent_session_id: Some("parent".into()),
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision: 1,
app: Box::new(App::new(name.into())), app: Box::new(App::new(name.into())),
}); });
} }
@@ -4664,7 +4633,7 @@ mod completion_flow_tests {
let mut app = App::new("parent".into()); let mut app = App::new("parent".into());
app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot( 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(&mut app, "parent selection");
select_first_row(app.internal_workers[0].app.as_mut(), "child 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()), parent_session_id: Some("parent".into()),
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision: 1,
app: Box::new(App::new("old".into())), app: Box::new(App::new("old".into())),
}); });
app.cycle_worker_view(); app.cycle_worker_view();
@@ -4711,7 +4679,7 @@ mod completion_flow_tests {
let mut app = App::new("parent".into()); let mut app = App::new("parent".into());
app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot( app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot(
"child", "child", 1, "child", "child",
)]); )]);
let child = app.internal_workers[0].app.as_mut(); let child = app.internal_workers[0].app.as_mut();
child.scroll.follow_tail = false; child.scroll.follow_tail = false;
@@ -4733,11 +4701,9 @@ mod completion_flow_tests {
app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot( app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot(
"child", "child",
"renamed-child", "renamed-child",
2,
)]); )]);
let view = &app.internal_workers[0]; let view = &app.internal_workers[0];
assert_eq!(view.revision, 2);
assert_eq!(view.app.worker_name, "renamed-child"); assert_eq!(view.app.worker_name, "renamed-child");
assert!(!view.app.scroll.follow_tail); assert!(!view.app.scroll.follow_tail);
assert_eq!(view.app.scroll.top_offset, 7); 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() { fn task_pane_scroll_is_local_to_selected_worker_view() {
let mut app = App::new("parent".into()); let mut app = App::new("parent".into());
app.replace_internal_worker_snapshots(vec![ app.replace_internal_worker_snapshots(vec![
test_internal_worker_snapshot("child-a", "alpha", 1), test_internal_worker_snapshot("child-a", "alpha"),
test_internal_worker_snapshot("child-b", "beta", 1), test_internal_worker_snapshot("child-b", "beta"),
]); ]);
app.task_pane_scroll = 3; app.task_pane_scroll = 3;
@@ -4793,10 +4759,8 @@ mod completion_flow_tests {
}; };
app.handle_worker_event(Event::InternalWorker { app.handle_worker_event(Event::InternalWorker {
worker: worker.clone(), worker: worker.clone(),
revision: 2,
event: Box::new(Event::InternalWorker { event: Box::new(Event::InternalWorker {
worker: nested, worker: nested,
revision: 1,
event: Box::new(Event::TextDone { event: Box::new(Event::TextDone {
text: "nested".into(), text: "nested".into(),
}), }),
@@ -4809,11 +4773,9 @@ mod completion_flow_tests {
app.handle_worker_event(Event::InternalWorkerRemoved { app.handle_worker_event(Event::InternalWorkerRemoved {
worker: worker.clone(), worker: worker.clone(),
revision: 3,
}); });
app.handle_worker_event(Event::InternalWorker { app.handle_worker_event(Event::InternalWorker {
worker, worker,
revision: 4,
event: Box::new(Event::TextDone { event: Box::new(Event::TextDone {
text: "late".into(), text: "late".into(),
}), }),
@@ -4836,7 +4798,7 @@ mod completion_flow_tests {
} }
#[test] #[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 mut app = App::new("parent".into());
let worker = InternalWorkerRef { let worker = InternalWorkerRef {
session_id: "child-session".into(), session_id: "child-session".into(),
@@ -4846,18 +4808,13 @@ mod completion_flow_tests {
}; };
app.handle_worker_event(Event::InternalWorker { app.handle_worker_event(Event::InternalWorker {
worker: worker.clone(), worker: worker.clone(),
revision: 4,
event: Box::new(Event::TextDone { event: Box::new(Event::TextDone {
text: "current".into(), text: "current".into(),
}), }),
}); });
app.handle_worker_event(Event::InternalWorkerRemoved { app.handle_worker_event(Event::InternalWorkerRemoved { worker });
worker,
revision: 3,
});
assert_eq!(app.internal_workers.len(), 1); assert!(app.internal_workers.is_empty());
assert_eq!(app.internal_workers[0].revision, 4);
} }
#[test] #[test]
@@ -4870,7 +4827,6 @@ mod completion_flow_tests {
parent_session_id: None, parent_session_id: None,
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision: 1,
app: Box::new(App::new("old".into())), app: Box::new(App::new("old".into())),
}); });
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
@@ -4888,7 +4844,6 @@ mod completion_flow_tests {
parent_session_id: Some("parent-session".into()), parent_session_id: Some("parent-session".into()),
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision: 4,
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(), pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
@@ -4903,7 +4858,6 @@ mod completion_flow_tests {
assert_eq!(app.internal_workers.len(), 1); assert_eq!(app.internal_workers.len(), 1);
assert_eq!(app.internal_workers[0].worker.session_id, "replacement"); assert_eq!(app.internal_workers[0].worker.session_id, "replacement");
assert_eq!(app.internal_workers[0].revision, 4);
assert_eq!( assert_eq!(
app.internal_workers[0].app.worker_status, app.internal_workers[0].app.worker_status,
WorkerStatus::Running WorkerStatus::Running
@@ -4955,7 +4909,6 @@ mod completion_flow_tests {
protocol::CompactionLifecycle { protocol::CompactionLifecycle {
schema_version: 2, schema_version: 2,
compaction_id: "compaction-test".into(), compaction_id: "compaction-test".into(),
revision: 1,
internal_worker: None, internal_worker: None,
state, state,
started_at_ms: 1, started_at_ms: 1,
@@ -4975,7 +4928,6 @@ mod completion_flow_tests {
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running), lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
}); });
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Done); let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Done);
lifecycle.revision = 2;
lifecycle.new_segment_id = Some(id.to_string()); lifecycle.new_segment_id = Some(id.to_string());
app.handle_worker_event(Event::CompactDone { lifecycle }); 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] #[test]
fn compact_failed_replaces_live_block() { fn compact_failed_replaces_live_block() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
@@ -4997,7 +4968,6 @@ mod completion_flow_tests {
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running), lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
}); });
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Failed); let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Failed);
lifecycle.revision = 2;
lifecycle.error = Some("provider 429".into()); lifecycle.error = Some("provider 429".into());
app.handle_worker_event(Event::CompactFailed { lifecycle }); app.handle_worker_event(Event::CompactFailed { lifecycle });
@@ -5662,7 +5632,7 @@ mod completion_correlation_tests {
let first = app.refresh_completion().unwrap(); let first = app.refresh_completion().unwrap();
app.input.insert_char('x'); app.input.insert_char('x');
app.input.delete_before(); 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")); app.handle_worker_event(reply(&first, "old"));
assert!(app.completion.as_ref().unwrap().entries.is_empty()); assert!(app.completion.as_ref().unwrap().entries.is_empty());
let second = app.refresh_completion().unwrap(); let second = app.refresh_completion().unwrap();
@@ -5734,7 +5704,7 @@ mod completion_correlation_tests {
} }
#[test] #[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()); let mut app = App::new("test".into());
app.handle_worker_event(authority_snapshot("Writable: /tmp")); app.handle_worker_event(authority_snapshot("Writable: /tmp"));
crate::invocation_tests::select(&mut app, crate::invocation_tests::descriptor(), "run"); crate::invocation_tests::select(&mut app, crate::invocation_tests::descriptor(), "run");
@@ -5748,8 +5718,8 @@ mod completion_correlation_tests {
.unwrap(); .unwrap();
assert_ne!(request_id(&first), request_id(&denied)); assert_ne!(request_id(&first), request_id(&denied));
assert_ne!(request_id(&first), request_id(&current)); assert_ne!(request_id(&first), request_id(&current));
app.handle_worker_event(reply(&first, "old permission generation")); app.handle_worker_event(reply(&first, "old permission context"));
app.handle_worker_event(reply(&denied, "intermediate generation")); app.handle_worker_event(reply(&denied, "intermediate context"));
assert!(app.completion.as_ref().unwrap().entries.is_empty()); assert!(app.completion.as_ref().unwrap().entries.is_empty());
app.handle_worker_event(reply(&current, "資料/current")); app.handle_worker_event(reply(&current, "資料/current"));
assert_eq!( assert_eq!(
@@ -5789,7 +5759,6 @@ mod completion_correlation_tests {
parent_session_id: Some("parent".into()), parent_session_id: Some("parent".into()),
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision: 1,
event: Box::new(Event::WorkerState { event: Box::new(Event::WorkerState {
snapshot: protocol::WorkerStateSnapshot::from(WorkerStatus::Idle), snapshot: protocol::WorkerStateSnapshot::from(WorkerStatus::Idle),
}), }),
+3 -4
View File
@@ -674,7 +674,7 @@ mod tests {
fn worker(runtime_id: &str, worker_id: &str, profile: Option<&str>) -> BackendWorkerSummary { fn worker(runtime_id: &str, worker_id: &str, profile: Option<&str>) -> BackendWorkerSummary {
BackendWorkerSummary { BackendWorkerSummary {
restore_observation_token: Some("observed-worker-generation".into()), restore_observation_token: Some("observed-worker-state".into()),
runtime_id: runtime_id.to_string(), runtime_id: runtime_id.to_string(),
worker_id: worker_id.to_string(), worker_id: worker_id.to_string(),
resource_key: "W-1".to_string(), resource_key: "W-1".to_string(),
@@ -953,7 +953,6 @@ mod tests {
workspace_api: None, workspace_api: None,
memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot { memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "local".into(), workspace_id: "local".into(),
settings_revision: 1,
language: "English".into(), language: "English".into(),
}), }),
subjektiv_attached: false, subjektiv_attached: false,
@@ -1107,7 +1106,7 @@ mod tests {
target, target,
BackendWorkerPickerIntent::Resume, BackendWorkerPickerIntent::Resume,
Some(client::BackendWorkerRestoreRequest { Some(client::BackendWorkerRestoreRequest {
expected_observation_token: "observed-worker-generation".into(), expected_observation_token: "observed-worker-state".into(),
request_id: "resume-operation".into(), request_id: "resume-operation".into(),
}), }),
|_| Ok(false), |_| Ok(false),
@@ -1142,7 +1141,7 @@ mod tests {
target, target,
BackendWorkerPickerIntent::Resume, BackendWorkerPickerIntent::Resume,
Some(client::BackendWorkerRestoreRequest { Some(client::BackendWorkerRestoreRequest {
expected_observation_token: "observed-worker-generation".into(), expected_observation_token: "observed-worker-state".into(),
request_id: "resume-operation".into(), request_id: "resume-operation".into(),
}), }),
|_| Ok(false), |_| Ok(false),
+2 -10
View File
@@ -2625,7 +2625,6 @@ mod tests {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.handle_worker_event(Event::PendingSubmissionsChanged { app.handle_worker_event(Event::PendingSubmissionsChanged {
pending: protocol::PendingSubmissionsSnapshot { pending: protocol::PendingSubmissionsSnapshot {
revision: 2,
notification_count: 0, notification_count: 0,
notification_previews: vec![], notification_previews: vec![],
head_id: Some("submission-1".into()), head_id: Some("submission-1".into()),
@@ -2646,7 +2645,6 @@ mod tests {
assert!(matches!( assert!(matches!(
continue_next, continue_next,
Some(Method::ContinuePending { Some(Method::ContinuePending {
expected_revision: 2,
ref expected_head_id, ref expected_head_id,
}) if expected_head_id == "submission-1" }) if expected_head_id == "submission-1"
)); ));
@@ -2659,7 +2657,6 @@ mod tests {
assert!(matches!( assert!(matches!(
cancel, cancel,
Some(Method::CancelPendingSubmission { Some(Method::CancelPendingSubmission {
expected_revision: 2,
ref submission_id, ref submission_id,
}) if submission_id == "submission-1" }) if submission_id == "submission-1"
)); ));
@@ -2671,8 +2668,8 @@ mod tests {
assert!(matches!( assert!(matches!(
clear, clear,
Some(Method::ClearPendingSubmissions { Some(Method::ClearPendingSubmissions {
expected_revision: 2 ref expected_submission_ids
}) }) if expected_submission_ids == &["submission-1"]
)); ));
assert_eq!(app.queued_input_count(), 1); assert_eq!(app.queued_input_count(), 1);
} }
@@ -2682,7 +2679,6 @@ mod tests {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.handle_worker_event(Event::PendingSubmissionsChanged { app.handle_worker_event(Event::PendingSubmissionsChanged {
pending: protocol::PendingSubmissionsSnapshot { pending: protocol::PendingSubmissionsSnapshot {
revision: 2,
notification_count: 0, notification_count: 0,
notification_previews: vec![], notification_previews: vec![],
head_id: Some("submission-1".into()), head_id: Some("submission-1".into()),
@@ -3355,7 +3351,6 @@ mod tests {
parent_session_id: Some("parent-session".into()), parent_session_id: Some("parent-session".into()),
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision: 1,
event: Box::new(Event::WorkerState { event: Box::new(Event::WorkerState {
snapshot: WorkerStatus::Running.into(), snapshot: WorkerStatus::Running.into(),
}), }),
@@ -3471,7 +3466,6 @@ mod tests {
parent_session_id: Some("parent-session".into()), parent_session_id: Some("parent-session".into()),
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision: 1,
event: Box::new(Event::WorkerState { event: Box::new(Event::WorkerState {
snapshot: WorkerStatus::Running.into(), snapshot: WorkerStatus::Running.into(),
}), }),
@@ -3497,7 +3491,6 @@ mod tests {
parent_session_id: Some("parent-session".into()), parent_session_id: Some("parent-session".into()),
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision: 1,
event: Box::new(Event::WorkerState { event: Box::new(Event::WorkerState {
snapshot: WorkerStatus::Running.into(), snapshot: WorkerStatus::Running.into(),
}), }),
@@ -3537,7 +3530,6 @@ mod tests {
parent_session_id: Some("parent-session".into()), parent_session_id: Some("parent-session".into()),
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision: 1,
event: Box::new(Event::WorkerState { event: Box::new(Event::WorkerState {
snapshot: WorkerStatus::Running.into(), snapshot: WorkerStatus::Running.into(),
}), }),
+55 -25
View File
@@ -11,6 +11,14 @@
//! back to their original captured content so the Worker sees the full //! back to their original captured content so the Worker sees the full
//! pasted text (without the placeholder label). //! pasted text (without the placeholder label).
use std::{
cell::RefCell,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
};
use ratatui::style::{Color, Style}; use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthChar;
@@ -281,9 +289,19 @@ struct SelectedInvocation {
invocation_id: String, 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<AtomicBool>);
impl CompletionInputWatch {
pub(crate) fn is_cancelled(&self) -> bool {
self.0.load(Ordering::Relaxed)
}
}
pub struct InputBuffer { pub struct InputBuffer {
/// Monotonic semantic edit/cursor revision, including ABA changes. pending_completion: RefCell<Option<Arc<AtomicBool>>>,
revision: u64,
atoms: Vec<Atom>, atoms: Vec<Atom>,
selected_invocations: Vec<SelectedInvocation>, selected_invocations: Vec<SelectedInvocation>,
removed_attachment_stages: Vec<String>, removed_attachment_stages: Vec<String>,
@@ -300,7 +318,7 @@ pub struct InputBuffer {
impl Default for InputBuffer { impl Default for InputBuffer {
fn default() -> Self { fn default() -> Self {
Self { Self {
revision: 0, pending_completion: RefCell::new(None),
atoms: Vec::new(), atoms: Vec::new(),
selected_invocations: Vec::new(), selected_invocations: Vec::new(),
removed_attachment_stages: 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 { impl InputBuffer {
pub fn revision(&self) -> u64 { pub(crate) fn watch_completion_input(&self) -> CompletionInputWatch {
self.revision CompletionInputWatch(Arc::clone(
self.pending_completion
.borrow_mut()
.get_or_insert_with(|| Arc::new(AtomicBool::new(false))),
))
} }
fn bump_revision(&mut self) { fn cancel_completion_on_edit(&mut self) {
self.revision = self.revision.wrapping_add(1); if let Some(pending) = self.pending_completion.get_mut().take() {
pending.store(true, Ordering::Relaxed);
}
} }
pub fn new() -> Self { pub fn new() -> Self {
@@ -326,7 +356,7 @@ impl InputBuffer {
} }
pub fn clear(&mut self) { pub fn clear(&mut self) {
self.bump_revision(); self.cancel_completion_on_edit();
self.selected_invocations.clear(); self.selected_invocations.clear();
self.atoms.clear(); self.atoms.clear();
self.cursor = 0; self.cursor = 0;
@@ -349,7 +379,7 @@ impl InputBuffer {
/// by [`submit_segments`](Self::submit_segments), preserving typed chips /// by [`submit_segments`](Self::submit_segments), preserving typed chips
/// and placing the cursor at the end of the restored input. /// and placing the cursor at the end of the restored input.
pub fn replace_with_segments(&mut self, segments: &[protocol::Segment]) { pub fn replace_with_segments(&mut self, segments: &[protocol::Segment]) {
self.bump_revision(); self.cancel_completion_on_edit();
self.selected_invocations.clear(); self.selected_invocations.clear();
self.atoms.clear(); self.atoms.clear();
for segment in segments { for segment in segments {
@@ -400,7 +430,7 @@ impl InputBuffer {
} }
pub fn insert_char(&mut self, c: char) { 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.adjust_selection(self.cursor, self.cursor, 1);
self.atoms.insert(self.cursor, Atom::Char(c)); self.atoms.insert(self.cursor, Atom::Char(c));
self.cursor += 1; self.cursor += 1;
@@ -445,7 +475,7 @@ impl InputBuffer {
} }
pub fn insert_paste(&mut self, content: String) { pub fn insert_paste(&mut self, content: String) {
self.bump_revision(); self.cancel_completion_on_edit();
let measurement = measure_paste(&content); let measurement = measure_paste(&content);
if measurement.presentation() == PastePresentation::Text { if measurement.presentation() == PastePresentation::Text {
self.insert_str(&content); self.insert_str(&content);
@@ -469,7 +499,7 @@ impl InputBuffer {
#[cfg(test)] #[cfg(test)]
pub fn insert_uploaded_file(&mut self, file: protocol::UploadedFileRef) { 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.adjust_selection(self.cursor, self.cursor, 1);
self.atoms.insert(self.cursor, Atom::UploadedFile(file)); self.atoms.insert(self.cursor, Atom::UploadedFile(file));
self.cursor += 1; self.cursor += 1;
@@ -493,7 +523,7 @@ impl InputBuffer {
start: usize, start: usize,
descriptor: protocol::FeatureInvocationDescriptor, descriptor: protocol::FeatureInvocationDescriptor,
) { ) {
self.bump_revision(); self.cancel_completion_on_edit();
self.selected_invocations self.selected_invocations
.retain(|selection| selection.start != start); .retain(|selection| selection.start != start);
self.selected_invocations.push(SelectedInvocation { self.selected_invocations.push(SelectedInvocation {
@@ -508,7 +538,7 @@ impl InputBuffer {
} }
pub fn retry_attachment_stage(&mut self, id: &str) { pub fn retry_attachment_stage(&mut self, id: &str) {
self.bump_revision(); self.cancel_completion_on_edit();
for atom in &mut self.atoms { for atom in &mut self.atoms {
if let Atom::AttachmentStage { if let Atom::AttachmentStage {
id: stage_id, id: stage_id,
@@ -563,7 +593,7 @@ impl InputBuffer {
} }
fn replace_atoms(&mut self, start: usize, end: usize, atoms: Vec<Atom>) { fn replace_atoms(&mut self, start: usize, end: usize, atoms: Vec<Atom>) {
self.bump_revision(); self.cancel_completion_on_edit();
let inserted = atoms.len(); let inserted = atoms.len();
self.adjust_selection(start, end, inserted); self.adjust_selection(start, end, inserted);
let removed = self.atoms.splice(start..end, atoms).collect::<Vec<_>>(); let removed = self.atoms.splice(start..end, atoms).collect::<Vec<_>>();
@@ -669,7 +699,7 @@ impl InputBuffer {
id: &str, id: &str,
file: Option<protocol::UploadedFileRef>, file: Option<protocol::UploadedFileRef>,
) -> bool { ) -> bool {
self.bump_revision(); self.cancel_completion_on_edit();
let Some(index) = self.atoms.iter().position( let Some(index) = self.atoms.iter().position(
|atom| matches!(atom, Atom::AttachmentStage { id: stage_id, .. } if stage_id == id), |atom| matches!(atom, Atom::AttachmentStage { id: stage_id, .. } if stage_id == id),
) else { ) else {
@@ -965,12 +995,12 @@ impl InputBuffer {
} }
pub fn move_left(&mut self) { pub fn move_left(&mut self) {
self.bump_revision(); self.cancel_completion_on_edit();
self.cursor = self.cursor.saturating_sub(1); self.cursor = self.cursor.saturating_sub(1);
} }
pub fn move_right(&mut self) { pub fn move_right(&mut self) {
self.bump_revision(); self.cancel_completion_on_edit();
self.cursor = (self.cursor + 1).min(self.atoms.len()); 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 /// atoms sharing the same [`AtomClass`] — so `Word(Hiragana)` next to
/// `Word(Han)` are separate blocks, and a `Paste` atom is its own block. /// `Word(Han)` are separate blocks, and a `Paste` atom is its own block.
pub fn move_word_left(&mut self) { 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 { while self.cursor > 0 && atom_class(&self.atoms[self.cursor - 1]) == AtomClass::Sep {
self.cursor -= 1; self.cursor -= 1;
} }
@@ -993,7 +1023,7 @@ impl InputBuffer {
/// Move forward by one word. Mirror of [`move_word_left`]. /// Move forward by one word. Mirror of [`move_word_left`].
pub fn move_word_right(&mut self) { pub fn move_word_right(&mut self) {
self.bump_revision(); self.cancel_completion_on_edit();
while self.cursor < self.atoms.len() while self.cursor < self.atoms.len()
&& atom_class(&self.atoms[self.cursor]) == AtomClass::Sep && atom_class(&self.atoms[self.cursor]) == AtomClass::Sep
{ {
@@ -1009,7 +1039,7 @@ impl InputBuffer {
} }
pub fn move_start(&mut self) { pub fn move_start(&mut self) {
self.bump_revision(); self.cancel_completion_on_edit();
self.cursor = 0; self.cursor = 0;
} }
@@ -1058,13 +1088,13 @@ impl InputBuffer {
} }
pub fn move_home(&mut self) { pub fn move_home(&mut self) {
self.bump_revision(); self.cancel_completion_on_edit();
let (ranges, line, _) = self.logical_line_and_col(); let (ranges, line, _) = self.logical_line_and_col();
self.cursor = ranges[line].0; self.cursor = ranges[line].0;
} }
pub fn move_end(&mut self) { pub fn move_end(&mut self) {
self.bump_revision(); self.cancel_completion_on_edit();
let (ranges, line, _) = self.logical_line_and_col(); let (ranges, line, _) = self.logical_line_and_col();
self.cursor = ranges[line].1; self.cursor = ranges[line].1;
} }
@@ -1072,7 +1102,7 @@ impl InputBuffer {
/// Move one logical line up, preserving column (atom count from /// Move one logical line up, preserving column (atom count from
/// current line start). No-op if already on the first line. /// current line start). No-op if already on the first line.
pub fn move_up(&mut self) { pub fn move_up(&mut self) {
self.bump_revision(); self.cancel_completion_on_edit();
let (ranges, line, col) = self.logical_line_and_col(); let (ranges, line, col) = self.logical_line_and_col();
if line == 0 { if line == 0 {
return; return;
@@ -1083,7 +1113,7 @@ impl InputBuffer {
/// Move one logical line down, preserving column. /// Move one logical line down, preserving column.
pub fn move_down(&mut self) { pub fn move_down(&mut self) {
self.bump_revision(); self.cancel_completion_on_edit();
let (ranges, line, col) = self.logical_line_and_col(); let (ranges, line, col) = self.logical_line_and_col();
let Some(&(start, end)) = ranges.get(line + 1) else { let Some(&(start, end)) = ranges.get(line + 1) else {
return; return;
-2
View File
@@ -2187,7 +2187,6 @@ mod tests {
fn set_pending_submission(app: &mut App, id: &str) { fn set_pending_submission(app: &mut App, id: &str) {
app.handle_worker_event(Event::PendingSubmissionsChanged { app.handle_worker_event(Event::PendingSubmissionsChanged {
pending: protocol::PendingSubmissionsSnapshot { pending: protocol::PendingSubmissionsSnapshot {
revision: 1,
notification_count: 0, notification_count: 0,
notification_previews: vec![], notification_previews: vec![],
head_id: Some(id.into()), head_id: Some(id.into()),
@@ -2320,7 +2319,6 @@ mod tests {
parent_session_id: Some("parent-session".into()), parent_session_id: Some("parent-session".into()),
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision: 1,
event: Box::new(protocol::Event::TextDelta { event: Box::new(protocol::Event::TextDelta {
text: "child transcript".into(), text: "child transcript".into(),
}), }),
+3 -6
View File
@@ -75,7 +75,6 @@ pub struct WorkingDirectoryRepository {
pub id: String, pub id: String,
pub provider: String, pub provider: String,
pub source: server_api::RepositorySource, pub source: server_api::RepositorySource,
pub source_revision: u64,
pub source_fingerprint: String, pub source_fingerprint: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<RepositorySelector>, pub selector: Option<RepositorySelector>,
@@ -121,7 +120,7 @@ impl std::fmt::Debug for SensitiveString {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositorySshCredentialCandidate { pub struct RepositorySshCredentialCandidate {
pub credential_id: String, pub credential_id: String,
pub credential_revision: u64, pub public_key_fingerprint: String,
#[serde(skip, default)] #[serde(skip, default)]
pub private_key: SensitiveString, pub private_key: SensitiveString,
} }
@@ -130,7 +129,7 @@ pub struct RepositorySshCredentialCandidate {
pub struct RepositorySshMaterializationAccess { pub struct RepositorySshMaterializationAccess {
pub credential_candidates: Vec<RepositorySshCredentialCandidate>, pub credential_candidates: Vec<RepositorySshCredentialCandidate>,
pub host_trust_id: String, pub host_trust_id: String,
pub host_trust_revision: u64, pub host_key_fingerprint: String,
pub access: server_api::RepositoryAccessMode, pub access: server_api::RepositoryAccessMode,
pub expires_at_epoch_seconds: u64, pub expires_at_epoch_seconds: u64,
pub repository_id: String, pub repository_id: String,
@@ -146,7 +145,6 @@ pub struct RepositoryMaterializationContext {
pub workspace_id: String, pub workspace_id: String,
pub runtime_id: String, pub runtime_id: String,
pub operation_id: String, pub operation_id: String,
pub config_revision: u64,
pub config_projection_digest: String, pub config_projection_digest: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh: Option<RepositorySshMaterializationAccess>, pub ssh: Option<RepositorySshMaterializationAccess>,
@@ -191,10 +189,9 @@ pub struct RepositoryRefObservationRequest {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositoryRefObservation { pub struct RepositoryRefObservation {
pub repository_id: String, pub repository_id: String,
pub source_revision: u64,
pub source_fingerprint: String, pub source_fingerprint: String,
pub selector: String, pub selector: String,
pub revision_ref: String, pub resolved_ref: String,
pub observed_at_epoch_seconds: u64, pub observed_at_epoch_seconds: u64,
} }
+3 -22
View File
@@ -43,7 +43,6 @@ impl ConfigBundle {
pub fn computed_digest(&self) -> String { pub fn computed_digest(&self) -> String {
let mut lines = Vec::new(); let mut lines = Vec::new();
lines.push(format!("id\0{}", self.metadata.id)); 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!("workspace_id\0{}", self.metadata.workspace_id));
lines.push(format!("created_at\0{}", self.metadata.created_at)); lines.push(format!("created_at\0{}", self.metadata.created_at));
lines.push(format!( lines.push(format!(
@@ -82,7 +81,7 @@ impl ConfigBundle {
if let Some(prompt_catalog) = &self.prompt_catalog { if let Some(prompt_catalog) = &self.prompt_catalog {
lines.push(format!( lines.push(format!(
"prompt_catalog\0{}\0{}\0{}\0{}", "prompt_catalog\0{}\0{}\0{}\0{}",
prompt_catalog.config_revision, prompt_catalog.source_digest,
prompt_catalog.schema_fingerprint, prompt_catalog.schema_fingerprint,
prompt_catalog.toolchain_fingerprint, prompt_catalog.toolchain_fingerprint,
prompt_catalog.catalog_digest prompt_catalog.catalog_digest
@@ -100,12 +99,8 @@ impl ConfigBundle {
} }
if let Some(handle) = &self.profile_source_archive_handle { if let Some(handle) = &self.profile_source_archive_handle {
lines.push(format!( lines.push(format!(
"profile_archive_handle\0{}\0{}\0{}\0{}\0{}", "profile_archive_handle\0{}\0{}\0{}\0{}",
handle.workspace_id, handle.workspace_id, handle.resource_id, handle.digest, handle.max_bytes
handle.resource_id,
handle.digest,
handle.revision,
handle.max_bytes
)); ));
for (selector, path) in handle for (selector, path) in handle
.profile_source_graph .profile_source_graph
@@ -140,7 +135,6 @@ impl ConfigBundle {
id: self.metadata.id.clone(), id: self.metadata.id.clone(),
digest: self.metadata.digest.clone(), digest: self.metadata.digest.clone(),
digest_algorithm: CONFIG_BUNDLE_DIGEST_ALGORITHM.to_string(), digest_algorithm: CONFIG_BUNDLE_DIGEST_ALGORITHM.to_string(),
revision: self.metadata.revision.clone(),
workspace_id: self.metadata.workspace_id.clone(), workspace_id: self.metadata.workspace_id.clone(),
created_at: self.metadata.created_at.clone(), created_at: self.metadata.created_at.clone(),
provenance: self.metadata.provenance.clone(), provenance: self.metadata.provenance.clone(),
@@ -182,7 +176,6 @@ impl From<ConfigBundle> for server_api::WorkspaceRuntimeConfigResponse {
prompt_catalog: prompt_catalog.map(|catalog| { prompt_catalog: prompt_catalog.map(|catalog| {
server_api::WorkspaceRuntimePromptCatalog { server_api::WorkspaceRuntimePromptCatalog {
templates: catalog.templates, templates: catalog.templates,
config_revision: catalog.config_revision,
source_digest: catalog.source_digest, source_digest: catalog.source_digest,
schema_fingerprint: catalog.schema_fingerprint, schema_fingerprint: catalog.schema_fingerprint,
toolchain_fingerprint: catalog.toolchain_fingerprint, toolchain_fingerprint: catalog.toolchain_fingerprint,
@@ -200,7 +193,6 @@ impl From<ConfigBundleMetadata> for server_api::WorkspaceRuntimeConfigMetadata {
Self { Self {
id: metadata.id, id: metadata.id,
digest: metadata.digest, digest: metadata.digest,
revision: metadata.revision,
workspace_id: metadata.workspace_id, workspace_id: metadata.workspace_id,
created_at: metadata.created_at, created_at: metadata.created_at,
provenance: metadata.provenance.into(), provenance: metadata.provenance.into(),
@@ -303,8 +295,6 @@ impl From<BackendResourceHandle> for server_api::WorkspaceRuntimeResourceHandle
operation: handle.operation.into(), operation: handle.operation.into(),
expires_at_unix_seconds: handle.expires_at_unix_seconds, expires_at_unix_seconds: handle.expires_at_unix_seconds,
nonce: handle.nonce, nonce: handle.nonce,
revision: handle.revision,
generation: handle.generation,
max_bytes: handle.max_bytes, max_bytes: handle.max_bytes,
content_type: handle.content_type, content_type: handle.content_type,
redaction: handle.redaction.into(), redaction: handle.redaction.into(),
@@ -344,7 +334,6 @@ impl From<ResourceRedactionPolicy> for server_api::WorkspaceRuntimeResourceRedac
pub struct ConfigBundleMetadata { pub struct ConfigBundleMetadata {
pub id: String, pub id: String,
pub digest: String, pub digest: String,
pub revision: String,
pub workspace_id: String, pub workspace_id: String,
pub created_at: String, pub created_at: String,
pub provenance: ConfigBundleProvenance, pub provenance: ConfigBundleProvenance,
@@ -402,7 +391,6 @@ pub struct ConfigBundleSummary {
pub id: String, pub id: String,
pub digest: String, pub digest: String,
pub digest_algorithm: String, pub digest_algorithm: String,
pub revision: String,
pub workspace_id: String, pub workspace_id: String,
pub created_at: String, pub created_at: String,
pub provenance: ConfigBundleProvenance, 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_config_bundle_id(&bundle.metadata.id)?;
validate_non_empty("config bundle digest", &bundle.metadata.digest)?; validate_non_empty("config bundle digest", &bundle.metadata.digest)?;
validate_digest("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 workspace id", &bundle.metadata.workspace_id)?;
validate_non_empty("config bundle created_at", &bundle.metadata.created_at)?; validate_non_empty("config bundle created_at", &bundle.metadata.created_at)?;
validate_non_empty( validate_non_empty(
@@ -430,7 +417,6 @@ pub(crate) fn validate_config_bundle(bundle: &ConfigBundle) -> Result<(), Runtim
&bundle.metadata.provenance.source, &bundle.metadata.provenance.source,
)?; )?;
validate_boundary_text("config bundle id", &bundle.metadata.id)?; 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 workspace id", &bundle.metadata.workspace_id)?;
validate_boundary_text( validate_boundary_text(
"config bundle provenance source", "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 resource id", handle.resource_id.as_str()),
("resource handle digest", handle.digest.as_str()), ("resource handle digest", handle.digest.as_str()),
("resource handle nonce", handle.nonce.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 content type", handle.content_type.as_str()),
( (
"resource handle audit correlation id", "resource handle audit correlation id",
@@ -766,7 +751,6 @@ mod tests {
metadata: ConfigBundleMetadata { metadata: ConfigBundleMetadata {
id: "bundle-1".to_string(), id: "bundle-1".to_string(),
digest: String::new(), digest: String::new(),
revision: "rev-1".to_string(),
workspace_id: "workspace-1".to_string(), workspace_id: "workspace-1".to_string(),
created_at: "2026-06-26T00:00:00Z".to_string(), created_at: "2026-06-26T00:00:00Z".to_string(),
provenance: ConfigBundleProvenance { provenance: ConfigBundleProvenance {
@@ -823,7 +807,6 @@ mod tests {
bundle.prompt_catalog = Some( bundle.prompt_catalog = Some(
worker::EffectivePromptCatalog::new( worker::EffectivePromptCatalog::new(
std::collections::BTreeMap::from([("default".to_string(), "hello".to_string())]), std::collections::BTreeMap::from([("default".to_string(), "hello".to_string())]),
7,
"schema", "schema",
"toolchain", "toolchain",
) )
@@ -866,8 +849,6 @@ mod tests {
operation: crate::resource::BackendResourceOperation::FetchArchive, operation: crate::resource::BackendResourceOperation::FetchArchive,
expires_at_unix_seconds: 4_102_444_800, expires_at_unix_seconds: 4_102_444_800,
nonce: "nonce-not-for-browser".to_string(), nonce: "nonce-not-for-browser".to_string(),
revision: "rev-1".to_string(),
generation: Some(1),
max_bytes: 128, max_bytes: 128,
content_type: crate::resource::PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), content_type: crate::resource::PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(),
redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly, redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly,
+1 -1
View File
@@ -693,7 +693,7 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
} }
/// Observe a newer immutable Workspace Prompt projection. Profile-backed /// 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. /// safely ignore it.
fn observe_workspace_prompt_projection( fn observe_workspace_prompt_projection(
&self, &self,
+284 -19
View File
@@ -21,7 +21,9 @@ use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering}; 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 PREVIOUS_SCHEMA_VERSION: u32 = 8;
const LEGACY_SCHEMA_VERSION: u32 = 7; const LEGACY_SCHEMA_VERSION: u32 = 7;
const OLDEST_SCHEMA_VERSION: u32 = 6; 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"), format!("Runtime store schema version {schema_version} is out of range"),
) )
})?; })?;
let staging = migration_sibling(root, "schema-v9-staging")?; let staging = migration_sibling(root, "schema-v10-staging")?;
let backup = migration_sibling(root, "pre-schema-v9-backup")?; let backup = migration_sibling(root, "pre-schema-v10-backup")?;
if staging.exists() || backup.exists() { if staging.exists() || backup.exists() {
return Err(runtime_store_corrupt( return Err(runtime_store_corrupt(
root, root,
@@ -639,12 +641,15 @@ fn plan_runtime_store_migration(
} }
if !matches!( if !matches!(
current_schema_version, 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( return Err(runtime_store_corrupt(
&runtime_path, &runtime_path,
format!( 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( fn migrate_worker_record(
identity_document: serde_json::Value, mut identity_document: serde_json::Value,
source_schema_version: u32, source_schema_version: u32,
mapping: Option<&LegacyWorkerIdentityMapping>, mapping: Option<&LegacyWorkerIdentityMapping>,
identity_path: &Path, identity_path: &Path,
) -> Result<WorkerAggregateRecord, RuntimeError> { ) -> Result<WorkerAggregateRecord, RuntimeError> {
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 { if source_schema_version == PREVIOUS_SCHEMA_VERSION {
let identity: WorkerIdentityRecord = let identity: WorkerIdentityRecord =
serde_json::from_value(identity_document).map_err(|error| { 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)?; identity.validate_for_schema(identity_path, PREVIOUS_SCHEMA_VERSION)?;
let execution_path = identity_path.with_file_name(WORKER_EXECUTION_FILE); 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")?; 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)?; execution.validate_for_schema(&identity, &execution_path, PREVIOUS_SCHEMA_VERSION)?;
return Ok(WorkerAggregateRecord::from_v8(identity, execution)); return Ok(WorkerAggregateRecord::from_v8(identity, execution));
} }
@@ -1016,6 +1050,114 @@ fn migrate_worker_record(
Ok(WorkerAggregateRecord::from_v8(identity, execution)) 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 { fn runtime_worker_name(worker_id: WorkerId) -> String {
format!("worker-runtime-{worker_id}") format!("worker-runtime-{worker_id}")
} }
@@ -1216,6 +1358,9 @@ fn migrate_runtime_document(
serde_json::Value::from(SCHEMA_VERSION), serde_json::Value::from(SCHEMA_VERSION),
); );
object.remove("workers"); 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"); object.remove("next_worker_sequence");
let snapshot: RuntimeSnapshot = serde_json::from_value(document.clone()).map_err(|error| { let snapshot: RuntimeSnapshot = serde_json::from_value(document.clone()).map_err(|error| {
runtime_store_corrupt( 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> { fn recover_runtime_store_migration(root: &Path) -> Result<(), RuntimeError> {
let staging = migration_sibling(root, "schema-v9-staging")?; for suffix in ["schema-v9-staging", "pre-schema-v9-backup"] {
let backup = migration_sibling(root, "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 root_exists = migration_directory_exists(root)?;
let staging_exists = migration_directory_exists(&staging)?; let staging_exists = migration_directory_exists(&staging)?;
let backup_exists = migration_directory_exists(&backup)?; let backup_exists = migration_directory_exists(&backup)?;
@@ -1528,8 +1679,8 @@ fn migrate_runtime_store(
if !plan.migration_required { if !plan.migration_required {
return Ok(plan); return Ok(plan);
} }
let staging = migration_sibling(root, "schema-v9-staging")?; let staging = migration_sibling(root, "schema-v10-staging")?;
let backup = migration_sibling(root, "pre-schema-v9-backup")?; let backup = migration_sibling(root, "pre-schema-v10-backup")?;
if staging.exists() || backup.exists() { if staging.exists() || backup.exists() {
return Err(runtime_store_corrupt( return Err(runtime_store_corrupt(
root, root,
@@ -2875,7 +3026,7 @@ mod tests {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime-store"); let root = temp.path().join("runtime-store");
write_empty_schema_v8_store(&root); 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::create_dir_all(&staging).unwrap();
fs::write(staging.join("partial"), b"partial").unwrap(); fs::write(staging.join("partial"), b"partial").unwrap();
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap(); let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
@@ -2890,7 +3041,7 @@ mod tests {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime-store"); let root = temp.path().join("runtime-store");
write_empty_schema_v8_store(&root); 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(); fs::rename(&root, &backup).unwrap();
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap(); let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
@@ -2904,10 +3055,10 @@ mod tests {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime-store"); let root = temp.path().join("runtime-store");
write_empty_schema_v8_store(&root); 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(); copy_runtime_tree(&root, &root, &staging).unwrap();
migrate_runtime_store_in_place(&staging, "test-runtime").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(); fs::rename(&root, &backup).unwrap();
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap(); let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
@@ -2922,10 +3073,10 @@ mod tests {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime-store"); let root = temp.path().join("runtime-store");
write_empty_schema_v8_store(&root); 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::create_dir_all(&staging).unwrap();
fs::write(staging.join(RUNTIME_FILE), b"{\"schema_version\":8}").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(); fs::rename(&root, &backup).unwrap();
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").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"); let root = temp.path().join("runtime-store");
write_empty_schema_v8_store(&root); write_empty_schema_v8_store(&root);
migrate_runtime_store_in_place(&root, "test-runtime").unwrap(); 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); write_empty_schema_v8_store(&backup);
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap(); let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
@@ -2950,6 +3101,120 @@ mod tests {
assert!(!backup.exists()); 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::<serde_json::Value>(&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] #[test]
fn bounded_worker_record_read_rejects_oversize_before_parse() { fn bounded_worker_record_read_rejects_oversize_before_parse() {
let root = tempfile::tempdir().unwrap(); let root = tempfile::tempdir().unwrap();
@@ -3047,7 +3312,7 @@ mod tests {
)); ));
assert!(!worker_dir.join(WORKER_EXECUTION_FILE).exists()); assert!(!worker_dir.join(WORKER_EXECUTION_FILE).exists());
let aggregate: WorkerAggregateRecord = 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); assert_eq!(aggregate.schema_version, SCHEMA_VERSION);
} }
+198 -56
View File
@@ -413,7 +413,8 @@ pub struct RuntimeHttpWorkspacePromptProjectionRequest {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeHttpWorkspacePromptProjectionResponse { pub struct RuntimeHttpWorkspacePromptProjectionResponse {
pub workspace_id: String, 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. /// 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 if challenge.workspace_id != verified.workspace_id
|| challenge.runtime_id != verified.runtime_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_key_id != verified.issuer_key_id
|| challenge.workspace_identity_revision != verified.issuer_identity_revision || challenge.workspace_public_key_fingerprint != verified.issuer_public_key_fingerprint
|| challenge.workspace_trust_generation != verified.trust_generation || challenge.workspace_trust_id != verified.trust_id
|| challenge.runtime_id != auth.signer.runtime_id() || challenge.runtime_id != auth.signer.runtime_id()
|| challenge.runtime_public_key_fingerprint != auth.signer.public_key_fingerprint() || challenge.runtime_public_key_fingerprint != auth.signer.public_key_fingerprint()
{ {
@@ -599,6 +600,13 @@ async fn post_workspace_verification_challenge(
error.to_string(), 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)) Ok(Json(response))
} }
@@ -616,17 +624,17 @@ async fn post_workspace_verification_acknowledgement(
})?; })?;
if acknowledgement.workspace_id != verified.workspace_id if acknowledgement.workspace_id != verified.workspace_id
|| acknowledgement.runtime_id != verified.runtime_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_key_id != verified.issuer_key_id
|| acknowledgement.workspace_identity_revision != verified.issuer_identity_revision || acknowledgement.workspace_public_key_fingerprint
|| acknowledgement.workspace_trust_generation != verified.trust_generation != verified.issuer_public_key_fingerprint
|| acknowledgement.workspace_trust_id != verified.trust_id
|| acknowledgement.runtime_id != auth.signer.runtime_id() || acknowledgement.runtime_id != auth.signer.runtime_id()
|| acknowledgement.runtime_public_key_fingerprint != auth.signer.public_key_fingerprint() || acknowledgement.runtime_public_key_fingerprint != auth.signer.public_key_fingerprint()
|| acknowledgement.expires_at <= unix_now_i64() || acknowledgement.expires_at <= unix_now_i64()
|| acknowledgement.binding_revision == 0 || acknowledgement.binding_id.is_empty()
|| acknowledgement.runtime_identity_revision == 0 || acknowledgement.workspace_public_key_fingerprint.is_empty()
|| acknowledgement.workspace_identity_revision == 0 || acknowledgement.workspace_trust_id.is_empty()
|| acknowledgement.workspace_trust_generation == 0
|| acknowledgement.workspace_nonce.is_empty() || acknowledgement.workspace_nonce.is_empty()
|| acknowledgement.runtime_nonce.is_empty() || acknowledgement.runtime_nonce.is_empty()
|| acknowledgement.response_digest.len() != workspace_request_body_digest(&[]).len() || 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(), challenge_id: acknowledgement.challenge_id.clone(),
workspace_id: acknowledgement.workspace_id.clone(), workspace_id: acknowledgement.workspace_id.clone(),
runtime_id: acknowledgement.runtime_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_key_id: acknowledgement.workspace_key_id.clone(),
workspace_identity_revision: acknowledgement.workspace_identity_revision, workspace_public_key_fingerprint: acknowledgement.workspace_public_key_fingerprint.clone(),
workspace_trust_generation: acknowledgement.workspace_trust_generation, workspace_trust_id: acknowledgement.workspace_trust_id.clone(),
runtime_public_key_fingerprint: acknowledgement.runtime_public_key_fingerprint.clone(), runtime_public_key_fingerprint: acknowledgement.runtime_public_key_fingerprint.clone(),
runtime_identity_revision: acknowledgement.runtime_identity_revision,
workspace_nonce: acknowledgement.workspace_nonce.clone(), workspace_nonce: acknowledgement.workspace_nonce.clone(),
expires_at: acknowledgement.expires_at, expires_at: acknowledgement.expires_at,
}; };
@@ -689,17 +696,23 @@ async fn post_workspace_verification_acknowledgement(
) )
})?; })?;
auth.verifications auth.verifications
.record(WorkspaceRuntimeVerificationRecord { .acknowledge(
workspace_id: acknowledgement.workspace_id.clone(), &acknowledgement.challenge_id,
runtime_id: acknowledgement.runtime_id.clone(), WorkspaceRuntimeVerificationRecord {
binding_revision: acknowledgement.binding_revision, workspace_id: acknowledgement.workspace_id.clone(),
workspace_key_id: acknowledgement.workspace_key_id.clone(), runtime_id: acknowledgement.runtime_id.clone(),
workspace_identity_revision: acknowledgement.workspace_identity_revision, binding_id: acknowledgement.binding_id.clone(),
workspace_trust_generation: acknowledgement.workspace_trust_generation, workspace_key_id: acknowledgement.workspace_key_id.clone(),
runtime_public_key_fingerprint: acknowledgement.runtime_public_key_fingerprint.clone(), workspace_public_key_fingerprint: acknowledgement
runtime_identity_revision: acknowledgement.runtime_identity_revision, .workspace_public_key_fingerprint
verified_at: unix_now_i64(), .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| { .map_err(|error| {
RuntimeHttpRestError::new( RuntimeHttpRestError::new(
StatusCode::SERVICE_UNAVAILABLE, StatusCode::SERVICE_UNAVAILABLE,
@@ -711,7 +724,7 @@ async fn post_workspace_verification_acknowledgement(
challenge_id: acknowledgement.challenge_id, challenge_id: acknowledgement.challenge_id,
workspace_id: acknowledgement.workspace_id, workspace_id: acknowledgement.workspace_id,
runtime_id: acknowledgement.runtime_id, runtime_id: acknowledgement.runtime_id,
binding_revision: acknowledgement.binding_revision, binding_id: acknowledgement.binding_id.clone(),
accepted_at: unix_now_i64(), accepted_at: unix_now_i64(),
})) }))
} }
@@ -810,14 +823,16 @@ async fn observe_workspace_prompt_projection(
)); ));
} }
let workspace_id = request.projection.workspace_id.clone(); 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 state
.runtime .runtime
.observe_workspace_prompt_projection(request.projection) .observe_workspace_prompt_projection(request.projection)
.map_err(RuntimeHttpRestError::runtime)?; .map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkspacePromptProjectionResponse { Ok(Json(RuntimeHttpWorkspacePromptProjectionResponse {
workspace_id, 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 body_digest = workspace_request_body_digest(&body);
let expected = WorkspaceCapabilityExpectation { let expected = WorkspaceCapabilityExpectation {
workspace_id: &claims.issuer_workspace_id, workspace_id: &claims.issuer_workspace_id,
binding_revision: claims.binding_revision, binding_id: &claims.binding_id,
runtime_id: workspace_auth.signer.runtime_id(), runtime_id: workspace_auth.signer.runtime_id(),
worker_id: expected_worker_id.as_deref(), worker_id: expected_worker_id.as_deref(),
operation: required_permission, operation: required_permission,
@@ -2145,13 +2160,13 @@ async fn require_runtime_auth(
.into_response(); .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_key_id != verified.issuer_key_id
|| record.workspace_identity_revision != verified.issuer_identity_revision || record.workspace_public_key_fingerprint
|| record.workspace_trust_generation != verified.trust_generation != verified.issuer_public_key_fingerprint
|| record.workspace_trust_id != verified.trust_id
|| record.runtime_public_key_fingerprint || record.runtime_public_key_fingerprint
!= workspace_auth.signer.public_key_fingerprint() != workspace_auth.signer.public_key_fingerprint()
|| record.runtime_identity_revision == 0
{ {
return RuntimeHttpRestError::new( return RuntimeHttpRestError::new(
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
@@ -2782,9 +2797,8 @@ mod tests {
key_id: "workspace-key".to_string(), key_id: "workspace-key".to_string(),
algorithm: "ed25519".to_string(), algorithm: "ed25519".to_string(),
public_key: workspace_identity.public_key.clone(), public_key: workspace_identity.public_key.clone(),
public_key_fingerprint: workspace_fingerprint, public_key_fingerprint: workspace_fingerprint.clone(),
identity_revision: 1, trust_id: "trust-test".into(),
trust_generation: 1,
state: WorkspaceIssuerTrustState::Active, state: WorkspaceIssuerTrustState::Active,
registered_at_unix: 1, registered_at_unix: 1,
updated_at_unix: 1, updated_at_unix: 1,
@@ -2805,12 +2819,11 @@ mod tests {
challenge_id: "challenge-1".to_string(), challenge_id: "challenge-1".to_string(),
workspace_id: "workspace-a".to_string(), workspace_id: "workspace-a".to_string(),
runtime_id: "runtime-test".to_string(), runtime_id: "runtime-test".to_string(),
binding_revision: 4, binding_id: "binding-test".into(),
workspace_key_id: "workspace-key".to_string(), workspace_key_id: "workspace-key".to_string(),
workspace_identity_revision: 1, workspace_public_key_fingerprint: workspace_fingerprint.clone(),
workspace_trust_generation: 1, workspace_trust_id: "trust-test".into(),
runtime_public_key_fingerprint: runtime_fingerprint, runtime_public_key_fingerprint: runtime_fingerprint,
runtime_identity_revision: 1,
workspace_nonce: "workspace-nonce".to_string(), workspace_nonce: "workspace-nonce".to_string(),
expires_at: unix_now_i64() + 60, expires_at: unix_now_i64() + 60,
}; };
@@ -2819,9 +2832,9 @@ mod tests {
issuer: "https://backend.test".to_string(), issuer: "https://backend.test".to_string(),
issuer_workspace_id: "workspace-a".to_string(), issuer_workspace_id: "workspace-a".to_string(),
issuer_key_id: "workspace-key".to_string(), issuer_key_id: "workspace-key".to_string(),
issuer_identity_revision: 1, issuer_public_key_fingerprint: workspace_fingerprint.clone(),
trust_generation: 1, trust_id: "trust-test".into(),
binding_revision: 4, binding_id: "binding-test".into(),
runtime_id: "runtime-test".to_string(), runtime_id: "runtime-test".to_string(),
worker_id: None, worker_id: None,
operation: WORKSPACE_VERIFICATION_OPERATION.to_string(), operation: WORKSPACE_VERIFICATION_OPERATION.to_string(),
@@ -2870,14 +2883,15 @@ mod tests {
challenge_id: verification_response.challenge_id.clone(), challenge_id: verification_response.challenge_id.clone(),
workspace_id: verification_response.workspace_id.clone(), workspace_id: verification_response.workspace_id.clone(),
runtime_id: verification_response.runtime_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_key_id: verification_response.workspace_key_id.clone(),
workspace_identity_revision: verification_response.workspace_identity_revision, workspace_public_key_fingerprint: verification_response
workspace_trust_generation: verification_response.workspace_trust_generation, .workspace_public_key_fingerprint
.clone(),
workspace_trust_id: verification_response.workspace_trust_id.clone(),
runtime_public_key_fingerprint: verification_response runtime_public_key_fingerprint: verification_response
.runtime_public_key_fingerprint .runtime_public_key_fingerprint
.clone(), .clone(),
runtime_identity_revision: verification_response.runtime_identity_revision,
workspace_nonce: verification_response.workspace_nonce.clone(), workspace_nonce: verification_response.workspace_nonce.clone(),
runtime_nonce: verification_response.runtime_nonce.clone(), runtime_nonce: verification_response.runtime_nonce.clone(),
response_digest: workspace_request_body_digest(&response_body), response_digest: workspace_request_body_digest(&response_body),
@@ -2974,13 +2988,11 @@ mod tests {
workspace_id: "workspace-b".to_string(), workspace_id: "workspace-b".to_string(),
source_runtime_id: "runtime-a".to_string(), source_runtime_id: "runtime-a".to_string(),
worker_id: retention_worker_id, worker_id: retention_worker_id,
expected_worker_revision: "revision".to_string(),
source_created_at: "2025-01-01T00:00:00Z".to_string(), source_created_at: "2025-01-01T00:00:00Z".to_string(),
removed_at: "2025-01-02T00:00:00Z".to_string(), removed_at: "2025-01-02T00:00:00Z".to_string(),
effective_profile: None, effective_profile: None,
retention_class: None, retention_class: None,
policy_id: "policy".to_string(), policy_id: "policy".to_string(),
policy_revision: 1,
session_disposition: SessionDisposition::Purge, session_disposition: SessionDisposition::Purge,
diagnostics_disposition: DiagnosticsDisposition::Retain, diagnostics_disposition: DiagnosticsDisposition::Retain,
}; };
@@ -3018,6 +3030,141 @@ mod tests {
assert_eq!(error.error.code, "worker_not_found"); 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")] #[cfg(feature = "ws-server")]
#[test] #[test]
fn runtime_protocol_replaces_serialized_tracked_source() { fn runtime_protocol_replaces_serialized_tracked_source() {
@@ -3107,7 +3254,6 @@ mod tests {
metadata: ConfigBundleMetadata { metadata: ConfigBundleMetadata {
id: "http-test-bundle".to_string(), id: "http-test-bundle".to_string(),
digest: String::new(), digest: String::new(),
revision: "test".to_string(),
workspace_id: "test-workspace".to_string(), workspace_id: "test-workspace".to_string(),
created_at: "test".to_string(), created_at: "test".to_string(),
provenance: ConfigBundleProvenance { provenance: ConfigBundleProvenance {
@@ -3164,7 +3310,6 @@ mod tests {
workspace_api: None, workspace_api: None,
memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot { memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "local".to_string(), workspace_id: "local".to_string(),
settings_revision: 1,
language: "English".to_string(), language: "English".to_string(),
}), }),
subjektiv_attached: false, subjektiv_attached: false,
@@ -4477,8 +4622,7 @@ mod tests {
algorithm: "ed25519".to_string(), algorithm: "ed25519".to_string(),
public_key: workspace_identity.public_key, public_key: workspace_identity.public_key,
public_key_fingerprint: workspace_fingerprint, public_key_fingerprint: workspace_fingerprint,
identity_revision: 1, trust_id: "trust-cleanup-test".to_string(),
trust_generation: 1,
state: WorkspaceIssuerTrustState::Active, state: WorkspaceIssuerTrustState::Active,
registered_at_unix: 1, registered_at_unix: 1,
updated_at_unix: 1, updated_at_unix: 1,
@@ -5041,7 +5185,6 @@ mod ws_tests {
metadata: ConfigBundleMetadata { metadata: ConfigBundleMetadata {
id: "ws-test-bundle".to_string(), id: "ws-test-bundle".to_string(),
digest: String::new(), digest: String::new(),
revision: "test".to_string(),
workspace_id: "test".to_string(), workspace_id: "test".to_string(),
created_at: "test".to_string(), created_at: "test".to_string(),
provenance: ConfigBundleProvenance { provenance: ConfigBundleProvenance {
@@ -5097,7 +5240,6 @@ mod ws_tests {
workspace_api: None, workspace_api: None,
memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot { memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "local".to_string(), workspace_id: "local".to_string(),
settings_revision: 1,
language: "English".to_string(), language: "English".to_string(),
}), }),
subjektiv_attached: false, subjektiv_attached: false,
+71 -9
View File
@@ -905,12 +905,11 @@ fn print_workspace_trust_mutation(
WorkspaceIssuerTrustMutation::Unchanged => "unchanged", WorkspaceIssuerTrustMutation::Unchanged => "unchanged",
}; };
println!( 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.workspace_id,
record.key_id, record.key_id,
record.public_key_fingerprint, record.public_key_fingerprint,
record.identity_revision, record.trust_id,
record.trust_generation,
record.state, record.state,
); );
} }
@@ -936,10 +935,38 @@ fn read_runtime_auth_file(path: &Path) -> Result<RuntimeAuthFile, ProcessError>
} }
let contents = String::from_utf8(contents) let contents = String::from_utf8(contents)
.map_err(|_| ProcessError::auth("runtime auth store is corrupt"))?; .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"))?; .map_err(|_| ProcessError::auth("runtime auth store is corrupt"))?;
validate_workspace_issuer_trust_records(&auth.workspace_issuers) validate_workspace_issuer_trust_records(&auth.workspace_issuers)
.map_err(|_| ProcessError::auth("runtime Workspace issuer trust store is corrupt"))?; .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) Ok(auth)
} }
@@ -1398,7 +1425,6 @@ mod tests {
algorithm: "ed25519".to_string(), algorithm: "ed25519".to_string(),
public_key: identity.public_key, public_key: identity.public_key,
public_key_fingerprint: fingerprint, public_key_fingerprint: fingerprint,
revision: 1,
}) })
.unwrap(), .unwrap(),
) )
@@ -1419,7 +1445,38 @@ mod tests {
let path = runtime_auth_path(&config); let path = runtime_auth_path(&config);
let first = read_runtime_auth_file(&path).unwrap(); let first = read_runtime_auth_file(&path).unwrap();
assert_eq!(first.workspace_issuers.len(), 1); 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::<server_api::CreateRemoteRuntimeRequest>(registration_wire)
.is_err()
);
run_trust_workspace_command(VecDeque::from([ run_trust_workspace_command(VecDeque::from([
"add".to_string(), "add".to_string(),
@@ -1430,7 +1487,10 @@ mod tests {
])) ]))
.unwrap(); .unwrap();
let replay = read_runtime_auth_file(&path).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 replacement = RuntimeIdentityMaterial::generate("WK-2").unwrap();
let public_key = decode_public_key(&replacement.public_key).unwrap(); let public_key = decode_public_key(&replacement.public_key).unwrap();
@@ -1451,7 +1511,6 @@ mod tests {
algorithm: "ed25519".to_string(), algorithm: "ed25519".to_string(),
public_key: replacement.public_key, public_key: replacement.public_key,
public_key_fingerprint: fingerprint, public_key_fingerprint: fingerprint,
revision: 2,
}) })
.unwrap(), .unwrap(),
) )
@@ -1483,7 +1542,10 @@ mod tests {
])) ]))
.unwrap(); .unwrap();
let revoked = read_runtime_auth_file(&path).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!( assert_eq!(
revoked.workspace_issuers[0].state, revoked.workspace_issuers[0].state,
worker_runtime::workspace_issuer::WorkspaceIssuerTrustState::Revoked worker_runtime::workspace_issuer::WorkspaceIssuerTrustState::Revoked
+3 -8
View File
@@ -17,7 +17,7 @@ pub const DEFAULT_BACKEND_RESOURCE_FETCH_TIMEOUT: std::time::Duration =
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
pub struct RepositorySshAccessSecretCandidate { pub struct RepositorySshAccessSecretCandidate {
pub credential_id: String, pub credential_id: String,
pub credential_revision: u64, pub public_key_fingerprint: String,
pub private_key: String, pub private_key: String,
} }
@@ -32,7 +32,7 @@ impl std::fmt::Debug for RepositorySshAccessSecretCandidate {
formatter formatter
.debug_struct("RepositorySshAccessSecretCandidate") .debug_struct("RepositorySshAccessSecretCandidate")
.field("credential_id", &self.credential_id) .field("credential_id", &self.credential_id)
.field("credential_revision", &self.credential_revision) .field("public_key_fingerprint", &self.public_key_fingerprint)
.field("private_key", &"[REDACTED]") .field("private_key", &"[REDACTED]")
.finish() .finish()
} }
@@ -89,9 +89,6 @@ pub struct BackendResourceHandle {
pub operation: BackendResourceOperation, pub operation: BackendResourceOperation,
pub expires_at_unix_seconds: i64, pub expires_at_unix_seconds: i64,
pub nonce: String, pub nonce: String,
pub revision: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generation: Option<u64>,
pub max_bytes: u64, pub max_bytes: u64,
pub content_type: String, pub content_type: String,
pub redaction: ResourceRedactionPolicy, pub redaction: ResourceRedactionPolicy,
@@ -425,8 +422,6 @@ mod tests {
operation: BackendResourceOperation::FetchArchive, operation: BackendResourceOperation::FetchArchive,
expires_at_unix_seconds: 4_102_444_800, expires_at_unix_seconds: 4_102_444_800,
nonce: "nonce-test".to_string(), nonce: "nonce-test".to_string(),
revision: sha256_hex(bytes),
generation: Some(1),
max_bytes: 1024, max_bytes: 1024,
content_type: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), content_type: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(),
redaction: ResourceRedactionPolicy::RuntimeInternalOnly, redaction: ResourceRedactionPolicy::RuntimeInternalOnly,
@@ -477,7 +472,7 @@ mod tests {
let secret = RepositorySshAccessSecret { let secret = RepositorySshAccessSecret {
credential_candidates: vec![RepositorySshAccessSecretCandidate { credential_candidates: vec![RepositorySshAccessSecretCandidate {
credential_id: "credential-1".to_string(), credential_id: "credential-1".to_string(),
credential_revision: 2, public_key_fingerprint: "key-2".into(),
private_key: "PRIVATE KEY secret bytes".to_string(), private_key: "PRIVATE KEY secret bytes".to_string(),
}], }],
known_hosts_entry: "host key secret bytes".to_string(), known_hosts_entry: "host key secret bytes".to_string(),
-8
View File
@@ -116,13 +116,11 @@ pub struct WorkerRetentionExecutionRequest {
pub workspace_id: String, pub workspace_id: String,
pub source_runtime_id: String, pub source_runtime_id: String,
pub worker_id: WorkerId, pub worker_id: WorkerId,
pub expected_worker_revision: String,
pub source_created_at: String, pub source_created_at: String,
pub removed_at: String, pub removed_at: String,
pub effective_profile: Option<String>, pub effective_profile: Option<String>,
pub retention_class: Option<String>, pub retention_class: Option<String>,
pub policy_id: String, pub policy_id: String,
pub policy_revision: u64,
pub session_disposition: SessionDisposition, pub session_disposition: SessionDisposition,
pub diagnostics_disposition: DiagnosticsDisposition, pub diagnostics_disposition: DiagnosticsDisposition,
} }
@@ -145,7 +143,6 @@ pub struct WorkerSessionArchiveManifest {
pub content_bytes: u64, pub content_bytes: u64,
pub content_file_count: u64, pub content_file_count: u64,
pub policy_id: String, pub policy_id: String,
pub policy_revision: u64,
pub operation_id: String, pub operation_id: String,
pub input_fingerprint: String, pub input_fingerprint: String,
} }
@@ -154,7 +151,6 @@ pub struct WorkerSessionArchiveManifest {
pub struct WorkerRetentionExecutionResult { pub struct WorkerRetentionExecutionResult {
pub operation_id: String, pub operation_id: String,
pub input_fingerprint: String, pub input_fingerprint: String,
pub expected_worker_revision: String,
pub worker_id: WorkerId, pub worker_id: WorkerId,
pub session_disposition: SessionDisposition, pub session_disposition: SessionDisposition,
pub diagnostics_disposition: DiagnosticsDisposition, pub diagnostics_disposition: DiagnosticsDisposition,
@@ -524,7 +520,6 @@ impl WorkerRetentionProvider for FsWorkerRetentionProvider {
let mut result = WorkerRetentionExecutionResult { let mut result = WorkerRetentionExecutionResult {
operation_id: request.operation_id.clone(), operation_id: request.operation_id.clone(),
input_fingerprint: request.input_fingerprint.clone(), input_fingerprint: request.input_fingerprint.clone(),
expected_worker_revision: request.expected_worker_revision.clone(),
worker_id: request.worker_id, worker_id: request.worker_id,
session_disposition: request.session_disposition, session_disposition: request.session_disposition,
diagnostics_disposition: request.diagnostics_disposition, diagnostics_disposition: request.diagnostics_disposition,
@@ -749,7 +744,6 @@ fn commit_session_archive(
content_bytes: bytes, content_bytes: bytes,
content_file_count: count, content_file_count: count,
policy_id: request.policy_id.clone(), policy_id: request.policy_id.clone(),
policy_revision: request.policy_revision,
operation_id: request.operation_id.clone(), operation_id: request.operation_id.clone(),
input_fingerprint: request.input_fingerprint.clone(), input_fingerprint: request.input_fingerprint.clone(),
}; };
@@ -1290,7 +1284,6 @@ mod tests {
WorkerRetentionExecutionRequest { WorkerRetentionExecutionRequest {
operation_id: "operation-a".to_string(), operation_id: "operation-a".to_string(),
input_fingerprint: "fingerprint-a".to_string(), input_fingerprint: "fingerprint-a".to_string(),
expected_worker_revision: "revision-a".to_string(),
archive_id: (disposition == SessionDisposition::Archive) archive_id: (disposition == SessionDisposition::Archive)
.then(|| "archive-a".to_string()), .then(|| "archive-a".to_string()),
workspace_id: "workspace-a".to_string(), workspace_id: "workspace-a".to_string(),
@@ -1301,7 +1294,6 @@ mod tests {
effective_profile: Some("builtin:coder".to_string()), effective_profile: Some("builtin:coder".to_string()),
retention_class: None, retention_class: None,
policy_id: "policy-a".to_string(), policy_id: "policy-a".to_string(),
policy_revision: 3,
session_disposition: disposition, session_disposition: disposition,
diagnostics_disposition: DiagnosticsDisposition::Purge, diagnostics_disposition: DiagnosticsDisposition::Purge,
} }
+57 -173
View File
@@ -457,7 +457,7 @@ impl Runtime {
state state
}; };
state.execution_backend = execution_backend; 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()?; state.persist_workers()?;
let runtime = Self { let runtime = Self {
inner: Arc::new(Mutex::new(state)), inner: Arc::new(Mutex::new(state)),
@@ -761,7 +761,7 @@ impl Runtime {
.zip(&ssh.credential_candidates) .zip(&ssh.credential_candidates)
.any(|(secret, metadata)| { .any(|(secret, metadata)| {
secret.credential_id != metadata.credential_id secret.credential_id != metadata.credential_id
|| secret.credential_revision != metadata.credential_revision || secret.public_key_fingerprint != metadata.public_key_fingerprint
}) })
{ {
return Err(RuntimeError::InvalidRequest( return Err(RuntimeError::InvalidRequest(
@@ -2157,7 +2157,8 @@ impl Runtime {
&& candidate.pending_stop.is_none() && candidate.pending_stop.is_none()
&& candidate.restore_guard.active_request_id.is_none(); && candidate.restore_guard.active_request_id.is_none();
if coordinate && request.preparation.is_none() && !recovering_pending { 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.restore_guard.active_request_id.is_some()
|| candidate.has_pending_lifecycle_operation() || candidate.has_pending_lifecycle_operation()
{ {
@@ -2176,7 +2177,7 @@ impl Runtime {
)); ));
} }
state.ensure_running()?; 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.restore_guard.active_request_id.is_some()
|| (candidate.has_pending_lifecycle_operation() && !recovering_pending) || (candidate.has_pending_lifecycle_operation() && !recovering_pending)
{ {
@@ -2283,7 +2284,7 @@ impl Runtime {
return worker.restore_guard.owners[id].request.clone(); return worker.restore_guard.owners[id].request.clone();
} }
runtime_api::WorkerRestoreRequest { 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(), request_id: uuid::Uuid::now_v7().to_string(),
preparation: worker preparation: worker
.workspace_id .workspace_id
@@ -5531,7 +5532,7 @@ impl RuntimeState {
ProfileSelector::Builtin(name) | ProfileSelector::Named(name) => Some(name.clone()), ProfileSelector::Builtin(name) | ProfileSelector::Named(name) => Some(name.clone()),
}; };
Ok(SubscriptionWorker { Ok(SubscriptionWorker {
restore_observation_token: Some(worker.restore_guard.generation.clone()), restore_observation_token: Some(worker.restore_guard.observation_token.clone()),
worker_id, worker_id,
runtime_id: None, runtime_id: None,
resource_key: None, resource_key: None,
@@ -5806,7 +5807,6 @@ impl RuntimeState {
InternalWorkerActivity { InternalWorkerActivity {
status: Some(snapshot.status), status: Some(snapshot.status),
parent_session_id: snapshot.worker.parent_session_id.clone(), parent_session_id: snapshot.worker.parent_session_id.clone(),
revision: snapshot.revision,
}, },
); );
for child in &snapshot.internal_workers { for child in &snapshot.internal_workers {
@@ -5854,42 +5854,22 @@ impl RuntimeState {
fn remove_internal_worker_subtree( fn remove_internal_worker_subtree(
activity: &mut InternalWorkerActivityProjection, activity: &mut InternalWorkerActivityProjection,
worker: &protocol::InternalWorkerRef, 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) { for session_id in Self::internal_worker_subtree_ids(activity, &worker.session_id) {
let active = activity.workers.remove(&session_id); let active = activity.workers.remove(&session_id);
let removed = activity.removed_workers.remove(&session_id); let removed = activity.removed_workers.remove(&session_id);
let (worker_revision, parent_session_id) = if session_id == worker.session_id { let parent_session_id = if session_id == worker.session_id {
(revision, worker.parent_session_id.clone()) worker.parent_session_id.clone()
} else if let Some(active) = active { } else if let Some(active) = active {
(active.revision, active.parent_session_id) active.parent_session_id
} else if let Some(removed) = removed { } else if let Some(removed) = removed {
(removed.revision, removed.parent_session_id) removed.parent_session_id
} else { } else {
continue; continue;
}; };
activity.removed_workers.insert( activity.removed_workers.insert(
session_id, session_id,
RemovedInternalWorkerActivity { RemovedInternalWorkerActivity { parent_session_id },
revision: worker_revision,
parent_session_id,
},
); );
} }
} }
@@ -5897,37 +5877,25 @@ impl RuntimeState {
fn touch_internal_worker_activity( fn touch_internal_worker_activity(
activity: &mut InternalWorkerActivityProjection, activity: &mut InternalWorkerActivityProjection,
worker: &protocol::InternalWorkerRef, worker: &protocol::InternalWorkerRef,
revision: u64,
) { ) {
activity activity
.workers .workers
.entry(worker.session_id.clone()) .entry(worker.session_id.clone())
.and_modify(|activity| { .and_modify(|activity| activity.parent_session_id = worker.parent_session_id.clone())
activity.parent_session_id = worker.parent_session_id.clone();
activity.revision = revision;
})
.or_insert_with(|| InternalWorkerActivity { .or_insert_with(|| InternalWorkerActivity {
status: None, status: None,
parent_session_id: worker.parent_session_id.clone(), parent_session_id: worker.parent_session_id.clone(),
revision,
}); });
} }
fn project_internal_worker_event( fn project_internal_worker_event(
activity: &mut InternalWorkerActivityProjection, activity: &mut InternalWorkerActivityProjection,
worker: &protocol::InternalWorkerRef, worker: &protocol::InternalWorkerRef,
revision: u64,
event: &protocol::Event, event: &protocol::Event,
) { ) {
if activity.removed_workers.contains_key(&worker.session_id) if activity.removed_workers.contains_key(&worker.session_id) {
|| activity
.workers
.get(&worker.session_id)
.is_some_and(|current| revision <= current.revision)
{
return; return;
} }
match event { match event {
protocol::Event::Snapshot { protocol::Event::Snapshot {
state, state,
@@ -5940,7 +5908,6 @@ impl RuntimeState {
InternalWorkerActivity { InternalWorkerActivity {
status: Some(state.catalog_status()), status: Some(state.catalog_status()),
parent_session_id: worker.parent_session_id.clone(), parent_session_id: worker.parent_session_id.clone(),
revision,
}, },
); );
for child in internal_workers { for child in internal_workers {
@@ -5949,23 +5916,16 @@ impl RuntimeState {
} }
protocol::Event::InternalWorker { protocol::Event::InternalWorker {
worker: nested_worker, worker: nested_worker,
revision: nested_revision,
event, event,
} => { } => {
Self::touch_internal_worker_activity(activity, worker, revision); Self::touch_internal_worker_activity(activity, worker);
Self::project_internal_worker_event( Self::project_internal_worker_event(activity, nested_worker, event);
activity,
nested_worker,
*nested_revision,
event,
);
} }
protocol::Event::InternalWorkerRemoved { protocol::Event::InternalWorkerRemoved {
worker: removed_worker, worker: removed_worker,
revision: removed_revision,
} => { } => {
Self::touch_internal_worker_activity(activity, worker, revision); Self::touch_internal_worker_activity(activity, worker);
Self::remove_internal_worker_subtree(activity, removed_worker, *removed_revision); Self::remove_internal_worker_subtree(activity, removed_worker);
} }
protocol::Event::WorkerState { snapshot } protocol::Event::WorkerState { snapshot }
| protocol::Event::CommandAcknowledged { | protocol::Event::CommandAcknowledged {
@@ -5979,11 +5939,10 @@ impl RuntimeState {
InternalWorkerActivity { InternalWorkerActivity {
status: Some(snapshot.catalog_status()), status: Some(snapshot.catalog_status()),
parent_session_id: worker.parent_session_id.clone(), 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); Self::internal_worker_snapshot_statuses(activity, child);
} }
} }
protocol::Event::InternalWorker { protocol::Event::InternalWorker { worker, event } => {
worker, Self::project_internal_worker_event(activity, worker, event);
revision,
event,
} => {
Self::project_internal_worker_event(activity, worker, *revision, event);
} }
protocol::Event::InternalWorkerRemoved { worker, revision } => { protocol::Event::InternalWorkerRemoved { worker } => {
Self::remove_internal_worker_subtree(activity, worker, *revision); Self::remove_internal_worker_subtree(activity, worker);
} }
_ => {} _ => {}
} }
@@ -6057,12 +6012,10 @@ impl RuntimeState {
struct InternalWorkerActivity { struct InternalWorkerActivity {
status: Option<protocol::WorkerStatus>, status: Option<protocol::WorkerStatus>,
parent_session_id: Option<String>, parent_session_id: Option<String>,
revision: u64,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct RemovedInternalWorkerActivity { struct RemovedInternalWorkerActivity {
revision: u64,
parent_session_id: Option<String>, parent_session_id: Option<String>,
} }
@@ -6111,8 +6064,9 @@ pub(crate) struct RestoreRequestOwner {
} }
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RestoreGuard { pub(crate) struct RestoreGuard {
generation: String, observation_token: String,
active_request_id: Option<String>, active_request_id: Option<String>,
owners: BTreeMap<String, RestoreRequestOwner>, owners: BTreeMap<String, RestoreRequestOwner>,
} }
@@ -6120,7 +6074,7 @@ pub(crate) struct RestoreGuard {
impl Default for RestoreGuard { impl Default for RestoreGuard {
fn default() -> Self { fn default() -> Self {
Self { Self {
generation: uuid::Uuid::now_v7().to_string(), observation_token: uuid::Uuid::now_v7().to_string(),
active_request_id: None, active_request_id: None,
owners: BTreeMap::new(), owners: BTreeMap::new(),
} }
@@ -6129,7 +6083,7 @@ impl Default for RestoreGuard {
impl RestoreGuard { impl RestoreGuard {
fn rotate(&mut self) { 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) { fn finish(&mut self, result: RuntimeWorkerRestoreResult) {
@@ -6199,7 +6153,7 @@ impl WorkerRecord {
fn summary(&self) -> WorkerSummary { fn summary(&self) -> WorkerSummary {
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_ref: self.worker_ref.clone(),
worker_id: self.worker_id, worker_id: self.worker_id,
status: self.status, status: self.status,
@@ -6217,7 +6171,7 @@ impl WorkerRecord {
fn detail(&self) -> WorkerDetail { fn detail(&self) -> WorkerDetail {
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_ref: self.worker_ref.clone(),
worker_id: self.worker_id, worker_id: self.worker_id,
status: self.status, status: self.status,
@@ -6740,11 +6694,6 @@ fn validate_create_workspace_scope(
snapshot.workspace_id 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) { if !manifest::is_normalized_workspace_memory_language(&snapshot.language) {
return Err(RuntimeError::InvalidRequest( return Err(RuntimeError::InvalidRequest(
"Memory settings language must be a normalized bounded UTF-8 value".to_string(), "Memory settings language must be a normalized bounded UTF-8 value".to_string(),
@@ -6953,12 +6902,10 @@ mod tests {
fn internal_worker_status_event( fn internal_worker_status_event(
worker: protocol::InternalWorkerRef, worker: protocol::InternalWorkerRef,
revision: u64,
status: protocol::WorkerStatus, status: protocol::WorkerStatus,
) -> protocol::Event { ) -> protocol::Event {
protocol::Event::InternalWorker { protocol::Event::InternalWorker {
worker, worker,
revision,
event: Box::new(protocol::Event::WorkerState { event: Box::new(protocol::Event::WorkerState {
snapshot: status.into(), snapshot: status.into(),
}), }),
@@ -6967,13 +6914,11 @@ mod tests {
fn internal_worker_snapshot( fn internal_worker_snapshot(
worker: protocol::InternalWorkerRef, worker: protocol::InternalWorkerRef,
revision: u64,
status: protocol::WorkerStatus, status: protocol::WorkerStatus,
internal_workers: Vec<protocol::InternalWorkerSnapshot>, internal_workers: Vec<protocol::InternalWorkerSnapshot>,
) -> protocol::InternalWorkerSnapshot { ) -> protocol::InternalWorkerSnapshot {
protocol::InternalWorkerSnapshot { protocol::InternalWorkerSnapshot {
worker, worker,
revision,
status, status,
greeting: None, greeting: None,
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
@@ -7017,7 +6962,6 @@ mod tests {
&mut activity, &mut activity,
&internal_worker_status_event( &internal_worker_status_event(
internal_worker_ref("child-a", None), internal_worker_ref("child-a", None),
1,
protocol::WorkerStatus::Running, protocol::WorkerStatus::Running,
), ),
)); ));
@@ -7025,7 +6969,6 @@ mod tests {
&mut activity, &mut activity,
&internal_worker_status_event( &internal_worker_status_event(
internal_worker_ref("child-b", None), internal_worker_ref("child-b", None),
1,
protocol::WorkerStatus::Running, protocol::WorkerStatus::Running,
), ),
)); ));
@@ -7033,7 +6976,6 @@ mod tests {
&mut activity, &mut activity,
&internal_worker_status_event( &internal_worker_status_event(
internal_worker_ref("child-a", None), internal_worker_ref("child-a", None),
2,
protocol::WorkerStatus::Idle, protocol::WorkerStatus::Idle,
), ),
)); ));
@@ -7041,7 +6983,6 @@ mod tests {
&mut activity, &mut activity,
&internal_worker_status_event( &internal_worker_status_event(
internal_worker_ref("child-b", None), internal_worker_ref("child-b", None),
2,
protocol::WorkerStatus::Stopped, protocol::WorkerStatus::Stopped,
), ),
)); ));
@@ -7053,10 +6994,8 @@ mod tests {
let direct_child = internal_worker_ref("child", None); let direct_child = internal_worker_ref("child", None);
let nested_running = protocol::Event::InternalWorker { let nested_running = protocol::Event::InternalWorker {
worker: direct_child.clone(), worker: direct_child.clone(),
revision: 1,
event: Box::new(internal_worker_status_event( event: Box::new(internal_worker_status_event(
internal_worker_ref("grandchild", Some("child")), internal_worker_ref("grandchild", Some("child")),
1,
protocol::WorkerStatus::Running, protocol::WorkerStatus::Running,
)), )),
}; };
@@ -7067,10 +7006,8 @@ mod tests {
let nested_idle = protocol::Event::InternalWorker { let nested_idle = protocol::Event::InternalWorker {
worker: direct_child, worker: direct_child,
revision: 2,
event: Box::new(internal_worker_status_event( event: Box::new(internal_worker_status_event(
internal_worker_ref("grandchild", Some("child")), internal_worker_ref("grandchild", Some("child")),
2,
protocol::WorkerStatus::Idle, protocol::WorkerStatus::Idle,
)), )),
}; };
@@ -7087,7 +7024,6 @@ mod tests {
&mut activity, &mut activity,
&internal_worker_status_event( &internal_worker_status_event(
internal_worker_ref("child-a", None), internal_worker_ref("child-a", None),
1,
protocol::WorkerStatus::Running, protocol::WorkerStatus::Running,
), ),
); );
@@ -7101,44 +7037,23 @@ mod tests {
} }
#[test] #[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 mut activity = InternalWorkerActivityProjection::default();
let child_a = internal_worker_ref("child-a", None); let child_a = internal_worker_ref("child-a", None);
let child_b = internal_worker_ref("child-b", None); let child_b = internal_worker_ref("child-b", None);
assert!(RuntimeState::update_internal_worker_activity( assert!(RuntimeState::update_internal_worker_activity(
&mut 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( assert!(!RuntimeState::update_internal_worker_activity(
&mut 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( assert!(!RuntimeState::update_internal_worker_activity(
&mut activity, &mut activity,
&protocol::Event::InternalWorkerRemoved { &protocol::Event::InternalWorkerRemoved { worker: child_a },
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,
},
)); ));
assert!(!activity.workers.contains_key("child-a")); assert!(!activity.workers.contains_key("child-a"));
assert!(activity.removed_workers.contains_key("child-a")); assert!(activity.removed_workers.contains_key("child-a"));
@@ -7148,14 +7063,13 @@ mod tests {
&mut activity, &mut activity,
&protocol::Event::InternalWorkerRemoved { &protocol::Event::InternalWorkerRemoved {
worker: child_b.clone(), worker: child_b.clone(),
revision: 2,
}, },
)); ));
assert!(!activity.has_running_worker()); assert!(!activity.has_running_worker());
assert!(!RuntimeState::update_internal_worker_activity( assert!(!RuntimeState::update_internal_worker_activity(
&mut 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.workers.contains_key("child-b"));
assert!(!activity.has_running_worker()); assert!(!activity.has_running_worker());
@@ -7164,7 +7078,6 @@ mod tests {
&mut activity, &mut activity,
&parent_snapshot(vec![internal_worker_snapshot( &parent_snapshot(vec![internal_worker_snapshot(
child_b, child_b,
4,
protocol::WorkerStatus::Running, protocol::WorkerStatus::Running,
Vec::new(), Vec::new(),
)]), )]),
@@ -7182,16 +7095,14 @@ mod tests {
assert!(!RuntimeState::update_internal_worker_activity( assert!(!RuntimeState::update_internal_worker_activity(
&mut 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( assert!(RuntimeState::update_internal_worker_activity(
&mut activity, &mut activity,
&protocol::Event::InternalWorker { &protocol::Event::InternalWorker {
worker: child.clone(), worker: child.clone(),
revision: 2,
event: Box::new(internal_worker_status_event( event: Box::new(internal_worker_status_event(
grandchild.clone(), grandchild.clone(),
1,
protocol::WorkerStatus::Running, protocol::WorkerStatus::Running,
)), )),
}, },
@@ -7200,13 +7111,10 @@ mod tests {
&mut activity, &mut activity,
&protocol::Event::InternalWorker { &protocol::Event::InternalWorker {
worker: child.clone(), worker: child.clone(),
revision: 3,
event: Box::new(protocol::Event::InternalWorker { event: Box::new(protocol::Event::InternalWorker {
worker: grandchild.clone(), worker: grandchild.clone(),
revision: 2,
event: Box::new(internal_worker_status_event( event: Box::new(internal_worker_status_event(
great_grandchild, great_grandchild,
1,
protocol::WorkerStatus::Running, protocol::WorkerStatus::Running,
)), )),
}), }),
@@ -7217,10 +7125,8 @@ mod tests {
&mut activity, &mut activity,
&protocol::Event::InternalWorker { &protocol::Event::InternalWorker {
worker: child.clone(), worker: child.clone(),
revision: 4,
event: Box::new(protocol::Event::InternalWorkerRemoved { event: Box::new(protocol::Event::InternalWorkerRemoved {
worker: grandchild.clone(), worker: grandchild.clone(),
revision: 3,
}), }),
}, },
)); ));
@@ -7235,10 +7141,8 @@ mod tests {
&mut activity, &mut activity,
&protocol::Event::InternalWorker { &protocol::Event::InternalWorker {
worker: child, worker: child,
revision: 5,
event: Box::new(internal_worker_status_event( event: Box::new(internal_worker_status_event(
grandchild, grandchild,
4,
protocol::WorkerStatus::Running, protocol::WorkerStatus::Running,
)), )),
}, },
@@ -7338,7 +7242,6 @@ mod tests {
workspace_api: None, workspace_api: None,
memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot { memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "local".to_string(), workspace_id: "local".to_string(),
settings_revision: 1,
language: "English".to_string(), language: "English".to_string(),
}), }),
subjektiv_attached: false, subjektiv_attached: false,
@@ -7367,7 +7270,7 @@ mod tests {
request.backend_job = Some(crate::catalog::BackendJobExecutionBinding { request.backend_job = Some(crate::catalog::BackendJobExecutionBinding {
job_id: "job-1".into(), job_id: "job-1".into(),
attempt_id: "attempt-1".into(), attempt_id: "attempt-1".into(),
input_revision: Some("revision-1".into()), input_digest: Some(format!("sha256:{}", "a".repeat(64))),
subjektiv_consolidation: false, subjektiv_consolidation: false,
}); });
let owner = scope("local", "server-job-test"); let owner = scope("local", "server-job-test");
@@ -7416,7 +7319,7 @@ mod tests {
request.backend_job = Some(crate::catalog::BackendJobExecutionBinding { request.backend_job = Some(crate::catalog::BackendJobExecutionBinding {
job_id: "job-1".into(), job_id: "job-1".into(),
attempt_id: "attempt-1".into(), attempt_id: "attempt-1".into(),
input_revision: None, input_digest: None,
subjektiv_consolidation: true, subjektiv_consolidation: true,
}); });
assert!(validate_create_worker_request(&request).is_err()); assert!(validate_create_worker_request(&request).is_err());
@@ -7444,7 +7347,6 @@ mod tests {
kind: server_api::RepositorySourceKind::Ssh, kind: server_api::RepositorySourceKind::Ssh,
uri: "ssh://git@example.test/repo.git".to_string(), uri: "ssh://git@example.test/repo.git".to_string(),
}, },
source_revision: 1,
source_fingerprint: "sha256:source".to_string(), source_fingerprint: "sha256:source".to_string(),
selector: None, selector: None,
}, },
@@ -7455,16 +7357,15 @@ mod tests {
workspace_id: "workspace-1".to_string(), workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(), runtime_id: "runtime-1".to_string(),
operation_id: "operation-1".to_string(), operation_id: "operation-1".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(), config_projection_digest: "sha256:projection".to_string(),
ssh: Some(RepositorySshMaterializationAccess { ssh: Some(RepositorySshMaterializationAccess {
credential_candidates: vec![RepositorySshCredentialCandidate { credential_candidates: vec![RepositorySshCredentialCandidate {
credential_id: "credential-1".to_string(), credential_id: "credential-1".to_string(),
credential_revision: 1, public_key_fingerprint: "key-1".into(),
private_key: SensitiveString::new("private-key-bytes"), private_key: SensitiveString::new("private-key-bytes"),
}], }],
host_trust_id: "host-trust-1".to_string(), host_trust_id: "host-trust-1".to_string(),
host_trust_revision: 1, host_key_fingerprint: "key-1".into(),
access: server_api::RepositoryAccessMode::ReadOnly, access: server_api::RepositoryAccessMode::ReadOnly,
expires_at_epoch_seconds: u64::MAX, expires_at_epoch_seconds: u64::MAX,
repository_id: "repository-1".to_string(), repository_id: "repository-1".to_string(),
@@ -7512,8 +7413,6 @@ mod tests {
operation: crate::resource::BackendResourceOperation::FetchOnce, operation: crate::resource::BackendResourceOperation::FetchOnce,
expires_at_unix_seconds: i64::MAX, expires_at_unix_seconds: i64::MAX,
nonce: "repository-access-1".to_string(), nonce: "repository-access-1".to_string(),
revision: "1".to_string(),
generation: None,
max_bytes: crate::resource::DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES, max_bytes: crate::resource::DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES,
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(), content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly, redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly,
@@ -7544,12 +7443,12 @@ mod tests {
credential_candidates: vec![ credential_candidates: vec![
crate::resource::RepositorySshAccessSecretCandidate { crate::resource::RepositorySshAccessSecretCandidate {
credential_id: "credential-1".to_string(), credential_id: "credential-1".to_string(),
credential_revision: 1, public_key_fingerprint: "key-1".into(),
private_key: "private-key-bytes-1".to_string(), private_key: "private-key-bytes-1".to_string(),
}, },
crate::resource::RepositorySshAccessSecretCandidate { crate::resource::RepositorySshAccessSecretCandidate {
credential_id: "credential-2".to_string(), credential_id: "credential-2".to_string(),
credential_revision: 3, public_key_fingerprint: "key-3".into(),
private_key: "private-key-bytes-2".to_string(), private_key: "private-key-bytes-2".to_string(),
}, },
], ],
@@ -7567,23 +7466,22 @@ mod tests {
workspace_id: "workspace-1".to_string(), workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(), runtime_id: "runtime-1".to_string(),
operation_id: "operation-1".to_string(), operation_id: "operation-1".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(), config_projection_digest: "sha256:projection".to_string(),
ssh: Some(RepositorySshMaterializationAccess { ssh: Some(RepositorySshMaterializationAccess {
credential_candidates: vec![ credential_candidates: vec![
RepositorySshCredentialCandidate { RepositorySshCredentialCandidate {
credential_id: "credential-1".to_string(), credential_id: "credential-1".to_string(),
credential_revision: 1, public_key_fingerprint: "key-1".into(),
private_key: SensitiveString::default(), private_key: SensitiveString::default(),
}, },
RepositorySshCredentialCandidate { RepositorySshCredentialCandidate {
credential_id: "credential-2".to_string(), credential_id: "credential-2".to_string(),
credential_revision: 3, public_key_fingerprint: "key-3".into(),
private_key: SensitiveString::default(), private_key: SensitiveString::default(),
}, },
], ],
host_trust_id: "host-trust-1".to_string(), host_trust_id: "host-trust-1".to_string(),
host_trust_revision: 1, host_key_fingerprint: "key-1".into(),
access: server_api::RepositoryAccessMode::ReadOnly, access: server_api::RepositoryAccessMode::ReadOnly,
expires_at_epoch_seconds: u64::MAX, expires_at_epoch_seconds: u64::MAX,
repository_id: "repository-1".to_string(), repository_id: "repository-1".to_string(),
@@ -7652,12 +7550,12 @@ mod tests {
credential_candidates: vec![ credential_candidates: vec![
crate::resource::RepositorySshAccessSecretCandidate { crate::resource::RepositorySshAccessSecretCandidate {
credential_id: "credential-1".to_string(), credential_id: "credential-1".to_string(),
credential_revision: 1, public_key_fingerprint: "key-1".into(),
private_key: "create-private-key-bytes-1".to_string(), private_key: "create-private-key-bytes-1".to_string(),
}, },
crate::resource::RepositorySshAccessSecretCandidate { crate::resource::RepositorySshAccessSecretCandidate {
credential_id: "credential-2".to_string(), credential_id: "credential-2".to_string(),
credential_revision: 3, public_key_fingerprint: "key-3".into(),
private_key: "create-private-key-bytes-2".to_string(), private_key: "create-private-key-bytes-2".to_string(),
}, },
], ],
@@ -7677,7 +7575,6 @@ mod tests {
kind: server_api::RepositorySourceKind::Ssh, kind: server_api::RepositorySourceKind::Ssh,
uri: "ssh://git@example.test/repo.git".to_string(), uri: "ssh://git@example.test/repo.git".to_string(),
}, },
source_revision: 1,
source_fingerprint: "sha256:source".to_string(), source_fingerprint: "sha256:source".to_string(),
selector: None, selector: None,
}, },
@@ -7688,23 +7585,22 @@ mod tests {
workspace_id: "workspace-1".to_string(), workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(), runtime_id: "runtime-1".to_string(),
operation_id: "operation-create".to_string(), operation_id: "operation-create".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(), config_projection_digest: "sha256:projection".to_string(),
ssh: Some(RepositorySshMaterializationAccess { ssh: Some(RepositorySshMaterializationAccess {
credential_candidates: vec![ credential_candidates: vec![
RepositorySshCredentialCandidate { RepositorySshCredentialCandidate {
credential_id: "credential-1".to_string(), credential_id: "credential-1".to_string(),
credential_revision: 1, public_key_fingerprint: "key-1".into(),
private_key: SensitiveString::default(), private_key: SensitiveString::default(),
}, },
RepositorySshCredentialCandidate { RepositorySshCredentialCandidate {
credential_id: "credential-2".to_string(), credential_id: "credential-2".to_string(),
credential_revision: 3, public_key_fingerprint: "key-3".into(),
private_key: SensitiveString::default(), private_key: SensitiveString::default(),
}, },
], ],
host_trust_id: "host-trust-1".to_string(), host_trust_id: "host-trust-1".to_string(),
host_trust_revision: 1, host_key_fingerprint: "key-1".into(),
access: server_api::RepositoryAccessMode::ReadOnly, access: server_api::RepositoryAccessMode::ReadOnly,
expires_at_epoch_seconds: u64::MAX, expires_at_epoch_seconds: u64::MAX,
repository_id: "repository-1".to_string(), repository_id: "repository-1".to_string(),
@@ -7751,7 +7647,6 @@ mod tests {
}); });
request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot { request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: workspace_id.to_string(), workspace_id: workspace_id.to_string(),
settings_revision: 1,
language: "English".to_string(), language: "English".to_string(),
}); });
request request
@@ -7769,14 +7664,12 @@ mod tests {
request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot { request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-b".to_string(), workspace_id: "workspace-b".to_string(),
settings_revision: 1,
language: "English".to_string(), language: "English".to_string(),
}); });
assert!(validate_create_workspace_scope(&request, Some("workspace-a")).is_err()); assert!(validate_create_workspace_scope(&request, Some("workspace-a")).is_err());
request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot { request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-a".to_string(), workspace_id: "workspace-a".to_string(),
settings_revision: 2,
language: " english ".to_string(), language: " english ".to_string(),
}); });
assert!(validate_create_workspace_scope(&request, Some("workspace-a")).is_err()); assert!(validate_create_workspace_scope(&request, Some("workspace-a")).is_err());
@@ -7791,7 +7684,6 @@ mod tests {
metadata: ConfigBundleMetadata { metadata: ConfigBundleMetadata {
id: "bundle-1".to_string(), id: "bundle-1".to_string(),
digest: String::new(), digest: String::new(),
revision: "rev-1".to_string(),
workspace_id: "workspace-1".to_string(), workspace_id: "workspace-1".to_string(),
created_at: "2026-06-26T00:00:00Z".to_string(), created_at: "2026-06-26T00:00:00Z".to_string(),
provenance: ConfigBundleProvenance { provenance: ConfigBundleProvenance {
@@ -8350,7 +8242,6 @@ mod tests {
kind: server_api::RepositorySourceKind::LocalPath, kind: server_api::RepositorySourceKind::LocalPath,
uri: "/unused-test-repository".into(), uri: "/unused-test-repository".into(),
}, },
source_revision: 1,
source_fingerprint: "sha256:test-source".into(), source_fingerprint: "sha256:test-source".into(),
selector: None, selector: None,
}, },
@@ -8368,7 +8259,6 @@ mod tests {
workspace_id: "workspace-a".into(), workspace_id: "workspace-a".into(),
runtime_id: "runtime-1".into(), runtime_id: "runtime-1".into(),
operation_id: "operation-1".into(), operation_id: "operation-1".into(),
config_revision: 1,
config_projection_digest: "sha256:test-projection".into(), config_projection_digest: "sha256:test-projection".into(),
ssh: None, ssh: None,
}, },
@@ -8842,7 +8732,6 @@ mod tests {
&created.worker_ref, &created.worker_ref,
internal_worker_status_event( internal_worker_status_event(
internal_worker_ref("child-live", None), internal_worker_ref("child-live", None),
1,
protocol::WorkerStatus::Running, protocol::WorkerStatus::Running,
), ),
) )
@@ -8861,7 +8750,6 @@ mod tests {
&created.worker_ref, &created.worker_ref,
protocol::Event::InternalWorkerRemoved { protocol::Event::InternalWorkerRemoved {
worker: internal_worker_ref("child-live", None), worker: internal_worker_ref("child-live", None),
revision: 2,
}, },
) )
.unwrap(); .unwrap();
@@ -8879,7 +8767,6 @@ mod tests {
&created.worker_ref, &created.worker_ref,
internal_worker_status_event( internal_worker_status_event(
internal_worker_ref("child-live", None), internal_worker_ref("child-live", None),
3,
protocol::WorkerStatus::Running, protocol::WorkerStatus::Running,
), ),
) )
@@ -8900,7 +8787,6 @@ mod tests {
&created.worker_ref, &created.worker_ref,
parent_snapshot(vec![internal_worker_snapshot( parent_snapshot(vec![internal_worker_snapshot(
internal_worker_ref("child-live", None), internal_worker_ref("child-live", None),
4,
protocol::WorkerStatus::Running, protocol::WorkerStatus::Running,
Vec::new(), Vec::new(),
)]), )]),
@@ -9427,7 +9313,6 @@ mod tests {
bundle.prompt_catalog = Some( bundle.prompt_catalog = Some(
worker::EffectivePromptCatalog::new( worker::EffectivePromptCatalog::new(
BTreeMap::from([("default".to_string(), "workspace prompt".to_string())]), BTreeMap::from([("default".to_string(), "workspace prompt".to_string())]),
7,
"schema", "schema",
"toolchain", "toolchain",
) )
@@ -10287,7 +10172,6 @@ mod tests {
workspace_id: "workspace-a".into(), workspace_id: "workspace-a".into(),
runtime_id: "peer-runtime".into(), runtime_id: "peer-runtime".into(),
operation_id: format!("worker-restore:{}", request.request_id), operation_id: format!("worker-restore:{}", request.request_id),
config_revision: 1,
config_projection_digest: "snapshot".into(), config_projection_digest: "snapshot".into(),
ssh: None, ssh: None,
}, },
@@ -10405,7 +10289,7 @@ mod tests {
} }
#[test] #[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 (runtime, backend) = runtime_and_backend();
let worker = runtime.create_worker(task_request("state ABA")).unwrap(); let worker = runtime.create_worker(task_request("state ABA")).unwrap();
let request = runtime.test_restore_request(&worker.worker_ref); let request = runtime.test_restore_request(&worker.worker_ref);
@@ -11160,7 +11044,7 @@ mod tests {
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
#[test] #[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 dir = tempfile::tempdir().unwrap();
let options = let options =
FsRuntimeStoreOptions::new(dir.path().join("runtime")).with_runtime_id("guard-test"); 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()) .restore_worker_operation(&worker.worker_ref, request.clone())
.unwrap(); .unwrap();
runtime.stop_worker(&worker.worker_ref, None).unwrap(); runtime.stop_worker(&worker.worker_ref, None).unwrap();
let generation = runtime let observation_token = runtime
.worker_detail(&worker.worker_ref) .worker_detail(&worker.worker_ref)
.unwrap() .unwrap()
.restore_observation_token; .restore_observation_token;
@@ -11188,7 +11072,7 @@ mod tests {
.worker_detail(&worker.worker_ref) .worker_detail(&worker.worker_ref)
.unwrap() .unwrap()
.restore_observation_token, .restore_observation_token,
generation observation_token
); );
assert_eq!( assert_eq!(
restarted restarted
@@ -12680,7 +12564,7 @@ mod tests {
assert!( assert!(
error error
.to_string() .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); let _ = std::fs::remove_dir_all(root);
@@ -12722,7 +12606,7 @@ mod tests {
let worker_aggregate: serde_json::Value = let worker_aggregate: serde_json::Value =
serde_json::from_slice(&std::fs::read(worker_store_dir.join("worker.json")).unwrap()) serde_json::from_slice(&std::fs::read(worker_store_dir.join("worker.json")).unwrap())
.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["status"], serde_json::json!("stopped"));
assert_eq!( assert_eq!(
worker_aggregate["execution_state"]["state"], worker_aggregate["execution_state"]["state"],
@@ -13072,7 +12956,7 @@ mod tests {
.unwrap() .unwrap()
.remove("restore_guard") .remove("restore_guard")
.unwrap(); .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!(guard["active_request_id"].is_null());
assert_eq!( assert_eq!(
guard["owners"] guard["owners"]
@@ -13669,7 +13553,7 @@ mod tests {
); );
let migrated_aggregate: serde_json::Value = let migrated_aggregate: serde_json::Value =
serde_json::from_slice(&std::fs::read(&worker_path).unwrap()).unwrap(); 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("request").is_none());
assert!(migrated_aggregate.get("execution").is_none()); assert!(migrated_aggregate.get("execution").is_none());
assert_eq!( assert_eq!(
@@ -374,7 +374,6 @@ fn api_archive_manifest(manifest: &DomainArchiveManifest) -> WorkerSessionArchiv
content_bytes: manifest.content_bytes, content_bytes: manifest.content_bytes,
content_file_count: manifest.content_file_count, content_file_count: manifest.content_file_count,
policy_id: manifest.policy_id.clone(), policy_id: manifest.policy_id.clone(),
policy_revision: manifest.policy_revision,
operation_id: manifest.operation_id.clone(), operation_id: manifest.operation_id.clone(),
input_fingerprint: manifest.input_fingerprint.clone(), input_fingerprint: manifest.input_fingerprint.clone(),
} }
+43 -61
View File
@@ -133,7 +133,7 @@ use worker::feature::builtin::{
WorkspaceClientWorkerObservationProvider, WorkspaceClientWorkerObservationProvider,
}; };
#[cfg(feature = "ws-server")] #[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::{ use worker::{
PreparedWorker, PromptCatalogSource, SegmentLogSink, SubjektivSessionAttributionLifecycle, PreparedWorker, PromptCatalogSource, SegmentLogSink, SubjektivSessionAttributionLifecycle,
Worker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout, Worker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout,
@@ -142,6 +142,15 @@ use worker::{
bash_output_dir_for_worker_id, 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 DEFAULT_BACKEND_ID: &str = "worker-crate";
const RUNTIME_TASK_TIMEOUT: Duration = Duration::from_secs(10); const RUNTIME_TASK_TIMEOUT: Duration = Duration::from_secs(10);
const SPAWN_RESTORE_TASK_TIMEOUT: Duration = Duration::from_secs(60); const SPAWN_RESTORE_TASK_TIMEOUT: Duration = Duration::from_secs(60);
@@ -403,26 +412,12 @@ impl WorkspacePromptProjectionCache {
.active .active
.lock() .lock()
.map_err(|_| "Workspace Prompt projection cache lock was poisoned".to_string())?; .map_err(|_| "Workspace Prompt projection cache lock was poisoned".to_string())?;
if let Some(current) = active.get(&workspace_id) { // Runtime receives authoritative projections in operation/arrival order.
if current.projection.config_revision > resolution.projection.config_revision { // Equal content reuses the compiled catalog; counters are not authority.
return Ok(current.clone()); if let Some(current) = active.get(&workspace_id)
} && current.projection == resolution.projection
if current.projection.config_revision == resolution.projection.config_revision {
&& (current.projection.source_digest != resolution.projection.source_digest return Ok(current.clone());
|| 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
));
}
} }
active.insert(workspace_id, resolution.clone()); active.insert(workspace_id, resolution.clone());
Ok(resolution) Ok(resolution)
@@ -688,7 +683,7 @@ impl ProfileRuntimeWorkerFactory {
None => fallback, None => fallback,
}; };
// Saved policy is execution authority. Rebinding current settings here // 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)?; validate_worker_memory_settings(&manifest, request)?;
Ok((manifest, loader)) Ok((manifest, loader))
} }
@@ -1195,11 +1190,8 @@ fn validate_worker_memory_settings(
"Workspace Worker restored without its bound Memory settings snapshot".to_string() "Workspace Worker restored without its bound Memory settings snapshot".to_string()
})?; })?;
return Err(format!( return Err(format!(
"Workspace Worker Memory settings snapshot mismatch: expected {} revision {}, restored {} revision {}", "Workspace Worker Memory settings snapshot mismatch: expected {} language {}, restored {} language {}",
expected.workspace_id, expected.workspace_id, expected.language, actual.workspace_id, actual.language
expected.settings_revision,
actual.workspace_id,
actual.settings_revision
)); ));
} }
let subjektiv_settings = manifest.feature.subjektiv.workspace_settings(); let subjektiv_settings = manifest.feature.subjektiv.workspace_settings();
@@ -1216,11 +1208,8 @@ fn validate_worker_memory_settings(
.to_string() .to_string()
})?; })?;
return Err(format!( return Err(format!(
"Workspace Worker subjektiv settings snapshot mismatch: expected {} revision {}, restored {} revision {}", "Workspace Worker subjektiv settings snapshot mismatch: expected {} language {}, restored {} language {}",
expected.workspace_id, expected.workspace_id, expected.language, actual.workspace_id, actual.language
expected.settings_revision,
actual.workspace_id,
actual.settings_revision
)); ));
} }
} else if subjektiv_settings.is_some() { } else if subjektiv_settings.is_some() {
@@ -4789,7 +4778,6 @@ mod tests {
request.subjektiv_attached = true; request.subjektiv_attached = true;
request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot { request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-saved-subjektiv".to_string(), workspace_id: "workspace-saved-subjektiv".to_string(),
settings_revision: 17,
language: "English".to_string(), language: "English".to_string(),
}); });
bind_workspace_memory_settings(&mut saved, &request).unwrap(); bind_workspace_memory_settings(&mut saved, &request).unwrap();
@@ -5982,7 +5970,6 @@ mod tests {
); );
let mut catalog = worker::EffectivePromptCatalog::new( let mut catalog = worker::EffectivePromptCatalog::new(
templates, templates,
17,
projection.schema_fingerprint.clone(), projection.schema_fingerprint.clone(),
projection.toolchain_fingerprint.clone(), projection.toolchain_fingerprint.clone(),
) )
@@ -6005,7 +5992,6 @@ mod tests {
missing_instruction_bundle.prompt_catalog = Some( missing_instruction_bundle.prompt_catalog = Some(
worker::EffectivePromptCatalog::new( worker::EffectivePromptCatalog::new(
templates, templates,
17,
projection.schema_fingerprint.clone(), projection.schema_fingerprint.clone(),
projection.toolchain_fingerprint.clone(), projection.toolchain_fingerprint.clone(),
) )
@@ -6115,19 +6101,19 @@ mod tests {
.with_runtime_store_dir(&runtime_store) .with_runtime_store_dir(&runtime_store)
.with_runtime_id("runtime-subjektiv-test"); .with_runtime_id("runtime-subjektiv-test");
let before = persisted_files(&runtime_store); let before = persisted_files(&runtime_store);
let mut wrong_revision = request.clone(); let mut wrong_settings = request.clone();
wrong_revision wrong_settings
.request .request
.memory_settings .memory_settings
.as_mut() .as_mut()
.unwrap() .unwrap()
.settings_revision = 18; .language = "different language".into();
let mut detached = request.clone(); let mut detached = request.clone();
detached.request.subjektiv_attached = false; detached.request.subjektiv_attached = false;
let mut missing_settings = request.clone(); let mut missing_settings = request.clone();
missing_settings.request.memory_settings = None; missing_settings.request.memory_settings = None;
for (invalid, expected) in [ for (invalid, expected) in [
(wrong_revision, "subjektiv settings snapshot mismatch"), (wrong_settings, "subjektiv settings snapshot mismatch"),
(detached, "unauthorized subjektiv attachment"), (detached, "unauthorized subjektiv attachment"),
(missing_settings, "missing its trusted settings snapshot"), (missing_settings, "missing its trusted settings snapshot"),
] { ] {
@@ -6660,7 +6646,6 @@ mod tests {
let cache = WorkspacePromptProjectionCache::default(); let cache = WorkspacePromptProjectionCache::default();
let catalog_v1 = worker::EffectivePromptCatalog::new( let catalog_v1 = worker::EffectivePromptCatalog::new(
BTreeMap::from([("default".to_string(), "prompt-v1".to_string())]), BTreeMap::from([("default".to_string(), "prompt-v1".to_string())]),
8,
"schema", "schema",
"toolchain", "toolchain",
) )
@@ -6674,7 +6659,6 @@ mod tests {
.unwrap(); .unwrap();
let catalog_v2 = worker::EffectivePromptCatalog::new( let catalog_v2 = worker::EffectivePromptCatalog::new(
BTreeMap::from([("default".to_string(), "prompt-v2".to_string())]), BTreeMap::from([("default".to_string(), "prompt-v2".to_string())]),
9,
"schema", "schema",
"toolchain", "toolchain",
) )
@@ -6691,7 +6675,7 @@ mod tests {
cache.observe(projection_v2).unwrap(); cache.observe(projection_v2).unwrap();
let active = cache.active("workspace-a").unwrap().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!( assert_eq!(
active.projection.catalog.catalog_digest, active.projection.catalog.catalog_digest,
catalog_v2.catalog_digest catalog_v2.catalog_digest
@@ -6699,10 +6683,9 @@ mod tests {
} }
#[test] #[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( let catalog = worker::EffectivePromptCatalog::new(
BTreeMap::from([("default".to_string(), "prompt".to_string())]), BTreeMap::from([("default".to_string(), "prompt".to_string())]),
8,
"schema", "schema",
"toolchain", "toolchain",
) )
@@ -6724,22 +6707,20 @@ mod tests {
let cache = WorkspacePromptProjectionCache::default(); let cache = WorkspacePromptProjectionCache::default();
cache.observe(first).unwrap(); cache.observe(first).unwrap();
let error = cache.observe(drifted).unwrap_err(); assert_eq!(
assert!( cache.observe(drifted).unwrap().projection.source_digest,
error "source-b"
.to_string()
.contains("without a config revision transition")
); );
} }
#[test] #[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 templates = BTreeMap::from([("default".to_string(), "prompt".to_string())]);
let first_catalog = let first_catalog =
worker::EffectivePromptCatalog::new(templates.clone(), 8, "schema-a", "toolchain") worker::EffectivePromptCatalog::new(templates.clone(), "schema-a", "toolchain")
.unwrap(); .unwrap();
let drifted_catalog = 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( let first = worker::WorkspacePromptProjection::new(
"workspace-a", "workspace-a",
"source-a", "source-a",
@@ -6757,8 +6738,14 @@ mod tests {
let cache = WorkspacePromptProjectionCache::default(); let cache = WorkspacePromptProjectionCache::default();
cache.observe(first).unwrap(); cache.observe(first).unwrap();
let error = cache.observe(drifted).unwrap_err(); assert_eq!(
assert!(error.contains("without a config revision transition")); cache
.observe(drifted)
.unwrap()
.projection
.schema_fingerprint,
"schema-b"
);
} }
#[test] #[test]
@@ -7165,7 +7152,6 @@ mod tests {
metadata: crate::config_bundle::ConfigBundleMetadata { metadata: crate::config_bundle::ConfigBundleMetadata {
id: "adapter-test-bundle".to_string(), id: "adapter-test-bundle".to_string(),
digest: String::new(), digest: String::new(),
revision: "test".to_string(),
workspace_id: "adapter-test".to_string(), workspace_id: "adapter-test".to_string(),
created_at: "test".to_string(), created_at: "test".to_string(),
provenance: crate::config_bundle::ConfigBundleProvenance { provenance: crate::config_bundle::ConfigBundleProvenance {
@@ -7273,7 +7259,7 @@ mod tests {
request.backend_job = Some(crate::catalog::BackendJobExecutionBinding { request.backend_job = Some(crate::catalog::BackendJobExecutionBinding {
job_id: "job-1".into(), job_id: "job-1".into(),
attempt_id: "attempt-1".into(), attempt_id: "attempt-1".into(),
input_revision: None, input_digest: None,
subjektiv_consolidation: granted, subjektiv_consolidation: granted,
}); });
for tools in [false, true] { for tools in [false, true] {
@@ -7329,7 +7315,6 @@ mod tests {
}; };
let settings = manifest::WorkspaceMemorySettingsSnapshot { let settings = manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-subject".to_string(), workspace_id: "workspace-subject".to_string(),
settings_revision: 3,
language: "English".to_string(), language: "English".to_string(),
}; };
let mut ordinary_request = create_request("ordinary"); let mut ordinary_request = create_request("ordinary");
@@ -7987,7 +7972,6 @@ mod tests {
kind: server_api::RepositorySourceKind::LocalPath, kind: server_api::RepositorySourceKind::LocalPath,
uri: repo.display().to_string(), uri: repo.display().to_string(),
}, },
source_revision: 1,
source_fingerprint: "sha256:test".to_string(), source_fingerprint: "sha256:test".to_string(),
selector: Some(RepositorySelector::from("HEAD")), selector: Some(RepositorySelector::from("HEAD")),
}, },
@@ -8197,7 +8181,6 @@ mod tests {
); );
let mut effective = worker::EffectivePromptCatalog::new( let mut effective = worker::EffectivePromptCatalog::new(
templates, templates,
7,
projection.schema_fingerprint.clone(), projection.schema_fingerprint.clone(),
projection.toolchain_fingerprint.clone(), projection.toolchain_fingerprint.clone(),
) )
@@ -8213,7 +8196,7 @@ mod tests {
.unwrap() .unwrap()
.unwrap(); .unwrap();
assert_eq!(resolution.projection.config_revision, 7); assert_eq!(resolution.projection.source_digest, "source-7");
assert_eq!( assert_eq!(
resolution.catalog.notify_wrapper("restored").unwrap(), resolution.catalog.notify_wrapper("restored").unwrap(),
"PENDING-LAUNCH restored" "PENDING-LAUNCH restored"
@@ -8250,7 +8233,6 @@ mod tests {
}); });
request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot { request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-restore".to_string(), workspace_id: "workspace-restore".to_string(),
settings_revision: 1,
language: "English".to_string(), language: "English".to_string(),
}); });
let identity = RuntimeIdentityMaterial::generate("runtime-restore").unwrap(); let identity = RuntimeIdentityMaterial::generate("runtime-restore").unwrap();
+7 -32
View File
@@ -736,7 +736,6 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
fn current_prompt_projection( fn current_prompt_projection(
&self, &self,
minimum_revision: Option<u64>,
) -> Result<Option<WorkspacePromptCatalogResolution>, WorkspaceClientError> { ) -> Result<Option<WorkspacePromptCatalogResolution>, WorkspaceClientError> {
let Some(cache) = self.prompt_projection_cache.as_ref() else { let Some(cache) = self.prompt_projection_cache.as_ref() else {
return Ok(None); return Ok(None);
@@ -744,11 +743,6 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
if let Some(resolution) = cache if let Some(resolution) = cache
.active(&self.workspace_id) .active(&self.workspace_id)
.map_err(WorkspaceClientError::Request)? .map_err(WorkspaceClientError::Request)?
.filter(|resolution| {
minimum_revision
.map(|minimum| resolution.projection.config_revision >= minimum)
.unwrap_or(true)
})
{ {
return Ok(Some((*resolution).clone())); return Ok(Some((*resolution).clone()));
} }
@@ -763,11 +757,6 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
if let Some(resolution) = cache if let Some(resolution) = cache
.active(&self.workspace_id) .active(&self.workspace_id)
.map_err(WorkspaceClientError::Request)? .map_err(WorkspaceClientError::Request)?
.filter(|resolution| {
minimum_revision
.map(|minimum| resolution.projection.config_revision >= minimum)
.unwrap_or(true)
})
{ {
return Ok(Some((*resolution).clone())); return Ok(Some((*resolution).clone()));
} }
@@ -807,14 +796,6 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
let resolution = cache let resolution = cache
.observe(projection) .observe(projection)
.map_err(WorkspaceClientError::Request)?; .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())) Ok(Some((*resolution).clone()))
} }
@@ -1082,7 +1063,6 @@ mod tests {
"default".to_string(), "default".to_string(),
"workspace prompt".to_string(), "workspace prompt".to_string(),
)]), )]),
3,
"schema", "schema",
"toolchain", "toolchain",
) )
@@ -1103,22 +1083,20 @@ mod tests {
) )
.with_prompt_projection_cache(cache); .with_prompt_projection_cache(cache);
let projection = client.current_prompt_projection(None).unwrap().unwrap(); let projection = client.current_prompt_projection().unwrap().unwrap();
let second = client.current_prompt_projection(None).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_eq!(projection.projection.source_digest, "source-3");
assert!(Arc::ptr_eq(&projection.catalog, &second.catalog)); assert!(Arc::ptr_eq(&projection.catalog, &second.catalog));
} }
#[test] #[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::io::{Read, Write};
use std::net::TcpListener; use std::net::TcpListener;
let catalog = worker::EffectivePromptCatalog::new( let catalog = worker::EffectivePromptCatalog::new(
std::collections::BTreeMap::from([("default".to_string(), "stale prompt".to_string())]), std::collections::BTreeMap::from([("default".to_string(), "stale prompt".to_string())]),
3,
"schema", "schema",
"toolchain", "toolchain",
) )
@@ -1132,7 +1110,6 @@ mod tests {
.unwrap(); .unwrap();
let body = serde_json::to_string(&projection).unwrap(); let body = serde_json::to_string(&projection).unwrap();
let cache = Arc::new(WorkspacePromptProjectionCache::default()); let cache = Arc::new(WorkspacePromptProjectionCache::default());
cache.observe(projection).unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap()); let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = std::thread::spawn(move || { let server = std::thread::spawn(move || {
@@ -1151,10 +1128,10 @@ mod tests {
RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a") RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a")
.with_prompt_projection_cache(cache); .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(); server.join().unwrap();
assert!(error.to_string().contains("required revision 4, got 3")); assert_eq!(fetched.projection.source_digest, "source-3");
} }
#[test] #[test]
@@ -1168,7 +1145,6 @@ mod tests {
"default".to_string(), "default".to_string(),
"shared prompt".to_string(), "shared prompt".to_string(),
)]), )]),
5,
"schema", "schema",
"toolchain", "toolchain",
) )
@@ -1211,7 +1187,7 @@ mod tests {
let barrier = barrier.clone(); let barrier = barrier.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
barrier.wait(); barrier.wait();
client.current_prompt_projection(None).unwrap().unwrap() client.current_prompt_projection().unwrap().unwrap()
}) })
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -1239,7 +1215,6 @@ mod tests {
"default".to_string(), "default".to_string(),
"foreign prompt".to_string(), "foreign prompt".to_string(),
)]), )]),
4,
"schema", "schema",
"toolchain", "toolchain",
) )
@@ -1270,7 +1245,7 @@ mod tests {
RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a") RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a")
.with_prompt_projection_cache(Arc::new(WorkspacePromptProjectionCache::default())); .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(); server.join().unwrap();
assert!(error.to_string().contains("scope mismatch")); assert!(error.to_string().contains("scope mismatch"));
+257 -101
View File
@@ -40,6 +40,7 @@ const REPOSITORY_MAX_BYTES: u64 = 5 * 1024 * 1024 * 1024;
static NEXT_WORKING_DIRECTORY_SEQUENCE: AtomicU64 = AtomicU64::new(0); static NEXT_WORKING_DIRECTORY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "FrozenEvidenceRead")]
pub struct WorkingDirectoryEvidence { pub struct WorkingDirectoryEvidence {
pub repository_id: String, pub repository_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@@ -48,16 +49,61 @@ pub struct WorkingDirectoryEvidence {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_tree: Option<String>, pub resolved_tree: Option<String>,
pub materializer_kind: MaterializerKind, pub materializer_kind: MaterializerKind,
#[serde(default, skip_serializing_if = "Option::is_none")] /// SSH authority must be reacquired before opening a session, even if an old
pub repository_source_revision: Option<u64>, /// 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")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub repository_source_fingerprint: Option<String>, pub repository_source_fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub operation_id: Option<String>, pub operation_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub credential_revision: Option<u64>, pub public_key_fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub host_trust_revision: Option<u64>, pub host_key_fingerprint: Option<String>,
}
// 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<String>,
resolved_commit: String,
resolved_tree: Option<String>,
materializer_kind: MaterializerKind,
repository_source_fingerprint: Option<String>,
operation_id: Option<String>,
public_key_fingerprint: Option<String>,
host_key_fingerprint: Option<String>,
repository_access_required: Option<bool>,
credential_revision: Option<u64>,
host_trust_revision: Option<u64>,
}
impl TryFrom<FrozenEvidenceRead> for WorkingDirectoryEvidence {
type Error = &'static str;
fn try_from(wire: FrozenEvidenceRead) -> Result<Self, Self::Error> {
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)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -156,7 +202,7 @@ impl WorkingDirectoryBinding {
WorkingDirectoryStatusKind::Active | WorkingDirectoryStatusKind::CleanupPending WorkingDirectoryStatusKind::Active | WorkingDirectoryStatusKind::CleanupPending
) && binding_paths_are_available(self) ) && 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_selector = current_selector;
summary.current_ref = current_ref; summary.current_ref = current_ref;
summary.current_tree = current_tree; summary.current_tree = current_tree;
@@ -274,7 +320,7 @@ fn binding_paths_are_available(binding: &WorkingDirectoryBinding) -> bool {
root.join(".git").is_dir() root.join(".git").is_dir()
} }
fn binding_current_revision( fn binding_current_commit(
binding: &WorkingDirectoryBinding, binding: &WorkingDirectoryBinding,
) -> (Option<String>, Option<String>, Option<String>) { ) -> (Option<String>, Option<String>, Option<String>) {
let current_ref = git_stdout(binding.root(), ["rev-parse", "HEAD"]) let current_ref = git_stdout(binding.root(), ["rev-parse", "HEAD"])
@@ -305,13 +351,12 @@ fn binding_cleanliness(binding: &WorkingDirectoryBinding) -> String {
#[derive(Debug)] #[derive(Debug)]
struct PendingRepositoryAccessLease { struct PendingRepositoryAccessLease {
generation: u64, lease_id: uuid::Uuid,
access: RepositorySshMaterializationAccess, access: RepositorySshMaterializationAccess,
} }
#[derive(Debug, Default)] #[derive(Debug, Default)]
struct RepositoryAccessExpiryState { struct RepositoryAccessExpiryState {
next_generation: u64,
pending: HashMap<String, PendingRepositoryAccessLease>, pending: HashMap<String, PendingRepositoryAccessLease>,
shutdown: bool, shutdown: bool,
} }
@@ -360,23 +405,11 @@ impl RepositoryAccessExpiryScheduler {
} }
} }
fn next_generation(
state: &mut RepositoryAccessExpiryState,
) -> Result<u64, WorkingDirectoryDiagnostic> {
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( fn store_pending(
&self, &self,
working_directory_id: &str, working_directory_id: &str,
access: RepositorySshMaterializationAccess, access: RepositorySshMaterializationAccess,
) -> Result<u64, WorkingDirectoryDiagnostic> { ) -> Result<uuid::Uuid, WorkingDirectoryDiagnostic> {
let mut state = self.inner.state.lock().map_err(|_| { let mut state = self.inner.state.lock().map_err(|_| {
WorkingDirectoryDiagnostic::new( WorkingDirectoryDiagnostic::new(
"working_directory_repository_access_unavailable", "working_directory_repository_access_unavailable",
@@ -389,14 +422,14 @@ impl RepositoryAccessExpiryScheduler {
"Runtime Repository access state is shutting down", "Runtime Repository access state is shutting down",
)); ));
} }
let generation = Self::next_generation(&mut state)?; let lease_id = uuid::Uuid::now_v7();
state.pending.insert( state.pending.insert(
working_directory_id.to_string(), working_directory_id.to_string(),
PendingRepositoryAccessLease { generation, access }, PendingRepositoryAccessLease { lease_id, access },
); );
drop(state); drop(state);
self.inner.wake.notify_one(); self.inner.wake.notify_one();
Ok(generation) Ok(lease_id)
} }
fn pending( fn pending(
@@ -452,14 +485,14 @@ impl RepositoryAccessExpiryScheduler {
Ok(()) Ok(())
} }
fn next_expiry(state: &RepositoryAccessExpiryState) -> Option<(String, u64, u64)> { fn next_expiry(state: &RepositoryAccessExpiryState) -> Option<(String, uuid::Uuid, u64)> {
state state
.pending .pending
.iter() .iter()
.map(|(working_directory_id, lease)| { .map(|(working_directory_id, lease)| {
( (
working_directory_id.clone(), working_directory_id.clone(),
lease.generation, lease.lease_id,
lease.access.expires_at_epoch_seconds, lease.access.expires_at_epoch_seconds,
) )
}) })
@@ -469,14 +502,14 @@ impl RepositoryAccessExpiryScheduler {
fn expire_if_current( fn expire_if_current(
state: &mut RepositoryAccessExpiryState, state: &mut RepositoryAccessExpiryState,
working_directory_id: &str, working_directory_id: &str,
generation: u64, lease_id: uuid::Uuid,
now_epoch_seconds: u64, now_epoch_seconds: u64,
) { ) {
let should_remove = state let should_remove = state
.pending .pending
.get(working_directory_id) .get(working_directory_id)
.is_some_and(|lease| { .is_some_and(|lease| {
lease.generation == generation lease.lease_id == lease_id
&& lease.access.expires_at_epoch_seconds <= now_epoch_seconds && lease.access.expires_at_epoch_seconds <= now_epoch_seconds
}); });
if should_remove { if should_remove {
@@ -494,7 +527,7 @@ impl RepositoryAccessExpiryScheduler {
if state.shutdown { if state.shutdown {
return; 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) Self::next_expiry(&state)
else { else {
state = match inner.wake.wait(state) { state = match inner.wake.wait(state) {
@@ -518,7 +551,7 @@ impl RepositoryAccessExpiryScheduler {
Self::expire_if_current( Self::expire_if_current(
&mut state, &mut state,
&working_directory_id, &working_directory_id,
generation, lease_id,
now_epoch_seconds, now_epoch_seconds,
); );
break; break;
@@ -527,21 +560,21 @@ impl RepositoryAccessExpiryScheduler {
} }
#[cfg(test)] #[cfg(test)]
fn pending_generation(&self, working_directory_id: &str) -> Option<u64> { fn pending_lease_id(&self, working_directory_id: &str) -> Option<uuid::Uuid> {
self.inner self.inner
.state .state
.lock() .lock()
.ok()? .ok()?
.pending .pending
.get(working_directory_id) .get(working_directory_id)
.map(|lease| lease.generation) .map(|lease| lease.lease_id)
} }
#[cfg(test)] #[cfg(test)]
fn expire_pending_for_test( fn expire_pending_for_test(
&self, &self,
working_directory_id: &str, working_directory_id: &str,
generation: u64, lease_id: uuid::Uuid,
now_epoch_seconds: u64, now_epoch_seconds: u64,
) { ) {
let Ok(mut state) = self.inner.state.lock() else { let Ok(mut state) = self.inner.state.lock() else {
@@ -550,7 +583,7 @@ impl RepositoryAccessExpiryScheduler {
Self::expire_if_current( Self::expire_if_current(
&mut state, &mut state,
working_directory_id, working_directory_id,
generation, lease_id,
now_epoch_seconds, now_epoch_seconds,
); );
} }
@@ -701,6 +734,26 @@ impl RuntimeGitMaterializer {
fn read_binding( fn read_binding(
&self, &self,
working_directory_id: &str, working_directory_id: &str,
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
// 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<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
// 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<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> { ) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
let working_directory_root = self.working_directory_root(working_directory_id); let working_directory_root = self.working_directory_root(working_directory_id);
let path = working_directory_root.join(MATERIALIZATION_RECORD); 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", "working directory working_directory record is invalid; backend-private path details were omitted",
) )
})?; })?;
Ok(WorkingDirectoryBinding { let binding = WorkingDirectoryBinding {
working_directory: record.working_directory, working_directory: record.working_directory,
root: record.root.clone(), root: record.root.clone(),
cwd: record.root, cwd: record.root,
@@ -726,7 +779,39 @@ impl RuntimeGitMaterializer {
occupancy: self.occupancy.clone(), occupancy: self.occupancy.clone(),
occupancy_id: working_directory_id.to_string(), occupancy_id: working_directory_id.to_string(),
generation: self.occupancy.generation(&working_directory_id), 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( fn store_repository_access(
@@ -750,8 +835,7 @@ impl RuntimeGitMaterializer {
if binding if binding
.working_directory .working_directory
.evidence .evidence
.credential_revision .repository_access_required
.is_some()
{ {
return Err(WorkingDirectoryDiagnostic::new( return Err(WorkingDirectoryDiagnostic::new(
"working_directory_remote_repository_access_required", "working_directory_remote_repository_access_required",
@@ -832,7 +916,6 @@ impl RuntimeGitMaterializer {
if materialization.workspace_id.trim().is_empty() if materialization.workspace_id.trim().is_empty()
|| materialization.runtime_id.trim().is_empty() || materialization.runtime_id.trim().is_empty()
|| materialization.operation_id.trim().is_empty() || materialization.operation_id.trim().is_empty()
|| materialization.config_revision == 0
|| materialization.config_projection_digest.trim().is_empty() || materialization.config_projection_digest.trim().is_empty()
{ {
return Err(WorkingDirectoryDiagnostic::new( return Err(WorkingDirectoryDiagnostic::new(
@@ -1032,16 +1115,17 @@ impl RuntimeGitMaterializer {
resolved_commit, resolved_commit,
resolved_tree, resolved_tree,
materializer_kind: MaterializerKind::RuntimeGitClone, 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()), repository_source_fingerprint: Some(request.repository.source_fingerprint.clone()),
operation_id: context.map(|value| value.operation_id.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.ssh.as_ref())
.and_then(|value| value.credential_candidates.first()) .and_then(|value| value.credential_candidates.first())
.map(|candidate| candidate.credential_revision), .map(|candidate| candidate.public_key_fingerprint.clone()),
host_trust_revision: context host_key_fingerprint: context
.and_then(|value| value.ssh.as_ref()) .and_then(|value| value.ssh.as_ref())
.map(|value| value.host_trust_revision), .map(|value| value.host_key_fingerprint.clone()),
}, },
cleanup_target: WorkingDirectoryCleanupTarget { cleanup_target: WorkingDirectoryCleanupTarget {
kind: "runtime_git_clone".to_string(), kind: "runtime_git_clone".to_string(),
@@ -1122,11 +1206,10 @@ impl WorkingDirectoryMaterializer for RuntimeGitMaterializer {
} }
Err(error) => return Err(error), Err(error) => return Err(error),
}; };
if binding if !binding
.working_directory .working_directory
.evidence .evidence
.credential_revision .repository_access_required
.is_none()
{ {
return Err(WorkingDirectoryDiagnostic::new( return Err(WorkingDirectoryDiagnostic::new(
"working_directory_repository_access_not_applicable", "working_directory_repository_access_not_applicable",
@@ -1146,13 +1229,15 @@ impl WorkingDirectoryMaterializer for RuntimeGitMaterializer {
)?; )?;
binding.working_directory.evidence.operation_id = binding.working_directory.evidence.operation_id =
Some(request.materialization.operation_id.clone()); Some(request.materialization.operation_id.clone());
binding.working_directory.evidence.credential_revision = Some( binding.working_directory.evidence.public_key_fingerprint = Some(
ssh.credential_candidates ssh.credential_candidates
.first() .first()
.expect("validated SSH credential candidate") .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.write_record(&binding)?;
self.store_repository_access(&request.working_directory_id, ssh) self.store_repository_access(&request.working_directory_id, ssh)
} }
@@ -1195,15 +1280,15 @@ impl WorkingDirectoryMaterializer for RuntimeGitMaterializer {
"Repository provider returned an ambiguous ref observation", "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( WorkingDirectoryDiagnostic::new(
"repository_ref_response_invalid", "repository_ref_response_invalid",
"Repository provider returned an invalid ref observation", "Repository provider returned an invalid ref observation",
) )
})?; })?;
if observed_selector != selector if observed_selector != selector
|| !matches!(revision_ref.len(), 40 | 64) || !matches!(resolved_ref.len(), 40 | 64)
|| !revision_ref.bytes().all(|byte| byte.is_ascii_hexdigit()) || !resolved_ref.bytes().all(|byte| byte.is_ascii_hexdigit())
{ {
return Err(WorkingDirectoryDiagnostic::new( return Err(WorkingDirectoryDiagnostic::new(
"repository_ref_response_invalid", "repository_ref_response_invalid",
@@ -1213,10 +1298,9 @@ impl WorkingDirectoryMaterializer for RuntimeGitMaterializer {
Ok(RepositoryRefObservation { Ok(RepositoryRefObservation {
repository_id: request.repository.id.clone(), repository_id: request.repository.id.clone(),
source_revision: request.repository.source_revision,
source_fingerprint: request.repository.source_fingerprint.clone(), source_fingerprint: request.repository.source_fingerprint.clone(),
selector: selector.to_string(), selector: selector.to_string(),
revision_ref: revision_ref.to_ascii_lowercase(), resolved_ref: resolved_ref.to_ascii_lowercase(),
observed_at_epoch_seconds: SystemTime::now() observed_at_epoch_seconds: SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.unwrap_or_default() .unwrap_or_default()
@@ -2294,11 +2378,11 @@ fn validate_ssh_materialization_access(
if access.credential_candidates.is_empty() if access.credential_candidates.is_empty()
|| access.credential_candidates.iter().any(|candidate| { || access.credential_candidates.iter().any(|candidate| {
candidate.credential_id.trim().is_empty() candidate.credential_id.trim().is_empty()
|| candidate.credential_revision == 0 || candidate.public_key_fingerprint.trim().is_empty()
|| !candidate.private_key.expose().contains("PRIVATE KEY") || !candidate.private_key.expose().contains("PRIVATE KEY")
}) })
|| access.host_trust_id.trim().is_empty() || 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() || access.known_hosts_entry.expose().trim().is_empty()
{ {
return Err(WorkingDirectoryDiagnostic::new( return Err(WorkingDirectoryDiagnostic::new(
@@ -3013,8 +3097,6 @@ mod tests {
operation: crate::resource::BackendResourceOperation::FetchOnce, operation: crate::resource::BackendResourceOperation::FetchOnce,
expires_at_unix_seconds: i64::MAX, expires_at_unix_seconds: i64::MAX,
nonce: "repository-access-1".to_string(), nonce: "repository-access-1".to_string(),
revision: "1".to_string(),
generation: None,
max_bytes: crate::resource::DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES, max_bytes: crate::resource::DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES,
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(), content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly, redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly,
@@ -3027,11 +3109,11 @@ mod tests {
crate::catalog::RepositorySshMaterializationAccess { crate::catalog::RepositorySshMaterializationAccess {
credential_candidates: vec![crate::catalog::RepositorySshCredentialCandidate { credential_candidates: vec![crate::catalog::RepositorySshCredentialCandidate {
credential_id: "credential-1".to_string(), 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"), private_key: crate::catalog::SensitiveString::new("PRIVATE KEY secret bytes"),
}], }],
host_trust_id: "trust-1".to_string(), host_trust_id: "trust-1".to_string(),
host_trust_revision: 4, host_key_fingerprint: "key-4".into(),
access: server_api::RepositoryAccessMode::ReadOnly, access: server_api::RepositoryAccessMode::ReadOnly,
expires_at_epoch_seconds: u64::MAX, expires_at_epoch_seconds: u64::MAX,
repository_id: "repo-main".to_string(), repository_id: "repo-main".to_string(),
@@ -3043,7 +3125,7 @@ mod tests {
} }
#[test] #[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 scheduler = RepositoryAccessExpiryScheduler::new();
let working_directory_id = "working-directory-refresh"; let working_directory_id = "working-directory-refresh";
let old_expiry = repository_access_now_epoch_seconds() + 60; let old_expiry = repository_access_now_epoch_seconds() + 60;
@@ -3052,9 +3134,9 @@ mod tests {
scheduler scheduler
.store_pending(working_directory_id, old_access) .store_pending(working_directory_id, old_access)
.unwrap(); .unwrap();
let old_generation = scheduler let old_lease_id = scheduler
.pending_generation(working_directory_id) .pending_lease_id(working_directory_id)
.expect("old pending generation"); .expect("old pending lease_id");
let barrier = Arc::new(std::sync::Barrier::new(2)); let barrier = Arc::new(std::sync::Barrier::new(2));
let refresh_barrier = Arc::clone(&barrier); let refresh_barrier = Arc::clone(&barrier);
@@ -3069,12 +3151,12 @@ mod tests {
}); });
barrier.wait(); barrier.wait();
refresh.join().unwrap(); refresh.join().unwrap();
let refreshed_generation = scheduler let refreshed_lease_id = scheduler
.pending_generation(working_directory_id) .pending_lease_id(working_directory_id)
.expect("refreshed pending generation"); .expect("refreshed pending lease_id");
assert_ne!(old_generation, refreshed_generation); 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 let refreshed = scheduler
.pending(working_directory_id) .pending(working_directory_id)
.unwrap() .unwrap()
@@ -3175,7 +3257,6 @@ mod tests {
kind: server_api::RepositorySourceKind::LocalPath, kind: server_api::RepositorySourceKind::LocalPath,
uri: repo.display().to_string(), uri: repo.display().to_string(),
}, },
source_revision: 1,
source_fingerprint: "sha256:test".to_string(), source_fingerprint: "sha256:test".to_string(),
selector: Some(RepositorySelector::from("HEAD")), selector: Some(RepositorySelector::from("HEAD")),
}, },
@@ -3190,6 +3271,48 @@ mod tests {
WorkerRef::new(WorkerId::from_legacy_u64(sequence)) 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] #[test]
fn repository_ref_observation_reads_the_provider_fresh() { fn repository_ref_observation_reads_the_provider_fresh() {
let repo = create_clean_repo(); let repo = create_clean_repo();
@@ -3207,7 +3330,7 @@ mod tests {
.observe_repository_ref(&observation_request) .observe_repository_ref(&observation_request)
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
first.revision_ref, first.resolved_ref,
git_stdout(repo.path(), ["rev-parse", "published"]).unwrap() git_stdout(repo.path(), ["rev-parse", "published"]).unwrap()
); );
fs::write(repo.path().join("second.txt"), "second\n").unwrap(); fs::write(repo.path().join("second.txt"), "second\n").unwrap();
@@ -3218,9 +3341,9 @@ mod tests {
let second = materializer let second = materializer
.observe_repository_ref(&observation_request) .observe_repository_ref(&observation_request)
.unwrap(); .unwrap();
assert_ne!(first.revision_ref, second.revision_ref); assert_ne!(first.resolved_ref, second.resolved_ref);
assert_eq!( assert_eq!(
second.revision_ref, second.resolved_ref,
git_stdout(repo.path(), ["rev-parse", "published"]).unwrap() git_stdout(repo.path(), ["rev-parse", "published"]).unwrap()
); );
} }
@@ -3290,8 +3413,8 @@ mod tests {
let still_first = materializer let still_first = materializer
.observe_repository_ref(&observation_request) .observe_repository_ref(&observation_request)
.unwrap(); .unwrap();
assert_eq!(still_first.revision_ref, first.revision_ref); assert_eq!(still_first.resolved_ref, first.resolved_ref);
assert_ne!(still_first.revision_ref, unpublished_second); assert_ne!(still_first.resolved_ref, unpublished_second);
git( git(
&workdir, &workdir,
@@ -3300,11 +3423,11 @@ mod tests {
let second = materializer let second = materializer
.observe_repository_ref(&observation_request) .observe_repository_ref(&observation_request)
.unwrap(); .unwrap();
assert_eq!(second.revision_ref, unpublished_second); assert_eq!(second.resolved_ref, unpublished_second);
assert_ne!(second.revision_ref, first.revision_ref); assert_ne!(second.resolved_ref, first.resolved_ref);
assert_ne!( assert_ne!(
git_stdout(&cache, ["rev-parse", "HEAD"]).unwrap(), 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()); let mut file_request = request(repo.path());
file_request.repository.source.kind = server_api::RepositorySourceKind::File; file_request.repository.source.kind = server_api::RepositorySourceKind::File;
file_request.repository.source.uri = format!("file://{}", repo.path().display()); 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(); file_request.repository.source_fingerprint = "sha256:file-source".to_string();
let file = materializer let file = materializer
.materialize(&worker_ref(3), &file_request) .materialize(&worker_ref(3), &file_request)
@@ -3478,7 +3600,6 @@ mod tests {
workspace_id: "workspace-1".to_string(), workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(), runtime_id: "runtime-1".to_string(),
operation_id: "operation-1".to_string(), operation_id: "operation-1".to_string(),
config_revision: 7,
config_projection_digest: "sha256:projection".to_string(), config_projection_digest: "sha256:projection".to_string(),
ssh: None, ssh: None,
}); });
@@ -3511,8 +3632,8 @@ mod tests {
"credential-1" "credential-1"
); );
assert_eq!( assert_eq!(
serialized["credential_candidates"][0]["credential_revision"], serialized["credential_candidates"][0]["public_key_fingerprint"],
2 "key-2"
); );
assert!( assert!(
serialized["credential_candidates"][0] serialized["credential_candidates"][0]
@@ -3564,27 +3685,26 @@ mod tests {
workspace_id: "workspace-1".to_string(), workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(), runtime_id: "runtime-1".to_string(),
operation_id: "operation-agent".to_string(), operation_id: "operation-agent".to_string(),
config_revision: 2,
config_projection_digest: "sha256:projection".to_string(), config_projection_digest: "sha256:projection".to_string(),
ssh: Some(crate::catalog::RepositorySshMaterializationAccess { ssh: Some(crate::catalog::RepositorySshMaterializationAccess {
credential_candidates: vec![ credential_candidates: vec![
crate::catalog::RepositorySshCredentialCandidate { crate::catalog::RepositorySshCredentialCandidate {
credential_id: "credential-1".to_string(), credential_id: "credential-1".to_string(),
credential_revision: 1, public_key_fingerprint: "key-1".into(),
private_key: crate::catalog::SensitiveString::new( private_key: crate::catalog::SensitiveString::new(
fs::read_to_string(&key_path).unwrap(), fs::read_to_string(&key_path).unwrap(),
), ),
}, },
crate::catalog::RepositorySshCredentialCandidate { crate::catalog::RepositorySshCredentialCandidate {
credential_id: "credential-2".to_string(), credential_id: "credential-2".to_string(),
credential_revision: 3, public_key_fingerprint: "key-3".into(),
private_key: crate::catalog::SensitiveString::new( private_key: crate::catalog::SensitiveString::new(
fs::read_to_string(&fallback_key_path).unwrap(), fs::read_to_string(&fallback_key_path).unwrap(),
), ),
}, },
], ],
host_trust_id: "trust-1".to_string(), host_trust_id: "trust-1".to_string(),
host_trust_revision: 1, host_key_fingerprint: "key-1".into(),
access: server_api::RepositoryAccessMode::ReadWrite, access: server_api::RepositoryAccessMode::ReadWrite,
expires_at_epoch_seconds: u64::MAX, expires_at_epoch_seconds: u64::MAX,
repository_id: "repo-main".to_string(), repository_id: "repo-main".to_string(),
@@ -3720,11 +3840,11 @@ mod tests {
ssh_backed_binding ssh_backed_binding
.working_directory .working_directory
.evidence .evidence
.credential_revision = Some(1); .public_key_fingerprint = Some("key-1".into());
ssh_backed_binding ssh_backed_binding
.working_directory .working_directory
.evidence .evidence
.host_trust_revision = Some(1); .host_key_fingerprint = Some("key-1".into());
materializer.write_record(&ssh_backed_binding).unwrap(); materializer.write_record(&ssh_backed_binding).unwrap();
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fail_workdir_cleanup_once(&materializer, &id); fail_workdir_cleanup_once(&materializer, &id);
@@ -3809,7 +3929,8 @@ mod tests {
); );
let mut rotated = initial_materialization.clone(); let mut rotated = initial_materialization.clone();
rotated.operation_id = "operation-agent-rotated".to_string(); 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; rotated.ssh.as_mut().unwrap().access = server_api::RepositoryAccessMode::ReadOnly;
materializer materializer
.authorize_repository_access(&WorkingDirectoryRepositoryAccessRequest { .authorize_repository_access(&WorkingDirectoryRepositoryAccessRequest {
@@ -3819,8 +3940,8 @@ mod tests {
.unwrap(); .unwrap();
let rebound = materializer.bind_working_directory(&id, None).unwrap(); let rebound = materializer.bind_working_directory(&id, None).unwrap();
assert_eq!( assert_eq!(
rebound.working_directory.evidence.credential_revision, rebound.working_directory.evidence.public_key_fingerprint,
Some(2) Some("key-2".into())
); );
let rebound_environment = rebound.command_environment(); let rebound_environment = rebound.command_environment();
assert_eq!(rebound_environment["YOI_REPOSITORY_ACCESS"], "read_only"); assert_eq!(rebound_environment["YOI_REPOSITORY_ACCESS"], "read_only");
@@ -3927,7 +4048,8 @@ mod tests {
let mut read_write = rotated; let mut read_write = rotated;
read_write.operation_id = "operation-agent-read-write".to_string(); 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; read_write.ssh.as_mut().unwrap().access = server_api::RepositoryAccessMode::ReadWrite;
let read_write_command_policy = let read_write_command_policy =
RepositorySshCommandPolicy::from_access(read_write.ssh.as_ref().unwrap()).unwrap(); RepositorySshCommandPolicy::from_access(read_write.ssh.as_ref().unwrap()).unwrap();
@@ -4006,16 +4128,15 @@ mod tests {
workspace_id: "workspace-1".to_string(), workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(), runtime_id: "runtime-1".to_string(),
operation_id: "operation-read-only".to_string(), operation_id: "operation-read-only".to_string(),
config_revision: 2,
config_projection_digest: "sha256:projection".to_string(), config_projection_digest: "sha256:projection".to_string(),
ssh: Some(crate::catalog::RepositorySshMaterializationAccess { ssh: Some(crate::catalog::RepositorySshMaterializationAccess {
credential_candidates: vec![crate::catalog::RepositorySshCredentialCandidate { credential_candidates: vec![crate::catalog::RepositorySshCredentialCandidate {
credential_id: "credential-1".to_string(), credential_id: "credential-1".to_string(),
credential_revision: 1, public_key_fingerprint: "key-1".into(),
private_key: crate::catalog::SensitiveString::new("PRIVATE KEY placeholder"), private_key: crate::catalog::SensitiveString::new("PRIVATE KEY placeholder"),
}], }],
host_trust_id: "trust-1".to_string(), host_trust_id: "trust-1".to_string(),
host_trust_revision: 1, host_key_fingerprint: "key-1".into(),
access: server_api::RepositoryAccessMode::ReadOnly, access: server_api::RepositoryAccessMode::ReadOnly,
expires_at_epoch_seconds: u64::MAX, expires_at_epoch_seconds: u64::MAX,
repository_id: "repo-main".to_string(), repository_id: "repo-main".to_string(),
@@ -4176,7 +4297,6 @@ mod tests {
workspace_id: "workspace-1".to_string(), workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(), runtime_id: "runtime-1".to_string(),
operation_id: "operation-1".to_string(), operation_id: "operation-1".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(), config_projection_digest: "sha256:projection".to_string(),
ssh: Some(access), ssh: Some(access),
}); });
@@ -4205,7 +4325,6 @@ mod tests {
workspace_id: "workspace-1".to_string(), workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(), runtime_id: "runtime-1".to_string(),
operation_id: "operation-1".to_string(), operation_id: "operation-1".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(), config_projection_digest: "sha256:projection".to_string(),
ssh, ssh,
}; };
@@ -4283,11 +4402,11 @@ mod tests {
crate::catalog::RepositorySshMaterializationAccess { crate::catalog::RepositorySshMaterializationAccess {
credential_candidates: vec![crate::catalog::RepositorySshCredentialCandidate { credential_candidates: vec![crate::catalog::RepositorySshCredentialCandidate {
credential_id: "credential-1".to_string(), credential_id: "credential-1".to_string(),
credential_revision: 1, public_key_fingerprint: "key-1".into(),
private_key: crate::catalog::SensitiveString::new("PRIVATE KEY placeholder"), private_key: crate::catalog::SensitiveString::new("PRIVATE KEY placeholder"),
}], }],
host_trust_id: "trust-1".to_string(), host_trust_id: "trust-1".to_string(),
host_trust_revision: 1, host_key_fingerprint: "key-1".into(),
access: server_api::RepositoryAccessMode::ReadOnly, access: server_api::RepositoryAccessMode::ReadOnly,
expires_at_epoch_seconds: u64::MAX, expires_at_epoch_seconds: u64::MAX,
repository_id: "repo-main".to_string(), repository_id: "repo-main".to_string(),
@@ -4321,7 +4440,6 @@ mod tests {
workspace_id: "workspace-1".to_string(), workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(), runtime_id: "runtime-1".to_string(),
operation_id: "operation-1".to_string(), operation_id: "operation-1".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(), config_projection_digest: "sha256:projection".to_string(),
ssh: None, ssh: None,
}); });
@@ -4498,7 +4616,6 @@ mod tests {
workspace_id: "workspace-1".to_string(), workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(), runtime_id: "runtime-1".to_string(),
operation_id: "operation-1".to_string(), operation_id: "operation-1".to_string(),
config_revision: 7,
config_projection_digest: "sha256:projection".to_string(), config_projection_digest: "sha256:projection".to_string(),
ssh, ssh,
} }
@@ -4641,6 +4758,45 @@ mod tests {
.unwrap(); .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")] #[cfg(target_os = "linux")]
#[test] #[test]
fn removed_binding_cannot_activate_against_recreated_same_id() { fn removed_binding_cannot_activate_against_recreated_same_id() {
@@ -190,7 +190,7 @@ fn remove_with_checks(
} }
} else { } else {
let mut binding = materializer let mut binding = materializer
.read_binding(id) .read_binding_for_cleanup(id)
.map_err(|_| cause("ownership_unknown"))?; .map_err(|_| cause("ownership_unknown"))?;
validate_binding(materializer, id, &binding)?; validate_binding(materializer, id, &binding)?;
// Preflight the entire tree without following links or crossing mounts, // 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 // These fields are refreshed by authorize_repository_access, not a new
// materialization. All repository, creation and cleanup identity stays. // materialization. All repository, creation and cleanup identity stays.
directory.evidence.operation_id = None; directory.evidence.operation_id = None;
directory.evidence.credential_revision = None; directory.evidence.public_key_fingerprint = None;
directory.evidence.host_trust_revision = None; directory.evidence.host_key_fingerprint = None;
directory directory
}; };
immutable(saved) == immutable(current) immutable(saved) == immutable(current)
@@ -298,7 +298,7 @@ fn retry_inventory(
return Ok(before); return Ok(before);
} }
let binding = materializer let binding = materializer
.read_binding(id) .read_binding_for_cleanup(id)
.map_err(|_| cause("changes_present"))?; .map_err(|_| cause("changes_present"))?;
validate_binding(materializer, id, &binding)?; validate_binding(materializer, id, &binding)?;
if !same_materialization(&saved.working_directory, &binding.working_directory) if !same_materialization(&saved.working_directory, &binding.working_directory)
@@ -1211,6 +1211,13 @@ pub(super) mod tests {
for update in ["access", "repository", "creation"] { for update in ["access", "repository", "creation"] {
let (_runtime, materializer, binding) = fixture(); let (_runtime, materializer, binding) = fixture();
let id = &binding.working_directory.id; 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); fail_once(&materializer, id);
let mut record = materializer.read_binding(id).unwrap(); let mut record = materializer.read_binding(id).unwrap();
match update { match update {
@@ -1218,8 +1225,8 @@ pub(super) mod tests {
// The same serialized fields changed by authorize_repository_access. // The same serialized fields changed by authorize_repository_access.
record.working_directory.evidence.operation_id = record.working_directory.evidence.operation_id =
Some("reattach-operation".into()); Some("reattach-operation".into());
record.working_directory.evidence.credential_revision = Some(2); record.working_directory.evidence.public_key_fingerprint = Some("key-2".into());
record.working_directory.evidence.host_trust_revision = Some(2); record.working_directory.evidence.host_key_fingerprint = Some("key-2".into());
} }
"repository" => { "repository" => {
record.working_directory.repository_id = "substitute".into(); record.working_directory.repository_id = "substitute".into();
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -68,13 +68,14 @@ commands and generic POSIX operations are not exposed. The adapter uses the type
an owner config API as a bypass. an owner config API as a bypass.
Client-managed PATH validators are compared against freshly resolved Host 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 canonical entrypoints and node state from that same observation. Backend resolves
the live grant/link and validates canonical changes/CAS under its execution lock. the live grant/link and validates canonical changes/CAS under its execution lock.
No operation accepts LLM-supplied validators or `expected_digest` fields. Returned No operation accepts LLM-supplied validators or `expected_digest` fields. Returned
post-call path validators are refreshed from the exact committed revision, not post-call path validators are refreshed from the exact committed content, not
substituted with root validators or later unrelated state. UI/Worker edits and substituted with root validators or later unrelated state. Content changes and
same-bytes replacement revisions reject stale calls. Old descriptors cannot 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. bypass read-only restrictions, detach, revocation or Workspace boundaries.
Bounds: depth 0..8, 256 observed nodes/changes, 512-byte relative paths, 256 KiB Bounds: depth 0..8, 256 observed nodes/changes, 512-byte relative paths, 256 KiB
+3 -3
View File
@@ -10,7 +10,7 @@ pub struct BackendJobExecutionBinding {
pub job_id: String, pub job_id: String,
pub attempt_id: String, pub attempt_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub input_revision: Option<String>, pub input_digest: Option<String>,
#[serde(default)] #[serde(default)]
pub subjektiv_consolidation: bool, pub subjektiv_consolidation: bool,
} }
@@ -19,10 +19,10 @@ impl BackendJobExecutionBinding {
pub fn validate(&self) -> Result<(), &'static str> { pub fn validate(&self) -> Result<(), &'static str> {
if [&self.job_id, &self.attempt_id] if [&self.job_id, &self.attempt_id]
.into_iter() .into_iter()
.chain(self.input_revision.iter()) .chain(self.input_digest.iter())
.any(|value| value.trim().is_empty() || value.chars().any(char::is_control)) .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(()) Ok(())
} }
+33 -31
View File
@@ -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 { pub fn snapshot_event(&self) -> Event {
self.snapshot_event_with_entry_subscription().0 self.snapshot_event_with_entry_subscription().0
} }
@@ -546,7 +552,7 @@ fn notification_coalesce_remaining<St: Store + Clone>(
fn prepare_pending_run<St: Store + Clone>( fn prepare_pending_run<St: Store + Clone>(
pending_submissions: &crate::worker::PendingSubmissionHandle<St>, pending_submissions: &crate::worker::PendingSubmissionHandle<St>,
notify_buffer: &NotifyBuffer, notify_buffer: &NotifyBuffer,
fence: Option<(u64, &str)>, fence: Option<&str>,
allow_notification_run: bool, allow_notification_run: bool,
) -> Result<Option<PendingRun>, crate::worker::PendingSubmissionError> { ) -> Result<Option<PendingRun>, crate::worker::PendingSubmissionError> {
Ok(match pending_submissions.prepare_next_activation(fence)? { Ok(match pending_submissions.prepare_next_activation(fence)? {
@@ -2619,24 +2625,8 @@ async fn controller_loop<C, St>(
pending: pending_submissions.snapshot(), pending: pending_submissions.snapshot(),
}); });
} }
Method::CancelPendingSubmission { Method::CancelPendingSubmission { submission_id } => {
submission_id, match pending_submissions.cancel(&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) {
Ok(pending_snapshot) => { Ok(pending_snapshot) => {
let _ = working_event_tx.send(Event::PendingSubmissionsChanged { let _ = working_event_tx.send(Event::PendingSubmissionsChanged {
pending: pending_snapshot, pending: pending_snapshot,
@@ -2650,10 +2640,22 @@ async fn controller_loop<C, St>(
} }
} }
} }
Method::ContinuePending { Method::ClearPendingSubmissions {
expected_revision, expected_submission_ids,
expected_head_id, } => 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 { if shared_state.catalog_status() != WorkerStatus::Idle {
let _ = working_event_tx.send(Event::Error { let _ = working_event_tx.send(Event::Error {
code: ErrorCode::InvalidRequest, code: ErrorCode::InvalidRequest,
@@ -2664,7 +2666,7 @@ async fn controller_loop<C, St>(
match prepare_pending_run( match prepare_pending_run(
&pending_submissions, &pending_submissions,
&notify_buffer, &notify_buffer,
Some((expected_revision, &expected_head_id)), Some(&expected_head_id),
true, true,
) { ) {
Ok(Some(next)) => pending = Some(next), Ok(Some(next)) => pending = Some(next),
@@ -3023,8 +3025,9 @@ async fn controller_loop<C, St>(
}, },
Method::Shutdown { command } => { Method::Shutdown { command } => {
// Shutdown ignores the state-revision fence but remains bound to the // Shutdown applies to this controller at admission time, not
// current execution generation and command payload identity. // 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) { if let Err(disposition) = validate_shutdown_command(command, &shared_state) {
acknowledge_command( acknowledge_command(
&working_event_tx, &working_event_tx,
@@ -3619,9 +3622,8 @@ where
} }
Some(Method::CancelPendingSubmission { Some(Method::CancelPendingSubmission {
submission_id, submission_id,
expected_revision,
}) => { }) => {
match pending_submissions.cancel(&submission_id, expected_revision) { match pending_submissions.cancel(&submission_id) {
Ok(pending) => { Ok(pending) => {
let _ = working_event_tx.send(Event::PendingSubmissionsChanged { pending }); let _ = working_event_tx.send(Event::PendingSubmissionsChanged { pending });
} }
@@ -3633,8 +3635,8 @@ where
} }
} }
} }
Some(Method::ClearPendingSubmissions { expected_revision }) => { Some(Method::ClearPendingSubmissions { expected_submission_ids }) => {
match pending_submissions.clear(expected_revision) { match pending_submissions.clear(&expected_submission_ids) {
Ok(pending) => { Ok(pending) => {
let _ = working_event_tx.send(Event::PendingSubmissionsChanged { 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"); assert_eq!(request.path, "/api/w/workspace-1/drive/root");
Ok(crate::worker::WorkspaceResponse { status: 200, body: serde_json::json!({ Ok(crate::worker::WorkspaceResponse { status: 200, body: serde_json::json!({
"entry": {"workspace_id": "workspace-1", "node_id": "1"}, "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", "size": null, "content_type": null, "updated_by": "test",
"updated_at": "2026-10-08T00:00:00Z", "updated_at": "2026-10-08T00:00:00Z",
"latest_url": "/api/w/workspace-1/drive/download?entry_workspace_id=workspace-1&id=1" "latest_url": "/api/w/workspace-1/drive/download?entry_workspace_id=workspace-1&id=1"
+1 -1
View File
@@ -756,7 +756,7 @@ mod tests {
workspace_id: Some("workspace".into()), workspace_id: Some("workspace".into()),
worker_id: "worker".into(), worker_id: "worker".into(),
session_id: "session".into(), session_id: "session".into(),
session_revision: 3,
run_id: Some("run".into()), run_id: Some("run".into()),
turn_index: Some(2), turn_index: Some(2),
call_id: None, call_id: None,
@@ -2,7 +2,7 @@
//! //!
//! The tool intentionally accepts only the result value. Workspace and Worker //! The tool intentionally accepts only the result value. Workspace and Worker
//! identity come from the authenticated Workspace client, while Job, attempt, //! 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 crate::feature::background::BackgroundTaskCancellation;
use std::collections::HashMap; use std::collections::HashMap;
@@ -22,7 +22,7 @@ use crate::feature::{
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod}; use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
const TOOL_NAME: &str = "SubmitBackendJobResult"; 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)] #[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -194,7 +194,7 @@ impl Tool for SubmitResultTool {
let sealed = submission.sealed.as_mut().expect("sealed before dispatch"); let sealed = submission.sealed.as_mut().expect("sealed before dispatch");
if definitive_rejection && !was_previously_sealed { if definitive_rejection && !was_previously_sealed {
// Only a definitively unaccepted request can be corrected. Reuse // 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 // a conflict invalidates even that snapshot. Failed outcomes are
// rebuilt, since another generation can replace their marker. // rebuilt, since another generation can replace their marker.
submission.reusable_surface = if response.status != 409 { submission.reusable_surface = if response.status != 409 {
+17 -16
View File
@@ -188,7 +188,8 @@ impl DriveFeature {
return Err(DriveError::Limit); return Err(DriveError::Limit);
} }
let read = self.backend.read(&args.entry, args.max_bytes).await?; 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); return Err(DriveError::Conflict);
} }
self.observe(read.entry.clone())?; self.observe(read.entry.clone())?;
@@ -260,7 +261,7 @@ impl DriveFeature {
request_id, request_id,
mutation: DriveMutation::UpdateText { mutation: DriveMutation::UpdateText {
id: entry.entry, id: entry.entry,
expected_revision: entry.revision, expected_mutation_id: entry.last_mutation_id,
text, text,
content_type: entry.content_type.unwrap_or_else(default_media), content_type: entry.content_type.unwrap_or_else(default_media),
}, },
@@ -281,7 +282,7 @@ impl DriveFeature {
.backend .backend
.read(&entry.entry, DRIVE_TEXT_MAX_BYTES as u32) .read(&entry.entry, DRIVE_TEXT_MAX_BYTES as u32)
.await?; .await?;
if read.entry.revision != entry.revision { if read.entry.last_mutation_id != entry.last_mutation_id {
return Err(DriveError::Conflict); return Err(DriveError::Conflict);
} }
if read.truncated { if read.truncated {
@@ -297,7 +298,7 @@ impl DriveFeature {
request_id, request_id,
mutation: DriveMutation::UpdateText { mutation: DriveMutation::UpdateText {
id: entry.entry, id: entry.entry,
expected_revision: entry.revision, expected_mutation_id: entry.last_mutation_id,
text, text,
content_type: entry.content_type.unwrap_or_else(default_media), content_type: entry.content_type.unwrap_or_else(default_media),
}, },
@@ -316,7 +317,7 @@ impl DriveFeature {
request_id, request_id,
mutation: DriveMutation::Relocate { mutation: DriveMutation::Relocate {
id: entry.entry, id: entry.entry,
expected_revision: entry.revision, expected_mutation_id: entry.last_mutation_id,
parent: args.parent, parent: args.parent,
name: args.name, name: args.name,
}, },
@@ -334,7 +335,7 @@ impl DriveFeature {
request_id, request_id,
mutation: DriveMutation::Delete { mutation: DriveMutation::Delete {
id: entry.entry, id: entry.entry,
expected_revision: entry.revision, expected_mutation_id: entry.last_mutation_id,
}, },
}) })
.await?, .await?,
@@ -385,7 +386,7 @@ impl DriveFeature {
parent_id: Some(args.parent.node_id), parent_id: Some(args.parent.node_id),
name: Some(args.name), name: Some(args.name),
id: None, id: None,
expected_revision: None, expected_mutation_id: None,
content_type: args.content_type, content_type: args.content_type,
size: bytes.len() as u32, size: bytes.len() as u32,
sha256: digest(&bytes), sha256: digest(&bytes),
@@ -399,7 +400,7 @@ impl DriveFeature {
} }
_ => return Err(DriveError::Invalid("unknown operation".into())), _ => 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. // into a prior observation. The caller observes explicitly next time.
if let Some(id) = mutated_id { if let Some(id) = mutated_id {
self.observations self.observations
@@ -640,13 +641,13 @@ pub(super) const SPECS: &[Spec] = &[
Spec { Spec {
name: "DriveMetadata", name: "DriveMetadata",
operation: "metadata", 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::<EntryArgs>, schema: schema::<EntryArgs>,
}, },
Spec { Spec {
name: "DriveList", name: "DriveList",
operation: "list", 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::<ListArgs>, schema: schema::<ListArgs>,
}, },
Spec { Spec {
@@ -658,7 +659,7 @@ pub(super) const SPECS: &[Spec] = &[
Spec { Spec {
name: "DriveRead", name: "DriveRead",
operation: "read", 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::<ReadArgs>, schema: schema::<ReadArgs>,
}, },
Spec { Spec {
@@ -676,31 +677,31 @@ pub(super) const SPECS: &[Spec] = &[
Spec { Spec {
name: "DriveWrite", name: "DriveWrite",
operation: "write", 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::<WriteArgs>, schema: schema::<WriteArgs>,
}, },
Spec { Spec {
name: "DriveEdit", name: "DriveEdit",
operation: "edit", 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::<EditArgs>, schema: schema::<EditArgs>,
}, },
Spec { Spec {
name: "DriveRelocate", name: "DriveRelocate",
operation: "relocate", 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::<RelocateArgs>, schema: schema::<RelocateArgs>,
}, },
Spec { Spec {
name: "DriveDelete", name: "DriveDelete",
operation: "delete", 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::<EntryArgs>, schema: schema::<EntryArgs>,
}, },
Spec { Spec {
name: "DriveViewImage", name: "DriveViewImage",
operation: "view_image", 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::<EntryArgs>, schema: schema::<EntryArgs>,
}, },
Spec { Spec {
@@ -22,7 +22,9 @@ pub enum DriveError {
Denied, Denied,
#[error("Drive resource not found")] #[error("Drive resource not found")]
NotFound, 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, Conflict,
#[error("Invalid Drive arguments: {0}")] #[error("Invalid Drive arguments: {0}")]
Invalid(String), Invalid(String),
@@ -54,7 +56,11 @@ impl DriveBackend {
if let Some(parent) = &entry.parent { if let Some(parent) = &entry.parent {
self.validate_ref(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); return Err(DriveError::Unavailable);
} }
Ok(()) Ok(())
@@ -238,14 +244,14 @@ impl DriveBackend {
return Err(DriveError::Limit); return Err(DriveError::Limit);
} }
let mut bytes = Vec::with_capacity(total as usize); 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 { loop {
let offset = bytes.len() as u32; let offset = bytes.len() as u32;
let length = total.saturating_sub(offset).clamp(1, DRIVE_CHUNK_MAX_BYTES); let length = total.saturating_sub(offset).clamp(1, DRIVE_CHUNK_MAX_BYTES);
let query = query_string(&DriveReadChunkQuery { let query = query_string(&DriveReadChunkQuery {
entry_workspace_id: entry.entry.workspace_id.clone(), entry_workspace_id: entry.entry.workspace_id.clone(),
id: entry.entry.node_id.clone(), id: entry.entry.node_id.clone(),
expected_revision: entry.revision.clone(), expected_mutation_id: entry.last_mutation_id.clone(),
offset, offset,
length, length,
})?; })?;
@@ -144,9 +144,8 @@ impl WorkspaceClient for InternalChildWorkspaceClient {
fn current_prompt_projection( fn current_prompt_projection(
&self, &self,
minimum_revision: Option<u64>,
) -> Result<Option<WorkspacePromptCatalogResolution>, WorkspaceClientError> { ) -> Result<Option<WorkspacePromptCatalogResolution>, WorkspaceClientError> {
self.inner.current_prompt_projection(minimum_revision) self.inner.current_prompt_projection()
} }
fn list_workspace_workers( fn list_workspace_workers(
@@ -181,7 +180,7 @@ mod tests {
enum Call { enum Call {
Json(WorkspaceRequest, Option<Duration>), Json(WorkspaceRequest, Option<Duration>),
Binary(WorkspaceBinaryRequest, Option<Duration>), Binary(WorkspaceBinaryRequest, Option<Duration>),
Projection(Option<u64>), Projection,
Discovery(WorkspaceWorkerDiscoveryRequest), Discovery(WorkspaceWorkerDiscoveryRequest),
Remove(String, String, String), Remove(String, String, String),
Server, Server,
@@ -250,9 +249,8 @@ mod tests {
} }
fn current_prompt_projection( fn current_prompt_projection(
&self, &self,
revision: Option<u64>,
) -> Result<Option<WorkspacePromptCatalogResolution>, WorkspaceClientError> { ) -> Result<Option<WorkspacePromptCatalogResolution>, WorkspaceClientError> {
self.calls.lock().unwrap().push(Call::Projection(revision)); self.calls.lock().unwrap().push(Call::Projection);
Err(WorkspaceClientError::Request("projection marker".into())) Err(WorkspaceClientError::Request("projection marker".into()))
} }
fn list_workspace_workers( fn list_workspace_workers(
@@ -390,7 +388,7 @@ mod tests {
expected_bytes expected_bytes
); );
assert!( 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 { let discovery = WorkspaceWorkerDiscoveryRequest {
cursor: Some("next".into()), cursor: Some("next".into()),
@@ -422,7 +420,7 @@ mod tests {
Call::Json(request, Some(timeout)), Call::Json(request, Some(timeout)),
Call::Binary(bytes.clone(), None), Call::Binary(bytes.clone(), None),
Call::Binary(bytes, Some(timeout)), Call::Binary(bytes, Some(timeout)),
Call::Projection(Some(42)), Call::Projection,
Call::Discovery(discovery), Call::Discovery(discovery),
Call::Remove("runtime".into(), "worker".into(), "reason".into()), Call::Remove("runtime".into(), "worker".into(), "reason".into()),
Call::Server, Call::Server,
@@ -44,7 +44,7 @@ fn response(value: impl Serialize) -> WorkspaceResponse {
body: serde_json::to_string(&value).unwrap(), body: serde_json::to_string(&value).unwrap(),
} }
} }
fn entry(id: &str, revision: &str) -> DriveEntry { fn entry(id: &str, last_mutation_id: &str) -> DriveEntry {
DriveEntry { DriveEntry {
entry: DriveEntryRef { entry: DriveEntryRef {
workspace_id: "workspace".into(), workspace_id: "workspace".into(),
@@ -56,7 +56,7 @@ fn entry(id: &str, revision: &str) -> DriveEntry {
}), }),
name: "note.md".into(), name: "note.md".into(),
kind: DriveEntryKind::File, kind: DriveEntryKind::File,
revision: revision.into(), last_mutation_id: last_mutation_id.into(),
size: Some(5), size: Some(5),
content_type: Some("text/markdown".into()), content_type: Some("text/markdown".into()),
updated_by: "worker".into(), updated_by: "worker".into(),
@@ -83,7 +83,7 @@ async fn execute(
} }
#[test] #[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 client = Client::new(Vec::new());
let router = Arc::new(WorkdirSessionRouter::new()); let router = Arc::new(WorkdirSessionRouter::new());
assert!(DriveFeature::configured(client.clone(), false, router.clone()).is_none()); 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(); let (meta, _) = definition();
assert_eq!(name, meta.name); assert_eq!(name, meta.name);
let schema = meta.input_schema.to_string(); 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!(!schema.contains("grant_id"), "{name}");
} }
assert_eq!(feature.descriptor().instructions.len(), 1); assert_eq!(feature.descriptor().instructions.len(), 1);
@@ -139,7 +139,7 @@ async fn foreign_or_unobserved_entry_cannot_dispatch_mutation() {
} }
#[tokio::test] #[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 old = entry("2", "1");
let current = entry("2", "2"); let current = entry("2", "2");
let client = Client::new(vec![response(DriveReadTextResponse { let client = Client::new(vec![response(DriveReadTextResponse {
@@ -157,7 +157,10 @@ async fn stale_edit_returns_conflict_without_upgrading_revision_or_dispatching_w
.await; .await;
assert!(matches!(result, Err(DriveError::Conflict))); assert!(matches!(result, Err(DriveError::Conflict)));
assert_eq!(client.requests.lock().unwrap().len(), 1); 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] #[tokio::test]
@@ -192,8 +195,8 @@ async fn shared_edit_rules_reject_ambiguous_and_truncated_preimages_without_muta
} }
#[tokio::test] #[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 old = entry("9007199254740993", "9007199254740993");
let updated = entry("9007199254740993", "9007199254740994"); let updated = entry("9007199254740993", "9007199254740994");
let client = Client::new(Vec::new()); let client = Client::new(Vec::new());
@@ -220,7 +223,7 @@ async fn successful_mutation_uses_observed_decimal_revision_and_requires_next_ex
.unwrap(); .unwrap();
let requests = client.requests.lock().unwrap(); let requests = client.requests.lock().unwrap();
let body: Value = serde_json::from_str(requests[0].body.as_ref().unwrap()).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"); assert_eq!(body["mutation"]["id"]["node_id"], "9007199254740993");
drop(requests); drop(requests);
assert!(feature.observed(&old.entry, None).is_err()); 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<DriveMutationResponse> { fn invalid_completions(id: &str) -> Vec<DriveMutationResponse> {
let mut invalid_entry = entry("2", "2"); let mut invalid_entry = entry("2", "2");
invalid_entry.revision = "not-a-revision".into(); invalid_entry.last_mutation_id = "\ninvalid-request-id".into();
vec![ vec![
DriveMutationResponse { DriveMutationResponse {
request_id: "another-request".into(), request_id: "another-request".into(),
@@ -340,7 +343,7 @@ async fn invalid_mutation_completion_recovers_from_original_receipt_without_rese
.await .await
.unwrap(); .unwrap();
assert_eq!(output.value["request_id"], id); 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(); let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 2); assert_eq!(requests.len(), 2);
assert_eq!( 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}"))); assert!(requests[1].path.ends_with(&format!("/requests/{id}")));
let body: Value = serde_json::from_str(requests[0].body.as_ref().unwrap()).unwrap(); let body: Value = serde_json::from_str(requests[0].body.as_ref().unwrap()).unwrap();
assert_eq!(body["request_id"], id); 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 !operations
.iter() .iter()
.flat_map(|op| &op.parameters) .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); assert_eq!(client.requests.lock().unwrap().len(), 1);
} }
@@ -556,7 +559,7 @@ fn chunk(bytes: Vec<u8>) -> crate::worker::WorkspaceBinaryResponse {
} }
} }
#[tokio::test] #[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 metadata = entry("2", "9007199254740993");
let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec(); let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
bytes.resize(DRIVE_CHUNK_MAX_BYTES as usize + 13, 42); 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(); let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 2); assert_eq!(requests.len(), 2);
for request in &*requests { 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_eq!(request.max_response_bytes, DRIVE_CHUNK_MAX_BYTES as usize);
} }
assert!(requests[0].path.split('&').any(|pair| pair == "offset=0")); 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] #[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 [ for next in [
crate::worker::WorkspaceBinaryResponse { crate::worker::WorkspaceBinaryResponse {
status: 409, status: 409,
@@ -618,12 +625,12 @@ async fn expired_revision_or_partial_chunk_aborts_image_without_rebinding_or_ret
assert!( assert!(
requests requests
.iter() .iter()
.all(|request| request.path.contains("expected_revision=3&")) .all(|request| request.path.contains("expected_mutation_id=3&"))
); );
assert_eq!( assert_eq!(
client.json.requests.lock().unwrap().len(), client.json.requests.lock().unwrap().len(),
1, 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( execute(
&feature, &feature,
"DriveRead", "DriveRead",
json!({"entry":metadata.entry,"expected_revision":"1"}) json!({"entry":metadata.entry,"expected_mutation_id":"1"})
) )
.await .await
.is_err() .is_err()
@@ -1,5 +1,5 @@
//! Native non-indexable Drive entries. Publication resolves just one ID through //! 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::{ use super::{
DriveFeature, SPECS, Spec, DriveFeature, SPECS, Spec,
backend::{DriveError, decimal}, backend::{DriveError, decimal},
@@ -101,7 +101,7 @@ impl WipSubtreeProvider for DriveProvider {
}; };
let descriptor = descriptor(&specs, root); let descriptor = descriptor(&specs, root);
let reference = contextual_reference(INTERFACE, path); 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 { let object = Object {
name: if root { name: if root {
"drive".into() "drive".into()
@@ -183,7 +183,7 @@ fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclarati
} }
} }
fn descriptor(specs: &[Spec], _root: bool) -> InterfaceDescriptor { 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)], "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)], "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)], "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)) .any(|key| !declaration.parameters.iter().any(|p| p.name == *key))
{ {
return Err(map_error(DriveError::Invalid( 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())) 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 // Attachments use the existing ToolOutput side channel and durable Engine
// capture, not transient request injection or URL-only success. // capture, not transient request injection or URL-only success.
let result = if matches!(operation, "write" | "edit" | "relocate") { 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() .as_str()
.ok_or_else(|| { .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 { } else {
WipOperationOutput::native(value) WipOperationOutput::native(value)
}; };
@@ -557,7 +557,7 @@ where
input, input,
cache_key: Some(format!( cache_key: Some(format!(
"flow:{}:{}", "flow:{}:{}",
attempt.instance_id, attempt.checked_state_revision attempt.instance_id, attempt.attempt_id
)), )),
max_turns: Some(12), max_turns: Some(12),
engine_configurator: None, engine_configurator: None,
@@ -653,9 +653,7 @@ mod tests {
FlowTransitionAttempt { FlowTransitionAttempt {
attempt_id: id.to_string(), attempt_id: id.to_string(),
instance_id: "instance-1".to_string(), instance_id: "instance-1".to_string(),
definition_revision: 1,
definition_digest: "sha256:test".to_string(), definition_digest: "sha256:test".to_string(),
checked_state_revision: 0,
from_state: StateId::new("work").unwrap(), from_state: StateId::new("work").unwrap(),
reason: "ready".to_string(), reason: "ready".to_string(),
transitions: vec![ transitions: vec![
@@ -711,7 +709,6 @@ mod tests {
selector: "workspace:test-flow".parse().unwrap(), selector: "workspace:test-flow".parse().unwrap(),
workspace_id: "workspace-1".to_string(), workspace_id: "workspace-1".to_string(),
flow_id: "flow-1".to_string(), flow_id: "flow-1".to_string(),
revision: 1,
content_digest: definition.content_digest.clone(), content_digest: definition.content_digest.clone(),
definition, definition,
}, },
@@ -419,7 +419,7 @@ impl WipOperationHandler for CollectionHandler {
let mut digest = Sha256::new(); let mut digest = Sha256::new();
digest.update(self.domain.route()); digest.update(self.domain.route());
if matches!(self.domain, Domain::Workdir | Domain::Attachment) { 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 // registry/ledger in the same snapshot as a bounded page. Never infer
// collection freshness from just the first page's items. // collection freshness from just the first page's items.
let path = if matches!(self.domain, Domain::Workdir) { let path = if matches!(self.domain, Domain::Workdir) {
@@ -431,13 +431,13 @@ impl WipOperationHandler for CollectionHandler {
.provider .provider
.request(path) .request(path)
.ok() .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([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()); return Some(digest.finalize().to_vec());
} }
@@ -533,9 +533,9 @@ impl WipOperationHandler for CollectionHandler {
"invalid bounded inventory page".into(), "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( return Err(WipOperationError::OutcomeUnknown(
"inventory page revision missing".into(), "inventory page digest missing".into(),
)); ));
} }
let mut items = Vec::new(); let mut items = Vec::new();
@@ -1162,7 +1162,7 @@ pub(crate) mod tests {
let cursor = query.get("cursor").copied(); let cursor = query.get("cursor").copied();
let mut all: Vec<_> = state.workdirs.iter().collect(); let mut all: Vec<_> = state.workdirs.iter().collect();
all.sort_by_key(|v| v["working_directory_id"].as_str().unwrap()); 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() .iter()
.map(|b| format!("{b:02x}")) .map(|b| format!("{b:02x}"))
.collect::<String>(); .collect::<String>();
@@ -1179,7 +1179,7 @@ pub(crate) mod tests {
} else { } else {
None None
}; };
json!({"items":items, "next_cursor":next, "revision":revision}) json!({"items":items, "next_cursor":next, "digest":digest})
} }
(WorkspaceRequestMethod::Get, "workers/self/workdir-attachments") => { (WorkspaceRequestMethod::Get, "workers/self/workdir-attachments") => {
let query: BTreeMap<_, _> = path let query: BTreeMap<_, _> = path
@@ -1191,12 +1191,12 @@ pub(crate) mod tests {
.collect(); .collect();
let mut all: Vec<_> = state.attachments.iter().collect(); let mut all: Vec<_> = state.attachments.iter().collect();
all.sort_by_key(|v| v["alias"].as_str().unwrap()); 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() .iter()
.map(|b| format!("{b:02x}")) .map(|b| format!("{b:02x}"))
.collect::<String>(); .collect::<String>();
if let Some(expected) = query.get("connection_id") { if let Some(expected) = query.get("connection_id") {
json!({"items":all.into_iter().filter(|a| a["connection_id"] == *expected).collect::<Vec<_>>(), "next_offset":null, "revision":revision}) json!({"items":all.into_iter().filter(|a| a["connection_id"] == *expected).collect::<Vec<_>>(), "next_offset":null, "digest":digest})
} else { } else {
let limit = query let limit = query
.get("limit") .get("limit")
@@ -1209,7 +1209,7 @@ pub(crate) mod tests {
let count = all.len(); let count = all.len();
let items: Vec<_> = all.into_iter().skip(offset).take(limit).collect(); let items: Vec<_> = all.into_iter().skip(offset).take(limit).collect();
let next = (offset + limit < count).then_some(offset + limit); 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/") => { (WorkspaceRequestMethod::Get, p) if p.starts_with("repositories/") => {
+100 -58
View File
@@ -5,7 +5,6 @@
//! workspace backend instead of resolving `.yoi/memory` from a Worker workdir. //! workspace backend instead of resolving `.yoi/memory` from a Worker workdir.
use crate::subjektiv::{SubjektivHostConnection, SubjektivHostError}; use crate::subjektiv::{SubjektivHostConnection, SubjektivHostError};
use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
#[cfg(test)] #[cfg(test)]
use std::time::Duration; use std::time::Duration;
@@ -335,21 +334,21 @@ fn subjektiv_consolidation_tools(host: SubjektivHostConnection) -> Vec<ToolDefin
[ [
( (
"SubjektivMemoryQuery", "SubjektivMemoryQuery",
"Search current Memory revisions for the delegated subject.", "Search current Memory changes for the delegated subject.",
schema_for::<server_api::SubjektivMemoryQueryRequest>(), schema_for::<server_api::SubjektivMemoryQueryRequest>(),
Operation::Query, Operation::Query,
), ),
( (
"SubjektivMemoryRead", "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::<server_api::SubjektivMemoryReadRequest>(), schema_for::<server_api::SubjektivMemoryReadRequest>(),
Operation::Read, Operation::Read,
), ),
( (
"SubjektivMemoryListRevisions", "SubjektivMemoryListChanges",
"List immutable revisions of one Memory for the delegated subject.", "List immutable changes of one Memory for the delegated subject.",
schema_for::<server_api::SubjektivMemoryListRevisionsRequest>(), schema_for::<server_api::SubjektivMemoryListChangesRequest>(),
Operation::ListRevisions, Operation::ListChanges,
), ),
( (
"MemoryStagingList", "MemoryStagingList",
@@ -359,13 +358,13 @@ fn subjektiv_consolidation_tools(host: SubjektivHostConnection) -> Vec<ToolDefin
), ),
( (
"MemoryStagingRead", "MemoryStagingRead",
"Read one pending candidate, including typed revision proposal metadata and provenance.", "Read one pending candidate, including typed change proposal metadata and provenance.",
schema_for::<server_api::SubjektivMemoryCandidateReadRequest>(), schema_for::<server_api::SubjektivMemoryCandidateReadRequest>(),
Operation::ReadCandidate, Operation::ReadCandidate,
), ),
( (
"MemoryApplyCandidate", "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::<server_api::SubjektivMemoryCandidateDecisionRequest>(), schema_for::<server_api::SubjektivMemoryCandidateDecisionRequest>(),
Operation::DecideCandidate, Operation::DecideCandidate,
), ),
@@ -392,7 +391,7 @@ fn subjektiv_consolidation_tools(host: SubjektivHostConnection) -> Vec<ToolDefin
enum SubjectConsolidationOperation { enum SubjectConsolidationOperation {
Query, Query,
Read, Read,
ListRevisions, ListChanges,
ListCandidates, ListCandidates,
ReadCandidate, ReadCandidate,
DecideCandidate, DecideCandidate,
@@ -418,8 +417,8 @@ impl Tool for SubjectConsolidationTool {
Operation::Read => { Operation::Read => {
server_api::SubjektivMemoryBackendOperation::Read(parse_input(input_json)?) server_api::SubjektivMemoryBackendOperation::Read(parse_input(input_json)?)
} }
Operation::ListRevisions => { Operation::ListChanges => {
server_api::SubjektivMemoryBackendOperation::ListRevisions(parse_input(input_json)?) server_api::SubjektivMemoryBackendOperation::ListChanges(parse_input(input_json)?)
} }
Operation::ListCandidates => { Operation::ListCandidates => {
server_api::SubjektivMemoryBackendOperation::ListCandidates(parse_input( server_api::SubjektivMemoryBackendOperation::ListCandidates(parse_input(
@@ -452,12 +451,12 @@ impl Tool for SubjectConsolidationTool {
} }
server_api::SubjektivMemoryBackendResponse::Read(value) => { server_api::SubjektivMemoryBackendResponse::Read(value) => {
format!( format!(
"Read Memory {} revision {}.", "Read Memory {} change {}.",
value.memory_id, value.revision value.memory_id, value.change_id
) )
} }
server_api::SubjektivMemoryBackendResponse::ListRevisions(value) => { server_api::SubjektivMemoryBackendResponse::ListChanges(value) => {
format!("Listed {} revision(s).", value.items.len()) format!("Listed {} change(s).", value.items.len())
} }
server_api::SubjektivMemoryBackendResponse::Candidates(value) => { server_api::SubjektivMemoryBackendResponse::Candidates(value) => {
format!("Listed {} pending candidate(s).", value.items.len()) format!("Listed {} pending candidate(s).", value.items.len())
@@ -640,8 +639,7 @@ struct WorkspaceResidentSummarySource {
#[derive(Default)] #[derive(Default)]
struct SubjectBehaviorRefreshState { struct SubjectBehaviorRefreshState {
applied: Option<(u64, String)>, applied: Option<String>,
pending: HashMap<u64, String>,
} }
struct WorkspaceSubjektivResidentSummarySource { struct WorkspaceSubjektivResidentSummarySource {
@@ -727,8 +725,7 @@ impl WorkspaceSubjektivResidentSummarySource {
.behavior_state .behavior_state
.lock() .lock()
.expect("subject behavior state poisoned"); .expect("subject behavior state poisoned");
state.applied = Some((output.behavior_revision, output.behavior_md.clone())); state.applied = Some(output.behavior_md.clone());
state.pending.remove(&output.behavior_revision);
} }
} }
@@ -769,33 +766,22 @@ impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource
.expect("subject behavior state poisoned") .expect("subject behavior state poisoned")
.applied .applied
.as_ref() .as_ref()
.is_none_or(|(revision, body)| { .is_none_or(|body| body != &output.behavior_md);
*revision != output.behavior_revision || body != &output.behavior_md
});
if !changed { if !changed {
return Ok(None); return Ok(None);
} }
let rendered = self.render(&output)?; 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 { Ok(Some(ResidentContextRefresh {
body: rendered, body: rendered,
revision, behavior_md: output.behavior_md,
})) }))
} }
fn confirm_resident_context_revision(&self, revision: u64) { fn confirm_resident_context_behavior(&self, behavior_md: &str) {
let mut state = self self.behavior_state
.behavior_state
.lock() .lock()
.expect("subject behavior state poisoned"); .expect("subject behavior state poisoned")
if let Some(body) = state.pending.remove(&revision) { .applied = Some(behavior_md.to_owned());
state.applied = Some((revision, body));
}
} }
fn invalidate_resident_context_representation(&self) { fn invalidate_resident_context_representation(&self) {
@@ -942,7 +928,7 @@ impl Hook<PreLlmRequest> for SubjektivBehaviorRefreshHook {
"Subject behavior refresh requires durable session append authority", "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) Ok(HookPreRequestAction::Continue)
} }
@@ -971,7 +957,7 @@ impl Hook<WorkerRestored> for SubjektivResidentRestoreRefreshHook {
body, body,
session_store::PromptRenderProvenance { session_store::PromptRenderProvenance {
workspace_id: self.workspace_id.clone(), workspace_id: self.workspace_id.clone(),
config_revision: projection.config_revision,
source_digest: projection.source_digest.clone(), source_digest: projection.source_digest.clone(),
projection_digest: projection.catalog_digest.clone(), projection_digest: projection.catalog_digest.clone(),
logical_name: WorkerPrompt::ResidentMemoryRestoreSection.key().to_string(), logical_name: WorkerPrompt::ResidentMemoryRestoreSection.key().to_string(),
@@ -1270,7 +1256,7 @@ mod tests {
availability: memory::backend::MemoryResidentSummaryAvailability, availability: memory::backend::MemoryResidentSummaryAvailability,
content: Option<String>, content: Option<String>,
scope_allowed: bool, scope_allowed: bool,
behavior: Mutex<(u64, String)>, behavior: Mutex<String>,
paths: Mutex<Vec<String>>, paths: Mutex<Vec<String>>,
timeouts: Mutex<Vec<Duration>>, timeouts: Mutex<Vec<Duration>>,
} }
@@ -1284,7 +1270,7 @@ mod tests {
availability, availability,
content: content.map(str::to_string), content: content.map(str::to_string),
scope_allowed: true, scope_allowed: true,
behavior: Mutex::new((3, "Be deliberate.".to_string())), behavior: Mutex::new("Be deliberate.".to_string()),
paths: Mutex::new(Vec::new()), paths: Mutex::new(Vec::new()),
timeouts: Mutex::new(Vec::new()), timeouts: Mutex::new(Vec::new()),
} }
@@ -1295,7 +1281,7 @@ mod tests {
availability: memory::backend::MemoryResidentSummaryAvailability::Ready, availability: memory::backend::MemoryResidentSummaryAvailability::Ready,
content: Some("must not be injected".into()), content: Some("must not be injected".into()),
scope_allowed: false, 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()), paths: Mutex::new(Vec::new()),
timeouts: Mutex::new(Vec::new()), timeouts: Mutex::new(Vec::new()),
} }
@@ -1333,14 +1319,13 @@ mod tests {
body: "subject scope unavailable".into(), 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 { Ok(WorkspaceResponse {
status: 200, status: 200,
body: serde_json::to_string( body: serde_json::to_string(
&server_api::SubjektivMemoryBackendResponse::ResidentContext( &server_api::SubjektivMemoryBackendResponse::ResidentContext(
server_api::SubjektivResidentContextOutput { server_api::SubjektivResidentContextOutput {
behavior_md, behavior_md,
behavior_revision,
memory_surface: memory::backend::MemoryResidentSummaryOutput { memory_surface: memory::backend::MemoryResidentSummaryOutput {
availability: self.availability, availability: self.availability,
content: self.content.clone(), content: self.content.clone(),
@@ -1483,7 +1468,7 @@ mod tests {
enabled enabled
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace".to_string(), workspace_id: "workspace".to_string(),
settings_revision: 1,
language: "English".to_string(), language: "English".to_string(),
}) })
.unwrap(); .unwrap();
@@ -1543,7 +1528,7 @@ mod tests {
.subjektiv .subjektiv
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace".into(), workspace_id: "workspace".into(),
settings_revision: 1,
language: "English".into(), language: "English".into(),
}) })
.unwrap(); .unwrap();
@@ -1586,7 +1571,7 @@ mod tests {
config config
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace".to_string(), workspace_id: "workspace".to_string(),
settings_revision: 1,
language: "English".to_string(), language: "English".to_string(),
}) })
.unwrap(); .unwrap();
@@ -1643,7 +1628,7 @@ permission = "write"
.subjektiv .subjektiv
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: workspace_id.to_string(), workspace_id: workspace_id.to_string(),
settings_revision: 1,
language: "English".to_string(), language: "English".to_string(),
}) })
.unwrap(); .unwrap();
@@ -1712,7 +1697,7 @@ permission = "write"
} }
#[tokio::test] #[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 manifest = subject_manifest("workspace");
let client = Arc::new(SubjectResidentClient::new( let client = Arc::new(SubjectResidentClient::new(
memory::backend::MemoryResidentSummaryAvailability::Ungenerated, memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
@@ -1732,15 +1717,15 @@ permission = "write"
.is_none() .is_none()
); );
*client.behavior.lock().unwrap() = (4, "Ask when uncertain.".to_string()); *client.behavior.lock().unwrap() = "Ask when uncertain.".to_string();
let refresh = source let refresh = source
.load_changed_resident_context() .load_changed_resident_context()
.await .await
.unwrap() .unwrap()
.expect("changed behavior must produce one refresh"); .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.")); assert!(refresh.body.contains("Ask when uncertain."));
source.confirm_resident_context_revision(refresh.revision); source.confirm_resident_context_behavior(&refresh.behavior_md);
assert!( assert!(
source source
.load_changed_resident_context() .load_changed_resident_context()
@@ -1749,15 +1734,15 @@ permission = "write"
.is_none() .is_none()
); );
*client.behavior.lock().unwrap() = (5, String::new()); *client.behavior.lock().unwrap() = String::new();
let cleared = source let cleared = source
.load_changed_resident_context() .load_changed_resident_context()
.await .await
.unwrap() .unwrap()
.expect("cleared behavior must produce one refresh"); .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")); 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!( assert!(
source source
.load_changed_resident_context() .load_changed_resident_context()
@@ -1772,7 +1757,7 @@ permission = "write"
.await .await
.unwrap() .unwrap()
.expect("history rewrite must require a durable current representation"); .expect("history rewrite must require a durable current representation");
assert_eq!(replay_after_rewrite.revision, 5); assert_eq!(replay_after_rewrite.behavior_md, "");
assert!( assert!(
replay_after_rewrite replay_after_rewrite
.body .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] #[tokio::test]
async fn subject_resident_source_fails_closed_without_trusted_subject_scope() { async fn subject_resident_source_fails_closed_without_trusted_subject_scope() {
let manifest = subject_manifest("workspace"); let manifest = subject_manifest("workspace");
@@ -2001,14 +2043,14 @@ permission = "write"
"MemoryApplyCandidate", "MemoryApplyCandidate",
"MemoryStagingList", "MemoryStagingList",
"MemoryStagingRead", "MemoryStagingRead",
"SubjektivMemoryListRevisions", "SubjektivMemoryListChanges",
"SubjektivMemoryQuery", "SubjektivMemoryQuery",
"SubjektivMemoryRead", "SubjektivMemoryRead",
] ]
); );
let decision = tool_meta(tools, "MemoryApplyCandidate").to_string(); let decision = tool_meta(tools, "MemoryApplyCandidate").to_string();
assert!(decision.contains("request_id")); assert!(decision.contains("request_id"));
assert!(decision.contains("expected_revision")); assert!(decision.contains("expected_change_id"));
assert!(decision.contains("already_covered")); assert!(decision.contains("already_covered"));
assert!(!decision.contains("subject_id")); assert!(!decision.contains("subject_id"));
assert!(!decision.contains("workspace_id")); assert!(!decision.contains("workspace_id"));
@@ -972,7 +972,7 @@ impl WorkerAuditBase {
) -> Self { ) -> Self {
self.memory_settings = settings.map(|snapshot| memory::audit::MemorySettingsAudit { self.memory_settings = settings.map(|snapshot| memory::audit::MemorySettingsAudit {
workspace_id: snapshot.workspace_id.clone(), workspace_id: snapshot.workspace_id.clone(),
settings_revision: snapshot.settings_revision,
language: snapshot.language.clone(), language: snapshot.language.clone(),
}); });
self self
@@ -1292,7 +1292,7 @@ permission = "write"
config config
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".to_string(), workspace_id: "workspace-1".to_string(),
settings_revision: 1,
language: "English".to_string(), language: "English".to_string(),
}) })
.unwrap(); .unwrap();
@@ -1365,7 +1365,7 @@ permission = "write"
.start_run_committed(HookInvocationContext { .start_run_committed(HookInvocationContext {
worker_id: "worker-1".to_string(), worker_id: "worker-1".to_string(),
session_id: "session-1".to_string(), session_id: "session-1".to_string(),
session_revision: 1,
run_id: Some("run-1".to_string()), run_id: Some("run-1".to_string()),
..Default::default() ..Default::default()
}) })
@@ -1456,7 +1456,7 @@ permission = "write"
config config
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".to_string(), workspace_id: "workspace-1".to_string(),
settings_revision: 1,
language: "English".to_string(), language: "English".to_string(),
}) })
.unwrap(); .unwrap();
@@ -2002,7 +2002,7 @@ permission = "write"
CommittedSessionCapture { CommittedSessionCapture {
session_id: "session-1".to_string(), session_id: "session-1".to_string(),
segment_id: "segment-1".to_string(), segment_id: "segment-1".to_string(),
session_revision: history_len.try_into().unwrap(),
entry_count: history_len, entry_count: history_len,
run_exit: CommittedRunExit::Finished, run_exit: CommittedRunExit::Finished,
history: (0..history_len) history: (0..history_len)
@@ -386,7 +386,7 @@ fn evidence_origin(origin: &protocol::SessionEntryProvenance) -> EvidenceOrigin
worker_id: None, worker_id: None,
flow_selector: None, flow_selector: None,
flow_definition_id: None, flow_definition_id: None,
flow_definition_revision: None, flow_definition_fingerprint: None,
} }
} }
@@ -161,18 +161,23 @@ impl SubjektivSurfaceLifecycleFeature {
impl FeatureModule for SubjektivSurfaceLifecycleFeature { impl FeatureModule for SubjektivSurfaceLifecycleFeature {
fn descriptor(&self) -> FeatureDescriptor { fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin("subjektiv-memory-surface-lifecycle", "subjektiv Memory Surface Lifecycle") FeatureDescriptor::builtin(
.with_description("Rebuilds one bounded subject Memory surface after each committed consolidation run.") "subjektiv-memory-surface-lifecycle",
.with_background_task(BackgroundTaskDeclaration::worker_managed( "subjektiv Memory Surface Lifecycle",
TASK_NAME, )
"Generate and publish a current subject Memory surface from bounded confirmed revisions.", .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> { fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
let declaration = BackgroundTaskDeclaration::worker_managed( let declaration = BackgroundTaskDeclaration::worker_managed(
TASK_NAME, 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); let mut spec = BackgroundTaskSpec::single_flight(declaration, TASK_TIMEOUT);
spec.trigger = BackgroundTaskTrigger::RunCommitted; spec.trigger = BackgroundTaskTrigger::RunCommitted;
@@ -240,16 +245,17 @@ impl SubjektivSurfaceLifecycleTask {
if let Some(snapshot_id) = &generation.current_snapshot_id { if let Some(snapshot_id) = &generation.current_snapshot_id {
return Ok(serde_json::json!({ return Ok(serde_json::json!({
"availability": "ready", "generation_id": generation.generation_id, "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. // 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. // reuse a failed marker, which a later generation can replace.
if let Some(surface) = reusable_surface.filter(|surface| { if let Some(surface) = reusable_surface.filter(|surface| {
surface["availability"] == "ready" 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()); return Ok(surface.clone());
} }
@@ -307,7 +313,7 @@ impl SubjektivSurfaceLifecycleTask {
Ok(output) => { Ok(output) => {
return Ok(serde_json::json!({ return Ok(serde_json::json!({
"availability": "ready", "generation_id": generation.generation_id, "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 { .. }) Err(SubjektivHostError::Conflict { .. })
@@ -418,23 +424,24 @@ impl SubjektivSurfaceLifecycleTask {
.await .await
.map_err(surface_hook_error)?; .map_err(surface_hook_error)?;
// Success must attest the actual persisted marker, not merely receipt of // 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. // must yield a Backend rejection/non-failed status, never a failed claim.
let confirmed_status = if job_confirmation_required { let confirmed_status = if job_confirmation_required {
"failed_confirmed" "failed_confirmed"
} else { } else {
"failed" "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( return Err(HookError::new(
HookErrorCategory::Internal, 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!({ Ok(serde_json::json!({
"availability": "failed", "generation_id": generation.generation_id, "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 {
server_api::SubjektivSurfaceMaterial { server_api::SubjektivSurfaceMaterial {
memory_id: id.into(), memory_id: id.into(),
revision: 1, change_id: "change-1".into(),
kind, kind,
body_md, body_md,
why_useful: "budget fixture".into(), why_useful: "budget fixture".into(),
@@ -619,7 +626,7 @@ mod tests {
server_api::SubjektivSurfacePrepareResponse { server_api::SubjektivSurfacePrepareResponse {
generation_id: "generation-1".into(), generation_id: "generation-1".into(),
current_snapshot_id: None, current_snapshot_id: None,
store_revision: 1, memory_fingerprint: "memory-set-1".into(),
active_memory_count: materials.len(), active_memory_count: materials.len(),
materials, materials,
body_token_budget: 1_024, body_token_budget: 1_024,
@@ -646,9 +653,9 @@ mod tests {
job_results: Mutex<Vec<serde_json::Value>>, job_results: Mutex<Vec<serde_json::Value>>,
result_responses: result_responses:
Mutex<std::collections::VecDeque<Result<WorkspaceResponse, WorkspaceClientError>>>, Mutex<std::collections::VecDeque<Result<WorkspaceResponse, WorkspaceClientError>>>,
current_revision: AtomicUsize, current_fingerprint: Mutex<Option<String>>,
failure_status: Mutex<String>, failure_status: Mutex<String>,
failure_revision: AtomicUsize, failure_fingerprint: Mutex<Option<String>>,
ready_snapshot: Mutex<Option<String>>, ready_snapshot: Mutex<Option<String>>,
} }
@@ -662,9 +669,9 @@ mod tests {
failure_reasons: Mutex::new(Vec::new()), failure_reasons: Mutex::new(Vec::new()),
job_results: Mutex::new(Vec::new()), job_results: Mutex::new(Vec::new()),
result_responses: Mutex::new(Default::default()), result_responses: Mutex::new(Default::default()),
current_revision: AtomicUsize::new(0), current_fingerprint: Mutex::new(None),
failure_status: Mutex::new("failed".into()), failure_status: Mutex::new("failed".into()),
failure_revision: AtomicUsize::new(0), failure_fingerprint: Mutex::new(None),
ready_snapshot: Mutex::new(None), ready_snapshot: Mutex::new(None),
} }
} }
@@ -676,13 +683,12 @@ mod tests {
} }
} }
fn revision(&self, fallback: usize) -> u64 { fn fingerprint(&self, fallback: usize) -> String {
let current = self.current_revision.load(Ordering::SeqCst); self.current_fingerprint
if current == 0 { .lock()
fallback as u64 .unwrap()
} else { .clone()
current as u64 .unwrap_or_else(|| format!("memory-set-{fallback}"))
}
} }
fn response(value: server_api::SubjektivMemoryBackendResponse) -> WorkspaceResponse { fn response(value: server_api::SubjektivMemoryBackendResponse) -> WorkspaceResponse {
@@ -741,7 +747,7 @@ mod tests {
let mut prepared = let mut prepared =
generation(self.input_token_budget, vec![material("memory-1", 32)]); generation(self.input_token_budget, vec![material("memory-1", 32)]);
prepared.generation_id = format!("generation-{call}"); 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(); prepared.current_snapshot_id = self.ready_snapshot.lock().unwrap().clone();
Ok(Self::response( Ok(Self::response(
server_api::SubjektivMemoryBackendResponse::SurfacePrepared(prepared), server_api::SubjektivMemoryBackendResponse::SurfacePrepared(prepared),
@@ -760,7 +766,7 @@ mod tests {
server_api::SubjektivMemoryBackendResponse::SurfacePublished( server_api::SubjektivMemoryBackendResponse::SurfacePublished(
server_api::SubjektivSurfacePublishResponse { server_api::SubjektivSurfacePublishResponse {
snapshot_id: format!("surface-{call}"), snapshot_id: format!("surface-{call}"),
built_from_store_revision: self.revision(call), built_from_memory_fingerprint: self.fingerprint(call),
empty: false, empty: false,
}, },
), ),
@@ -771,12 +777,14 @@ mod tests {
Ok(Self::response( Ok(Self::response(
server_api::SubjektivMemoryBackendResponse::SurfaceFailed( server_api::SubjektivMemoryBackendResponse::SurfaceFailed(
server_api::SubjektivSurfaceFailureResponse { server_api::SubjektivSurfaceFailureResponse {
store_revision: if self.failure_revision.load(Ordering::SeqCst) > 0 memory_fingerprint: self
{ .failure_fingerprint
self.failure_revision.load(Ordering::SeqCst) as u64 .lock()
} else { .unwrap()
self.revision(self.prepare_calls.load(Ordering::SeqCst)) .clone()
}, .unwrap_or_else(|| {
self.fingerprint(self.prepare_calls.load(Ordering::SeqCst))
}),
status: self.failure_status.lock().unwrap().clone(), status: self.failure_status.lock().unwrap().clone(),
}, },
), ),
@@ -814,7 +822,7 @@ mod tests {
serde_json::json!({ serde_json::json!({
"points": [{ "points": [{
"body_md": "- Keep the confirmed constraint.", "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(), .to_string(),
@@ -863,7 +871,7 @@ permission = "write"
.subjektiv .subjektiv
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".into(), workspace_id: "workspace-1".into(),
settings_revision: 1,
language: "English".into(), language: "English".into(),
}) })
.unwrap(); .unwrap();
@@ -899,7 +907,7 @@ permission = "write"
.start_run_committed(HookInvocationContext { .start_run_committed(HookInvocationContext {
worker_id: "worker-1".into(), worker_id: "worker-1".into(),
session_id: "session-1".into(), session_id: "session-1".into(),
session_revision: 1,
run_id: Some("run-1".into()), run_id: Some("run-1".into()),
..Default::default() ..Default::default()
}) })
@@ -1146,7 +1154,10 @@ permission = "write"
results[0]["result"]["surface"]["generation_id"], results[0]["result"]["surface"]["generation_id"],
"generation-1" "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_eq!(workspace.prepare_calls.load(Ordering::SeqCst), 1);
assert!( assert!(
tool.execute( tool.execute(
@@ -1289,7 +1300,7 @@ permission = "write"
async fn job_result_definitive_rejection_allows_correction_and_reuses_current_ready_surface() { async fn job_result_definitive_rejection_allows_correction_and_reuses_current_ready_surface() {
for status in [400, 403, 422] { for status in [400, 403, 422] {
let workspace = Arc::new(SurfaceWorkspaceClient::new(0)); 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 workspace
.result_responses .result_responses
.lock() .lock()
@@ -1317,7 +1328,7 @@ permission = "write"
assert_eq!( assert_eq!(
workspace.prepare_calls.load(Ordering::SeqCst), workspace.prepare_calls.load(Ordering::SeqCst),
2, 2,
"reuse must verify current revision" "reuse must verify current Memory fingerprint"
); );
assert_eq!( assert_eq!(
workspace.publish_calls.load(Ordering::SeqCst), workspace.publish_calls.load(Ordering::SeqCst),
@@ -1338,7 +1349,7 @@ permission = "write"
async fn job_result_rejection_rebuilds_conflicting_or_stale_surfaces() { async fn job_result_rejection_rebuilds_conflicting_or_stale_surfaces() {
for status in [400, 409] { for status in [400, 409] {
let workspace = Arc::new(SurfaceWorkspaceClient::new(0)); 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 workspace
.result_responses .result_responses
.lock() .lock()
@@ -1350,14 +1361,20 @@ permission = "write"
.await .await
.is_err() .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()) tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
.await .await
.unwrap(); .unwrap();
let results = workspace.job_results.lock().unwrap(); let results = workspace.job_results.lock().unwrap();
assert_eq!(results.len(), 2); assert_eq!(results.len(), 2);
assert_eq!(results[0]["result"]["surface"]["store_revision"], 1); assert_eq!(
assert_eq!(results[1]["result"]["surface"]["store_revision"], 2); results[0]["result"]["surface"]["memory_fingerprint"],
"memory-set-1"
);
assert_eq!(
results[1]["result"]["surface"]["memory_fingerprint"],
"memory-set-2"
);
assert_ne!( assert_ne!(
results[0]["result"]["surface"]["generation_id"], results[0]["result"]["surface"]["generation_id"],
results[1]["result"]["surface"]["generation_id"] results[1]["result"]["surface"]["generation_id"]
@@ -1415,18 +1432,18 @@ permission = "write"
} }
#[tokio::test] #[tokio::test]
async fn job_result_never_submits_unconfirmed_failure_or_wrong_revision() { async fn job_result_never_submits_unconfirmed_failure_or_wrong_fingerprint() {
for (status, revision) in [ for (status, fingerprint) in [
("failed", 0), ("failed", "memory-set-1"),
("ready", 0), ("ready", "memory-set-1"),
("stale", 0), ("stale", "memory-set-1"),
("failed_confirmed", 2), ("failed_confirmed", "memory-set-2"),
] { ] {
let workspace = Arc::new(SurfaceWorkspaceClient::with_input_token_budget(1)); 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()); let tool = job_result_tool(workspace.clone());
*workspace.failure_status.lock().unwrap() = status.into(); *workspace.failure_status.lock().unwrap() = status.into();
workspace.failure_revision.store(revision, Ordering::SeqCst); *workspace.failure_fingerprint.lock().unwrap() = Some(fingerprint.into());
assert!( assert!(
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct()) tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
.await .await
@@ -1435,7 +1452,7 @@ permission = "write"
assert!(workspace.job_results.lock().unwrap().is_empty()); assert!(workspace.job_results.lock().unwrap().is_empty());
// Nothing was dispatched/sealed: a real confirmed outcome remains retryable. // Nothing was dispatched/sealed: a real confirmed outcome remains retryable.
*workspace.failure_status.lock().unwrap() = "failed_confirmed".into(); *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()) tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
.await .await
.unwrap(); .unwrap();
@@ -64,7 +64,7 @@ impl MemorySurfaceOutputState {
let allowed = self let allowed = self
.materials .materials
.iter() .iter()
.map(|material| (material.memory_id.as_str(), material.revision)) .map(|material| (material.memory_id.as_str(), material.change_id.as_str()))
.collect::<HashSet<_>>(); .collect::<HashSet<_>>();
let mut bodies = Vec::with_capacity(points.len()); let mut bodies = Vec::with_capacity(points.len());
for point in points { for point in points {
@@ -81,16 +81,20 @@ impl MemorySurfaceOutputState {
} }
let mut point_refs = HashSet::new(); let mut point_refs = HashSet::new();
for reference in &point.memory_refs { 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!( return Err(ToolError::InvalidArgument(format!(
"duplicate surface point ref {}@{}", "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!( return Err(ToolError::InvalidArgument(format!(
"surface ref {}@{} is not in the generation materials", "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 { fn material(id: &str) -> server_api::SubjektivSurfaceMaterial {
server_api::SubjektivSurfaceMaterial { server_api::SubjektivSurfaceMaterial {
memory_id: id.into(), memory_id: id.into(),
revision: 1, change_id: "change-1".into(),
kind: CandidateKind::Constraint, kind: CandidateKind::Constraint,
body_md: "Keep the boundary.".into(), body_md: "Keep the boundary.".into(),
why_useful: "Avoid regression.".into(), why_useful: "Avoid regression.".into(),
@@ -229,9 +233,9 @@ mod tests {
fn point(id: &str) -> server_api::SubjektivSurfacePoint { fn point(id: &str) -> server_api::SubjektivSurfacePoint {
server_api::SubjektivSurfacePoint { server_api::SubjektivSurfacePoint {
body_md: "- Keep the boundary unless it changes.".into(), 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(), memory_id: id.into(),
revision: 1, change_id: "change-1".into(),
}], }],
} }
} }
@@ -509,12 +509,11 @@ fn native_merge_request_tools(
} }
#[derive(Default)] #[derive(Default)]
struct MergeRequestRevisionState { struct MergeRequestObservationState {
revisions: HashMap<String, String>, content_digests: HashMap<String, String>,
mutation_sequence: u64,
} }
type MergeRequestRevisions = Arc<Mutex<MergeRequestRevisionState>>; type MergeRequestObservations = Arc<Mutex<MergeRequestObservationState>>;
/// Mount only the Merge Request operations enabled for this Worker as a native /// Mount only the Merge Request operations enabled for this Worker as a native
/// collection plus route-bound item objects. The ordinary Tool definitions remain /// 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) .filter(|tool| tool.projection.surface == NativeMergeRequestSurface::Item)
.cloned() .cloned()
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let revisions = Arc::new(Mutex::new(MergeRequestRevisionState::default())); let observations = Arc::new(Mutex::new(MergeRequestObservationState::default()));
let collection_descriptor = merge_request_descriptor( let collection_descriptor = merge_request_descriptor(
&collection_tools, &collection_tools,
@@ -574,7 +573,7 @@ pub fn mount_workspace_http_merge_request_wip(
tools: merge_request_operation_map(collection_tools), tools: merge_request_operation_map(collection_tools),
permissions: permissions.clone(), permissions: permissions.clone(),
collection_route: collection_route.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), tools: merge_request_operation_map(item_tools),
permissions, permissions,
collection_route: collection_route.clone(), collection_route: collection_route.clone(),
revisions, observations,
}), }),
})?; })?;
registry.replace_compatibility_tools(&collection_route, claimed_tools)?; registry.replace_compatibility_tools(&collection_route, claimed_tools)?;
@@ -613,7 +612,7 @@ struct MergeRequestCollectionWipHandler {
tools: HashMap<String, NativeMergeRequestTool>, tools: HashMap<String, NativeMergeRequestTool>,
permissions: Option<ToolPermissionConfig>, permissions: Option<ToolPermissionConfig>,
collection_route: String, collection_route: String,
revisions: MergeRequestRevisions, observations: MergeRequestObservations,
} }
#[async_trait] #[async_trait]
@@ -643,7 +642,7 @@ impl WipOperationHandler for MergeRequestCollectionWipHandler {
) )
})? })?
.to_string(); .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() { if let Some(object) = response.as_object_mut() {
object.insert( object.insert(
"path".into(), "path".into(),
@@ -663,7 +662,7 @@ struct MergeRequestItemResolver {
tools: HashMap<String, NativeMergeRequestTool>, tools: HashMap<String, NativeMergeRequestTool>,
permissions: Option<ToolPermissionConfig>, permissions: Option<ToolPermissionConfig>,
collection_route: String, collection_route: String,
revisions: MergeRequestRevisions, observations: MergeRequestObservations,
} }
impl WipDynamicItemResolver for MergeRequestItemResolver { impl WipDynamicItemResolver for MergeRequestItemResolver {
@@ -671,14 +670,13 @@ impl WipDynamicItemResolver for MergeRequestItemResolver {
if !is_merge_request_route_reference(item_reference) { if !is_merge_request_route_reference(item_reference) {
return None; return None;
} }
let revision = self let content_digest = self
.revisions .observations
.lock() .lock()
.unwrap_or_else(|error| error.into_inner()) .unwrap_or_else(|error| error.into_inner())
.revisions .content_digests
.get(item_reference) .get(item_reference)
.cloned() .cloned();
.unwrap_or_else(|| "unobserved".into());
let route = format!("{}/{}", self.collection_route, item_reference); let route = format!("{}/{}", self.collection_route, item_reference);
Some(WipDynamicItem { Some(WipDynamicItem {
object: Object { object: Object {
@@ -686,13 +684,15 @@ impl WipDynamicItemResolver for MergeRequestItemResolver {
description: Some("Authoritative Merge Request bound to this object route".into()), description: Some("Authoritative Merge Request bound to this object route".into()),
interfaces: vec![crate::wip::root_reference(MERGE_REQUEST_ITEM_INTERFACE)], interfaces: vec![crate::wip::root_reference(MERGE_REQUEST_ITEM_INTERFACE)],
r#ref: Some(format!("merge-request:{item_reference}")), 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 { handler: Arc::new(MergeRequestItemWipHandler {
tools: self.tools.clone(), tools: self.tools.clone(),
permissions: self.permissions.clone(), permissions: self.permissions.clone(),
merge_request_id: item_reference.into(), merge_request_id: item_reference.into(),
revisions: Arc::clone(&self.revisions), observations: Arc::clone(&self.observations),
}), }),
}) })
} }
@@ -702,7 +702,7 @@ struct MergeRequestItemWipHandler {
tools: HashMap<String, NativeMergeRequestTool>, tools: HashMap<String, NativeMergeRequestTool>,
permissions: Option<ToolPermissionConfig>, permissions: Option<ToolPermissionConfig>,
merge_request_id: String, merge_request_id: String,
revisions: MergeRequestRevisions, observations: MergeRequestObservations,
} }
#[async_trait] #[async_trait]
@@ -737,7 +737,7 @@ impl WipOperationHandler for MergeRequestItemWipHandler {
tool.kind.mutating(), tool.kind.mutating(),
)?; )?;
record_merge_request_observation( record_merge_request_observation(
&self.revisions, &self.observations,
&self.merge_request_id, &self.merge_request_id,
&response, &response,
tool.kind.mutating(), tool.kind.mutating(),
@@ -911,23 +911,22 @@ fn merge_request_reference(response: &Value) -> Option<&str> {
} }
fn record_merge_request_observation( fn record_merge_request_observation(
revisions: &MergeRequestRevisions, observations: &MergeRequestObservations,
reference: &str, reference: &str,
response: &Value, response: &Value,
mutation: bool, mutation: bool,
) { ) {
let mut state = revisions.lock().unwrap_or_else(|error| error.into_inner()); let mut state = observations
let revision = if mutation { .lock()
state.mutation_sequence = state.mutation_sequence.saturating_add(1); .unwrap_or_else(|error| error.into_inner());
format!( if mutation {
"{}#mutation-{}", state.content_digests.remove(reference);
merge_request_response_fingerprint(response),
state.mutation_sequence
)
} else { } else {
merge_request_response_fingerprint(response) state.content_digests.insert(
}; reference.to_string(),
state.revisions.insert(reference.to_string(), revision); merge_request_response_fingerprint(response),
);
}
} }
fn merge_request_response_fingerprint(response: &Value) -> String { 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'~')) .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~'))
} }
fn merge_request_route_validator(route: &str, revision: &str) -> Vec<u8> { fn merge_request_route_validator(route: &str, content_digest: &str) -> Vec<u8> {
let mut digest = Sha256::new(); let mut digest = Sha256::new();
digest.update(route.as_bytes()); digest.update(route.as_bytes());
digest.update([0]); digest.update([0]);
digest.update(revision.as_bytes()); digest.update(content_digest.as_bytes());
digest.finalize().to_vec() digest.finalize().to_vec()
} }
@@ -1428,7 +1427,7 @@ mod tests {
tools: HashMap::new(), tools: HashMap::new(),
permissions: None, permissions: None,
collection_route: "/merge-requests".into(), collection_route: "/merge-requests".into(),
revisions: Arc::new(Mutex::new(MergeRequestRevisionState::default())), observations: Arc::new(Mutex::new(MergeRequestObservationState::default())),
}; };
assert!( assert!(
resolver resolver
@@ -1574,16 +1573,16 @@ mod tests {
#[test] #[test]
fn successful_observations_and_mutations_stale_only_affected_merge_requests() { 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 { let resolver = MergeRequestItemResolver {
tools: HashMap::new(), tools: HashMap::new(),
permissions: None, permissions: None,
collection_route: "/merge-requests".into(), collection_route: "/merge-requests".into(),
revisions: Arc::clone(&revisions), observations: Arc::clone(&observations),
}; };
let first = resolver.resolve("MR-1").unwrap().object.validator; let first = resolver.resolve("MR-1").unwrap().object.validator;
record_merge_request_observation( record_merge_request_observation(
&revisions, &observations,
"MR-1", "MR-1",
&json!({"merge_request_id": "MR-1", "updated_at": "rev-1"}), &json!({"merge_request_id": "MR-1", "updated_at": "rev-1"}),
false, false,
@@ -1592,7 +1591,7 @@ mod tests {
assert_ne!(first, observed); assert_ne!(first, observed);
let other = resolver.resolve("MR-2").unwrap().object.validator; let other = resolver.resolve("MR-2").unwrap().object.validator;
record_merge_request_observation( record_merge_request_observation(
&revisions, &observations,
"MR-1", "MR-1",
&json!({"event_id": "review-1"}), &json!({"event_id": "review-1"}),
true, true,
@@ -1601,6 +1600,35 @@ mod tests {
assert_eq!(other, resolver.resolve("MR-2").unwrap().object.validator); 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] #[test]
fn native_errors_preserve_backend_rejections_and_unknown_post_dispatch_outcomes() { fn native_errors_preserve_backend_rejections_and_unknown_post_dispatch_outcomes() {
for (status, code) in [ for (status, code) in [
@@ -1681,7 +1709,7 @@ mod tests {
} }
#[test] #[test]
fn schemas_hide_revision_and_commit_authority() { fn schemas_hide_content_digest_and_commit_authority() {
let schemas = [ let schemas = [
schemars::schema_for!(OpenMergeRequestInput), schemars::schema_for!(OpenMergeRequestInput),
schemars::schema_for!(CompleteMergeRequestInput), schemars::schema_for!(CompleteMergeRequestInput),
@@ -1689,7 +1717,7 @@ mod tests {
for s in schemas { for s in schemas {
let j = serde_json::to_string(&s).unwrap(); let j = serde_json::to_string(&s).unwrap();
for banned in [ for banned in [
"revision_id", "content_digest_id",
"attempt_id", "attempt_id",
"base_commit", "base_commit",
"head_commit", "head_commit",
+28 -100
View File
@@ -5,8 +5,10 @@
//! local `.yoi/objectives` paths, so model-visible Objective tools go through //! local `.yoi/objectives` paths, so model-visible Objective tools go through
//! the scoped Workspace API. //! the scoped Workspace API.
use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::collections::BTreeMap;
use std::sync::{Arc, Mutex}; use std::sync::Arc;
#[cfg(test)]
use std::sync::Mutex;
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
use async_trait::async_trait; use async_trait::async_trait;
@@ -536,14 +538,6 @@ const OBJECTIVE_TOOL_NAMES: [&str; 7] = [
"ObjectiveUnlinkTicket", "ObjectiveUnlinkTicket",
]; ];
#[derive(Default)]
struct ObjectiveRevisionState {
revisions: HashMap<String, String>,
aliases_by_canonical: HashMap<String, BTreeSet<String>>,
}
type ObjectiveRevisions = Arc<Mutex<ObjectiveRevisionState>>;
/// Contribute the Objective Feature's native collection and item projections to /// Contribute the Objective Feature's native collection and item projections to
/// the Host-owned WIP registry. Ordinary Objective tools remain registered for /// the Host-owned WIP registry. Ordinary Objective tools remain registered for
/// Tool mode and are claimed only from the WIP compatibility projection. /// Tool mode and are claimed only from the WIP compatibility projection.
@@ -555,14 +549,13 @@ pub fn mount_workspace_http_objective_wip(
) -> Result<(), WipMountError> { ) -> Result<(), WipMountError> {
let collection_route = namespace_route.root().to_string(); let collection_route = namespace_route.root().to_string();
let backend = WorkspaceHttpObjectiveBackend::new(client); let backend = WorkspaceHttpObjectiveBackend::new(client);
let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default()));
let collection_descriptor = objective_collection_descriptor(); let collection_descriptor = objective_collection_descriptor();
let collection_validator = descriptor_validator(&collection_descriptor); let collection_validator = descriptor_validator(&collection_descriptor);
let collection_handler = Arc::new(ObjectiveCollectionWipHandler { let collection_handler = Arc::new(ObjectiveCollectionWipHandler {
backend: backend.clone(), backend: backend.clone(),
permissions: permissions.clone(), permissions: permissions.clone(),
collection_route: collection_route.clone(), collection_route: collection_route.clone(),
revisions: Arc::clone(&revisions),
}); });
registry.mount(WipProjection { registry.mount(WipProjection {
route: collection_route.clone(), route: collection_route.clone(),
@@ -600,7 +593,6 @@ pub fn mount_workspace_http_objective_wip(
backend, backend,
permissions, permissions,
collection_route: collection_route.clone(), collection_route: collection_route.clone(),
revisions,
}), }),
})?; })?;
registry.replace_compatibility_tools(&collection_route, OBJECTIVE_TOOL_NAMES)?; registry.replace_compatibility_tools(&collection_route, OBJECTIVE_TOOL_NAMES)?;
@@ -611,7 +603,6 @@ struct ObjectiveCollectionWipHandler {
backend: WorkspaceHttpObjectiveBackend, backend: WorkspaceHttpObjectiveBackend,
permissions: Option<ToolPermissionConfig>, permissions: Option<ToolPermissionConfig>,
collection_route: String, collection_route: String,
revisions: ObjectiveRevisions,
} }
#[async_trait] #[async_trait]
@@ -695,7 +686,7 @@ impl WipOperationHandler for ObjectiveCollectionWipHandler {
.create_value(&input) .create_value(&input)
.await .await
.map_err(map_backend_error)?; .map_err(map_backend_error)?;
record_revision(&self.revisions, None, &response);
if let Some(reference) = response if let Some(reference) = response
.get("objective") .get("objective")
.and_then(Json::as_str) .and_then(Json::as_str)
@@ -723,7 +714,6 @@ struct ObjectiveItemResolver {
backend: WorkspaceHttpObjectiveBackend, backend: WorkspaceHttpObjectiveBackend,
permissions: Option<ToolPermissionConfig>, permissions: Option<ToolPermissionConfig>,
collection_route: String, collection_route: String,
revisions: ObjectiveRevisions,
} }
impl WipDynamicItemResolver for ObjectiveItemResolver { impl WipDynamicItemResolver for ObjectiveItemResolver {
@@ -731,14 +721,6 @@ impl WipDynamicItemResolver for ObjectiveItemResolver {
if !is_objective_route_reference(item_reference) { if !is_objective_route_reference(item_reference) {
return None; 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); let route = format!("{}/{}", self.collection_route, item_reference);
Some(WipDynamicItem { Some(WipDynamicItem {
object: Object { object: Object {
@@ -746,13 +728,14 @@ impl WipDynamicItemResolver for ObjectiveItemResolver {
description: Some("Authoritative Objective bound to this object route".into()), description: Some("Authoritative Objective bound to this object route".into()),
interfaces: vec![crate::wip::root_reference(OBJECTIVE_ITEM_INTERFACE)], interfaces: vec![crate::wip::root_reference(OBJECTIVE_ITEM_INTERFACE)],
r#ref: Some(format!("objective:{item_reference}")), 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 { handler: Arc::new(ObjectiveItemWipHandler {
backend: self.backend.clone(), backend: self.backend.clone(),
permissions: self.permissions.clone(), permissions: self.permissions.clone(),
objective_reference: item_reference.to_string(), objective_reference: item_reference.to_string(),
revisions: Arc::clone(&self.revisions),
}), }),
}) })
} }
@@ -762,7 +745,6 @@ struct ObjectiveItemWipHandler {
backend: WorkspaceHttpObjectiveBackend, backend: WorkspaceHttpObjectiveBackend,
permissions: Option<ToolPermissionConfig>, permissions: Option<ToolPermissionConfig>,
objective_reference: String, objective_reference: String,
revisions: ObjectiveRevisions,
} }
#[async_trait] #[async_trait]
@@ -832,7 +814,7 @@ impl WipOperationHandler for ObjectiveItemWipHandler {
} }
_ => return Err(operation_not_found()), _ => return Err(operation_not_found()),
}; };
record_revision(&self.revisions, Some(&self.objective_reference), &response);
Ok(WipOperationOutput::native( Ok(WipOperationOutput::native(
json_to_wip(&response).map_err(WipOperationError::OutcomeUnknown)?, 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 { fn is_objective_route_reference(reference: &str) -> bool {
is_canonical_resource_key(reference, "O-") is_canonical_resource_key(reference, "O-")
|| (reference.len() >= 8 || (reference.len() >= 8
@@ -1059,11 +1011,11 @@ fn is_objective_route_reference(reference: &str) -> bool {
&& reference.bytes().all(|byte| byte.is_ascii_alphanumeric())) && reference.bytes().all(|byte| byte.is_ascii_alphanumeric()))
} }
fn route_validator(route: &str, revision: &str) -> Vec<u8> { fn route_validator(route: &str, signature: &str) -> Vec<u8> {
let mut digest = Sha256::new(); let mut digest = Sha256::new();
digest.update(route.as_bytes()); digest.update(route.as_bytes());
digest.update([0]); digest.update([0]);
digest.update(revision.as_bytes()); digest.update(signature.as_bytes());
digest.finalize().to_vec() digest.finalize().to_vec()
} }
@@ -1115,7 +1067,7 @@ fn objective_collection_descriptor() -> InterfaceDescriptor {
parameter("state", false, TypeExpr::String), parameter("state", false, TypeExpr::String),
parameter("linked_tickets", false, list_of(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, details: None,
}), }),
parameters, 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<T: for<'de> Deserialize<'de>>(input: &str) -> Result<T, ToolError
} }
const LIST_DESCRIPTION: &str = "Query authoritative Objectives with bounded typed filters, stable snippets, linked-Ticket context, and cursor metadata."; const LIST_DESCRIPTION: &str = "Query authoritative Objectives with bounded typed filters, stable snippets, linked-Ticket context, and cursor metadata.";
const SHOW_DESCRIPTION: &str = "Show one authoritative Objective with its revision, full linked-Ticket context, bounded body, and paged event metadata."; const SHOW_DESCRIPTION: &str = "Show one authoritative Objective with full linked-Ticket context, bounded body, and paged event metadata.";
const CREATE_DESCRIPTION: &str = const CREATE_DESCRIPTION: &str =
"Create an Objective record through Backend Workspace API authority."; "Create an Objective record through Backend Workspace API authority.";
const EDIT_DESCRIPTION: &str = const EDIT_DESCRIPTION: &str =
@@ -1549,17 +1501,16 @@ mod tests {
} }
} }
fn objective_response(revision: &str) -> crate::worker::WorkspaceResponse { fn objective_response(title: &str) -> crate::worker::WorkspaceResponse {
crate::worker::WorkspaceResponse { crate::worker::WorkspaceResponse {
status: 200, status: 200,
body: json!({ body: json!({
"id": "00001OBJECTIVE", "id": "00001OBJECTIVE",
"resource_key": "O-3", "resource_key": "O-3",
"title": "Objective", "title": title,
"body": "Body", "body": "Body",
"body_truncated": false, "body_truncated": false,
"state": "active", "state": "active",
"revision": revision,
"created_at": null, "created_at": null,
"updated_at": null, "updated_at": null,
"linked_ticket_summaries": [], "linked_ticket_summaries": [],
@@ -1635,7 +1586,7 @@ mod tests {
"body": "Body", "body": "Body",
"body_truncated": false, "body_truncated": false,
"state": "active", "state": "active",
"revision": "rev-1",
"created_at": null, "created_at": null,
"updated_at": null, "updated_at": null,
"linked_ticket_summaries": [], "linked_ticket_summaries": [],
@@ -1704,7 +1655,6 @@ mod tests {
"body": "Body", "body": "Body",
"body_truncated": false, "body_truncated": false,
"state": "active", "state": "active",
"revision": format!("rev-{mutation}"),
"created_at": null, "created_at": null,
"updated_at": null, "updated_at": null,
"linked_ticket_summaries": [], "linked_ticket_summaries": [],
@@ -1798,15 +1748,13 @@ mod tests {
} }
#[test] #[test]
fn dynamic_objective_routes_accept_only_objective_references_and_refresh_revision() { fn dynamic_objective_routes_accept_only_objective_references() {
let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default()));
let resolver = ObjectiveItemResolver { let resolver = ObjectiveItemResolver {
backend: WorkspaceHttpObjectiveBackend::new(Arc::new( backend: WorkspaceHttpObjectiveBackend::new(Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"), crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"),
)), )),
permissions: None, permissions: None,
collection_route: "/objectives".into(), collection_route: "/objectives".into(),
revisions: Arc::clone(&revisions),
}; };
let initial = resolver.resolve("O-3").unwrap(); let initial = resolver.resolve("O-3").unwrap();
assert_eq!(initial.object.name, "O-3"); assert_eq!(initial.object.name, "O-3");
@@ -1814,22 +1762,10 @@ mod tests {
assert!(resolver.resolve("T-3").is_none()); assert!(resolver.resolve("T-3").is_none());
assert!(resolver.resolve("O-3/other").is_none()); assert!(resolver.resolve("O-3/other").is_none());
record_revision( assert_ne!(
&revisions, initial.object.validator,
Some("00001OBJECTIVE"), resolver.resolve("O-4").unwrap().object.validator
&json!({"objective": "O-3", "revision": "rev-1"}),
); );
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] #[test]
@@ -1948,7 +1884,6 @@ mod tests {
rules: Vec::new(), rules: Vec::new(),
}), }),
objective_reference: "O-3".into(), objective_reference: "O-3".into(),
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
}; };
let result = denied let result = denied
.call( .call(
@@ -1972,7 +1907,6 @@ mod tests {
backend, backend,
permissions: None, permissions: None,
collection_route: "/objectives".into(), collection_route: "/objectives".into(),
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
}; };
let result = collection let result = collection
.call( .call(
@@ -2009,12 +1943,11 @@ mod tests {
objective_response("rev-4"), objective_response("rev-4"),
])); ]));
let backend = WorkspaceHttpObjectiveBackend::new(client.clone()); let backend = WorkspaceHttpObjectiveBackend::new(client.clone());
let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default()));
let collection = ObjectiveCollectionWipHandler { let collection = ObjectiveCollectionWipHandler {
backend: backend.clone(), backend: backend.clone(),
permissions: None, permissions: None,
collection_route: "/objectives".into(), collection_route: "/objectives".into(),
revisions: Arc::clone(&revisions),
}; };
let context = || WipCallContext { let context = || WipCallContext {
execution: ToolExecutionContext::direct(), execution: ToolExecutionContext::direct(),
@@ -2035,7 +1968,6 @@ mod tests {
backend, backend,
permissions: None, permissions: None,
objective_reference: "O-3".into(), objective_reference: "O-3".into(),
revisions,
}; };
assert!(item.call("read", &BTreeMap::new(), context()).await.is_ok()); assert!(item.call("read", &BTreeMap::new(), context()).await.is_ok());
assert!( assert!(
@@ -2100,7 +2032,6 @@ mod tests {
backend: WorkspaceHttpObjectiveBackend::new(client), backend: WorkspaceHttpObjectiveBackend::new(client),
permissions: None, permissions: None,
objective_reference: "O-404".into(), objective_reference: "O-404".into(),
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
}; };
for (operation, arguments) in [ for (operation, arguments) in [
("read", BTreeMap::new()), ("read", BTreeMap::new()),
@@ -2171,7 +2102,6 @@ mod tests {
)), )),
permissions: None, permissions: None,
collection_route: "/objectives".into(), collection_route: "/objectives".into(),
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
}; };
let output = match handler let output = match handler
.call( .call(
@@ -2198,7 +2128,7 @@ mod tests {
} }
#[tokio::test(flavor = "multi_thread")] #[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 listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap()); let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = thread::spawn(move || { let server = thread::spawn(move || {
@@ -2217,7 +2147,7 @@ mod tests {
"body": "Body", "body": "Body",
"body_truncated": false, "body_truncated": false,
"state": "active", "state": "active",
"revision": "rev-2",
"created_at": null, "created_at": null,
"updated_at": null, "updated_at": null,
"linked_ticket_summaries": [], "linked_ticket_summaries": [],
@@ -2233,7 +2163,7 @@ mod tests {
) )
.unwrap(); .unwrap();
}); });
let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default()));
let backend = WorkspaceHttpObjectiveBackend::new(Arc::new( let backend = WorkspaceHttpObjectiveBackend::new(Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace", base_url), crate::worker::TestWorkspaceHttpClient::new("workspace", base_url),
)); ));
@@ -2241,14 +2171,12 @@ mod tests {
backend: backend.clone(), backend: backend.clone(),
permissions: None, permissions: None,
collection_route: "/objectives".into(), collection_route: "/objectives".into(),
revisions: Arc::clone(&revisions),
}; };
let before = resolver.resolve("O-3").unwrap().object.validator; let before = resolver.resolve("O-3").unwrap().object.validator;
let handler = ObjectiveItemWipHandler { let handler = ObjectiveItemWipHandler {
backend, backend,
permissions: None, permissions: None,
objective_reference: "O-3".into(), objective_reference: "O-3".into(),
revisions,
}; };
let output = match handler let output = match handler
.call( .call(
@@ -2267,9 +2195,9 @@ mod tests {
server.join().unwrap(); server.join().unwrap();
let output = crate::wip::wip_to_json(&output.value).unwrap(); let output = crate::wip::wip_to_json(&output.value).unwrap();
assert_eq!(output["objective"], "O-3"); 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; let after = resolver.resolve("O-3").unwrap().object.validator;
assert_ne!(before, after); assert_eq!(before, after);
} }
#[test] #[test]
@@ -35,7 +35,7 @@ struct ModelTicketQueryItem {
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub(super) struct ModelTicketDetail { pub(super) struct ModelTicketDetail {
ticket: String, ticket: String,
item_revision: String, content_digest: String,
title: String, title: String,
body: String, body: String,
state: String, state: String,
@@ -82,7 +82,6 @@ pub(super) struct ModelObjectiveDetail {
body: String, body: String,
body_truncated: bool, body_truncated: bool,
state: String, state: String,
revision: String,
created_at: Option<String>, created_at: Option<String>,
updated_at: Option<String>, updated_at: Option<String>,
linked_tickets: Vec<ModelTicketSummary>, linked_tickets: Vec<ModelTicketSummary>,
@@ -284,7 +283,7 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
Ok(ModelTicketDetail { Ok(ModelTicketDetail {
ticket: resource_ref(root, "resource_key", "T-")?, ticket: resource_ref(root, "resource_key", "T-")?,
item_revision: string_field(root, "item_revision")?, content_digest: string_field(root, "content_digest")?,
title: string_field(root, "title")?, title: string_field(root, "title")?,
body: string_field(root, "body")?, body: string_field(root, "body")?,
state: string_field(root, "state")?, state: string_field(root, "state")?,
@@ -369,7 +368,6 @@ pub(super) fn project_objective_detail(value: Value) -> Result<ModelObjectiveDet
body: string_field(root, "body")?, body: string_field(root, "body")?,
body_truncated: bool_field(root, "body_truncated")?, body_truncated: bool_field(root, "body_truncated")?,
state: string_field(root, "state")?, state: string_field(root, "state")?,
revision: string_field(root, "revision")?,
created_at: optional_string(root, "created_at")?, created_at: optional_string(root, "created_at")?,
updated_at: optional_string(root, "updated_at")?, updated_at: optional_string(root, "updated_at")?,
linked_tickets: array_field(root, "linked_ticket_summaries")? linked_tickets: array_field(root, "linked_ticket_summaries")?
@@ -705,7 +703,7 @@ mod tests {
"closed", "closed",
] { ] {
let projected = project_ticket_detail(json!({ let projected = project_ticket_detail(json!({
"resource_key": "T-718", "item_revision": "rev-1", "title": "No Git required", "body": "Decision", "resource_key": "T-718", "content_digest": "rev-1", "title": "No Git required", "body": "Decision",
"state": state, "events": [{"sequence": 1, "kind": "completed", "body": "No Git needed"}], "state": state, "events": [{"sequence": 1, "kind": "completed", "body": "No Git needed"}],
"linked_objectives": [], "assignments": [], "implementation_reports": [], "linked_objectives": [], "assignments": [], "implementation_reports": [],
"merge_requests": [], "merge_request": null, "merge_requests": [], "merge_request": null,
@@ -735,7 +733,6 @@ mod tests {
"body": "Body", "body": "Body",
"body_truncated": false, "body_truncated": false,
"state": "active", "state": "active",
"revision": "rev-3",
"created_at": "2026-01-01T00:00:00Z", "created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-02T00:00:00Z", "updated_at": "2026-01-02T00:00:00Z",
"linked_tickets": ["00001M0E82D1V"], "linked_tickets": ["00001M0E82D1V"],
@@ -759,7 +756,6 @@ mod tests {
let text = json.to_string(); let text = json.to_string();
assert!(text.contains("O-543")); assert!(text.contains("O-543"));
assert!(text.contains("T-496")); assert!(text.contains("T-496"));
assert_eq!(json["revision"], "rev-3");
assert_eq!(json["body_truncated"], false); assert_eq!(json["body_truncated"], false);
assert!(!text.contains("00001M10HW6BV")); assert!(!text.contains("00001M10HW6BV"));
assert!(!text.contains("00001M0E82D1V")); assert!(!text.contains("00001M0E82D1V"));
@@ -845,7 +841,7 @@ mod tests {
let projected = project_ticket_detail(json!({ let projected = project_ticket_detail(json!({
"id": "internal-ticket", "id": "internal-ticket",
"resource_key": "T-588", "resource_key": "T-588",
"item_revision": "revision-1", "content_digest": "revision-1",
"title": "Queued Submit", "title": "Queued Submit",
"body": "Body", "body": "Body",
"state": "planning", "state": "planning",
@@ -896,7 +892,7 @@ mod tests {
.expect("current Ticket blocker shape must project"); .expect("current Ticket blocker shape must project");
let projected = serde_json::to_value(projected).expect("serialize Ticket detail"); let projected = serde_json::to_value(projected).expect("serialize Ticket detail");
assert_eq!(projected["item_revision"], "revision-1"); assert_eq!(projected["content_digest"], "revision-1");
assert_eq!( assert_eq!(
projected["relations"]["blockers"], projected["relations"]["blockers"],
json!([{ json!([{
@@ -948,7 +944,7 @@ mod tests {
}); });
let projected = project_ticket_detail(json!({ let projected = project_ticket_detail(json!({
"resource_key": "T-716", "resource_key": "T-716",
"item_revision": "current-ticket-revision", "content_digest": "current-ticket-revision",
"title": "Source evidence", "title": "Source evidence",
"body": "Body", "body": "Body",
"state": if merged { "done" } else { "inprogress" }, "state": if merged { "done" } else { "inprogress" },
@@ -986,7 +982,7 @@ mod tests {
Some(&json!(integration_error)) Some(&json!(integration_error))
); );
} }
assert_eq!(projected["item_revision"], "current-ticket-revision"); assert_eq!(projected["content_digest"], "current-ticket-revision");
assert_eq!(projected["evidence"]["review_after_rescope"], attested); assert_eq!(projected["evidence"]["review_after_rescope"], attested);
assert_eq!(projected["evidence"]["complete_for_integration"], merged); assert_eq!(projected["evidence"]["complete_for_integration"], merged);
} }
@@ -1,8 +1,8 @@
//! Host-scoped model tools for revisioned subjektiv Memory. //! Host-scoped model tools for change-identified subjektiv Memory.
//! //!
//! Confirmed Memory is read-only here. Explicit remembering and revision requests //! Confirmed Memory is read-only here. Explicit remembering and change requests
//! become immutable staging candidates; T-670 remains the only authority that may //! become immutable staging candidates; T-670 remains the only authority that may
//! adopt a candidate as a confirmed revision. //! adopt a candidate as a confirmed change.
use std::collections::HashSet; use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
@@ -13,10 +13,9 @@ use memory::extract::CandidateKind;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::Deserialize; use serde::Deserialize;
use server_api::{ use server_api::{
SubjektivMemoryBackendOperation, SubjektivMemoryBackendResponse, SubjektivMemoryBackendOperation, SubjektivMemoryBackendResponse, SubjektivMemoryChangeIntent,
SubjektivMemoryListRevisionsRequest, SubjektivMemoryQueryRequest, SubjektivMemoryReadRequest, SubjektivMemoryChangeProposal, SubjektivMemoryListChangesRequest, SubjektivMemoryQueryRequest,
SubjektivMemoryReceiptStatus, SubjektivMemoryReceiptStatusRequest, SubjektivMemoryReadRequest, SubjektivMemoryReceiptStatus, SubjektivMemoryReceiptStatusRequest,
SubjektivMemoryRevisionIntent, SubjektivMemoryRevisionProposal,
SubjektivMemoryStageExplicitRequest, SubjektivMemoryStageExplicitResponse, SubjektivMemoryStageExplicitRequest, SubjektivMemoryStageExplicitResponse,
SubjektivMemoryValidateProposalRequest, SubjektivMemoryValidateProposalRequest,
}; };
@@ -41,19 +40,19 @@ use server_api::SubjektivMemoryBackendRequest;
const QUERY_TOOL: &str = "SubjektivMemoryQuery"; const QUERY_TOOL: &str = "SubjektivMemoryQuery";
const READ_TOOL: &str = "SubjektivMemoryRead"; const READ_TOOL: &str = "SubjektivMemoryRead";
const LIST_REVISIONS_TOOL: &str = "SubjektivMemoryListRevisions"; const LIST_CHANGES_TOOL: &str = "SubjektivMemoryListChanges";
const REMEMBER_TOOL: &str = "SubjektivMemoryRemember"; const REMEMBER_TOOL: &str = "SubjektivMemoryRemember";
const PROPOSE_REVISION_TOOL: &str = "SubjektivMemoryProposeRevision"; const PROPOSE_CHANGE_TOOL: &str = "SubjektivMemoryProposeChange";
const COMMIT_HOOK: &str = "stage-explicit-memory-after-commit"; const COMMIT_HOOK: &str = "stage-explicit-memory-after-commit";
const RETRY_HOOK: &str = "retry-committed-explicit-memory"; const RETRY_HOOK: &str = "retry-committed-explicit-memory";
const REWRITE_HOOK: &str = "flush-explicit-memory-before-rewrite"; const REWRITE_HOOK: &str = "flush-explicit-memory-before-rewrite";
const MAX_EVIDENCE_REFS: usize = 10; const MAX_EVIDENCE_REFS: usize = 10;
const QUERY_DESCRIPTION: &str = "Search the connected subject's current Memory revisions. Omitted states means active only; results are bounded and cursors are bound to the subject, filters, order, and store snapshot."; const QUERY_DESCRIPTION: &str = "Search the connected subject's current Memory changes. Omitted states means active only; results are bounded and cursors are bound to the subject, filters, order, and store snapshot.";
const READ_DESCRIPTION: &str = "Read one current or immutable historical Memory revision for the connected subject, with line- and byte-bounded Markdown plus paged provenance anchors. Continue partial reads using the returned exact revision, line offset, and byte offset."; const READ_DESCRIPTION: &str = "Read one current or immutable historical Memory change for the connected subject, with line- and byte-bounded Markdown plus paged provenance anchors. Continue partial reads using the returned exact change, line offset, and byte offset.";
const LIST_REVISIONS_DESCRIPTION: &str = "List immutable revisions of one connected-subject Memory in descending revision order using snapshot-bound pagination."; const LIST_CHANGES_DESCRIPTION: &str = "List immutable changes of one connected-subject Memory in newest-first change order using snapshot-bound pagination.";
const REMEMBER_DESCRIPTION: &str = "Explicitly stage a new Memory candidate for the connected subject. This never writes confirmed Memory. With no entry_refs, the committed tool call itself becomes model-origin evidence after the run commits and status is pending_commit."; const REMEMBER_DESCRIPTION: &str = "Explicitly stage a new Memory candidate for the connected subject. This never writes confirmed Memory. With no entry_refs, the committed tool call itself becomes model-origin evidence after the run commits and status is pending_commit.";
const PROPOSE_DESCRIPTION: &str = "Stage a typed correction, resolution, retraction, or reopen proposal for an exact current Memory revision. This validates optimistic concurrency now but never creates a confirmed revision."; const PROPOSE_DESCRIPTION: &str = "Stage a typed correction, resolution, retraction, or reopen proposal for an exact current Memory change. This validates optimistic concurrency now but never creates a confirmed change.";
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct SubjektivMemoryFeature { pub(crate) struct SubjektivMemoryFeature {
@@ -73,7 +72,7 @@ struct PendingExplicit {
claim: String, claim: String,
why_useful: String, why_useful: String,
staleness: Option<String>, staleness: Option<String>,
proposal: Option<SubjektivMemoryRevisionProposal>, proposal: Option<SubjektivMemoryChangeProposal>,
} }
impl SubjektivMemoryFeature { impl SubjektivMemoryFeature {
@@ -102,12 +101,12 @@ impl FeatureModule for SubjektivMemoryFeature {
.with_tool(ToolDeclaration::new(QUERY_TOOL, QUERY_DESCRIPTION)) .with_tool(ToolDeclaration::new(QUERY_TOOL, QUERY_DESCRIPTION))
.with_tool(ToolDeclaration::new(READ_TOOL, READ_DESCRIPTION)) .with_tool(ToolDeclaration::new(READ_TOOL, READ_DESCRIPTION))
.with_tool(ToolDeclaration::new( .with_tool(ToolDeclaration::new(
LIST_REVISIONS_TOOL, LIST_CHANGES_TOOL,
LIST_REVISIONS_DESCRIPTION, LIST_CHANGES_DESCRIPTION,
)) ))
.with_tool(ToolDeclaration::new(REMEMBER_TOOL, REMEMBER_DESCRIPTION)) .with_tool(ToolDeclaration::new(REMEMBER_TOOL, REMEMBER_DESCRIPTION))
.with_tool(ToolDeclaration::new( .with_tool(ToolDeclaration::new(
PROPOSE_REVISION_TOOL, PROPOSE_CHANGE_TOOL,
PROPOSE_DESCRIPTION, PROPOSE_DESCRIPTION,
)) ))
.with_hook(HookDeclaration::new( .with_hook(HookDeclaration::new(
@@ -129,9 +128,9 @@ impl FeatureModule for SubjektivMemoryFeature {
(QUERY_TOOL, QUERY_DESCRIPTION, ReadOperation::Query), (QUERY_TOOL, QUERY_DESCRIPTION, ReadOperation::Query),
(READ_TOOL, READ_DESCRIPTION, ReadOperation::Read), (READ_TOOL, READ_DESCRIPTION, ReadOperation::Read),
( (
LIST_REVISIONS_TOOL, LIST_CHANGES_TOOL,
LIST_REVISIONS_DESCRIPTION, LIST_CHANGES_DESCRIPTION,
ReadOperation::ListRevisions, ReadOperation::ListChanges,
), ),
] { ] {
context.tools().register(ToolContribution::new( context.tools().register(ToolContribution::new(
@@ -149,9 +148,9 @@ impl FeatureModule for SubjektivMemoryFeature {
), ),
))?; ))?;
context.tools().register(ToolContribution::new( context.tools().register(ToolContribution::new(
PROPOSE_REVISION_TOOL, PROPOSE_CHANGE_TOOL,
explicit_tool_definition( explicit_tool_definition(
PROPOSE_REVISION_TOOL, PROPOSE_CHANGE_TOOL,
PROPOSE_DESCRIPTION, PROPOSE_DESCRIPTION,
self.state.clone(), self.state.clone(),
ExplicitOperation::Propose, ExplicitOperation::Propose,
@@ -184,7 +183,7 @@ impl FeatureModule for SubjektivMemoryFeature {
enum ReadOperation { enum ReadOperation {
Query, Query,
Read, Read,
ListRevisions, ListChanges,
} }
fn read_tool_definition( fn read_tool_definition(
@@ -197,7 +196,7 @@ fn read_tool_definition(
let schema = match operation { let schema = match operation {
ReadOperation::Query => schema_for::<SubjektivMemoryQueryRequest>(), ReadOperation::Query => schema_for::<SubjektivMemoryQueryRequest>(),
ReadOperation::Read => schema_for::<SubjektivMemoryReadRequest>(), ReadOperation::Read => schema_for::<SubjektivMemoryReadRequest>(),
ReadOperation::ListRevisions => schema_for::<SubjektivMemoryListRevisionsRequest>(), ReadOperation::ListChanges => schema_for::<SubjektivMemoryListChangesRequest>(),
}; };
let tool: Arc<dyn Tool> = Arc::new(SubjektivReadTool { let tool: Arc<dyn Tool> = Arc::new(SubjektivReadTool {
state: state.clone(), state: state.clone(),
@@ -231,10 +230,9 @@ impl Tool for SubjektivReadTool {
ReadOperation::Read => { ReadOperation::Read => {
SubjektivMemoryBackendOperation::Read(parse(input_json, READ_TOOL)?) SubjektivMemoryBackendOperation::Read(parse(input_json, READ_TOOL)?)
} }
ReadOperation::ListRevisions => SubjektivMemoryBackendOperation::ListRevisions(parse( ReadOperation::ListChanges => {
input_json, SubjektivMemoryBackendOperation::ListChanges(parse(input_json, LIST_CHANGES_TOOL)?)
LIST_REVISIONS_TOOL, }
)?),
}; };
let response = match self.state.execute(operation) { let response = match self.state.execute(operation) {
Ok(response) => response, Ok(response) => response,
@@ -247,14 +245,14 @@ impl Tool for SubjektivReadTool {
), ),
SubjektivMemoryBackendResponse::Read(value) => ( SubjektivMemoryBackendResponse::Read(value) => (
format!( format!(
"Read Memory {} revision {}.", "Read Memory {} change {}.",
value.memory_id, value.revision value.memory_id, value.change_id
), ),
serde_json::to_value(value), serde_json::to_value(value),
), ),
SubjektivMemoryBackendResponse::ListRevisions(value) => ( SubjektivMemoryBackendResponse::ListChanges(value) => (
format!( format!(
"Listed {} revision(s) for Memory {}.", "Listed {} change(s) for Memory {}.",
value.items.len(), value.items.len(),
value.memory_id value.memory_id
), ),
@@ -296,7 +294,7 @@ fn explicit_tool_definition(
Arc::new(move || { Arc::new(move || {
let schema = match operation { let schema = match operation {
ExplicitOperation::Remember => schema_for::<RememberParams>(), ExplicitOperation::Remember => schema_for::<RememberParams>(),
ExplicitOperation::Propose => schema_for::<ProposeRevisionParams>(), ExplicitOperation::Propose => schema_for::<ProposeChangeParams>(),
}; };
let tool: Arc<dyn Tool> = Arc::new(SubjektivExplicitTool { let tool: Arc<dyn Tool> = Arc::new(SubjektivExplicitTool {
state: state.clone(), state: state.clone(),
@@ -325,11 +323,11 @@ struct RememberParams {
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct ProposeRevisionParams { struct ProposeChangeParams {
memory_id: String, memory_id: String,
#[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] #[schemars(length(min = 1))]
expected_revision: u64, expected_change_id: String,
intent: SubjektivMemoryRevisionIntent, intent: SubjektivMemoryChangeIntent,
claim: String, claim: String,
why_useful: String, why_useful: String,
change_reason: String, change_reason: String,
@@ -371,10 +369,10 @@ impl Tool for SubjektivExplicitTool {
) )
} }
ExplicitOperation::Propose => { ExplicitOperation::Propose => {
let params: ProposeRevisionParams = parse(input_json, PROPOSE_REVISION_TOOL)?; let params: ProposeChangeParams = parse(input_json, PROPOSE_CHANGE_TOOL)?;
if params.expected_revision == 0 { if params.expected_change_id.trim().is_empty() {
return Err(ToolError::InvalidArgument( return Err(ToolError::InvalidArgument(
"expected_revision must be a positive integer".into(), "expected_change_id must not be empty".into(),
)); ));
} }
let validation = let validation =
@@ -383,7 +381,7 @@ impl Tool for SubjektivExplicitTool {
.execute(SubjektivMemoryBackendOperation::ValidateProposal( .execute(SubjektivMemoryBackendOperation::ValidateProposal(
SubjektivMemoryValidateProposalRequest { SubjektivMemoryValidateProposalRequest {
memory_id: params.memory_id.clone(), memory_id: params.memory_id.clone(),
expected_revision: params.expected_revision, expected_change_id: params.expected_change_id.clone(),
intent: params.intent, intent: params.intent,
}, },
)) { )) {
@@ -398,9 +396,9 @@ impl Tool for SubjektivExplicitTool {
))); )));
} }
}; };
let proposal = SubjektivMemoryRevisionProposal { let proposal = SubjektivMemoryChangeProposal {
memory_id: params.memory_id, memory_id: params.memory_id,
expected_revision: params.expected_revision, expected_change_id: params.expected_change_id.clone(),
intent: params.intent, intent: params.intent,
change_reason: params.change_reason, change_reason: params.change_reason,
}; };
@@ -447,7 +445,7 @@ impl Tool for SubjektivExplicitTool {
"status": "pending_commit", "status": "pending_commit",
"target": proposal.as_ref().map(|value| serde_json::json!({ "target": proposal.as_ref().map(|value| serde_json::json!({
"memory_id": value.memory_id, "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), "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.", "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 session_id = capture.session_id.clone();
let view = SessionCapture::from_history_entries(capture.segment_id, capture.history); let view = SessionCapture::from_history_entries(capture.segment_id, capture.history);
let committed = view let committed = view
.committed_pending_tool_calls(&[REMEMBER_TOOL, PROPOSE_REVISION_TOOL]) .committed_pending_tool_calls(&[REMEMBER_TOOL, PROPOSE_CHANGE_TOOL])
.into_iter() .into_iter()
.any(|call| { .any(|call| {
receipt_id(&session_id, &call.call_id) receipt_id(&session_id, &call.call_id)
@@ -537,7 +535,7 @@ impl SubjektivMemoryState {
why_useful: String, why_useful: String,
staleness: Option<String>, staleness: Option<String>,
entries: Vec<SessionEntryEvidence>, entries: Vec<SessionEntryEvidence>,
proposal: Option<SubjektivMemoryRevisionProposal>, proposal: Option<SubjektivMemoryChangeProposal>,
) -> Result<SubjektivMemoryStageExplicitResponse, ToolError> { ) -> Result<SubjektivMemoryStageExplicitResponse, ToolError> {
if entries.is_empty() { if entries.is_empty() {
return Err(ToolError::ExecutionFailed( return Err(ToolError::ExecutionFailed(
@@ -620,7 +618,7 @@ fn replay_pending_committed_memory_with_policy(
let view = let view =
SessionCapture::from_history_entries(capture.segment_id.clone(), capture.history.clone()); SessionCapture::from_history_entries(capture.segment_id.clone(), capture.history.clone());
let mut failures = Vec::new(); 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) { let receipt_id = match receipt_id(&capture.session_id, &call.call_id) {
Ok(value) => value, Ok(value) => value,
Err(error) => { Err(error) => {
@@ -693,12 +691,12 @@ fn pending_from_committed_call(
None, None,
) )
} }
PROPOSE_REVISION_TOOL => { PROPOSE_CHANGE_TOOL => {
let params: ProposeRevisionParams = parse(arguments, PROPOSE_REVISION_TOOL)?; let params: ProposeChangeParams = parse(arguments, PROPOSE_CHANGE_TOOL)?;
let response = state.execute(SubjektivMemoryBackendOperation::ValidateProposal( let response = state.execute(SubjektivMemoryBackendOperation::ValidateProposal(
SubjektivMemoryValidateProposalRequest { SubjektivMemoryValidateProposalRequest {
memory_id: params.memory_id.clone(), memory_id: params.memory_id.clone(),
expected_revision: params.expected_revision, expected_change_id: params.expected_change_id.clone(),
intent: params.intent, intent: params.intent,
}, },
))?; ))?;
@@ -716,9 +714,9 @@ fn pending_from_committed_call(
params.why_useful, params.why_useful,
params.staleness, params.staleness,
params.entry_refs, params.entry_refs,
Some(SubjektivMemoryRevisionProposal { Some(SubjektivMemoryChangeProposal {
memory_id: params.memory_id, memory_id: params.memory_id,
expected_revision: params.expected_revision, expected_change_id: params.expected_change_id.clone(),
intent: params.intent, intent: params.intent,
change_reason: params.change_reason, change_reason: params.change_reason,
}), }),
@@ -1067,7 +1065,7 @@ mod tests {
Ok(CommittedSessionCapture { Ok(CommittedSessionCapture {
session_id: "session-1".into(), session_id: "session-1".into(),
segment_id: "segment-1".into(), segment_id: "segment-1".into(),
session_revision: 1,
entry_count: 0, entry_count: 0,
run_exit: CommittedRunExit::Finished, run_exit: CommittedRunExit::Finished,
history: Vec::new(), history: Vec::new(),
@@ -1096,9 +1094,9 @@ mod tests {
vec![ vec![
QUERY_TOOL, QUERY_TOOL,
READ_TOOL, READ_TOOL,
LIST_REVISIONS_TOOL, LIST_CHANGES_TOOL,
REMEMBER_TOOL, REMEMBER_TOOL,
PROPOSE_REVISION_TOOL, PROPOSE_CHANGE_TOOL,
] ]
); );
assert_eq!(descriptor.hooks.len(), 3); assert_eq!(descriptor.hooks.len(), 3);
@@ -1128,11 +1126,11 @@ mod tests {
.any(|field| field == "entry_refs") .any(|field| field == "entry_refs")
); );
let propose = schema_for::<ProposeRevisionParams>(); let propose = schema_for::<ProposeChangeParams>();
let propose_text = serde_json::to_string(&propose).unwrap(); let propose_text = serde_json::to_string(&propose).unwrap();
assert!(!propose_text.contains("subject_id")); assert!(!propose_text.contains("subject_id"));
assert!(!propose_text.contains("\"kind\"")); assert!(!propose_text.contains("\"kind\""));
assert!(propose_text.contains("expected_revision")); assert!(propose_text.contains("expected_change_id"));
assert!(propose_text.contains("change_reason")); assert!(propose_text.contains("change_reason"));
let parsed: RememberParams = serde_json::from_str( let parsed: RememberParams = serde_json::from_str(
@@ -1152,7 +1150,7 @@ mod tests {
worker_id: Some("w".repeat(64)), worker_id: Some("w".repeat(64)),
flow_selector: Some("f".repeat(64)), flow_selector: Some("f".repeat(64)),
flow_definition_id: Some("d".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) let evidence = (0..MAX_EVIDENCE_REFS)
.map(|index| memory::extract::StagingEvidence { .map(|index| memory::extract::StagingEvidence {
@@ -1178,8 +1176,8 @@ mod tests {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let response = server_api::SubjektivMemoryReadResponse { let response = server_api::SubjektivMemoryReadResponse {
memory_id: "memory-1".into(), memory_id: "memory-1".into(),
revision: 1, change_id: "change-1".into(),
current_revision: 1, current_change_id: "change-1".into(),
kind: CandidateKind::Lesson, kind: CandidateKind::Lesson,
state: server_api::SubjektivMemoryState::Active, state: server_api::SubjektivMemoryState::Active,
claim: "Escape-aware read".into(), claim: "Escape-aware read".into(),
@@ -1252,7 +1250,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn backend_conflict_discriminants_survive_as_structured_tool_output() { 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 { let tool = SubjektivReadTool {
state: SubjektivMemoryState { state: SubjektivMemoryState {
host: crate::subjektiv::test_connection(Arc::new(ConflictWorkspaceClient( host: crate::subjektiv::test_connection(Arc::new(ConflictWorkspaceClient(
@@ -1291,7 +1289,7 @@ mod tests {
operation: ExplicitOperation::Remember, operation: ExplicitOperation::Remember,
}; };
let input = 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 let output = tool
.execute(input, ToolExecutionContext::new("call-1", "batch-1", 0)) .execute(input, ToolExecutionContext::new("call-1", "batch-1", 0))
.await .await
@@ -1321,7 +1319,7 @@ mod tests {
Ok(CommittedSessionCapture { Ok(CommittedSessionCapture {
session_id: "session-1".into(), session_id: "session-1".into(),
segment_id: "segment-1".into(), segment_id: "segment-1".into(),
session_revision: 1,
entry_count: 3, entry_count: 3,
run_exit: CommittedRunExit::Interrupted, run_exit: CommittedRunExit::Interrupted,
history: vec![ history: vec![
@@ -1380,7 +1378,7 @@ mod tests {
Ok(CommittedSessionCapture { Ok(CommittedSessionCapture {
session_id: "session-1".into(), session_id: "session-1".into(),
segment_id: "segment-1".into(), segment_id: "segment-1".into(),
session_revision: 2,
entry_count: 2, entry_count: 2,
run_exit: CommittedRunExit::Finished, run_exit: CommittedRunExit::Finished,
history: vec![ history: vec![
@@ -1457,7 +1455,7 @@ mod tests {
Ok(CommittedSessionCapture { Ok(CommittedSessionCapture {
session_id: "session-1".into(), session_id: "session-1".into(),
segment_id: "segment-1".into(), segment_id: "segment-1".into(),
session_revision: 2,
entry_count: 2, entry_count: 2,
run_exit: CommittedRunExit::Finished, run_exit: CommittedRunExit::Finished,
history: vec![ history: vec![
+123 -96
View File
@@ -63,7 +63,7 @@ impl WorkspaceTicketReadKind {
"Query authoritative Workspace Tickets with bounded typed filters, stable snippets, evidence summaries, and cursor metadata." "Query authoritative Workspace Tickets with bounded typed filters, stable snippets, evidence summaries, and cursor metadata."
} }
Self::Show => { 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, ticket: String,
/// Stable key for exact replay of this completion decision. /// Stable key for exact replay of this completion decision.
operation_key: String, operation_key: String,
/// Current item revision from ShowTicket. /// SHA-256 of the current editable Ticket content from ShowTicket.
expected_item_revision: String, expected_content_digest: String,
expected_state: TicketWorkflowState, expected_state: TicketWorkflowState,
/// Required explanation of the completion decision; MR approval is not a prerequisite. /// Required explanation of the completion decision; MR approval is not a prerequisite.
reason: String, reason: String,
@@ -308,8 +308,8 @@ struct WorkspaceTicketStateUpdateInput {
/// Ticket reference. Prefer `T-*`. /// Ticket reference. Prefer `T-*`.
ticket: String, ticket: String,
operation_key: String, operation_key: String,
/// Current item revision from ShowTicket. /// SHA-256 of the current editable Ticket content from ShowTicket.
expected_item_revision: String, expected_content_digest: String,
expected_state: TicketWorkflowState, expected_state: TicketWorkflowState,
/// Progress-display state only: this does not start Workers or integrate Merge Requests. /// Progress-display state only: this does not start Workers or integrate Merge Requests.
state: TicketWorkflowState, state: TicketWorkflowState,
@@ -326,8 +326,8 @@ struct WorkspaceTicketCloseInput {
ticket: String, ticket: String,
/// Stable key for exact replay of this close decision. /// Stable key for exact replay of this close decision.
operation_key: String, operation_key: String,
/// Current item revision from ShowTicket. /// SHA-256 of the current editable Ticket content from ShowTicket.
expected_item_revision: String, expected_content_digest: String,
expected_state: TicketWorkflowState, expected_state: TicketWorkflowState,
/// Required explanation for closing this Ticket. /// Required explanation for closing this Ticket.
resolution: String, resolution: String,
@@ -381,13 +381,13 @@ struct WorkspaceTicketDecisionTool {
fn validate_ticket_decision( fn validate_ticket_decision(
ticket: &str, ticket: &str,
operation_key: &str, operation_key: &str,
revision: &str, content_digest: &str,
reason: &str, reason: &str,
) -> Result<(), ToolError> { ) -> Result<(), ToolError> {
for (name, value) in [ for (name, value) in [
("ticket", ticket), ("ticket", ticket),
("operation_key", operation_key), ("operation_key", operation_key),
("expected_item_revision", revision), ("expected_content_digest", content_digest),
("reason", reason), ("reason", reason),
] { ] {
if value.trim().is_empty() { if value.trim().is_empty() {
@@ -412,7 +412,7 @@ impl Tool for WorkspaceTicketDecisionTool {
validate_ticket_decision( validate_ticket_decision(
&value.ticket, &value.ticket,
&value.operation_key, &value.operation_key,
&value.expected_item_revision, &value.expected_content_digest,
&value.reason, &value.reason,
)?; )?;
let mut body = serde_json::to_value(&value) let mut body = serde_json::to_value(&value)
@@ -428,12 +428,12 @@ impl Tool for WorkspaceTicketDecisionTool {
validate_ticket_decision( validate_ticket_decision(
&value.ticket, &value.ticket,
&value.operation_key, &value.operation_key,
&value.expected_item_revision, &value.expected_content_digest,
&value.resolution, &value.resolution,
)?; )?;
let body = json!({ let body = json!({
"operation_key": value.operation_key, "operation_key": value.operation_key,
"expected_item_revision": value.expected_item_revision, "expected_content_digest": value.expected_content_digest,
"expected_state": value.expected_state, "expected_state": value.expected_state,
"state": TicketWorkflowState::Closed, "state": TicketWorkflowState::Closed,
"reason": value.resolution, "reason": value.resolution,
@@ -447,7 +447,7 @@ impl Tool for WorkspaceTicketDecisionTool {
validate_ticket_decision( validate_ticket_decision(
&value.ticket, &value.ticket,
&value.operation_key, &value.operation_key,
&value.expected_item_revision, &value.expected_content_digest,
&value.reason, &value.reason,
)?; )?;
let mut body = serde_json::to_value(&value) let mut body = serde_json::to_value(&value)
@@ -1005,7 +1005,7 @@ impl WorkspaceHttpTicketBackend {
)?; )?;
serde_json::to_value(ticket::TicketStateUpdate { serde_json::to_value(ticket::TicketStateUpdate {
operation_key: uuid::Uuid::now_v7().to_string(), 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, expected_state,
state, state,
reason: reason.into(), reason: reason.into(),
@@ -1205,7 +1205,7 @@ impl WorkspaceHttpTicketBackend {
)?; )?;
let body = serde_json::to_value(ticket::TicketCompletion { let body = serde_json::to_value(ticket::TicketCompletion {
operation_key: uuid::Uuid::now_v7().to_string(), 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, expected_state: snapshot.meta.workflow_state,
reason: resolution.as_str().to_owned(), reason: resolution.as_str().to_owned(),
references: Vec::new(), references: Vec::new(),
@@ -1763,13 +1763,12 @@ fn native_ticket_tools(
} }
#[derive(Default)] #[derive(Default)]
struct TicketRevisionState { struct TicketObservationState {
revisions: HashMap<String, String>, content_digests: HashMap<String, String>,
aliases_by_canonical: HashMap<String, BTreeSet<String>>, aliases_by_canonical: HashMap<String, BTreeSet<String>>,
mutation_sequence: u64,
} }
type TicketRevisions = Arc<Mutex<TicketRevisionState>>; type TicketObservations = Arc<Mutex<TicketObservationState>>;
/// Mount the enabled Ticket Feature surface as native collection and route-bound /// Mount the enabled Ticket Feature surface as native collection and route-bound
/// item objects. Only tools already enabled for this Worker are projected and /// 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) .filter(|tool| tool.projection.surface == NativeTicketSurface::Item)
.cloned() .cloned()
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let revisions = Arc::new(Mutex::new(TicketRevisionState::default())); let observations = Arc::new(Mutex::new(TicketObservationState::default()));
let collection_descriptor = ticket_descriptor( let collection_descriptor = ticket_descriptor(
&collection_tools, &collection_tools,
@@ -1824,7 +1823,7 @@ pub fn mount_workspace_http_ticket_wip(
tools: operation_map(collection_tools), tools: operation_map(collection_tools),
permissions: permissions.clone(), permissions: permissions.clone(),
collection_route: collection_route.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), tools: operation_map(item_tools),
permissions, permissions,
collection_route: collection_route.clone(), collection_route: collection_route.clone(),
revisions, observations,
}), }),
})?; })?;
registry.replace_compatibility_tools(&collection_route, claimed_tools)?; registry.replace_compatibility_tools(&collection_route, claimed_tools)?;
@@ -1861,7 +1860,7 @@ struct TicketCollectionWipHandler {
tools: HashMap<String, NativeTicketTool>, tools: HashMap<String, NativeTicketTool>,
permissions: Option<ToolPermissionConfig>, permissions: Option<ToolPermissionConfig>,
collection_route: String, collection_route: String,
revisions: TicketRevisions, observations: TicketObservations,
} }
#[async_trait] #[async_trait]
@@ -1879,7 +1878,7 @@ impl WipOperationHandler for TicketCollectionWipHandler {
match operation { match operation {
"query" => add_ticket_paths(&mut response, &self.collection_route), "query" => add_ticket_paths(&mut response, &self.collection_route),
"create" => { "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) { if let Some(reference) = ticket_reference(&response).map(ToOwned::to_owned) {
response.as_object_mut().map(|object| { response.as_object_mut().map(|object| {
object.insert( object.insert(
@@ -1901,7 +1900,7 @@ struct TicketItemResolver {
tools: HashMap<String, NativeTicketTool>, tools: HashMap<String, NativeTicketTool>,
permissions: Option<ToolPermissionConfig>, permissions: Option<ToolPermissionConfig>,
collection_route: String, collection_route: String,
revisions: TicketRevisions, observations: TicketObservations,
} }
impl WipDynamicItemResolver for TicketItemResolver { impl WipDynamicItemResolver for TicketItemResolver {
@@ -1909,14 +1908,13 @@ impl WipDynamicItemResolver for TicketItemResolver {
if !is_ticket_route_reference(item_reference) { if !is_ticket_route_reference(item_reference) {
return None; return None;
} }
let revision = self let content_digest = self
.revisions .observations
.lock() .lock()
.unwrap_or_else(|error| error.into_inner()) .unwrap_or_else(|error| error.into_inner())
.revisions .content_digests
.get(item_reference) .get(item_reference)
.cloned() .cloned();
.unwrap_or_else(|| "unobserved".into());
let route = format!("{}/{}", self.collection_route, item_reference); let route = format!("{}/{}", self.collection_route, item_reference);
Some(WipDynamicItem { Some(WipDynamicItem {
object: Object { object: Object {
@@ -1924,13 +1922,15 @@ impl WipDynamicItemResolver for TicketItemResolver {
description: Some("Authoritative Ticket bound to this object route".into()), description: Some("Authoritative Ticket bound to this object route".into()),
interfaces: vec![crate::wip::root_reference(TICKET_ITEM_INTERFACE)], interfaces: vec![crate::wip::root_reference(TICKET_ITEM_INTERFACE)],
r#ref: Some(format!("ticket:{item_reference}")), 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 { handler: Arc::new(TicketItemWipHandler {
tools: self.tools.clone(), tools: self.tools.clone(),
permissions: self.permissions.clone(), permissions: self.permissions.clone(),
ticket_reference: item_reference.into(), ticket_reference: item_reference.into(),
revisions: Arc::clone(&self.revisions), observations: Arc::clone(&self.observations),
}), }),
}) })
} }
@@ -1940,7 +1940,7 @@ struct TicketItemWipHandler {
tools: HashMap<String, NativeTicketTool>, tools: HashMap<String, NativeTicketTool>,
permissions: Option<ToolPermissionConfig>, permissions: Option<ToolPermissionConfig>,
ticket_reference: String, ticket_reference: String,
revisions: TicketRevisions, observations: TicketObservations,
} }
#[async_trait] #[async_trait]
@@ -1960,13 +1960,13 @@ impl WipOperationHandler for TicketItemWipHandler {
let output = execute_native_ticket_tool(tool, &self.permissions, input, context).await?; let output = execute_native_ticket_tool(tool, &self.permissions, input, context).await?;
let response = ticket_tool_output_json(output)?; let response = ticket_tool_output_json(output)?;
record_ticket_observation( record_ticket_observation(
&self.revisions, &self.observations,
Some(&self.ticket_reference), Some(&self.ticket_reference),
&response, &response,
tool.projection.mutating, tool.projection.mutating,
); );
if 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( Ok(WipOperationOutput::native(
json_to_wip(&response).map_err(WipOperationError::OutcomeUnknown)?, 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)) .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( fn record_ticket_observation(
revisions: &TicketRevisions, observations: &TicketObservations,
bound_reference: Option<&str>, bound_reference: Option<&str>,
response: &Value, response: &Value,
mutation: bool, mutation: bool,
) { ) {
let observed_canonical = ticket_reference(response).map(ToOwned::to_owned); 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| { let existing_bound_group = bound_reference.and_then(|reference| {
state state
.aliases_by_canonical .aliases_by_canonical
@@ -2180,29 +2173,22 @@ fn record_ticket_observation(
aliases.insert(reference.to_string()); aliases.insert(reference.to_string());
} }
let revision = if mutation { // Mutation receipts are not full representations. Forget the observed
state.mutation_sequence = state.mutation_sequence.saturating_add(1); // content rather than manufacturing a local change counter.
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())
};
for alias in &aliases { 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); state.aliases_by_canonical.insert(group_key, aliases);
} }
fn record_affected_ticket_mutations( fn record_affected_ticket_mutations(
revisions: &TicketRevisions, observations: &TicketObservations,
bound_reference: &str, bound_reference: &str,
response: &Value, response: &Value,
) { ) {
@@ -2228,7 +2214,7 @@ fn record_affected_ticket_mutations(
); );
} }
for reference in affected.difference(&subject_references) { 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())) && reference.bytes().all(|byte| byte.is_ascii_alphanumeric()))
} }
fn route_validator(route: &str, revision: &str) -> Vec<u8> { fn route_validator(route: &str, content_digest: &str) -> Vec<u8> {
let mut digest = Sha256::new(); let mut digest = Sha256::new();
digest.update(route.as_bytes()); digest.update(route.as_bytes());
digest.update([0]); digest.update([0]);
digest.update(revision.as_bytes()); digest.update(content_digest.as_bytes());
digest.finalize().to_vec() digest.finalize().to_vec()
} }
@@ -2427,7 +2413,7 @@ mod tests {
fn decision_input() -> Value { fn decision_input() -> Value {
json!({ json!({
"ticket": "T-718", "operation_key": "decision-1", "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" "reason": "No repository changes are needed"
}) })
} }
@@ -2482,8 +2468,8 @@ mod tests {
let request: ticket::TicketStateUpdate = serde_json::from_value(body.clone()).unwrap(); let request: ticket::TicketStateUpdate = serde_json::from_value(body.clone()).unwrap();
assert!(uuid::Uuid::parse_str(&request.operation_key).is_ok()); assert!(uuid::Uuid::parse_str(&request.operation_key).is_ok());
assert_eq!( assert_eq!(
request.expected_item_revision, request.expected_content_digest,
ticket::ticket_item_revision(&snapshot) ticket::ticket_content_digest(&snapshot)
); );
assert_eq!(request.expected_state, TicketWorkflowState::Planning); assert_eq!(request.expected_state, TicketWorkflowState::Planning);
assert_eq!(request.state, TicketWorkflowState::Closed); assert_eq!(request.state, TicketWorkflowState::Closed);
@@ -2592,8 +2578,8 @@ mod tests {
assert!(uuid::Uuid::parse_str(&request.operation_key).is_ok()); assert!(uuid::Uuid::parse_str(&request.operation_key).is_ok());
assert!(keys.insert(request.operation_key)); assert!(keys.insert(request.operation_key));
assert_eq!( assert_eq!(
request.expected_item_revision, request.expected_content_digest,
ticket::ticket_item_revision(&snapshot) ticket::ticket_content_digest(&snapshot)
); );
assert_eq!(request.expected_state, TicketWorkflowState::Ready); assert_eq!(request.expected_state, TicketWorkflowState::Ready);
assert_eq!(request.reason, "Resolved without repository changes"); assert_eq!(request.reason, "Resolved without repository changes");
@@ -2643,7 +2629,7 @@ mod tests {
); );
for field in [ for field in [
"operation_key", "operation_key",
"expected_item_revision", "expected_content_digest",
"expected_state", "expected_state",
"resolution", "resolution",
] { ] {
@@ -2660,13 +2646,19 @@ mod tests {
.iter() .iter()
.any(|p| p.name == "references" && !p.required) .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 { let resolver = TicketItemResolver {
tools: operation_map(tools), tools: operation_map(tools),
permissions: None, permissions: None,
collection_route: "/tickets".into(), 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 bound = resolver.resolve("T-718").unwrap();
let before = bound.object.validator; let before = bound.object.validator;
let other = resolver.resolve("T-719").unwrap().object.validator; let other = resolver.resolve("T-719").unwrap().object.validator;
@@ -2702,7 +2694,7 @@ mod tests {
assert_eq!( assert_eq!(
body, body,
json!({ 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", "expected_state": "planning", "state": "closed",
"reason": "No repository changes are needed", "references": [] "reason": "No repository changes are needed", "references": []
}) })
@@ -2794,7 +2786,7 @@ mod tests {
assert_eq!( assert_eq!(
body, body,
json!({ 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": [] "expected_state": "planning", "reason": "No repository changes are needed", "references": []
}) })
); );
@@ -2859,7 +2851,7 @@ mod tests {
assert!(validator.is_valid(&valid)); assert!(validator.is_valid(&valid));
for field in [ for field in [
"operation_key", "operation_key",
"expected_item_revision", "expected_content_digest",
"expected_state", "expected_state",
reason_field, reason_field,
] { ] {
@@ -2875,7 +2867,7 @@ mod tests {
for field in [ for field in [
reason_field, reason_field,
"operation_key", "operation_key",
"expected_item_revision", "expected_content_digest",
"ticket", "ticket",
] { ] {
let mut invalid = valid.clone(); let mut invalid = valid.clone();
@@ -2968,7 +2960,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn native_ticket_completion_binds_subject_and_stales_only_its_ticket_not_mrs() { async fn native_ticket_completion_binds_subject_and_stales_only_its_ticket_not_mrs() {
let client = decision_client(200, TicketWorkflowState::Done); 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 = let tools =
native_ticket_tools(client.clone(), TicketFeatureAccess::work_report()).unwrap(); native_ticket_tools(client.clone(), TicketFeatureAccess::work_report()).unwrap();
let descriptor = ticket_descriptor(&tools, "Ticket", "bound").unwrap(); let descriptor = ticket_descriptor(&tools, "Ticket", "bound").unwrap();
@@ -2980,7 +2972,7 @@ mod tests {
assert!(!complete.parameters.iter().any(|p| p.name == "ticket")); assert!(!complete.parameters.iter().any(|p| p.name == "ticket"));
for field in [ for field in [
"operation_key", "operation_key",
"expected_item_revision", "expected_content_digest",
"expected_state", "expected_state",
"reason", "reason",
] { ] {
@@ -2995,8 +2987,14 @@ mod tests {
tools: operation_map(tools), tools: operation_map(tools),
permissions: None, permissions: None,
collection_route: "/tickets".into(), 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 bound = resolver.resolve("T-718").unwrap();
let before = bound.object.validator; let before = bound.object.validator;
let other = resolver.resolve("T-719").unwrap().object.validator; let other = resolver.resolve("T-719").unwrap().object.validator;
@@ -3138,12 +3136,12 @@ mod tests {
.is_err() .is_err()
); );
let revisions = Arc::new(Mutex::new(TicketRevisionState::default())); let observations = Arc::new(Mutex::new(TicketObservationState::default()));
let resolver = TicketItemResolver { let resolver = TicketItemResolver {
tools: HashMap::new(), tools: HashMap::new(),
permissions: None, permissions: None,
collection_route: "/tickets".into(), collection_route: "/tickets".into(),
revisions, observations,
}; };
assert!(resolver.resolve("T-42").is_some()); assert!(resolver.resolve("T-42").is_some());
assert!(resolver.resolve("00001TICKET").is_some()); assert!(resolver.resolve("00001TICKET").is_some());
@@ -3202,36 +3200,36 @@ mod tests {
#[test] #[test]
fn ticket_alias_validators_refresh_together_after_mutation() { 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 { let resolver = TicketItemResolver {
tools: HashMap::new(), tools: HashMap::new(),
permissions: None, permissions: None,
collection_route: "/tickets".into(), collection_route: "/tickets".into(),
revisions: Arc::clone(&revisions), observations: Arc::clone(&observations),
}; };
let unobserved_internal = resolver.resolve("00001TICKET").unwrap().object.validator; let unobserved_internal = resolver.resolve("00001TICKET").unwrap().object.validator;
record_ticket_observation( record_ticket_observation(
&revisions, &observations,
Some("00001TICKET"), Some("00001TICKET"),
&json!({"ticket": "00001TICKET", "ok": true}), &json!({"ticket": "00001TICKET", "ok": true}),
true, true,
); );
assert_ne!( assert_eq!(
unobserved_internal, unobserved_internal,
resolver.resolve("00001TICKET").unwrap().object.validator, 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( record_ticket_observation(
&revisions, &observations,
Some("00001TICKET"), Some("00001TICKET"),
&json!({"ticket": "T-42", "item_revision": "rev-1"}), &json!({"ticket": "T-42", "content_digest": "rev-1"}),
false, false,
); );
let canonical_v1 = resolver.resolve("T-42").unwrap().object.validator; let canonical_v1 = resolver.resolve("T-42").unwrap().object.validator;
let internal_v1 = resolver.resolve("00001TICKET").unwrap().object.validator; let internal_v1 = resolver.resolve("00001TICKET").unwrap().object.validator;
record_ticket_observation( record_ticket_observation(
&revisions, &observations,
Some("T-42"), Some("T-42"),
&json!({"ticket": "T-42", "updated_at": "rev-2"}), &json!({"ticket": "T-42", "updated_at": "rev-2"}),
true, 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] #[test]
fn related_ticket_mutations_invalidate_target_and_queue_observations() { 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"] { for ticket in ["T-42", "T-43", "T-44"] {
record_ticket_observation( record_ticket_observation(
&revisions, &observations,
Some(ticket), Some(ticket),
&json!({"ticket": ticket, "item_revision": "rev-1"}), &json!({"ticket": ticket, "content_digest": "rev-1"}),
false, false,
); );
} }
@@ -3261,12 +3288,12 @@ mod tests {
tools: HashMap::new(), tools: HashMap::new(),
permissions: None, permissions: None,
collection_route: "/tickets".into(), collection_route: "/tickets".into(),
revisions: Arc::clone(&revisions), observations: Arc::clone(&observations),
}; };
let target_v1 = resolver.resolve("T-43").unwrap().object.validator; let target_v1 = resolver.resolve("T-43").unwrap().object.validator;
let queued_v1 = resolver.resolve("T-44").unwrap().object.validator; let queued_v1 = resolver.resolve("T-44").unwrap().object.validator;
record_affected_ticket_mutations( record_affected_ticket_mutations(
&revisions, &observations,
"T-42", "T-42",
&json!({ &json!({
"ticket": "T-42", "ticket": "T-42",
@@ -99,7 +99,6 @@ fn same_snapshot(
) -> Result<(), WipOperationError> { ) -> Result<(), WipOperationError> {
if snapshot.connection_id != other.connection_id if snapshot.connection_id != other.connection_id
|| snapshot.validator != other.validator || snapshot.validator != other.validator
|| snapshot.revision != other.revision
|| snapshot.digest != other.digest || snapshot.digest != other.digest
|| snapshot.entrypoints != other.entrypoints || snapshot.entrypoints != other.entrypoints
{ {
@@ -504,7 +503,7 @@ impl ConfigHandler {
"Workspace config text exceeds limit", "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( let metadata = observe(
&self.backend, &self.backend,
&self.attachment.connection_id, &self.attachment.connection_id,
@@ -716,7 +715,6 @@ impl ConfigHandler {
connection_id: self.attachment.connection_id.clone(), connection_id: self.attachment.connection_id.clone(),
validator: self.snapshot.validator.clone(), validator: self.snapshot.validator.clone(),
request: ConfigCommitRequest { request: ConfigCommitRequest {
base_revision: self.snapshot.revision,
base_digest: self.snapshot.digest.clone(), base_digest: self.snapshot.digest.clone(),
changes, changes,
entrypoints: self.snapshot.entrypoints.clone(), entrypoints: self.snapshot.entrypoints.clone(),
@@ -735,10 +733,7 @@ impl ConfigHandler {
) )
.await .await
.map_err(|_| unknown())?; .map_err(|_| unknown())?;
if updated.revision != result.revision if updated.digest != result.digest || updated.validator != result.validator {
|| updated.digest != result.digest
|| updated.validator != result.validator
{
return Err(unknown()); return Err(unknown());
} }
let node = updated let node = updated
@@ -746,7 +741,7 @@ impl ConfigHandler {
.iter() .iter()
.find(|n| n.path == self.node.path) .find(|n| n.path == self.node.path)
.ok_or_else(unknown)?; .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())?; "activation": "Saved to canonical Workspace configuration; existing Worker manifests are not rewritten"})).map_err(|_| unknown())?;
Ok(WipOperationOutput::native_with_validator( Ok(WipOperationOutput::native_with_validator(
value, value,
@@ -14,7 +14,6 @@ use protocol::{FeatureInvocation, FeatureInvocationIdentity};
use serde::Serialize; use serde::Serialize;
use serde_json::{Value as Json, json}; use serde_json::{Value as Json, json};
use server_api::*; use server_api::*;
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@@ -24,7 +23,6 @@ struct State {
read_only: bool, read_only: bool,
active: Option<WorkspaceConfigAttachment>, active: Option<WorkspaceConfigAttachment>,
next_connection: u64, next_connection: u64,
revision: u64,
entries: BTreeMap<String, (ConfigContentType, String)>, entries: BTreeMap<String, (ConfigContentType, String)>,
commits: Vec<WorkspaceConfigCommitRequest>, commits: Vec<WorkspaceConfigCommitRequest>,
requests: Vec<WorkspaceRequest>, requests: Vec<WorkspaceRequest>,
@@ -32,6 +30,7 @@ struct State {
fail_current: bool, fail_current: bool,
lose_commit_reply: bool, lose_commit_reply: bool,
race_commit: bool, race_commit: bool,
mismatched_commit_reply: Option<&'static str>,
} }
#[derive(Debug)] #[derive(Debug)]
struct Router { struct Router {
@@ -47,7 +46,6 @@ impl Router {
read_only: false, read_only: false,
active: None, active: None,
next_connection: 0, next_connection: 0,
revision: 1,
entries: BTreeMap::from([ entries: BTreeMap::from([
( (
"main.dcdl".into(), "main.dcdl".into(),
@@ -64,6 +62,7 @@ impl Router {
fail_current: false, fail_current: false,
lose_commit_reply: false, lose_commit_reply: false,
race_commit: false, race_commit: false,
mismatched_commit_reply: None,
})), })),
}) })
} }
@@ -88,20 +87,30 @@ fn denied(status: u16, classification: WorkspaceConfigFailureClassification) ->
} }
} }
fn digest(content: &str) -> String { fn digest(content: &str) -> String {
Sha256::digest(content.as_bytes()) config_source::digest_bytes(content.as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
} }
impl State { impl State {
fn tree_digest(&self) -> String { 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 { fn validator(&self, path: &str) -> String {
format!( format!(
"wc:{}:{}:{}:{}", "wc:{}:{}:{}",
self.active.as_ref().unwrap().connection_id, self.active.as_ref().unwrap().connection_id,
self.revision,
self.tree_digest(), self.tree_digest(),
path path
) )
@@ -262,7 +271,6 @@ impl WorkspaceClient for Router {
Ok(response(&WorkspaceConfigObserveResponse { Ok(response(&WorkspaceConfigObserveResponse {
connection_id: state.active.as_ref().unwrap().connection_id.clone(), connection_id: state.active.as_ref().unwrap().connection_id.clone(),
validator: state.validator(""), validator: state.validator(""),
revision: state.revision,
digest: state.tree_digest(), digest: state.tree_digest(),
entrypoints: vec!["main.dcdl".into()], entrypoints: vec!["main.dcdl".into()],
nodes, nodes,
@@ -294,7 +302,7 @@ impl WorkspaceClient for Router {
let request: WorkspaceConfigCommitRequest = serde_json::from_value(input).unwrap(); let request: WorkspaceConfigCommitRequest = serde_json::from_value(input).unwrap();
state.commits.push(request.clone()); state.commits.push(request.clone());
if state.race_commit { if state.race_commit {
state.revision += 1; state.entries.get_mut("main.dcdl").unwrap().1 = "model = concurrent".into();
state.race_commit = false; state.race_commit = false;
} }
if state.read_only { if state.read_only {
@@ -305,7 +313,6 @@ impl WorkspaceClient for Router {
} }
assert_eq!(request.request.entrypoints, ["main.dcdl"]); assert_eq!(request.request.entrypoints, ["main.dcdl"]);
if request.validator != state.validator("") if request.validator != state.validator("")
|| request.request.base_revision != state.revision
|| request.request.base_digest != state.tree_digest() || request.request.base_digest != state.tree_digest()
{ {
return Ok(denied( return Ok(denied(
@@ -396,18 +403,23 @@ impl WorkspaceClient for Router {
)); ));
} }
state.entries = entries; state.entries = entries;
state.revision += 1;
if state.lose_commit_reply { if state.lose_commit_reply {
state.lose_commit_reply = false; state.lose_commit_reply = false;
return Err(WorkspaceClientError::Request( return Err(WorkspaceClientError::Request(
"secret-token /private/host/config".into(), "secret-token /private/host/config".into(),
)); ));
} }
Ok(response(&WorkspaceConfigCommitResponse { let mut result = WorkspaceConfigCommitResponse {
validator: state.validator(""), validator: state.validator(""),
revision: state.revision,
digest: state.tree_digest(), 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}"), _ => panic!("unregistered endpoint: {suffix}"),
} }
@@ -668,13 +680,20 @@ fn runtime_for_cross(client: Arc<dyn WorkspaceClient>) -> (WorkspaceConfigFeatur
} }
#[tokio::test] #[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 client = Router::new();
let (feature, runtime) = runtime(client.clone()); let (feature, runtime) = runtime(client.clone());
feature.attach("initial", None).await.unwrap(); feature.attach("initial", None).await.unwrap();
let path = "/workspace-config/main.dcdl"; let path = "/workspace-config/main.dcdl";
observe_interface(&runtime, path, true).await; 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!( assert!(
call(&runtime, path, "write", json!({"content":"overwritten"})) call(&runtime, path, "write", json!({"content":"overwritten"}))
.await .await
@@ -690,7 +709,7 @@ async fn workspace_config_production_stale_same_bytes_and_commit_race_never_over
); );
assert_eq!( assert_eq!(
client.state.lock().unwrap().entries["main.dcdl"].1, client.state.lock().unwrap().entries["main.dcdl"].1,
"model = old" "model = concurrent"
); );
assert_eq!(client.state.lock().unwrap().commits.len(), 1); 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] #[tokio::test]
async fn workspace_config_production_bounds_and_traversal_reject_before_content_calls() { async fn workspace_config_production_bounds_and_traversal_reject_before_content_calls() {
let client = Router::new(); 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:?}"); assert!(error.to_string().contains(expected), "{error:?}");
let state = client.state.lock().unwrap(); let state = client.state.lock().unwrap();
assert!(state.commits.is_empty()); assert!(state.commits.is_empty());
assert_eq!(state.revision, 1);
assert_eq!(state.entries["main.dcdl"].1, "model = old"); assert_eq!(state.entries["main.dcdl"].1, "model = old");
// Argument-only rejection must not even fetch a preimage. // Argument-only rejection must not even fetch a preimage.
assert!( assert!(

Some files were not shown because too many files have changed in this diff Show More