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",
"tempfile",
"thiserror 2.0.18",
"ticket",
"uuid",
]
@@ -4788,6 +4789,7 @@ dependencies = [
"serde",
"serde_json",
"server-api",
"sha2 0.11.0",
"tempfile",
"thiserror 2.0.18",
"uuid",
+1 -1
View File
@@ -172,5 +172,5 @@ webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation
[workspace.metadata.wip-upstream]
repository = "https://gitea.hareworks.net/Hare/wip-rs"
version = "0.2.0"
reference-revision = "6086f3c5ef10aa1464ce9c824750d7f667217bfd"
reference-commit = "6086f3c5ef10aa1464ce9c824750d7f667217bfd"
source = "crates.io"
+19 -19
View File
@@ -3,11 +3,11 @@ use server_api::{
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest,
RuntimeTrustKeyRevealResponse, SubjektivMemoryDetailQuery, SubjektivMemoryListQuery,
SubjektivMemoryListRevisionsResponse, SubjektivMemoryQueryResponse,
SubjektivMemoryReadResponse, SubjektivMemoryRevisionsQuery, SubjektivResidentSurfaceResponse,
SubjektivSubjectListQuery, SubjektivSubjectListResponse, SubjektivSubjectResponse,
WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource,
RuntimeTrustKeyRevealResponse, SubjektivMemoryChangesQuery, SubjektivMemoryDetailQuery,
SubjektivMemoryListChangesResponse, SubjektivMemoryListQuery, SubjektivMemoryQueryResponse,
SubjektivMemoryReadResponse, SubjektivResidentSurfaceResponse, SubjektivSubjectListQuery,
SubjektivSubjectListResponse, SubjektivSubjectResponse, WorkerLaunchOptionsResponse,
WorkspaceRuntimeDetail, WorkspaceRuntimeResource,
};
use ticket::{
MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation,
@@ -168,7 +168,7 @@ impl BackendWorkspaceProductClient {
};
Ok(ticket::TicketStateUpdate {
operation_key: uuid::Uuid::now_v7().to_string(),
expected_item_revision: ticket::ticket_item_revision(&current),
expected_content_digest: ticket::ticket_content_digest(&current),
expected_state: parse(&change.from)?,
state: parse(&change.to)?,
reason: if change.body.as_str().is_empty() {
@@ -208,7 +208,7 @@ impl BackendWorkspaceProductClient {
let current = self.show_ticket(id)?;
let request = ticket::TicketCompletion {
operation_key: uuid::Uuid::now_v7().to_string(),
expected_item_revision: ticket::ticket_item_revision(&current),
expected_content_digest: ticket::ticket_content_digest(&current),
expected_state: current.meta.workflow_state,
reason: resolution.as_str().to_string(),
references: Vec::new(),
@@ -521,19 +521,19 @@ impl BackendWorkspaceProductClient {
})
}
pub fn list_subjektiv_memory_revisions(
pub fn list_subjektiv_memory_changes(
&self,
subject_id: &str,
memory_id: &str,
query: &SubjektivMemoryRevisionsQuery,
) -> Result<SubjektivMemoryListRevisionsResponse, BackendWorkspaceClientError> {
query: &SubjektivMemoryChangesQuery,
) -> Result<SubjektivMemoryListChangesResponse, BackendWorkspaceClientError> {
let workspace_id = self.workspace_id.clone();
let subject_id = subject_id.to_string();
let memory_id = memory_id.to_string();
let query = query.clone();
self.generated(move |client| async move {
client
.subjektiv_memory_revisions(workspace_id, subject_id, memory_id, query)
.subjektiv_memory_changes(workspace_id, subject_id, memory_id, query)
.await
})
}
@@ -997,7 +997,7 @@ mod tests {
}
#[test]
fn legacy_state_adapter_uses_snapshot_revision_without_overriding_stale_expected_state() {
fn legacy_state_adapter_uses_snapshot_content_without_overriding_stale_expected_state() {
let temp = tempfile::tempdir().unwrap();
let backend =
ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a")
@@ -1021,8 +1021,8 @@ mod tests {
let body: serde_json::Value =
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
assert_eq!(
body["expected_item_revision"],
ticket::ticket_item_revision(&snapshot)
body["expected_content_digest"],
ticket::ticket_content_digest(&snapshot)
);
assert_eq!(body["expected_state"], "ready");
assert_eq!(body["state"], "done");
@@ -1057,8 +1057,8 @@ mod tests {
let body: serde_json::Value =
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
assert_eq!(
body["expected_item_revision"],
ticket::ticket_item_revision(&snapshot)
body["expected_content_digest"],
ticket::ticket_content_digest(&snapshot)
);
assert_eq!(body["expected_state"], "planning");
assert_eq!(body["reason"], "The request was withdrawn");
@@ -1070,7 +1070,7 @@ mod tests {
fn ticket_backend_completion_forwards_cas_judgment_and_backend_conflicts() {
let (base_url, request, handle) = one_response_server(
"409 Conflict",
r#"{"error":"ticket_conflict","message":"stale revision","diagnostics":[]}"#,
r#"{"error":"ticket_conflict","message":"stale content","diagnostics":[]}"#,
);
let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
@@ -1083,7 +1083,7 @@ mod tests {
"T-718",
ticket::TicketCompletion {
operation_key: "research-done".into(),
expected_item_revision: "ticket:1".into(),
expected_content_digest: "ticket:1".into(),
expected_state: ticket::TicketWorkflowState::Planning,
reason: "Research findings recorded".into(),
references: Vec::new(),
@@ -1100,7 +1100,7 @@ mod tests {
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
assert_eq!(body["state"], "done");
assert_eq!(body["operation_key"], "research-done");
assert_eq!(body["expected_item_revision"], "ticket:1");
assert_eq!(body["expected_content_digest"], "ticket:1");
assert_eq!(body["expected_state"], "planning");
assert_eq!(body["reason"], "Research findings recorded");
assert!(body.get("merge_request_ids").is_none());
+29 -6
View File
@@ -230,9 +230,7 @@ mod tests {
use config_source::{ConfigContentType, ConfigEntry};
fn snapshot() -> ConfigTreeSnapshot {
ConfigTreeSnapshot::from_entries(
7,
[
ConfigTreeSnapshot::from_entries([
ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
@@ -246,11 +244,37 @@ mod tests {
"{}",
)
.unwrap(),
],
)
])
.unwrap()
}
#[test]
fn wasm_analysis_uses_applied_tree_content_identity() {
let base = snapshot();
let path = VirtualPath::parse("main.dcdl").unwrap();
let change = ConfigTreeChange::Update {
path: path.clone(),
expected_digest: base.entries[&path].content_digest.clone(),
content: "{ broken = ; }".into(),
};
let changed = base.apply(&[change]).unwrap();
let diagnostics = session_environment(changed.clone()).analyze(&path, None);
assert!(!diagnostics.is_empty());
assert!(
diagnostics
.iter()
.all(|diagnostic| diagnostic.tree_digest == changed.digest)
);
assert_ne!(changed.digest, base.digest);
let restored = changed.apply(&changed.changes_to(&base)).unwrap();
assert_eq!(restored.digest, base.digest);
assert!(
session_environment(restored)
.analyze(&path, None)
.is_empty()
);
}
#[test]
fn wasm_completion_boundary_preserves_read_only_builtin_detail() {
let snapshot = snapshot();
@@ -301,7 +325,6 @@ mod tests {
})
.unwrap();
assert_eq!(diagnostic.path, path);
assert_eq!(diagnostic.revision, 7);
assert_eq!(diagnostic.tree_digest, snapshot.digest);
assert!(
diagnostic
@@ -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, };
+313 -138
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_IMPORT_DEPTH: usize = 32;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, ts_rs::TS)]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, ts_rs::TS)]
pub struct VirtualPath(String);
impl<'de> Deserialize<'de> for VirtualPath {
fn deserialize<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 {
pub fn parse(value: impl AsRef<str>) -> Result<Self, ConfigTreeError> {
let value = value.as_ref();
@@ -267,27 +274,46 @@ impl ConfigEntry {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, ts_rs::TS)]
pub struct ConfigTreeSnapshot {
#[ts(type = "number")]
pub revision: u64,
/// SHA-256 of ordered virtual paths, content types and complete source bytes.
/// Identifies tree content, not an edit sequence or freshness counter.
pub digest: String,
pub entries: BTreeMap<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 {
pub fn empty() -> Self {
Self::from_entries(0, Vec::new()).expect("empty config snapshot is valid")
Self::from_entries(Vec::new()).expect("empty config snapshot is valid")
}
pub fn from_entries(
revision: u64,
entries: impl IntoIterator<Item = ConfigEntry>,
) -> Result<Self, ConfigTreeError> {
let mut ordered = BTreeMap::new();
let mut total = 0usize;
for entry in entries {
validate_workspace_path(&entry.path)?;
if digest_bytes(entry.content.as_bytes()) != entry.content_digest {
return Err(ConfigTreeError::EntryDigestMismatch(entry.path));
}
if entry.content.len() > MAX_ENTRY_BYTES {
return Err(ConfigTreeError::LimitExceeded("entry bytes"));
}
@@ -306,12 +332,25 @@ impl ConfigTreeSnapshot {
}
let digest = snapshot_digest(&ordered);
Ok(Self {
revision,
digest,
entries: ordered,
})
}
/// Verify external snapshot metadata against the complete source content.
pub fn validate(&self) -> Result<(), ConfigTreeError> {
for (path, entry) in &self.entries {
if path != &entry.path {
return Err(ConfigTreeError::EntryPathMismatch(path.clone()));
}
}
let rebuilt = Self::from_entries(self.entries.values().cloned())?;
if self.digest != rebuilt.digest {
return Err(ConfigTreeError::TreeDigestMismatch);
}
Ok(())
}
pub fn list_prefix(&self, prefix: Option<&VirtualPath>) -> Vec<&ConfigEntry> {
self.entries
.values()
@@ -364,7 +403,11 @@ impl ConfigTreeSnapshot {
changes
}
/// Apply an atomic batch. Each expected_digest compares the target's complete
/// source bytes only; it is not a tree precondition. Callers needing whole-tree
/// CAS must also compare the authoritative tree digest under their write lock.
pub fn apply(&self, changes: &[ConfigTreeChange]) -> Result<Self, ConfigTreeError> {
self.validate()?;
if changes.len() > MAX_CHANGE_COUNT {
return Err(ConfigTreeError::LimitExceeded("change count"));
}
@@ -440,7 +483,7 @@ impl ConfigTreeSnapshot {
}
}
}
Self::from_entries(self.revision, entries.into_values())
Self::from_entries(entries.into_values())
}
}
@@ -788,8 +831,6 @@ pub struct ConfigDiagnosticLabel {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
pub struct ConfigDiagnostic {
pub path: VirtualPath,
#[ts(type = "number")]
pub revision: u64,
pub tree_digest: String,
pub kind: String,
pub span: ConfigSpan,
@@ -1436,7 +1477,6 @@ impl SnapshotEnvironment {
) -> ConfigDiagnostic {
ConfigDiagnostic {
path,
revision: self.snapshot.revision,
tree_digest: self.snapshot.digest.clone(),
kind: kind.into(),
span: ConfigSpan {
@@ -1572,13 +1612,39 @@ impl SnapshotImportLoader {
import_edges: BTreeMap::new(),
}
}
/// Translate an Engine cache identity back to its actual virtual source path.
/// Only exact keys produced by this loader are recognized; ordinary source
/// paths (including paths containing '@') are not split heuristically.
fn source_path(&self, key: &str) -> Result<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(
&self,
current_key: Option<&str>,
specifier: &str,
) -> Result<VirtualPath, ConfigTreeError> {
let current = current_key
.map(VirtualPath::parse)
.map(|key| self.source_path(key))
.transpose()?
.ok_or_else(|| ConfigTreeError::InvalidImport(specifier.into()))?;
resolve_import(&current, specifier)
@@ -1594,11 +1660,10 @@ impl ImportLoader for SnapshotImportLoader {
let path = self.resolve(current_key, specifier).map_err(import_error)?;
// Guard the import graph before returning a module to Decodal. In particular,
// cycles hidden inside lazy object fields must not recurse during materialization.
let current = current_key
.expect("resolve requires a source context")
.split("@sha256:")
.next()
.unwrap();
let current = self
.source_path(current_key.expect("resolve requires a source context"))
.map_err(import_error)?
.to_string();
if self
.import_edges
.entry(current.to_string())
@@ -1614,7 +1679,7 @@ impl ImportLoader for SnapshotImportLoader {
// A language service can keep using its loader after a diagnostic.
// Only successful edges may be cached as already validated.
self.import_edges
.get_mut(current)
.get_mut(&current)
.unwrap()
.remove(path.as_str());
return Err(diagnostic);
@@ -1651,7 +1716,10 @@ impl ImportLoader for SnapshotImportLoader {
return Err(import_error(ConfigTreeError::LimitExceeded("total bytes")));
}
return Ok(LoadedImport::source(
format!("{path}@{}", digest_bytes(resource.source.as_bytes())),
format!(
"config-source://{path}@{}",
digest_bytes(resource.source.as_bytes())
),
path.as_str(),
resource.source,
));
@@ -1747,9 +1815,16 @@ fn validate_import_graph(
fn snapshot_import_cache_key(entry: &ConfigEntry) -> String {
// The source id remains the virtual path for diagnostics and relative-import
// resolution. The cache identity also includes immutable source content so
// equal paths from different revisions cannot alias in an Engine cache.
format!("{}@{}", entry.path, entry.content_digest)
// resolution. Cache identity binds path, import interpretation and source bytes,
// so changed content or content type cannot alias in an Engine cache.
// The URI-like prefix is rejected by VirtualPath, keeping engine identities
// disjoint from literal paths even when a path spells another entry's digest.
format!(
"config-source://{}@{}@{}",
entry.path,
entry.content_type.media_type(),
entry.content_digest
)
}
fn is_builtin_path(path: &VirtualPath) -> bool {
@@ -1881,7 +1956,6 @@ fn project_engine_diagnostic(
.unwrap_or(fallback_path);
ConfigDiagnostic {
path,
revision: snapshot.revision,
tree_digest: snapshot.digest.clone(),
span: ConfigSpan {
start_byte: diagnostic.span.start,
@@ -1911,7 +1985,6 @@ fn project_diagnostic(
) -> ConfigDiagnostic {
ConfigDiagnostic {
path: fallback_path,
revision: snapshot.revision,
tree_digest: snapshot.digest.clone(),
kind: diagnostic_kind(diagnostic.kind).to_string(),
span: ConfigSpan {
@@ -2174,6 +2247,12 @@ pub enum ConfigTreeError {
NotFound(VirtualPath),
#[error("virtual config entry changed: {0}")]
EntryConflict(VirtualPath),
#[error("virtual config entry content digest mismatch: {0}")]
EntryDigestMismatch(VirtualPath),
#[error("virtual config entry path does not match its tree key: {0}")]
EntryPathMismatch(VirtualPath),
#[error("virtual config tree content digest mismatch")]
TreeDigestMismatch,
#[error("virtual config path changed more than once in one candidate: {0}")]
PathChangedMoreThanOnce(VirtualPath),
#[error("duplicate virtual config path")]
@@ -2254,13 +2333,10 @@ mod tests {
#[test]
fn candidate_changes_are_atomic_ordered_and_conflict_checked() {
let base = ConfigTreeSnapshot::from_entries(
7,
[
let base = ConfigTreeSnapshot::from_entries([
entry("profiles/a.dcdl", "{ a = 1; }"),
entry("shared.dcdl", "{}"),
],
)
])
.unwrap();
let updated = base
.apply(&[
@@ -2296,30 +2372,96 @@ mod tests {
));
}
#[test]
fn source_edits_use_content_preconditions_and_preserve_equal_tree_identity() {
let base = ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{ x = 1; }")]).unwrap();
let expected_digest = base.entries[&path("main.dcdl")].content_digest.clone();
let update = |source: &str| ConfigTreeChange::Update {
path: path("main.dcdl"),
expected_digest: expected_digest.clone(),
content: source.into(),
};
assert_eq!(base.apply(&[]).unwrap(), base);
assert_eq!(base.apply(&[update("{ x = 1; }")]).unwrap(), base);
let changed = base.apply(&[update("{ x = 2; }")]).unwrap();
assert!(matches!(
changed.apply(&[update("{ x = 3; }")]),
Err(ConfigTreeError::EntryConflict(_))
));
let restored = changed.apply(&changed.changes_to(&base)).unwrap();
assert_eq!(restored, base);
assert!(restored.apply(&[update("{ x = 3; }")]).is_ok());
// Equal evaluated values are not equal source trees.
let whitespace_edit = base.apply(&[update("{ x = 1; }\n")]).unwrap();
assert_ne!(whitespace_edit.digest, base.digest);
let contract = ToolchainContract::new(1, vec![path("main.dcdl")], 1);
assert_eq!(
SnapshotEnvironment::new(base)
.evaluate_contract(&contract)
.unwrap(),
SnapshotEnvironment::new(whitespace_edit)
.evaluate_contract(&contract)
.unwrap()
);
}
#[test]
fn snapshot_json_rejects_forged_content_identity() {
let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{}")]).unwrap();
let original = serde_json::to_value(&snapshot).unwrap();
assert_eq!(
original
.as_object()
.unwrap()
.keys()
.cloned()
.collect::<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]
fn snapshot_digest_is_deterministic() {
let left = ConfigTreeSnapshot::from_entries(
1,
[entry("z.dcdl", "{}"), entry("a.dcdl", "{ x = 1; }")],
)
let left = ConfigTreeSnapshot::from_entries([
entry("z.dcdl", "{}"),
entry("a.dcdl", "{ x = 1; }"),
])
.unwrap();
let right = ConfigTreeSnapshot::from_entries(
99,
[entry("a.dcdl", "{ x = 1; }"), entry("z.dcdl", "{}")],
)
let right = ConfigTreeSnapshot::from_entries([
entry("a.dcdl", "{ x = 1; }"),
entry("z.dcdl", "{}"),
])
.unwrap();
assert_eq!(left.digest, right.digest);
}
#[test]
fn entrypoint_can_assert_empty_workspace_schema_global() {
let snapshot = ConfigTreeSnapshot::from_entries(
1,
[entry(
let snapshot = ConfigTreeSnapshot::from_entries([entry(
"main.dcdl",
"{ answer = 42; } as WorkspaceConfigSchema",
)],
)
)])
.unwrap();
let contract = ToolchainContract::new(1, vec![path("main.dcdl")], 1);
SnapshotEnvironment::new(snapshot)
@@ -2373,7 +2515,7 @@ mod tests {
#[test]
fn completion_projects_workspace_schema_fields_into_config_objects() {
let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap();
let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{}")]).unwrap();
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"builtin:profile",
"profile",
@@ -2458,7 +2600,7 @@ mod tests {
#[test]
fn completion_projects_array_element_and_nested_profile_fields() {
let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap();
let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{}")]).unwrap();
let environment = SnapshotEnvironment::new(snapshot)
.with_schema_bundle(profile_entries_completion_schema());
for (marked_source, expected, prefix_len) in [
@@ -2544,7 +2686,7 @@ mod tests {
#[test]
fn completion_tracks_fixed_array_positions_and_nested_array_ranges() {
let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap();
let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{}")]).unwrap();
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"builtin:profile",
"profile",
@@ -2617,7 +2759,7 @@ mod tests {
#[test]
fn completion_preserves_general_schema_member_behavior() {
let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap();
let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{}")]).unwrap();
let environment = SnapshotEnvironment::new(snapshot)
.with_schema_bundle(profile_entries_completion_schema());
for source in [
@@ -2642,7 +2784,7 @@ mod tests {
#[test]
fn completion_array_schema_retains_nested_profile_diagnostics() {
let valid = "{ profile = { default_profile = \"default\"; entries = [{ selector = \"default\"; profile = { slug = \"default\"; worker = { mode = \"interactive\"; }; }; }]; }; } as WorkspaceConfigSchema";
let snapshot = ConfigTreeSnapshot::from_entries(7, [entry("main.dcdl", valid)]).unwrap();
let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", valid)]).unwrap();
let environment = SnapshotEnvironment::new(snapshot)
.with_schema_bundle(profile_entries_completion_schema());
assert!(environment.analyze(&path("main.dcdl"), None).is_empty());
@@ -2663,7 +2805,7 @@ mod tests {
diagnostics.iter().any(|diagnostic| {
diagnostic.kind == "constraint_violation"
&& diagnostic.path == path("main.dcdl")
&& diagnostic.revision == 7
&& diagnostic.tree_digest == environment.snapshot().digest
&& diagnostic.span.end_byte > diagnostic.span.start_byte
&& (diagnostic.message.contains(field)
|| diagnostic
@@ -2690,7 +2832,7 @@ mod tests {
)])
.unwrap();
let source = "{ profile = { defaults = {}; entries = [{ selector = \"default\"; profile = {}; }]; }; } as WorkspaceConfigSchema";
let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", source)]).unwrap();
let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", source)]).unwrap();
let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema.clone());
assert!(environment.analyze(&path("main.dcdl"), None).is_empty());
let contract = ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, schema);
@@ -2760,13 +2902,10 @@ mod tests {
fn actual_profile_schema_diagnoses_nested_values_without_materializing_omissions() {
let schema = actual_profile_schema();
let source = "{ profile = { entries = [{ selector = \"project:alpha\"; profile = {}; }]; }; } as WorkspaceConfigSchema";
let snapshot = ConfigTreeSnapshot::from_entries(
8,
[
let snapshot = ConfigTreeSnapshot::from_entries([
entry("main.dcdl", source),
entry("recipe.dcdl", "{ worker = { mode = 42; }; }"),
],
)
])
.unwrap();
let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema.clone());
let contract = ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, schema);
@@ -2814,7 +2953,7 @@ mod tests {
"{diagnostic:?}"
);
assert_eq!(diagnostic.path, path(expected_path), "{diagnostic:?}");
assert_eq!(diagnostic.revision, 8);
assert_eq!(diagnostic.tree_digest, environment.snapshot().digest);
let diagnostic_source = if expected_path == "main.dcdl" {
&source
} else {
@@ -2874,10 +3013,10 @@ mod tests {
let source = format!(
r#"{{ profile.entries = [{{ selector = "project:scoped"; profile = {form}; }}]; }} as WorkspaceConfigSchema"#
);
let snapshot = ConfigTreeSnapshot::from_entries(
9,
[entry("main.dcdl", &source), entry("scoped.dcdl", recipe)],
)
let snapshot = ConfigTreeSnapshot::from_entries([
entry("main.dcdl", &source),
entry("scoped.dcdl", recipe),
])
.unwrap();
let schema = actual_profile_schema();
let environment =
@@ -2967,10 +3106,10 @@ mod tests {
let source = format!(
r#"{{ profile.entries = [{{ selector = "project:corpus"; profile = {form}; }}]; }} as WorkspaceConfigSchema"#
);
let snapshot = ConfigTreeSnapshot::from_entries(
10,
[entry("main.dcdl", &source), entry("recipe.dcdl", &recipe)],
)
let snapshot = ConfigTreeSnapshot::from_entries([
entry("main.dcdl", &source),
entry("recipe.dcdl", &recipe),
])
.unwrap();
let schema = actual_profile_schema();
let environment =
@@ -3001,7 +3140,7 @@ mod tests {
}
let source = "{} as WorkspaceConfigSchema";
let environment = SnapshotEnvironment::new(
ConfigTreeSnapshot::from_entries(10, [entry("main.dcdl", source)]).unwrap(),
ConfigTreeSnapshot::from_entries([entry("main.dcdl", source)]).unwrap(),
)
.with_schema_bundle(actual_profile_schema());
for (profile_path, prefix, label) in [
@@ -3051,7 +3190,7 @@ mod tests {
.unwrap(),
])
.unwrap();
let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap();
let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{}")]).unwrap();
let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema);
for (marked_source, expected) in [
("{ ed| } as WorkspaceConfigSchema", "editor"),
@@ -3148,14 +3287,11 @@ mod tests {
#[test]
fn relative_imports_and_completion_share_the_snapshot_namespace() {
let snapshot = ConfigTreeSnapshot::from_entries(
1,
[
let snapshot = ConfigTreeSnapshot::from_entries([
entry("profiles/main.dcdl", r#"import "../shared/value.dcdl""#),
entry("shared/value.dcdl", "{ answer = 42; }"),
entry("other.dcdl", "{}"),
],
)
])
.unwrap();
assert_eq!(
resolve_import(&path("profiles/main.dcdl"), "../shared/value.dcdl").unwrap(),
@@ -3207,7 +3343,7 @@ mod tests {
#[test]
fn workspace_schema_applies_defaults_with_asymmetric_decodal_validation() {
let snapshot =
ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{ web = {}; }")]).unwrap();
ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{ web = {}; }")]).unwrap();
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"builtin:web",
"web",
@@ -3239,8 +3375,7 @@ mod tests {
("{ web = {}; custom = 42; }", "custom"),
("{ web = { typo = true; }; }", "typo"),
] {
let snapshot =
ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", source)]).unwrap();
let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", source)]).unwrap();
let diagnostics = SnapshotEnvironment::new(snapshot)
.evaluate_contract(&ToolchainContract::with_schema_bundle(
1,
@@ -3258,13 +3393,10 @@ mod tests {
#[test]
fn workspace_schema_supports_typed_associative_collections() {
let snapshot = ConfigTreeSnapshot::from_entries(
1,
[entry(
let snapshot = ConfigTreeSnapshot::from_entries([entry(
"main.dcdl",
"{ features = { web = { enabled = true; }; tickets = { enabled = false; }; }; }",
)],
)
)])
.unwrap();
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"builtin:features",
@@ -3315,8 +3447,7 @@ mod tests {
"constraintviolation",
),
] {
let snapshot =
ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", source)]).unwrap();
let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", source)]).unwrap();
let diagnostics = SnapshotEnvironment::new(snapshot)
.evaluate_contract(&ToolchainContract::with_schema_bundle(
1,
@@ -3335,7 +3466,7 @@ mod tests {
fn language_service_and_formatter_accept_decodal_0_4_schema_syntax() {
let source =
"{} as { features = {...{ enabled = Bool; }}; web = { enabled = Bool; ...Unknown }; }";
let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("schema.dcdl", source)]).unwrap();
let snapshot = ConfigTreeSnapshot::from_entries([entry("schema.dcdl", source)]).unwrap();
let environment = SnapshotEnvironment::new(snapshot);
let diagnostics = environment.analyze(&path("schema.dcdl"), None);
assert!(
@@ -3370,13 +3501,10 @@ mod tests {
#[test]
fn workspace_schema_preserves_fields_only_where_rest_is_explicit() {
let snapshot = ConfigTreeSnapshot::from_entries(
1,
[entry(
let snapshot = ConfigTreeSnapshot::from_entries([entry(
"main.dcdl",
"{ web = { enabled = true; extension_value = 42; }; }",
)],
)
)])
.unwrap();
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"builtin:web",
@@ -3402,8 +3530,7 @@ mod tests {
#[test]
fn unresolved_unknown_cannot_be_materialized() {
let snapshot =
ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "Unknown")]).unwrap();
let snapshot = ConfigTreeSnapshot::from_entries([entry("main.dcdl", "Unknown")]).unwrap();
let diagnostics = SnapshotEnvironment::new(snapshot)
.evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1))
.unwrap_err();
@@ -3413,10 +3540,8 @@ mod tests {
#[test]
fn workspace_schema_type_mismatch_is_a_decodal_diagnostic() {
let snapshot = ConfigTreeSnapshot::from_entries(
1,
[entry("main.dcdl", "{ web = { enabled = 1; }; }")],
)
let snapshot =
ConfigTreeSnapshot::from_entries([entry("main.dcdl", "{ web = { enabled = 1; }; }")])
.unwrap();
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"builtin:web",
@@ -3459,9 +3584,82 @@ mod tests {
let first = text_entry("skills/debug-rust/SKILL.md", "first");
let second = text_entry("skills/debug-rust/SKILL.md", "second");
let first_key = snapshot_import_cache_key(&first);
assert!(first_key.starts_with("skills/debug-rust/SKILL.md@sha256:"));
assert!(
first_key.starts_with("config-source://skills/debug-rust/SKILL.md@text/plain@sha256:")
);
assert!(first_key.ends_with(&first.content_digest));
assert_ne!(first_key, snapshot_import_cache_key(&second));
let different_type = entry("skills/debug-rust/SKILL.md", "first");
assert_ne!(first_key, snapshot_import_cache_key(&different_type));
}
#[test]
fn cached_import_identity_resolves_relative_paths_and_keeps_literal_at_paths() {
let source = entry("profiles/name@sha256:literal.dcdl", "{}");
let loader =
SnapshotImportLoader::new(ConfigTreeSnapshot::from_entries([source.clone()]).unwrap());
for key in [
source.path.as_str().to_owned(),
snapshot_import_cache_key(&source),
] {
assert_eq!(
loader.resolve(Some(&key), "./shared.dcdl").unwrap(),
path("profiles/shared.dcdl")
);
assert_eq!(loader.source_path(&key).unwrap(), source.path);
}
}
#[test]
fn snapshot_json_rejects_cache_identity_paths_even_with_valid_content_digests() {
let source = entry("a.dcdl", "{}");
// Build adversarial JSON with a correct tree digest, bypassing the private
// constructor only to describe the untrusted input at this boundary.
let forged = ConfigEntry::new(
VirtualPath(snapshot_import_cache_key(&source)),
ConfigContentType::Decodal,
r#"import "./shared.dcdl""#,
)
.unwrap();
let snapshot = ConfigTreeSnapshot::from_entries([source, forged]).unwrap();
let json = serde_json::to_string(&snapshot).unwrap();
let error = serde_json::from_str::<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]
@@ -3476,9 +3674,7 @@ mod tests {
"---\n",
"# Debug Rust\n",
);
let snapshot = ConfigTreeSnapshot::from_entries(
3,
[
let snapshot = ConfigTreeSnapshot::from_entries([
entry(
"main.dcdl",
r#"{ skill = import "./skills/debug-rust/SKILL.md" as { frontmatter = { name = String; description = String; ...Unknown }; content = String; }; }"#,
@@ -3512,13 +3708,10 @@ mod tests {
content: source.to_string(),
}
);
let snapshot = ConfigTreeSnapshot::from_entries(
1,
[
let snapshot = ConfigTreeSnapshot::from_entries([
entry("main.dcdl", r#"import "./skills/plain/SKILL.md""#),
text_entry("skills/plain/SKILL.md", source),
],
)
])
.unwrap();
let result = SnapshotEnvironment::new(snapshot)
.evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1))
@@ -3536,16 +3729,13 @@ mod tests {
project_markdown_document("---\nname: missing-close\nbody\n").unwrap_err(),
"opening YAML frontmatter delimiter has no closing delimiter"
);
let snapshot = ConfigTreeSnapshot::from_entries(
1,
[
let snapshot = ConfigTreeSnapshot::from_entries([
entry("main.dcdl", r#"import "./skills/broken/SKILL.md""#),
text_entry(
"skills/broken/SKILL.md",
"---\nname: [unterminated\n---\nbody\n",
),
],
)
])
.unwrap();
let diagnostics = SnapshotEnvironment::new(snapshot)
.evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1))
@@ -3557,7 +3747,7 @@ mod tests {
#[test]
fn builtin_imports_are_ordinary_values_with_confined_transitive_sources() {
let snapshot = ConfigTreeSnapshot::from_entries(1, [
let snapshot = ConfigTreeSnapshot::from_entries([
entry("main.dcdl", r#"import "$builtin/profiles/companion.dcdl" // { worker = { mode = "wip"; }; feature = { workspace_config = { enabled = true; }; }; }"#),
entry("profiles/base.dcdl", "{ slug = \"workspace-shadow\"; }"),
]).unwrap();
@@ -3583,13 +3773,10 @@ mod tests {
);
// Intersections conflict on explicit builtin values, exactly like any other import.
let snapshot = ConfigTreeSnapshot::from_entries(
1,
[entry(
let snapshot = ConfigTreeSnapshot::from_entries([entry(
"main.dcdl",
r#"import "$builtin/profiles/companion.dcdl" & { slug = "different"; }"#,
)],
)
)])
.unwrap();
assert!(
SnapshotEnvironment::new(snapshot)
@@ -3612,13 +3799,10 @@ mod tests {
"builtin:companion",
] {
let source = format!("import {specifier:?}");
let snapshot = ConfigTreeSnapshot::from_entries(
1,
[
let snapshot = ConfigTreeSnapshot::from_entries([
entry("main.dcdl", &source),
entry("profiles/base.dcdl", "{ spoof = true; }"),
],
)
])
.unwrap();
let diagnostics = SnapshotEnvironment::new(snapshot)
.evaluate_contract(&contract)
@@ -3684,7 +3868,7 @@ mod tests {
#[test]
fn builtin_values_do_not_bypass_cycle_or_path_limits() {
let snapshot = ConfigTreeSnapshot::from_entries(1, [
let snapshot = ConfigTreeSnapshot::from_entries([
entry("main.dcdl", r#"{ builtin = import "$builtin/profiles/companion.dcdl"; cycle = import "./cycle.dcdl"; }"#),
entry("cycle.dcdl", r#"import "./main.dcdl""#),
]).unwrap();
@@ -3718,7 +3902,7 @@ mod tests {
#[test]
fn rejected_import_graph_edges_stay_rejected_after_diagnostics() {
let mut loader = SnapshotImportLoader::new(
ConfigTreeSnapshot::from_entries(1, [entry("a.dcdl", "{}"), entry("b.dcdl", "{}")])
ConfigTreeSnapshot::from_entries([entry("a.dcdl", "{}"), entry("b.dcdl", "{}")])
.unwrap(),
);
loader.load(Some("a.dcdl"), "./b.dcdl").unwrap();
@@ -3760,7 +3944,7 @@ mod tests {
entries.push(entry(&format!("{index}.dcdl"), &source));
}
let diagnostics =
SnapshotEnvironment::new(ConfigTreeSnapshot::from_entries(1, entries).unwrap())
SnapshotEnvironment::new(ConfigTreeSnapshot::from_entries(entries).unwrap())
.evaluate_contract(&ToolchainContract::new(1, vec![path("0.dcdl")], 1))
.unwrap_err();
assert!(
@@ -3781,7 +3965,7 @@ mod tests {
entries.push(text_entry(&format!("unused/{i}.txt"), ""));
}
let environment =
SnapshotEnvironment::new(ConfigTreeSnapshot::from_entries(1, entries).unwrap());
SnapshotEnvironment::new(ConfigTreeSnapshot::from_entries(entries).unwrap());
let diagnostics = environment.evaluate_contract(&contract).unwrap_err();
assert!(diagnostics[0].message.contains("entry count"));
let mut entries = vec![entry(
@@ -3798,20 +3982,17 @@ mod tests {
entries.push(text_entry(&format!("unused/{i}.txt"), &"a".repeat(bytes)));
}
let environment =
SnapshotEnvironment::new(ConfigTreeSnapshot::from_entries(1, entries).unwrap());
SnapshotEnvironment::new(ConfigTreeSnapshot::from_entries(entries).unwrap());
let diagnostics = environment.evaluate_contract(&contract).unwrap_err();
assert!(diagnostics[0].message.contains("total bytes"));
}
#[test]
fn host_environment_evaluation_uses_only_snapshot_imports() {
let snapshot = ConfigTreeSnapshot::from_entries(
3,
[
let snapshot = ConfigTreeSnapshot::from_entries([
entry("profiles/main.dcdl", r#"import "./shared.dcdl""#),
entry("profiles/shared.dcdl", "{ answer = 42; }"),
],
)
])
.unwrap();
let contract = ToolchainContract::new(
DEFAULT_SCHEMA_VERSION,
@@ -3826,13 +4007,10 @@ mod tests {
#[test]
fn candidate_evaluation_rejects_invalid_unreferenced_decodal_source() {
let snapshot = ConfigTreeSnapshot::from_entries(
1,
[
let snapshot = ConfigTreeSnapshot::from_entries([
entry("workspace.dcdl", "{ answer = 42; }"),
entry("unused.dcdl", "{ broken = ; }"),
],
)
])
.unwrap();
let diagnostics = SnapshotEnvironment::new(snapshot)
.evaluate_contract(&ToolchainContract::new(1, vec![path("workspace.dcdl")], 1))
@@ -3844,7 +4022,7 @@ mod tests {
#[test]
fn missing_import_and_cycles_are_structured_failures() {
let missing =
ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", r#"import "./missing.dcdl""#)])
ConfigTreeSnapshot::from_entries([entry("main.dcdl", r#"import "./missing.dcdl""#)])
.unwrap();
let contract = ToolchainContract::new(1, vec![path("main.dcdl")], 1);
let diagnostics = SnapshotEnvironment::new(missing)
@@ -3852,13 +4030,10 @@ mod tests {
.unwrap_err();
assert_eq!(diagnostics[0].kind, "import");
let cycle = ConfigTreeSnapshot::from_entries(
1,
[
let cycle = ConfigTreeSnapshot::from_entries([
entry("a.dcdl", r#"import "./b.dcdl""#),
entry("b.dcdl", r#"import "./a.dcdl""#),
],
)
])
.unwrap();
let diagnostics = SnapshotEnvironment::new(cycle)
.evaluate_contract(&ToolchainContract::new(1, vec![path("a.dcdl")], 1))
+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)]
pub struct BuiltinFlowSource {
pub slug: &'static str,
/// Monotonic resource revision. Increment when built-in semantics change;
/// Runtime also pins the compiled content digest.
pub revision: u64,
pub path: &'static str,
pub content: &'static str,
}
@@ -24,7 +21,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
match slug {
CODER_REVIEW_FLOW_SLUG => Some(BuiltinFlowSource {
slug: CODER_REVIEW_FLOW_SLUG,
revision: 5,
path: "builtin/flows/coder-review.dcdl",
content: CODER_REVIEW_FLOW_SOURCE,
}),
@@ -35,7 +32,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] {
const SOURCES: &[BuiltinFlowSource] = &[BuiltinFlowSource {
slug: CODER_REVIEW_FLOW_SLUG,
revision: 5,
path: "builtin/flows/coder-review.dcdl",
content: CODER_REVIEW_FLOW_SOURCE,
}];
@@ -64,7 +61,6 @@ mod tests {
"MergeRequestShow",
"MergeRequestReview",
"MergeRequestComplete",
"new immutable revision",
] {
assert!(!source.content.contains(stale), "stale contract {stale}");
}
@@ -74,10 +70,6 @@ mod tests {
fn every_builtin_flow_compiles_and_matches_catalog_identity() {
assert!(!builtin_flow_sources().is_empty());
for source in builtin_flow_sources() {
assert!(
source.revision > 0,
"built-in Flow revision must be positive"
);
let definition = source.compile().unwrap_or_else(|error| {
panic!(
"builtin Flow {} failed to compile: {:?}",
@@ -88,7 +80,6 @@ mod tests {
let selected =
builtin_flow_source(source.slug).expect("builtin Flow must be selectable");
assert_eq!(selected.content, source.content);
assert_eq!(selected.revision, source.revision);
}
}
}
+126 -56
View File
@@ -21,10 +21,8 @@ pub enum FlowInstanceStatus {
pub struct FlowInstance {
pub instance_id: String,
pub definition_id: String,
pub definition_revision: u64,
pub definition_digest: String,
pub current_state: StateId,
pub state_revision: u64,
pub status: FlowInstanceStatus,
pub active_attempt_id: Option<String>,
}
@@ -33,7 +31,6 @@ impl FlowInstance {
pub fn start(
instance_id: impl Into<String>,
definition_id: impl Into<String>,
definition_revision: u64,
definition: &CompiledFlowDefinition,
) -> Result<Self, FlowTransitionError> {
let instance_id = instance_id.into();
@@ -41,11 +38,6 @@ impl FlowInstance {
ensure_non_empty("instance_id", &instance_id)
.and_then(|_| ensure_non_empty("definition_id", &definition_id))
.map_err(FlowTransitionError::InvalidRequest)?;
if definition_revision == 0 {
return Err(FlowTransitionError::InvalidRequest(
"definition_revision must be positive".to_string(),
));
}
let initial_state = definition.state(&definition.initial).ok_or_else(|| {
FlowTransitionError::Invariant("compiled initial state is missing".to_string())
})?;
@@ -57,10 +49,8 @@ impl FlowInstance {
Ok(Self {
instance_id,
definition_id,
definition_revision,
definition_digest: definition.content_digest.clone(),
current_state: definition.initial.clone(),
state_revision: 0,
status,
active_attempt_id: None,
})
@@ -77,9 +67,7 @@ pub struct FlowTransitionRequest {
pub struct FlowTransitionAttempt {
pub attempt_id: String,
pub instance_id: String,
pub definition_revision: u64,
pub definition_digest: String,
pub checked_state_revision: u64,
pub from_state: StateId,
pub reason: String,
pub transitions: Vec<TransitionCheckSnapshot>,
@@ -174,7 +162,6 @@ pub enum FlowEventKind {
TransitionRequested {
attempt_id: String,
state_id: StateId,
state_revision: u64,
reason: String,
transitions: Vec<TransitionCheckSnapshot>,
},
@@ -201,7 +188,6 @@ pub enum FlowEventKind {
StateEntered {
attempt_id: String,
state_id: StateId,
state_revision: u64,
status: FlowInstanceStatus,
},
}
@@ -214,7 +200,7 @@ pub struct FlowRuntimeEvent {
/// Durable Flow authority owned by one Runtime Worker.
///
/// Workspace authority resolves and revisions the source, but never mutates
/// Workspace authority resolves the exact source content, but never mutates
/// this value. Runtime persists the complete snapshot with the Worker session
/// and replaces it only after the corresponding session-log write succeeds.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -240,12 +226,8 @@ impl FlowRuntimeState {
"resolved Flow selector does not match compiled definition name".to_string(),
));
}
let instance = FlowInstance::start(
instance_id,
source.flow_id.clone(),
source.revision,
&source.definition,
)?;
let instance =
FlowInstance::start(instance_id, source.flow_id.clone(), &source.definition)?;
let initial = source
.definition
.state(&source.definition.initial)
@@ -257,7 +239,6 @@ impl FlowRuntimeState {
event: FlowEventKind::StateEntered {
attempt_id: String::new(),
state_id: instance.current_state.clone(),
state_revision: instance.state_revision,
status: instance.status,
},
};
@@ -282,8 +263,22 @@ impl FlowRuntimeState {
if let Some(attempt) = &self.active_attempt {
return Ok(attempt.clone());
}
let attempt_id = attempt_id.into();
// The event log is the identity authority: an attempt ID is used once,
// including failed/cancelled attempts. This prevents a delayed result
// from an earlier visit to the same state from matching a later attempt.
if self.events.iter().any(|event| {
matches!(
&event.event,
FlowEventKind::TransitionRequested { attempt_id: used, .. } if used == &attempt_id
)
}) {
return Err(FlowTransitionError::InvalidRequest(
"transition attempt ID has already been used".to_string(),
));
}
let request = FlowTransitionRequest {
attempt_id: attempt_id.into(),
attempt_id,
reason: reason.into(),
};
let (attempt, events) = begin_transition(&mut self.instance, &self.definition, request)?;
@@ -329,7 +324,7 @@ pub enum FlowTransitionError {
NotActive,
#[error("another transition attempt is already active")]
AttemptInProgress,
#[error("Flow definition does not match the instance's pinned revision")]
#[error("Flow definition content does not match the instance's pinned digest")]
DefinitionMismatch,
#[error("invalid transition request: {0}")]
InvalidRequest(String),
@@ -337,7 +332,7 @@ pub enum FlowTransitionError {
Invariant(String),
}
pub fn begin_transition(
fn begin_transition(
instance: &mut FlowInstance,
definition: &CompiledFlowDefinition,
request: FlowTransitionRequest,
@@ -374,9 +369,7 @@ pub fn begin_transition(
let attempt = FlowTransitionAttempt {
attempt_id: request.attempt_id,
instance_id: instance.instance_id.clone(),
definition_revision: instance.definition_revision,
definition_digest: instance.definition_digest.clone(),
checked_state_revision: instance.state_revision,
from_state: instance.current_state.clone(),
reason: request.reason,
transitions,
@@ -387,7 +380,6 @@ pub fn begin_transition(
FlowEventKind::TransitionRequested {
attempt_id: attempt.attempt_id.clone(),
state_id: attempt.from_state.clone(),
state_revision: attempt.checked_state_revision,
reason: attempt.reason.clone(),
transitions: attempt.transitions.clone(),
},
@@ -398,20 +390,23 @@ pub fn begin_transition(
Ok((attempt, events))
}
pub fn resolve_transition(
fn resolve_transition(
instance: &mut FlowInstance,
definition: &CompiledFlowDefinition,
mut attempt: FlowTransitionAttempt,
outcome: FlowVerifierOutcome,
) -> Result<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(
"attempt is not the active/latest attempt for this Flow instance".to_string(),
));
}
if definition.content_digest != instance.definition_digest
|| attempt.definition_digest != instance.definition_digest
|| attempt.definition_revision != instance.definition_revision
{
return Err(FlowTransitionError::DefinitionMismatch);
}
@@ -447,7 +442,7 @@ pub fn resolve_transition(
)
}
FlowVerifierOutcome::Completed { results } => {
if instance.state_revision != attempt.checked_state_revision
if instance.status != FlowInstanceStatus::Active
|| instance.current_state != attempt.from_state
{
attempt.status = FlowAttemptStatus::Rejected;
@@ -604,9 +599,6 @@ fn resolve_completed_results(
results,
});
instance.current_state = transition.target.clone();
instance.state_revision = instance.state_revision.checked_add(1).ok_or_else(|| {
FlowTransitionError::Invariant("Flow state revision overflowed".to_string())
})?;
instance.status = if transition.target.as_str() == CANCELLED_STATE_ID {
FlowInstanceStatus::Cancelled
} else if target_state.terminal {
@@ -617,7 +609,6 @@ fn resolve_completed_results(
events.push(FlowEventKind::StateEntered {
attempt_id: attempt.attempt_id.clone(),
state_id: instance.current_state.clone(),
state_revision: instance.state_revision,
status: instance.status,
});
Ok(FlowTransitionResolution {
@@ -735,16 +726,109 @@ mod tests {
attempt
}
fn runtime_state() -> FlowRuntimeState {
let definition = definition();
FlowRuntimeState::start(
&crate::ResolvedFlowSource {
selector: "workspace:simple".parse().unwrap(),
workspace_id: "workspace-1".into(),
flow_id: "flow-1".into(),
content_digest: definition.content_digest.clone(),
definition,
},
"instance-1",
)
.unwrap()
.0
}
#[test]
fn cancelled_attempt_id_cannot_be_reused_even_after_restore() {
let mut state = runtime_state();
state.begin_or_recover_transition("first", "ready").unwrap();
state
.resolve_active_transition("first", FlowVerifierOutcome::Cancelled)
.unwrap();
let mut restored: FlowRuntimeState =
serde_json::from_value(serde_json::to_value(&state).unwrap()).unwrap();
let before = restored.clone();
assert!(matches!(
restored.begin_or_recover_transition("first", "try again"),
Err(FlowTransitionError::InvalidRequest(_))
));
assert_eq!(restored, before);
restored
.begin_or_recover_transition("second", "try again")
.unwrap();
assert_eq!(
restored.instance.active_attempt_id.as_deref(),
Some("second")
);
}
#[test]
fn late_result_cannot_consume_a_new_attempt_in_the_same_state() {
let mut state = runtime_state();
state.begin_or_recover_transition("first", "ready").unwrap();
state
.resolve_active_transition("first", FlowVerifierOutcome::Cancelled)
.unwrap();
state
.begin_or_recover_transition("second", "ready")
.unwrap();
let before = state.clone();
assert!(matches!(
state.resolve_active_transition(
"first",
FlowVerifierOutcome::Completed { results: vec![] }
),
Err(FlowTransitionError::InvalidRequest(_))
));
assert_eq!(state, before);
state
.resolve_active_transition("second", FlowVerifierOutcome::Cancelled)
.unwrap();
assert!(state.active_attempt.is_none());
}
#[test]
fn source_content_is_checked_before_start_and_before_accepting_a_result() {
let mut state = runtime_state();
let mut changed = state.definition.clone();
changed.content_digest = "different-content".into();
assert_eq!(
begin_transition(
&mut state.instance,
&changed,
FlowTransitionRequest {
attempt_id: "first".into(),
reason: "ready".into(),
}
)
.unwrap_err(),
FlowTransitionError::DefinitionMismatch
);
assert!(state.instance.active_attempt_id.is_none());
state.begin_or_recover_transition("first", "ready").unwrap();
state.definition = changed;
let before = state.clone();
assert_eq!(
state
.resolve_active_transition("first", FlowVerifierOutcome::Cancelled)
.unwrap_err(),
FlowTransitionError::DefinitionMismatch
);
assert_eq!(state, before);
}
#[test]
fn exactly_one_met_transition_enters_terminal_state() {
let definition = definition();
let mut instance = FlowInstance {
instance_id: "instance-1".to_string(),
definition_id: "definition-1".to_string(),
definition_revision: 1,
definition_digest: definition.content_digest.clone(),
current_state: definition.initial.clone(),
state_revision: 0,
status: FlowInstanceStatus::Active,
active_attempt_id: None,
};
@@ -770,7 +854,7 @@ mod tests {
)
.unwrap();
assert_eq!(instance.current_state.as_str(), "done");
assert_eq!(instance.state_revision, 1);
assert_eq!(instance.status, FlowInstanceStatus::Completed);
assert!(resolution.rejection.is_none());
assert!(matches!(
@@ -788,10 +872,8 @@ mod tests {
let mut instance = FlowInstance {
instance_id: "instance-1".to_string(),
definition_id: "definition-1".to_string(),
definition_revision: 1,
definition_digest: definition.content_digest.clone(),
current_state: definition.initial.clone(),
state_revision: 0,
status: FlowInstanceStatus::Active,
active_attempt_id: None,
};
@@ -827,10 +909,8 @@ mod tests {
let mut instance = FlowInstance {
instance_id: "instance-1".to_string(),
definition_id: "definition-1".to_string(),
definition_revision: 1,
definition_digest: definition.content_digest.clone(),
current_state: definition.initial.clone(),
state_revision: 0,
status: FlowInstanceStatus::Active,
active_attempt_id: None,
};
@@ -852,7 +932,7 @@ mod tests {
)
.unwrap();
assert_eq!(instance.current_state.as_str(), "work");
assert_eq!(instance.state_revision, 0);
assert_eq!(instance.active_attempt_id, None);
assert_eq!(
resolution.rejection.unwrap().code,
@@ -866,10 +946,8 @@ mod tests {
let mut instance = FlowInstance {
instance_id: "instance-1".to_string(),
definition_id: "definition-1".to_string(),
definition_revision: 1,
definition_digest: definition.content_digest.clone(),
current_state: definition.initial.clone(),
state_revision: 0,
status: FlowInstanceStatus::Active,
active_attempt_id: None,
};
@@ -901,15 +979,13 @@ mod tests {
let mut instance = FlowInstance {
instance_id: "instance-1".to_string(),
definition_id: "definition-1".to_string(),
definition_revision: 1,
definition_digest: definition.content_digest.clone(),
current_state: definition.initial.clone(),
state_revision: 0,
status: FlowInstanceStatus::Active,
active_attempt_id: None,
};
let attempt = begin(&mut instance);
instance.state_revision = 1;
instance.current_state = StateId::new("done").unwrap();
let results = attempt
.transitions
.iter()
@@ -938,10 +1014,8 @@ mod tests {
let mut instance = FlowInstance {
instance_id: "instance-1".to_string(),
definition_id: "definition-1".to_string(),
definition_revision: 1,
definition_digest: definition.content_digest.clone(),
current_state: definition.initial.clone(),
state_revision: 0,
status: FlowInstanceStatus::Active,
active_attempt_id: None,
};
@@ -967,7 +1041,7 @@ mod tests {
FlowRejectionCode::MultipleConditionsMet
);
assert_eq!(instance.current_state.as_str(), "work");
assert_eq!(instance.state_revision, 0);
assert!(
resolution
.events
@@ -982,10 +1056,8 @@ mod tests {
let mut instance = FlowInstance {
instance_id: "instance-1".to_string(),
definition_id: "definition-1".to_string(),
definition_revision: 1,
definition_digest: definition.content_digest.clone(),
current_state: definition.initial.clone(),
state_revision: 0,
status: FlowInstanceStatus::Active,
active_attempt_id: None,
};
@@ -1015,10 +1087,8 @@ mod tests {
let mut instance = FlowInstance {
instance_id: "instance-1".to_string(),
definition_id: "definition-1".to_string(),
definition_revision: 1,
definition_digest: definition.content_digest.clone(),
current_state: definition.initial.clone(),
state_revision: 0,
status: FlowInstanceStatus::Active,
active_attempt_id: None,
};
-1
View File
@@ -124,7 +124,6 @@ pub struct ResolvedFlowSource {
pub selector: FlowSelector,
pub workspace_id: String,
pub flow_id: String,
pub revision: u64,
pub content_digest: String,
pub definition: crate::CompiledFlowDefinition,
}
+13 -7
View File
@@ -100,8 +100,6 @@ pub struct JobRequest {
/// Stable caller-selected idempotency key within the host's namespace.
pub job_id: String,
pub purpose: String,
/// Revision fenced by the structured result.
pub input_revision: String,
pub input_ref: String,
/// Bounded immutable snapshot; must not contain credentials.
pub input: Value,
@@ -126,7 +124,6 @@ impl JobRequest {
pub fn validate_fields(&self) -> Result<()> {
validate_identifier("job_id", &self.job_id, 256)?;
validate_identifier("purpose", &self.purpose, MAX_JOB_PURPOSE_BYTES)?;
validate_identifier("input_revision", &self.input_revision, 256)?;
validate_bounded_text("input_ref", &self.input_ref, MAX_JOB_REFERENCE_BYTES)?;
validate_bounded_text("instruction", &self.instruction, MAX_JOB_INSTRUCTION_BYTES)?;
self.profile_selector()?;
@@ -157,6 +154,13 @@ impl JobRequest {
fingerprint(self)
}
/// Content identity for result fencing; hosts compare it with the reserved
/// attempt's input, never with a caller-selected counter.
pub fn input_digest(&self) -> Result<String> {
self.validate_input()?;
fingerprint(&self.input)
}
pub fn worker_input(&self, attempt_id: &str) -> Result<String> {
worker_input(self, attempt_id)
}
@@ -167,7 +171,8 @@ impl JobRequest {
pub struct JobResultSubmission {
pub job_id: String,
pub attempt_id: String,
pub input_revision: String,
/// SHA-256 of the exact JSON encoding of the immutable input snapshot.
pub input_digest: String,
pub result: Value,
}
@@ -202,7 +207,8 @@ pub enum JobAttemptState {
#[serde(deny_unknown_fields)]
pub struct JobOutcome {
pub job_id: String,
pub input_revision: String,
/// SHA-256 of the exact JSON encoding of the immutable input snapshot.
pub input_digest: String,
pub attempt_id: String,
pub attempt: u8,
pub state: JobState,
@@ -263,11 +269,11 @@ pub fn worker_input_with_label(
let input = serde_json::to_string(&request.input)
.map_err(|error| JobError::InvalidInput(format!("serialize Job input: {error}")))?;
Ok(format!(
"{}\n\n{label} envelope (immutable):\njob_id: {}\nattempt_id: {}\ninput_revision: {}\ninput_ref: {}\ninput_json: {}\n\nReturn success only through the structured {label} result capability. Final prose and Worker Idle/Stopped state are not result authority.",
"{}\n\n{label} envelope (immutable):\njob_id: {}\nattempt_id: {}\ninput_digest: {}\ninput_ref: {}\ninput_json: {}\n\nReturn success only through the structured {label} result capability. Final prose and Worker Idle/Stopped state are not result authority.",
request.instruction,
request.job_id,
attempt_id,
request.input_revision,
request.input_digest()?,
request.input_ref,
input
))
+26 -10
View File
@@ -5,7 +5,6 @@ fn request() -> JobRequest {
JobRequest {
job_id: "check:r7".into(),
purpose: "snapshot_check".into(),
input_revision: "7".into(),
input_ref: "snapshot://7".into(),
input: json!({"title": "Check me"}),
instruction: "Check this immutable snapshot.".into(),
@@ -15,6 +14,25 @@ fn request() -> JobRequest {
}
}
#[test]
fn input_content_identity_is_computed_and_old_counter_is_not_accepted() {
let original = request();
let mut changed = original.clone();
changed.input = json!({"title": "changed"});
assert_ne!(
original.input_digest().unwrap(),
changed.input_digest().unwrap()
);
changed.instruction.push_str(" Extra instruction.");
assert_eq!(
changed.input_digest().unwrap(),
fingerprint(&changed.input).unwrap()
);
let mut old = serde_json::to_value(&original).unwrap();
old["input_revision"] = json!(7);
assert!(serde_json::from_value::<JobRequest>(old).is_err());
}
#[test]
fn shared_lifecycle_enums_round_trip_all_host_states() {
for (state, name) in [
@@ -53,7 +71,7 @@ fn shared_lifecycle_enums_round_trip_all_host_states() {
fn outcome_is_a_neutral_read_snapshot_with_optional_result_and_failure() {
let mut outcome = JobOutcome {
job_id: "job-1".into(),
input_revision: "7".into(),
input_digest: request().input_digest().unwrap(),
attempt_id: "job-1:attempt:2".into(),
attempt: 2,
state: JobState::Pending,
@@ -66,7 +84,7 @@ fn outcome_is_a_neutral_read_snapshot_with_optional_result_and_failure() {
assert_eq!(
encoded,
json!({
"job_id": "job-1", "input_revision": "7", "attempt_id": "job-1:attempt:2",
"job_id": "job-1", "input_digest": request().input_digest().unwrap(), "attempt_id": "job-1:attempt:2",
"attempt": 2, "state": "pending", "attempt_state": "dispatching",
"result": null, "failure_category": null, "failure_detail": null
})
@@ -124,7 +142,7 @@ fn outcome_is_a_neutral_read_snapshot_with_optional_result_and_failure() {
fn neutral_request_and_result_have_strict_round_trip_contracts() {
let request = request();
let encoded = serde_json::to_value(&request).unwrap();
assert_eq!(encoded.as_object().unwrap().len(), 8);
assert_eq!(encoded.as_object().unwrap().len(), 7);
assert!(encoded.get("serialization_key").is_none());
assert_eq!(
serde_json::from_value::<JobRequest>(encoded.clone()).unwrap(),
@@ -137,10 +155,11 @@ fn neutral_request_and_result_have_strict_round_trip_contracts() {
with_provenance["source_worker"] = json!({});
assert!(serde_json::from_value::<JobRequest>(with_provenance).is_err());
let input_digest = request.input_digest().unwrap();
let submission = JobResultSubmission {
job_id: request.job_id,
attempt_id: "check:r7:attempt:1".into(),
input_revision: request.input_revision,
input_digest,
result: json!({"ok": true}),
};
let mut encoded = serde_json::to_value(&submission).unwrap();
@@ -168,7 +187,6 @@ fn fingerprint_covers_every_immutable_field_and_preserves_replay() {
for mutate in [
|r: &mut JobRequest| r.job_id.push('x'),
|r: &mut JobRequest| r.purpose.push('x'),
|r: &mut JobRequest| r.input_revision.push('x'),
|r: &mut JobRequest| r.input_ref.push('x'),
|r: &mut JobRequest| r.input = json!({"title": "changed"}),
|r: &mut JobRequest| r.instruction.push('x'),
@@ -216,7 +234,6 @@ fn registry_profiles_are_not_authority_or_a_fixed_allowlist() {
fn validation_enforces_utf8_bytes_and_metadata_bounds() {
let mut r = request();
r.job_id = "x".repeat(256);
r.input_revision = "x".repeat(256);
r.purpose = "x".repeat(MAX_JOB_PURPOSE_BYTES);
r.input_ref = "x".repeat(MAX_JOB_REFERENCE_BYTES);
r.instruction = "x".repeat(MAX_JOB_INSTRUCTION_BYTES);
@@ -225,7 +242,6 @@ fn validation_enforces_utf8_bytes_and_metadata_bounds() {
r.validate().unwrap();
for mutate in [
|r: &mut JobRequest| r.job_id.push('x'),
|r: &mut JobRequest| r.input_revision.push('x'),
|r: &mut JobRequest| r.purpose.push('x'),
|r: &mut JobRequest| r.input_ref.push('x'),
|r: &mut JobRequest| r.instruction.push('x'),
@@ -287,7 +303,7 @@ fn result_digest_and_failure_detail_bound_exact_utf8_encoding() {
}
#[test]
fn worker_input_is_revision_and_attempt_bound_not_final_prose_authority() {
fn worker_input_is_content_and_attempt_bound_not_final_prose_authority() {
let request = request();
let attempt = attempt_id(&request.job_id, 2);
assert_eq!(attempt, "check:r7:attempt:2");
@@ -297,7 +313,7 @@ fn worker_input_is_revision_and_attempt_bound_not_final_prose_authority() {
"Job envelope (immutable):",
"job_id: check:r7",
"attempt_id: check:r7:attempt:2",
"input_revision: 7",
"input_digest: sha256:",
"input_ref: snapshot://7",
r#"input_json: {"title":"Check me"}"#,
"structured Job result capability",
+113 -14
View File
@@ -391,8 +391,7 @@ impl ResolvedMemoryFeatureConfig {
return Err("disabled Memory feature must not carry Workspace settings");
}
if let Some(settings) = &self.workspace_settings
&& (settings.settings_revision == 0
|| !is_normalized_workspace_memory_language(&settings.language))
&& !is_normalized_workspace_memory_language(&settings.language)
{
return Err("Memory Workspace settings snapshot metadata is invalid");
}
@@ -473,8 +472,7 @@ impl ResolvedSubjektivFeatureConfig {
return Err("disabled subjektiv feature must not carry Workspace settings");
}
if let Some(settings) = &self.workspace_settings
&& (settings.settings_revision == 0
|| !is_normalized_workspace_memory_language(&settings.language))
&& !is_normalized_workspace_memory_language(&settings.language)
{
return Err("subjektiv Workspace settings snapshot metadata is invalid");
}
@@ -705,10 +703,8 @@ pub fn is_normalized_workspace_memory_language(language: &str) -> bool {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceMemorySettingsSnapshot {
/// Workspace that owns the settings revision.
/// Workspace that owns these settings.
pub workspace_id: String,
/// Monotonic Workspace Memory settings revision.
pub settings_revision: u64,
/// Normalized language used for Memory extraction and consolidation output.
pub language: String,
}
@@ -1155,7 +1151,8 @@ struct LegacyMemoryConfig {
consolidation_threshold_bytes: Option<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;
/// Serialize a resolved Worker Manifest for durable Worker-specific storage.
@@ -1187,6 +1184,7 @@ pub fn read_persisted_worker_manifest_snapshot(
))
})?;
if version != RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION
&& version != MEMORY_COUNTER_MANIFEST_SNAPSHOT_SCHEMA_VERSION
&& version != PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION
{
return Err(serde_json::Error::io(std::io::Error::new(
@@ -1218,6 +1216,9 @@ pub fn read_persisted_worker_manifest_snapshot(
if version == PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION {
migrate_legacy_manifest_authority(&mut manifest)?;
}
if version != RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION {
migrate_legacy_memory_settings_counter(&mut manifest)?;
}
return validate_persisted_worker_manifest(serde_json::from_value(manifest)?);
}
@@ -1240,6 +1241,32 @@ fn validate_persisted_worker_manifest(
Ok(manifest)
}
/// Frozen migration for schema 2/3 resolved manifests. Keep actual Workspace
/// identity and language unchanged; the obsolete sequence has no execution role.
fn migrate_legacy_memory_settings_counter(
manifest: &mut serde_json::Value,
) -> Result<(), serde_json::Error> {
for namespace in ["memory", "subjektiv"] {
let Some(settings) = manifest
.get_mut("feature")
.and_then(|feature| feature.get_mut(namespace))
.and_then(|feature| feature.get_mut("workspace_settings"))
.and_then(serde_json::Value::as_object_mut)
else {
continue;
};
if let Some(counter) = settings.remove("settings_revision")
&& !counter.as_u64().is_some_and(|counter| counter > 0)
{
return Err(serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"legacy Workspace Memory settings snapshot has an invalid counter",
)));
}
}
Ok(())
}
fn migrate_legacy_manifest_authority(
manifest: &mut serde_json::Value,
) -> Result<(), serde_json::Error> {
@@ -1385,6 +1412,7 @@ fn migrate_legacy_resolved_manifest_snapshot(
}
feature.insert("memory".to_string(), resolved);
migrate_legacy_manifest_authority(&mut snapshot)?;
migrate_legacy_memory_settings_counter(&mut snapshot)?;
validate_persisted_worker_manifest(serde_json::from_value(snapshot)?)
}
@@ -1749,7 +1777,6 @@ model_id = "claude-sonnet-4-20250514"
worker_max_turns = 2\n\n\
[feature.memory.workspace_settings]\n\
workspace_id = \"workspace-1\"\n\
settings_revision = 7\n\
language = \"日本語\"\n"
);
let manifest = WorkerManifest::from_toml(&toml).unwrap();
@@ -1774,7 +1801,6 @@ model_id = "claude-sonnet-4-20250514"
fn resolved_memory_execution_validation_fails_closed() {
let snapshot = WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".to_string(),
settings_revision: 1,
language: "English".to_string(),
};
let mut enabled = ResolvedMemoryFeatureConfig::default();
@@ -1793,7 +1819,6 @@ model_id = "claude-sonnet-4-20250514"
fn resolved_subjektiv_policy_is_inert_until_trusted_attachment() {
let snapshot = WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".to_string(),
settings_revision: 1,
language: "English".to_string(),
};
let mut enabled = ResolvedSubjektivFeatureConfig::default();
@@ -1816,6 +1841,80 @@ model_id = "claude-sonnet-4-20250514"
assert!(WorkerManifest::from_toml(&toml).is_err());
}
#[test]
fn persisted_counter_migration_preserves_memory_and_subject_settings() {
for version in [2, 3] {
let mut manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
manifest.feature.memory.profile.enabled = true;
manifest.feature.subjektiv.profile.enabled = true;
let settings = WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".into(),
language: "日本語".into(),
};
manifest
.feature
.memory
.bind_workspace_settings(settings.clone())
.unwrap();
manifest
.feature
.subjektiv
.bind_workspace_settings(settings.clone())
.unwrap();
let mut snapshot = write_persisted_worker_manifest_snapshot(&manifest).unwrap();
snapshot["schema_version"] = version.into();
for namespace in ["memory", "subjektiv"] {
snapshot["manifest"]["feature"][namespace]["workspace_settings"]["settings_revision"] =
99.into();
}
let restored = read_persisted_worker_manifest_snapshot(snapshot).unwrap();
assert_eq!(
restored.feature.memory.workspace_settings(),
Some(settings.clone())
);
assert_eq!(
restored.feature.subjektiv.workspace_settings(),
Some(settings)
);
let current = write_persisted_worker_manifest_snapshot(&restored).unwrap();
assert_eq!(current["schema_version"], 4);
for namespace in ["memory", "subjektiv"] {
assert_eq!(
current["manifest"]["feature"][namespace]["workspace_settings"],
serde_json::json!({
"workspace_id": "workspace-1", "language": "日本語"
})
);
}
read_persisted_worker_manifest_snapshot(current).unwrap();
}
}
#[test]
fn current_settings_snapshot_rejects_removed_counter_outside_migration() {
assert!(
serde_json::from_value::<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]
fn persisted_manifest_adapter_migrates_legacy_memory_authority() {
let mut manifest =
@@ -1863,7 +1962,7 @@ model_id = "claude-sonnet-4-20250514"
"Français"
);
let current = write_persisted_worker_manifest_snapshot(&migrated).unwrap();
assert_eq!(current["schema_version"], 3);
assert_eq!(current["schema_version"], 4);
assert!(current["manifest"].get("memory").is_none());
let mut disabled =
@@ -1905,7 +2004,7 @@ model_id = "claude-sonnet-4-20250514"
let restored = read_persisted_worker_manifest_snapshot(versioned).unwrap();
let current = write_persisted_worker_manifest_snapshot(&restored).unwrap();
assert_eq!(current["schema_version"], 3);
assert_eq!(current["schema_version"], 4);
assert!(current["manifest"].get("plugins").is_none());
assert!(current["manifest"]["feature"].get("plugins").is_none());
assert!(current["manifest"]["feature"].get("workers").is_none());
@@ -1976,7 +2075,7 @@ model_id = "claude-sonnet-4-20250514"
assert!(
read_persisted_worker_manifest_snapshot(serde_json::json!({
"schema_version": 4,
"schema_version": 5,
"manifest": manifest,
}))
.is_err()
-1
View File
@@ -1489,7 +1489,6 @@ mod tests {
let tmp = TempDir::new().unwrap();
for (field, value) in [
("workspace_id", serde_json::json!("workspace-a")),
("settings_revision", serde_json::json!(2)),
("language", serde_json::json!("Japanese")),
] {
let artifact = serde_json::json!({
-2
View File
@@ -184,8 +184,6 @@ impl OperationCounts {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct MemorySettingsAudit {
pub workspace_id: String,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub settings_revision: u64,
pub language: String,
}
+30 -2
View File
@@ -108,8 +108,9 @@ pub struct EvidenceOrigin {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flow_definition_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub flow_definition_revision: Option<u64>,
/// SHA-256 of the exact pinned Flow definition content, when available.
/// Historical origins without captured definition content leave this absent.
pub flow_definition_fingerprint: Option<String>,
}
/// Host-resolved source/evidence metadata for an individual staging claim.
@@ -156,6 +157,33 @@ pub fn split_frontmatter(content: &str) -> Result<(&str, &str), RecordLintError>
mod tests {
use super::*;
#[test]
fn flow_origin_captures_content_identity_without_a_state_counter() {
let origin: EvidenceOrigin = serde_json::from_value(serde_json::json!({
"kind": "flow_instruction",
"flow_definition_id": "flow-definition",
"flow_definition_fingerprint": "a".repeat(64)
}))
.unwrap();
assert_eq!(
origin.flow_definition_fingerprint.as_deref(),
Some("a".repeat(64).as_str())
);
let value = serde_json::to_value(&origin).unwrap();
assert_eq!(value["flow_definition_fingerprint"], "a".repeat(64));
let historical: EvidenceOrigin = serde_json::from_value(serde_json::json!({
"kind": "flow_instruction", "flow_definition_id": "flow-definition"
}))
.unwrap();
assert!(historical.flow_definition_fingerprint.is_none());
assert!(
serde_json::to_value(historical)
.unwrap()
.get("flow_definition_fingerprint")
.is_none()
);
}
#[test]
fn splits_simple() {
let doc = "---\nfoo: 1\n---\nbody here\n";
+1
View File
@@ -11,6 +11,7 @@ schemars = { workspace = true, features = ["chrono04"] }
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
thiserror.workspace = true
ticket = { path = "../ticket" }
uuid = { workspace = true, features = ["v7"] }
[dev-dependencies]
+109 -58
View File
@@ -8,7 +8,7 @@ use std::{
use thiserror::Error;
use uuid::Uuid;
const SCHEMA_VERSION: i64 = 12;
const SCHEMA_VERSION: i64 = 13;
const MAX_BODY_BYTES: usize = 16 * 1024;
const DOMAIN_TABLES: [&str; 5] = [
"merge_requests",
@@ -103,7 +103,7 @@ pub struct ReviewRequestedEvent {
pub sequence: u64,
pub subject_ref: String,
#[serde(default)]
pub ticket_item_revision: String,
pub ticket_content_digest: String,
#[serde(default)]
pub ticket_merge_request_subjects: Vec<MergeRequestReviewSubject>,
pub requested_by: WorkerIdentity,
@@ -118,7 +118,7 @@ pub struct ReviewEvent {
pub request_event_id: String,
pub subject_ref: String,
#[serde(default)]
pub ticket_item_revision: String,
pub ticket_content_digest: String,
#[serde(default)]
pub ticket_merge_request_subjects: Vec<MergeRequestReviewSubject>,
pub decision: ReviewDecision,
@@ -249,7 +249,7 @@ pub enum MergeRequestEvidenceError {
ReviewRequestMissing,
ReviewRequestMismatch,
ApprovalNotEffective,
ItemRevisionMismatch,
TicketContentMismatch,
SourceSnapshotMismatch,
RequirementApprovalMissing,
}
@@ -268,7 +268,7 @@ impl MergeRequestEvidenceError {
Self::ReviewRequestMissing => "review_request_missing",
Self::ReviewRequestMismatch => "review_request_mismatch",
Self::ApprovalNotEffective => "approval_not_effective",
Self::ItemRevisionMismatch => "item_revision_mismatch",
Self::TicketContentMismatch => "ticket_content_mismatch",
Self::SourceSnapshotMismatch => "source_snapshot_mismatch",
Self::RequirementApprovalMissing => "requirement_approval_missing",
}
@@ -290,14 +290,14 @@ impl MergeRequestEvidenceError {
"Reviewer approval does not match linked ReviewRequested evidence"
}
Self::ApprovalNotEffective => "Reviewer approval is not the effective review",
Self::ItemRevisionMismatch => {
"Reviewer approval does not attest the current Ticket revision"
Self::TicketContentMismatch => {
"Reviewer approval does not attest the current Ticket digest"
}
Self::SourceSnapshotMismatch => {
"Reviewer approval does not attest the exact linked source set"
}
Self::RequirementApprovalMissing => {
"no effective Reviewer approval attests the current Ticket revision and exact linked source set"
"no effective Reviewer approval attests the current Ticket digest and exact linked source set"
}
}
}
@@ -382,7 +382,7 @@ impl MergeRequest {
})
.ok_or(MergeRequestEvidenceError::ReviewRequestMissing)?;
if requested.subject_ref != review.subject_ref
|| requested.ticket_item_revision != review.ticket_item_revision
|| requested.ticket_content_digest != review.ticket_content_digest
|| requested.ticket_merge_request_subjects != review.ticket_merge_request_subjects
|| requested.reviewer != review.reviewer
|| requested.sequence >= review.sequence
@@ -393,7 +393,7 @@ impl MergeRequest {
}
}
/// Finds an effective approval of the authoritative current Ticket revision and
/// Finds an effective approval of the authoritative current Ticket digest and
/// exact linked source snapshot. Callers supply all linked requests and resolved
/// source refs (recorded merged refs for completion); this function does not read
/// authority, resolve selectors, require merged state, or mutate lifecycle state.
@@ -401,7 +401,7 @@ impl MergeRequest {
/// attestation. Snapshot ordering is immaterial, but duplicate ids are rejected.
pub fn requirement_approval<'a>(
requests: &'a [MergeRequest],
item_revision: &str,
content_digest: &str,
subjects: &[MergeRequestReviewSubject],
event_id: Option<&str>,
) -> Result<&'a ReviewEvent, MergeRequestEvidenceError> {
@@ -436,8 +436,10 @@ pub fn requirement_approval<'a>(
{
return Err(MergeRequestEvidenceError::ApprovalNotEffective);
}
if review.ticket_item_revision != item_revision || item_revision.trim().is_empty() {
return Err(MergeRequestEvidenceError::ItemRevisionMismatch);
if review.ticket_content_digest != content_digest
|| content_digest.trim().is_empty()
{
return Err(MergeRequestEvidenceError::TicketContentMismatch);
}
if !same_source_snapshot(&review.ticket_merge_request_subjects, subjects) {
return Err(MergeRequestEvidenceError::SourceSnapshotMismatch);
@@ -508,7 +510,7 @@ pub struct OpenMergeRequest {
pub struct RequestMergeRequestReview {
pub merge_request_id: String,
pub ticket_id: String,
pub ticket_item_revision: String,
pub ticket_content_digest: String,
pub ticket_merge_request_subjects: Vec<MergeRequestReviewSubject>,
pub subject_ref: String,
pub child_session_id: String,
@@ -604,6 +606,7 @@ pub struct CompleteMergeRequest {
pub struct TicketCompletionEvent {
pub operation_id: String,
pub ticket_id: String,
/// Frozen historical payload only; not a content attestation or live API.
pub item_revision: String,
pub merge_request_ids: Vec<String>,
pub requirement_approval_event_id: String,
@@ -805,11 +808,11 @@ impl MergeRequestStore {
}
let mut c = self.lock()?;
let t = c.transaction()?;
let current_revision = current_ticket_revision(&t, &i.auth.workspace_id, &i.ticket_id)?
let current_digest = current_ticket_content_digest(&t, &i.auth.workspace_id, &i.ticket_id)?
.ok_or(MergeRequestError::NotFound)?;
if current_revision != i.ticket_item_revision {
if current_digest != i.ticket_content_digest {
return Err(MergeRequestError::Conflict(
"Ticket item revision changed before review request".into(),
"Ticket item digest changed before review request".into(),
));
}
let current_mr = load_mr(&t, &i.auth.workspace_id, &i.merge_request_id)?
@@ -848,7 +851,7 @@ impl MergeRequestStore {
event_id: Uuid::now_v7().to_string(),
sequence: next_seq(&t, &mr.workspace_id, &mr.merge_request_id)?,
subject_ref: i.subject_ref,
ticket_item_revision: i.ticket_item_revision,
ticket_content_digest: i.ticket_content_digest,
ticket_merge_request_subjects: i.ticket_merge_request_subjects,
requested_by: WorkerIdentity {
runtime_id: i.auth.runtime_id,
@@ -944,12 +947,12 @@ impl MergeRequestStore {
"review grant invalid".into(),
));
};
let (ticket_item_revision, ticket_merge_request_subjects) = load_mr(&t, &ws, &mr)?
let (ticket_content_digest, ticket_merge_request_subjects) = load_mr(&t, &ws, &mr)?
.and_then(|request| {
request.thread.into_iter().find_map(|event| match event {
MergeRequestThreadEvent::ReviewRequested(event) if event.event_id == req => {
Some((
event.ticket_item_revision,
event.ticket_content_digest,
event.ticket_merge_request_subjects,
))
}
@@ -982,7 +985,7 @@ impl MergeRequestStore {
sequence: next_seq(&t, &ws, &mr)?,
request_event_id: req,
subject_ref: subject,
ticket_item_revision,
ticket_content_digest,
ticket_merge_request_subjects,
decision: i.decision,
body: i.body,
@@ -1056,13 +1059,12 @@ impl MergeRequestStore {
}
if let Some(review) = &review {
let connection = self.lock()?;
let current_revision =
current_ticket_revision(&connection, &i.auth.workspace_id, &i.ticket_id)?
let current_digest =
current_ticket_content_digest(&connection, &i.auth.workspace_id, &i.ticket_id)?
.ok_or(MergeRequestError::NotFound)?;
if review.ticket_item_revision != current_revision {
if review.ticket_content_digest != current_digest {
b.push(
"approval predates the current Ticket revision; fresh review is required"
.into(),
"approval predates the current Ticket digest; fresh review is required".into(),
);
}
}
@@ -1147,12 +1149,12 @@ impl MergeRequestStore {
));
}
let connection = self.lock()?;
let current_revision =
current_ticket_revision(&connection, &mr.workspace_id, &i.ticket_id)?
let current_digest =
current_ticket_content_digest(&connection, &mr.workspace_id, &i.ticket_id)?
.ok_or(MergeRequestError::NotFound)?;
if review.ticket_item_revision != current_revision {
if review.ticket_content_digest != current_digest {
return Err(MergeRequestError::NotReady(
"approval predates the current Ticket revision; fresh review is required".into(),
"approval predates the current Ticket digest; fresh review is required".into(),
));
}
let state: Option<String> = connection
@@ -1227,12 +1229,12 @@ impl MergeRequestStore {
"current effective review does not approve the source ref".into(),
));
}
let current_revision =
current_ticket_revision(&transaction, &mr.workspace_id, &i.ticket_id)?
let current_digest =
current_ticket_content_digest(&transaction, &mr.workspace_id, &i.ticket_id)?
.ok_or(MergeRequestError::NotFound)?;
if review.ticket_item_revision != current_revision {
if review.ticket_content_digest != current_digest {
return Err(MergeRequestError::NotReady(
"approval predates the current Ticket revision; fresh review is required".into(),
"approval predates the current Ticket digest; fresh review is required".into(),
));
}
let state: Option<String> = transaction
@@ -1655,34 +1657,13 @@ fn linked_merge_request_ids(
.map_err(Into::into)
}
fn current_ticket_revision(
fn current_ticket_content_digest(
connection: &Connection,
workspace_id: &str,
ticket_id: &str,
) -> Result<Option<String>, MergeRequestError> {
let event_index = connection
.query_row(
"SELECT event_index
FROM typed_ticket_events
WHERE workspace_id=?1 AND ticket_id=?2
AND kind IN ('create','item_edit')
ORDER BY event_index DESC
LIMIT 1",
params![workspace_id, ticket_id],
|row| row.get::<_, i64>(0),
)
.optional()?;
if let Some(event_index) = event_index {
return Ok(Some(format!("{ticket_id}:{event_index}")));
}
connection
.query_row(
"SELECT updated_at FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2",
params![workspace_id, ticket_id],
|row| row.get(0),
)
.optional()
.map_err(Into::into)
ticket::sqlite_ticket_content_digest(connection, workspace_id, ticket_id)
.map_err(|error| MergeRequestError::Operation(error.to_string()))
}
fn load_mr(c: &Connection, w: &str, m: &str) -> Result<Option<MergeRequest>, MergeRequestError> {
@@ -1773,12 +1754,82 @@ pub fn migrate(c: &Connection) -> Result<(), MergeRequestError> {
match schema_state(c)? {
SchemaState::Fresh => fresh(c),
SchemaState::Current(SCHEMA_VERSION) => verify(c),
SchemaState::Current(12) => migrate_v12_content_attestations(c),
SchemaState::Current(v) => Err(MergeRequestError::Operation(format!(
"unsupported schema {v}"
))),
}
}
/// Frozen v12 migration: historical counters cannot prove the reviewed content.
/// Preserve request/review identities and integration evidence, but do not
/// manufacture content attestations from today's Ticket. Fresh review is needed.
fn migrate_v12_content_attestations(c: &Connection) -> Result<(), MergeRequestError> {
let transaction = c.unchecked_transaction()?;
verify(&transaction)?;
// Keep the exact historical audit bytes independently of the current DTO
// projection. An unknown content attestation must not erase its old evidence.
transaction.execute_batch(
"CREATE TABLE merge_request_legacy_review_archive (
workspace_id TEXT NOT NULL,
merge_request_id TEXT NOT NULL,
event_id TEXT NOT NULL,
payload_json TEXT NOT NULL,
PRIMARY KEY(workspace_id, merge_request_id, event_id)
);
INSERT INTO merge_request_legacy_review_archive
SELECT workspace_id,merge_request_id,event_id,payload_json
FROM merge_request_thread_events WHERE kind IN ('review_requested','review');
CREATE TRIGGER merge_request_legacy_review_archive_no_update
BEFORE UPDATE ON merge_request_legacy_review_archive
BEGIN SELECT RAISE(ABORT, 'historical review evidence is immutable'); END;
CREATE TRIGGER merge_request_legacy_review_archive_no_delete
BEFORE DELETE ON merge_request_legacy_review_archive
BEGIN SELECT RAISE(ABORT, 'historical review evidence is retained'); END;",
)?;
let rows = {
let mut statement = transaction.prepare(
"SELECT workspace_id,merge_request_id,event_id,payload_json FROM merge_request_thread_events WHERE kind IN ('review_requested','review')",
)?;
statement
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
})?
.collect::<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)]
enum SchemaState {
Fresh,
+37 -62
View File
@@ -24,7 +24,7 @@ fn subjects() -> Vec<MergeRequestReviewSubject> {
fn add_review(
request: &mut MergeRequest,
event_id: &str,
revision: &str,
digest: &str,
snapshot: &[MergeRequestReviewSubject],
decision: ReviewDecision,
) {
@@ -37,7 +37,7 @@ fn add_review(
event_id: format!("request-{event_id}"),
sequence,
subject_ref: subject.subject_ref.clone(),
ticket_item_revision: revision.into(),
ticket_content_digest: digest.into(),
ticket_merge_request_subjects: snapshot.to_vec(),
requested_by: actor(),
reviewer: actor(),
@@ -48,7 +48,7 @@ fn add_review(
sequence: sequence + 1,
request_event_id: requested.event_id.clone(),
subject_ref: requested.subject_ref.clone(),
ticket_item_revision: revision.into(),
ticket_content_digest: digest.into(),
ticket_merge_request_subjects: snapshot.to_vec(),
decision,
body: String::new(),
@@ -84,7 +84,7 @@ fn merged_requests() -> Vec<MergeRequest> {
add_review(
&mut request,
&approval_id,
"revision-one",
"digest-one",
&snapshot,
ReviewDecision::Approve,
);
@@ -109,22 +109,17 @@ fn merged_requests() -> Vec<MergeRequest> {
}
#[test]
fn postmerge_latest_approval_attests_the_current_revision_without_replacing_integration() {
fn postmerge_latest_approval_attests_the_current_digest_without_replacing_integration() {
let mut requests = merged_requests();
let snapshot = subjects();
assert_eq!(
requirement_approval(
&requests,
"revision-two",
&snapshot,
Some("integration-MR-1")
),
Err(MergeRequestEvidenceError::ItemRevisionMismatch)
requirement_approval(&requests, "digest-two", &snapshot, Some("integration-MR-1")),
Err(MergeRequestEvidenceError::TicketContentMismatch)
);
add_review(
&mut requests[0],
"postmerge",
"revision-two",
"digest-two",
&snapshot,
ReviewDecision::Approve,
);
@@ -134,28 +129,23 @@ fn postmerge_latest_approval_attests_the_current_revision_without_replacing_inte
"integration-MR-1"
);
assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, None)
requirement_approval(&requests, "digest-two", &snapshot, None)
.unwrap()
.event_id,
"postmerge"
);
assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, Some("postmerge"))
requirement_approval(&requests, "digest-two", &snapshot, Some("postmerge"))
.unwrap()
.event_id,
"postmerge"
);
assert_eq!(
requirement_approval(
&requests,
"revision-one",
&snapshot,
Some("integration-MR-1")
),
requirement_approval(&requests, "digest-one", &snapshot, Some("integration-MR-1")),
Err(MergeRequestEvidenceError::ApprovalNotEffective)
);
assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, Some("unknown")),
requirement_approval(&requests, "digest-two", &snapshot, Some("unknown")),
Err(MergeRequestEvidenceError::RequirementApprovalMissing)
);
}
@@ -167,80 +157,70 @@ fn later_request_changes_prevents_older_requirement_approval_from_being_effectiv
add_review(
&mut requests[0],
"approved",
"revision-two",
"digest-two",
&snapshot,
ReviewDecision::Approve,
);
add_review(
&mut requests[0],
"changes",
"revision-two",
"digest-two",
&snapshot,
ReviewDecision::RequestChanges,
);
assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, None),
requirement_approval(&requests, "digest-two", &snapshot, None),
Err(MergeRequestEvidenceError::RequirementApprovalMissing)
);
assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, Some("approved")),
requirement_approval(&requests, "digest-two", &snapshot, Some("approved")),
Err(MergeRequestEvidenceError::ApprovalNotEffective)
);
assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, Some("changes")),
requirement_approval(&requests, "digest-two", &snapshot, Some("changes")),
Err(MergeRequestEvidenceError::ApprovalNotApproved)
);
assert!(requests[0].integration_approval().is_ok());
}
#[test]
fn changed_revision_or_any_linked_source_invalidates_a_multiple_mr_attestation() {
fn changed_digest_or_any_linked_source_invalidates_a_multiple_mr_attestation() {
let requests = merged_requests();
let original = subjects();
assert!(requirement_approval(&requests, "revision-one", &original, None).is_ok());
assert!(requirement_approval(&requests, "digest-one", &original, None).is_ok());
assert_eq!(
requirement_approval(
&requests,
"revision-two",
&original,
Some("integration-MR-1")
),
Err(MergeRequestEvidenceError::ItemRevisionMismatch)
requirement_approval(&requests, "digest-two", &original, Some("integration-MR-1")),
Err(MergeRequestEvidenceError::TicketContentMismatch)
);
let mut changed = original.clone();
changed[1].subject_ref = "changed-source-two".into();
assert_eq!(
requirement_approval(
&requests,
"revision-one",
&changed,
Some("integration-MR-1")
),
requirement_approval(&requests, "digest-one", &changed, Some("integration-MR-1")),
Err(MergeRequestEvidenceError::SourceSnapshotMismatch)
);
let mut reversed = original.clone();
reversed.reverse();
assert!(requirement_approval(&requests, "revision-one", &reversed, None).is_ok());
assert!(requirement_approval(&requests, "digest-one", &reversed, None).is_ok());
// Even after the caller refreshes the linked request collection, an old
// attestation cannot authorize completion against a smaller linked set.
assert_eq!(
requirement_approval(
&requests[..1],
"revision-one",
"digest-one",
&original[..1],
Some("integration-MR-1")
),
Err(MergeRequestEvidenceError::SourceSnapshotMismatch)
);
assert_eq!(
requirement_approval(&requests, "revision-one", &original[..1], None),
requirement_approval(&requests, "digest-one", &original[..1], None),
Err(MergeRequestEvidenceError::SourceSnapshotMismatch)
);
assert_eq!(
requirement_approval(
&requests,
"revision-one",
"digest-one",
&[original[0].clone(), original[0].clone()],
None
),
@@ -249,24 +229,24 @@ fn changed_revision_or_any_linked_source_invalidates_a_multiple_mr_attestation()
}
#[test]
fn refreshed_postmerge_approval_can_attest_a_changed_multiple_mr_set_and_revision() {
fn refreshed_postmerge_approval_can_attest_a_changed_multiple_mr_set_and_digest() {
let mut requests = merged_requests();
let mut snapshot = subjects();
snapshot.pop();
requests.pop();
assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, None),
requirement_approval(&requests, "digest-two", &snapshot, None),
Err(MergeRequestEvidenceError::RequirementApprovalMissing)
);
add_review(
&mut requests[0],
"refreshed-set",
"revision-two",
"digest-two",
&snapshot,
ReviewDecision::Approve,
);
assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, None)
requirement_approval(&requests, "digest-two", &snapshot, None)
.unwrap()
.event_id,
"refreshed-set"
@@ -282,7 +262,7 @@ fn requirement_approval_must_bind_its_own_mr_source_and_corresponding_request()
let original = subjects();
for (case, expected) in [
("missing", MergeRequestEvidenceError::ReviewRequestMissing),
("revision", MergeRequestEvidenceError::ReviewRequestMismatch),
("digest", MergeRequestEvidenceError::ReviewRequestMismatch),
("snapshot", MergeRequestEvidenceError::ReviewRequestMismatch),
("reviewer", MergeRequestEvidenceError::ReviewRequestMismatch),
("source", MergeRequestEvidenceError::ApprovalSourceMismatch),
@@ -294,7 +274,7 @@ fn requirement_approval_must_bind_its_own_mr_source_and_corresponding_request()
review.request_event_id = "absent-request".into();
}
MergeRequestThreadEvent::ReviewRequested(requested) => match case {
"revision" => requested.ticket_item_revision = "other-revision".into(),
"digest" => requested.ticket_content_digest = "other-digest".into(),
"snapshot" => {
requested.ticket_merge_request_subjects.pop();
}
@@ -309,12 +289,7 @@ fn requirement_approval_must_bind_its_own_mr_source_and_corresponding_request()
}
}
assert_eq!(
requirement_approval(
&requests,
"revision-one",
&original,
Some("integration-MR-1")
),
requirement_approval(&requests, "digest-one", &original, Some("integration-MR-1")),
Err(expected),
"{case}"
);
@@ -339,14 +314,14 @@ fn revoked_requirement_approval_is_rejected_and_discovery_uses_another_valid_mr(
assert_eq!(
requirement_approval(
&requests,
"revision-one",
"digest-one",
&subjects(),
Some("integration-MR-1")
),
Err(MergeRequestEvidenceError::ApprovalRevoked)
);
assert_eq!(
requirement_approval(&requests, "revision-one", &subjects(), None)
requirement_approval(&requests, "digest-one", &subjects(), None)
.unwrap()
.event_id,
"integration-MR-2"
@@ -441,8 +416,8 @@ fn evidence_error_codes_are_stable_machine_diagnostics_separate_from_messages()
"approval_not_effective",
),
(
MergeRequestEvidenceError::ItemRevisionMismatch,
"item_revision_mismatch",
MergeRequestEvidenceError::TicketContentMismatch,
"ticket_content_mismatch",
),
(
MergeRequestEvidenceError::SourceSnapshotMismatch,
+254 -24
View File
@@ -35,11 +35,24 @@ fn auth_repo(repository_id: &str) -> MergeRequestAuth {
..auth()
}
}
fn content_digest(title: &str) -> String {
let connection = Connection::open_in_memory().unwrap();
connection.execute_batch("CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,title TEXT,body TEXT); CREATE TABLE typed_ticket_targets(workspace_id TEXT,ticket_id TEXT,ordinal INTEGER,repository_key TEXT,ref_selector TEXT,access TEXT);").unwrap();
connection
.execute(
"INSERT INTO typed_tickets VALUES('W','T',?1,'Requirements')",
[title],
)
.unwrap();
ticket::sqlite_ticket_content_digest(&connection, "W", "T")
.unwrap()
.unwrap()
}
fn fixture() -> (tempfile::TempDir, MergeRequestStore) {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("db");
let c = Connection::open(&p).unwrap();
c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO repositories VALUES('W','R2');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t');CREATE VIEW ticket_active_worker_assignments AS SELECT current.* FROM ticket_current_worker_assignments current JOIN typed_tickets ticket ON ticket.workspace_id=current.workspace_id AND ticket.ticket_id=current.ticket_id WHERE ticket.workflow_state NOT IN ('done','closed');").unwrap();
c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,title TEXT NOT NULL,body TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_targets(workspace_id TEXT,ticket_id TEXT,ordinal INTEGER,repository_key TEXT,ref_selector TEXT,access TEXT);CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO repositories VALUES('W','R2');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t','Title','Requirements');CREATE VIEW ticket_active_worker_assignments AS SELECT current.* FROM ticket_current_worker_assignments current JOIN typed_tickets ticket ON ticket.workspace_id=current.workspace_id AND ticket.ticket_id=current.ticket_id WHERE ticket.workflow_state NOT IN ('done','closed');").unwrap();
drop(c);
let a = Assignments(Arc::new(Mutex::new(CurrentAssignment {
assignment_id: "A".into(),
@@ -50,6 +63,221 @@ fn fixture() -> (tempfile::TempDir, MergeRequestStore) {
let s = MergeRequestStore::open(&p, Arc::new(a), Arc::new(Repositories)).unwrap();
(d, s)
}
/// Frozen v12 fixture: counters must never be promoted to content attestations.
fn freeze_v12_review_payloads(connection: &Connection) {
let rows = {
let mut statement = connection.prepare(
"SELECT event_id,payload_json FROM merge_request_thread_events WHERE kind IN ('review_requested','review')",
).unwrap();
statement
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.unwrap()
.collect::<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) {
s.open_merge_request(OpenMergeRequest {
merge_request_id: merge_request_id.into(),
@@ -108,7 +336,7 @@ fn request_for(
.collect();
s.request_review(RequestMergeRequestReview {
merge_request_id: merge_request_id.into(),
ticket_item_revision: "t".into(),
ticket_content_digest: content_digest("Title"),
ticket_merge_request_subjects,
ticket_id: "T".into(),
subject_ref: subject.into(),
@@ -236,13 +464,13 @@ fn integration_preserves_selectors_ticket_state_and_assignment_and_replays_its_m
assert!(active);
let json = serde_json::to_string(&mr).unwrap();
for banned in [
"revision_id",
"digest_id",
"attempt_id",
"base_commit",
"head_commit",
"source_commit",
"result_commit",
"current_revision",
"current_digest",
] {
assert!(!json.contains(banned), "{banned} in {json}")
}
@@ -375,7 +603,7 @@ fn review_revocation_invalidates_readiness() {
}
#[test]
fn fresh_schema_uses_version_12_and_reopens_as_current() {
fn fresh_schema_uses_version_13_and_reopens_as_current() {
let c = Connection::open_in_memory().unwrap();
c.execute_batch(
"CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,PRIMARY KEY(workspace_id,ticket_id));",
@@ -388,7 +616,7 @@ fn fresh_schema_uses_version_12_and_reopens_as_current() {
r.get::<_, i64>(0)
})
.unwrap(),
12
13
);
merge_request::migrate(&c).unwrap();
}
@@ -724,9 +952,8 @@ fn ticket_rescope_requires_fresh_review_before_merge_and_remains_recoverable() {
let stale = approve(&store, "subject", "token-stale");
let connection = Connection::open(dir.path().join("db")).unwrap();
connection
.execute(
"INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)",
[],
.execute_batch(
"UPDATE typed_tickets SET title='Rescoped' WHERE workspace_id='W' AND ticket_id='T'; INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)",
)
.unwrap();
connection
@@ -750,7 +977,7 @@ fn ticket_rescope_requires_fresh_review_before_merge_and_remains_recoverable() {
readiness
.blockers
.iter()
.any(|blocker| blocker.contains("Ticket revision"))
.any(|blocker| blocker.contains("Ticket digest"))
);
let stale_completion = CompleteMergeRequest {
@@ -789,7 +1016,7 @@ fn ticket_rescope_requires_fresh_review_before_merge_and_remains_recoverable() {
.request_review(RequestMergeRequestReview {
merge_request_id: "MR".into(),
ticket_id: "T".into(),
ticket_item_revision: "T:1".into(),
ticket_content_digest: content_digest("Rescoped"),
ticket_merge_request_subjects: vec![MergeRequestReviewSubject {
merge_request_id: "MR".into(),
subject_ref: "subject".into(),
@@ -852,9 +1079,8 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
.unwrap();
let connection = Connection::open(dir.path().join("db")).unwrap();
connection
.execute(
"INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)",
[],
.execute_batch(
"UPDATE typed_tickets SET title='Rescoped' WHERE workspace_id='W' AND ticket_id='T'; INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)",
)
.unwrap();
connection
@@ -873,11 +1099,11 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
assert_eq!(
requirement_approval(
&before_refresh,
"T:1",
&content_digest("Rescoped"),
&snapshot,
Some(&integration_approval.event_id),
),
Err(MergeRequestEvidenceError::ItemRevisionMismatch)
Err(MergeRequestEvidenceError::TicketContentMismatch)
);
assert_eq!(before_refresh[0].merged_result().unwrap(), &merge);
assert_eq!(
@@ -899,7 +1125,7 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
.request_review(RequestMergeRequestReview {
merge_request_id: "MR".into(),
ticket_id: "T".into(),
ticket_item_revision: "T:1".into(),
ticket_content_digest: content_digest("Rescoped"),
ticket_merge_request_subjects: vec![MergeRequestReviewSubject {
merge_request_id: "MR".into(),
subject_ref: "subject".into(),
@@ -927,7 +1153,7 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
assert_eq!(
requirement_approval(
&refreshed,
"T:1",
&content_digest("Rescoped"),
&snapshot,
Some(&requirement_review.event_id),
)
@@ -935,7 +1161,7 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
&requirement_review
);
assert_eq!(
requirement_approval(&refreshed, "T:1", &snapshot, None).unwrap(),
requirement_approval(&refreshed, &content_digest("Rescoped"), &snapshot, None).unwrap(),
&requirement_review
);
assert_eq!(refreshed[0].merged_result().unwrap(), &merge);
@@ -970,7 +1196,7 @@ fn review_request_rejects_a_snapshot_that_omits_a_linked_merge_request() {
let result = store.request_review(RequestMergeRequestReview {
merge_request_id: "MR".into(),
ticket_id: "T".into(),
ticket_item_revision: "t".into(),
ticket_content_digest: content_digest("Title"),
ticket_merge_request_subjects: vec![MergeRequestReviewSubject {
merge_request_id: "MR".into(),
subject_ref: "subject-one".into(),
@@ -1058,7 +1284,13 @@ fn partial_and_full_integration_retain_ticket_state_and_active_assignment() {
})
.collect::<Vec<_>>();
assert_eq!(
requirement_approval(&requests, "t", &snapshot, Some(&second.event_id)).unwrap(),
requirement_approval(
&requests,
&content_digest("Title"),
&snapshot,
Some(&second.event_id)
)
.unwrap(),
&second
);
let first_request = requests
@@ -1217,9 +1449,7 @@ fn persisted_integration_evidence_rejects_missing_mismatched_revoked_or_late_app
match case {
"source-mismatch" => altered.subject_ref = "different-source".into(),
"not-approved" => altered.decision = ReviewDecision::RequestChanges,
"request-mismatch" => {
altered.ticket_item_revision = "different-revision".into()
}
"request-mismatch" => altered.ticket_content_digest = "different-digest".into(),
_ => unreachable!(),
}
connection.execute(
+45 -30
View File
@@ -103,11 +103,15 @@ impl AuthenticatedInputSource {
/// Caller-owned identity for one state-changing Worker command.
///
/// A controller accepts command ids in strictly increasing order. Exact retries
/// of an accepted id must retain the same command kind.
/// Commands operate on the controller state at admission time, not a caller's
/// previously observed Run. A controller accepts command ids in strictly
/// increasing order; retrying an accepted id cannot apply it to a later Run.
/// Exact retries must retain the same command kind. Removed preconditions are
/// rejected at the wire boundary rather than treated as arrival-time commands.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct WorkerCommandEnvelope {
pub command_id: u64,
}
@@ -312,16 +316,15 @@ pub enum Method {
/// are immutable and therefore cannot be cancelled here.
CancelPendingSubmission {
submission_id: String,
expected_revision: u64,
},
/// Remove every queued submission while preserving the active run.
/// Remove exactly the observed ordered set of submissions, preserving the
/// active run. Reject if the FIFO content changed before admission.
ClearPendingSubmissions {
expected_revision: u64,
expected_submission_ids: Vec<String>,
},
/// Activate the next queued submission while the Worker is idle. This is an
/// explicit recovery operation and never resumes a paused run implicitly.
ContinuePending {
expected_revision: u64,
expected_head_id: String,
},
Resume {
@@ -365,7 +368,7 @@ pub enum Method {
ListCompletions {
kind: CompletionKind,
prefix: String,
/// Client query generation. Echoed verbatim so identical-prefix ABA
/// Client query identity. Echoed verbatim so identical-prefix ABA
/// responses cannot cross edits, target switches, or permission changes.
#[serde(default, skip_serializing_if = "Option::is_none")]
request_id: Option<String>,
@@ -766,7 +769,6 @@ pub enum InternalWorkerKind {
pub struct CompactionLifecycle {
pub schema_version: u32,
pub compaction_id: String,
pub revision: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub internal_worker: Option<InternalWorkerRef>,
pub state: CompactionLifecycleState,
@@ -806,14 +808,13 @@ pub struct InternalWorkerRef {
pub kind: InternalWorkerKind,
}
/// Reconnect state for one visible Internal Worker. The revision fences live
/// `Event::InternalWorker` updates that raced with parent snapshot assembly.
/// Reconnect state for one visible Internal Worker. The parent serializes
/// snapshot publication and subsequent live events on the same ordered stream.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct InternalWorkerSnapshot {
pub worker: InternalWorkerRef,
pub revision: u64,
pub session: SessionSnapshot,
/// Public execution metadata for this child. Older snapshots and service
/// workers may omit it; clients must never substitute the parent's values.
@@ -869,12 +870,11 @@ pub struct PendingSubmissionSummary {
pub byte_len: u64,
}
/// Revisioned session-owned FIFO projection used by snapshots and live events.
/// Session-owned FIFO content projection used by snapshots and live events.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct PendingSubmissionsSnapshot {
pub revision: u64,
#[serde(default)]
pub notification_count: u32,
/// Ordered display-only previews of waiting notifications, each bounded like
@@ -1356,7 +1356,7 @@ pub enum Event {
Snapshot {
session: SessionSnapshot,
greeting: Greeting,
/// Full revisioned live execution state. `Stopped` remains Runtime
/// Full live execution state. `Stopped` remains Runtime
/// catalog authority and is deliberately not represented here.
state: WorkerStateSnapshot,
/// Unfinished model output that has already streamed in the current
@@ -1370,10 +1370,10 @@ pub enum Event {
},
/// A live event from a parent-owned Internal Worker. The payload reuses the
/// normal Worker event vocabulary while the wrapper carries stable origin
/// identity and a per-child revision fence.
/// identity. Apply in arrival order on the parent's serialized stream; a
/// reconnect snapshot replaces the projection before live events resume.
InternalWorker {
worker: InternalWorkerRef,
revision: u64,
event: Box<Event>,
},
/// Terminal removal fence for one parent-owned Internal Worker session.
@@ -1383,7 +1383,6 @@ pub enum Event {
/// the projection.
InternalWorkerRemoved {
worker: InternalWorkerRef,
revision: u64,
},
/// Server-side segment log rotated to a fresh `SegmentStart`.
///
@@ -2276,6 +2275,28 @@ mod tests {
assert!(error.to_string().contains("params"));
}
#[test]
fn lifecycle_commands_reject_removed_nested_preconditions() {
for method in ["pause", "cancel", "resume", "compact", "shutdown"] {
let wire = serde_json::json!({
"method": method,
"params": { "command": { "command_id": 11 } }
});
// Current commands are arrival-time operations, not Run-bound CAS.
assert!(serde_json::from_value::<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]
fn lifecycle_methods_roundtrip_with_command_identity() {
for method in [
@@ -3048,7 +3069,6 @@ mod tests {
CompactionLifecycle {
schema_version: 2,
compaction_id: "0192f0e8-4d84-7d6e-a000-000000000000".into(),
revision: 1,
internal_worker: None,
state,
started_at_ms: 1_700_000_000_000,
@@ -3069,7 +3089,9 @@ mod tests {
assert_eq!(parsed["event"], "compact_start");
assert_eq!(parsed["data"]["lifecycle"]["state"], "running");
let decoded: Event = serde_json::from_str(&json).unwrap();
assert!(matches!(decoded, Event::CompactStart { lifecycle } if lifecycle.revision == 1));
assert!(
matches!(decoded, Event::CompactStart { lifecycle } if lifecycle.state == CompactionLifecycleState::Running)
);
}
#[test]
@@ -3292,7 +3314,6 @@ mod tests {
parent_session_id: Some("parent-session".into()),
kind: InternalWorkerKind::SubWorker,
},
revision: 7,
event: Box::new(Event::TextDone {
text: "result".into(),
}),
@@ -3300,14 +3321,9 @@ mod tests {
let json = serde_json::to_string(&event).unwrap();
let decoded: Event = serde_json::from_str(&json).unwrap();
match decoded {
Event::InternalWorker {
worker,
revision,
event,
} => {
Event::InternalWorker { worker, event } => {
assert_eq!(worker.session_id, "session-1");
assert_eq!(worker.parent_session_id.as_deref(), Some("parent-session"));
assert_eq!(revision, 7);
assert!(matches!(*event, Event::TextDone { ref text } if text == "result"));
}
other => panic!("expected internal Worker event, got {other:?}"),
@@ -3323,21 +3339,20 @@ mod tests {
parent_session_id: Some("parent-session".into()),
kind: InternalWorkerKind::SubWorker,
},
revision: 8,
};
let json = serde_json::to_string(&event).unwrap();
let decoded: Event = serde_json::from_str(&json).unwrap();
assert!(matches!(
decoded,
Event::InternalWorkerRemoved { worker, revision }
if worker.session_id == "session-1" && revision == 8
Event::InternalWorkerRemoved { worker }
if worker.session_id == "session-1"
));
}
#[test]
fn pending_notification_previews_are_optional_and_roundtrip() {
let legacy = serde_json::json!({
"revision": 1, "notification_count": 1,
"notification_count": 1,
"head_id": "notification-head", "submissions": []
});
let mut snapshot: PendingSubmissionsSnapshot =
-1
View File
@@ -322,7 +322,6 @@ fn rust_serialized_subscription_frame_fixtures() -> Vec<SubscriptionFrame> {
parent_session_id: Some("fixture-parent-session".into()),
kind: InternalWorkerKind::SubWorker,
},
revision: 1,
session: SessionSnapshot {
pending_submissions: PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
+3 -12
View File
@@ -250,7 +250,6 @@ pub struct WorkingDirectoryRepository {
pub id: String,
pub provider: String,
pub source: RepositorySource,
pub source_revision: u64,
pub source_fingerprint: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<RepositorySelector>,
@@ -291,9 +290,6 @@ pub struct BackendResourceHandle {
pub operation: BackendResourceOperation,
pub expires_at_unix_seconds: i64,
pub nonce: String,
pub revision: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generation: Option<u64>,
pub max_bytes: u64,
pub content_type: String,
pub redaction: ResourceRedactionPolicy,
@@ -305,14 +301,14 @@ pub struct BackendResourceHandle {
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct RepositorySshCredentialCandidate {
pub credential_id: String,
pub credential_revision: u64,
pub public_key_fingerprint: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct RepositorySshMaterializationAccess {
pub credential_candidates: Vec<RepositorySshCredentialCandidate>,
pub host_trust_id: String,
pub host_trust_revision: u64,
pub host_key_fingerprint: String,
pub access: RepositoryAccessMode,
pub expires_at_epoch_seconds: u64,
pub repository_id: String,
@@ -326,7 +322,6 @@ pub struct RepositoryMaterializationContext {
pub workspace_id: String,
pub runtime_id: String,
pub operation_id: String,
pub config_revision: u64,
pub config_projection_digest: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh: Option<RepositorySshMaterializationAccess>,
@@ -427,7 +422,7 @@ pub struct BackendJobExecutionBinding {
pub job_id: String,
pub attempt_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_revision: Option<String>,
pub input_digest: Option<String>,
#[serde(default)]
pub subjektiv_consolidation: bool,
}
@@ -689,13 +684,11 @@ pub struct WorkerRetentionExecutionRequest {
pub workspace_id: String,
pub source_runtime_id: String,
pub worker_id: WorkerId,
pub expected_worker_revision: String,
pub source_created_at: String,
pub removed_at: String,
pub effective_profile: Option<String>,
pub retention_class: Option<String>,
pub policy_id: String,
pub policy_revision: u64,
pub session_disposition: SessionDisposition,
pub diagnostics_disposition: DiagnosticsDisposition,
}
@@ -718,7 +711,6 @@ pub struct WorkerSessionArchiveManifest {
pub content_bytes: u64,
pub content_file_count: u64,
pub policy_id: String,
pub policy_revision: u64,
pub operation_id: String,
pub input_fingerprint: String,
}
@@ -727,7 +719,6 @@ pub struct WorkerSessionArchiveManifest {
pub struct WorkerRetentionExecutionResult {
pub operation_id: String,
pub input_fingerprint: String,
pub expected_worker_revision: String,
pub worker_id: WorkerId,
pub session_disposition: SessionDisposition,
pub diagnostics_disposition: DiagnosticsDisposition,
+77 -31
View File
@@ -41,6 +41,39 @@ fn canonical_positive_decimal(value: &str) -> bool {
&& value.bytes().all(|byte| byte.is_ascii_digit())
&& 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> {
let value = String::deserialize(deserializer)?;
if value.len() > DRIVE_TEXT_MAX_BYTES {
@@ -110,9 +143,10 @@ pub struct DriveEntry {
pub parent: Option<DriveEntryRef>,
pub name: String,
pub kind: DriveEntryKind,
#[serde(deserialize_with = "decimal")]
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
pub revision: String,
#[serde(deserialize_with = "node_mutation_id")]
#[schemars(length(max = 128))]
/// Request ID of the last committed mutation, or empty for the immutable root.
pub last_mutation_id: String,
/// File length in bytes; absent for folders.
#[schemars(range(min = 0, max = 16_777_216))]
pub size: Option<u32>,
@@ -201,10 +235,10 @@ pub struct DriveReadChunkQuery {
#[serde(deserialize_with = "decimal")]
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
pub id: String,
/// Bind a multi-chunk read to the same file revision.
#[serde(deserialize_with = "decimal")]
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
pub expected_revision: String,
/// Require each chunk to match the same committed mutation observed in metadata.
#[serde(deserialize_with = "mutation_id")]
#[schemars(length(max = 128))]
pub expected_mutation_id: String,
#[schemars(range(min = 0, max = 16_777_216))]
pub offset: u32,
#[serde(default = "default_read_size", deserialize_with = "bounded_chunk")]
@@ -219,8 +253,8 @@ pub struct DriveDownloadQuery {
#[serde(deserialize_with = "decimal")]
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
pub id: String,
#[serde(default, deserialize_with = "optional_decimal")]
pub expected_revision: Option<String>,
#[serde(default, deserialize_with = "optional_mutation_id")]
pub expected_mutation_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -248,26 +282,26 @@ pub enum DriveMutation {
},
UpdateText {
id: DriveEntryRef,
#[serde(deserialize_with = "decimal")]
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
expected_revision: String,
#[serde(deserialize_with = "mutation_id")]
#[schemars(length(max = 128))]
expected_mutation_id: String,
#[serde(deserialize_with = "bounded_text")]
text: String,
content_type: String,
},
Relocate {
id: DriveEntryRef,
#[serde(deserialize_with = "decimal")]
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
expected_revision: String,
#[serde(deserialize_with = "mutation_id")]
#[schemars(length(max = 128))]
expected_mutation_id: String,
parent: DriveEntryRef,
name: String,
},
Delete {
id: DriveEntryRef,
#[serde(deserialize_with = "decimal")]
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
expected_revision: String,
#[serde(deserialize_with = "mutation_id")]
#[schemars(length(max = 128))]
expected_mutation_id: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -287,8 +321,8 @@ pub enum DriveUploadOperation {
Update,
}
/// Flat query metadata accompanies the binary body. The service rejects invalid
/// combinations: create requires parent_id/name and forbids id/expected_revision;
/// update requires id/expected_revision and forbids parent_id/name. Both targets
/// combinations: create requires parent_id/name and forbids id/expected_mutation_id;
/// update requires id/expected_mutation_id and forbids parent_id/name. Both targets
/// are bound by entry_workspace_id; declared size and SHA256 must match the body.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
@@ -303,8 +337,8 @@ pub struct DriveUploadQuery {
pub name: Option<String>,
#[serde(default, deserialize_with = "optional_decimal")]
pub id: Option<String>,
#[serde(default, deserialize_with = "optional_decimal")]
pub expected_revision: Option<String>,
#[serde(default, deserialize_with = "optional_mutation_id")]
pub expected_mutation_id: Option<String>,
pub content_type: String,
/// Values above the file limit still decode as u32 so the service returns
/// typed Limit rather than a generic request-decoding error.
@@ -422,7 +456,7 @@ impl DriveApiError {
message: match code {
Denied => "Drive access denied",
NotFound => "Drive resource not found",
Conflict => "Drive revision or name conflict",
Conflict => "Drive content, location, or name conflict",
Invalid => "Invalid Drive request",
Limit => "Drive request exceeds a limit",
StorageUnavailable => "Drive storage unavailable",
@@ -614,7 +648,7 @@ mod tests {
let value = json!({
"entry":{"workspace_id":"a","node_id":"7"},
"parent":{"workspace_id":"a","node_id":"1"},
"name":"file.txt","kind":"file","revision":"2","size":5,
"name":"file.txt","kind":"file","last_mutation_id":"2","size":5,
"content_type":"text/plain","updated_by":"user-a","updated_at":"2026-10-08T00:00:00Z",
"latest_url":"/api/w/a/drive/download?entry_workspace_id=a&id=7"
});
@@ -665,7 +699,7 @@ mod tests {
fn drive_json_mutations_accept_only_bounded_text_and_workspace_bound_cas() {
let valid = json!({"request_id":"request-a", "mutation":{
"operation":"update_text", "id":{"workspace_id":"a","node_id":"7"},
"expected_revision":"9007199254740993", "text":"hello", "content_type":"text/plain"
"expected_mutation_id":"write-資料/one", "text":"hello", "content_type":"text/plain"
}});
let request: DriveMutationRequest = serde_json::from_value(valid.clone()).unwrap();
assert_eq!(serde_json::to_value(request).unwrap(), valid);
@@ -675,9 +709,16 @@ mod tests {
let mut invalid = valid.clone();
invalid["mutation"]["id"] = json!("7");
assert!(serde_json::from_value::<DriveMutationRequest>(invalid).is_err());
for bad in [
"".to_string(),
"\ncontrol".to_string(),
"x".repeat(129),
"é".repeat(65),
] {
let mut invalid = valid.clone();
invalid["mutation"]["expected_revision"] = json!("01");
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.
let mut boundary = valid;
boundary["mutation"]["text"] = json!("é".repeat(DRIVE_TEXT_MAX_BYTES / 2));
@@ -695,18 +736,18 @@ mod tests {
)
.is_err()
);
assert!(serde_json::from_value::<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 =
serde_json::from_value(json!({"entry_workspace_id":"a","id":"7"})).unwrap();
assert_eq!(default.max_bytes, 65536);
let chunk: DriveReadChunkQuery = serde_json::from_value(json!({"entry_workspace_id":"a","id":"7","expected_revision":"1","offset":0,"length":65536})).unwrap();
let chunk: DriveReadChunkQuery = serde_json::from_value(json!({"entry_workspace_id":"a","id":"7","expected_mutation_id":"1","offset":0,"length":65536})).unwrap();
assert_eq!(chunk.length, 65536);
}
#[test]
fn drive_upload_requires_binding_declared_integrity_and_request_identity() {
let valid = json!({"operation":"update","request_id":"request-a","entry_workspace_id":"a","id":"7","expected_revision":"2","content_type":"application/octet-stream","size":3,"sha256":"a".repeat(64)});
let valid = json!({"operation":"update","request_id":"request-a","entry_workspace_id":"a","id":"7","expected_mutation_id":"2","content_type":"application/octet-stream","size":3,"sha256":"a".repeat(64)});
assert!(serde_json::from_value::<DriveUploadQuery>(valid.clone()).is_ok());
for field in ["entry_workspace_id", "size", "sha256", "request_id"] {
let mut missing = valid.clone();
@@ -725,7 +766,7 @@ mod tests {
"service must receive oversized declared sizes for typed Limit classification"
);
}
assert!(serde_json::from_value::<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;
invalid["bytes"] = json!([0, 255]);
assert!(serde_json::from_value::<DriveUploadQuery>(invalid).is_err());
@@ -763,7 +804,12 @@ mod tests {
for (code, wire, status, message) in [
(Denied, "denied", 403, "Drive access denied"),
(NotFound, "not_found", 404, "Drive resource not found"),
(Conflict, "conflict", 409, "Drive revision or name conflict"),
(
Conflict,
"conflict",
409,
"Drive content, location, or name conflict",
),
(Invalid, "invalid", 400, "Invalid Drive request"),
(Limit, "limit", 413, "Drive request exceeds a limit"),
(
File diff suppressed because it is too large Load Diff
@@ -1996,33 +1996,39 @@ mod tests {
#[test]
fn generator_rejects_lossy_safe_integers_and_preserves_bounded_ranges() {
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["maximum"] =
let mut fixture: Value = serde_json::from_str(OPENAPI).unwrap();
fixture["components"]["schemas"]["RepositorySummary"]["properties"]["size_bytes"] = serde_json::json!({"type": "integer", "format": "uint64", "minimum": 0, "maximum": 9_007_199_254_740_991_u64});
fixture["components"]["schemas"]["RepositorySummary"]["required"]
.as_array_mut()
.unwrap()
.push(serde_json::json!("size_bytes"));
let mut document = fixture.clone();
document["components"]["schemas"]["RepositorySummary"]["properties"]["size_bytes"]["maximum"] =
serde_json::json!(9_007_199_254_740_992_u64);
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(error.to_string().contains("safe-number"));
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["minimum"] =
let mut document = fixture.clone();
document["components"]["schemas"]["RepositorySummary"]["properties"]["size_bytes"]["minimum"] =
serde_json::json!(-1);
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(error.to_string().contains("safe-number"));
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["maximum"] =
let mut document = fixture.clone();
document["components"]["schemas"]["RepositorySummary"]["properties"]["size_bytes"]["maximum"] =
serde_json::json!(9_007_199_254_740_990_u64);
let output = generate_repository_typescript(&document.to_string()).unwrap();
assert!(output.contains(
"source_revision: number & { readonly \"__yoiIntegerRange\": \"0..9007199254740990\"; }"
"size_bytes: number & { readonly \"__yoiIntegerRange\": \"0..9007199254740990\"; }"
));
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["format"] =
let mut document = fixture.clone();
document["components"]["schemas"]["RepositorySummary"]["properties"]["size_bytes"]["format"] =
serde_json::json!("int64");
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(error.to_string().contains("unsupported integer format"));
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
let mut document = fixture.clone();
document["components"]["schemas"]["RepositorySummary"]["properties"]["git"]["anyOf"]
.as_array_mut()
.unwrap()
+15 -8
View File
@@ -82,9 +82,6 @@ pub struct WorkspaceConfigNode {
pub struct WorkspaceConfigObserveResponse {
pub connection_id: String,
pub validator: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub revision: u64,
pub digest: String,
/// Canonical commit entrypoints from this exact metadata snapshot.
pub entrypoints: Vec<String>,
@@ -121,9 +118,6 @@ pub struct WorkspaceConfigCommitRequest {
#[serde(deny_unknown_fields)]
pub struct WorkspaceConfigCommitResponse {
pub validator: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub revision: u64,
pub digest: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -178,11 +172,11 @@ mod tests {
#[test]
fn workspace_config_observation_is_metadata_only_and_has_same_snapshot_cas_inputs() {
let value = serde_json::json!({"connection_id":"c", "validator":"v", "revision":7, "digest":"d", "entrypoints":["main.dcdl"], "nodes":[{"path":"", "kind":"directory", "validator":"v", "digest":null, "content_type":null, "operations":["apply_changes"]}]});
let value = serde_json::json!({"connection_id":"c", "validator":"v", "digest":"d", "entrypoints":["main.dcdl"], "nodes":[{"path":"", "kind":"directory", "validator":"v", "digest":null, "content_type":null, "operations":["apply_changes"]}]});
let response: WorkspaceConfigObserveResponse =
serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(response).unwrap(), value);
for field in ["revision", "digest", "entrypoints"] {
for field in ["validator", "digest", "entrypoints"] {
let mut missing = value.clone();
missing.as_object_mut().unwrap().remove(field);
assert!(serde_json::from_value::<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]
fn workspace_config_failure_classification_roundtrips_and_rejections_are_pre_effects() {
for classification in [
+3 -1
View File
@@ -51,7 +51,9 @@ pub enum LoggedSessionHistoryOrigin {
FlowInstruction {
selector: String,
definition_id: String,
definition_revision: u64,
/// Exact compiled source used for these instructions; absent in older logs.
#[serde(default, skip_serializing_if = "Option::is_none")]
definition_digest: Option<String>,
instance_id: String,
state_id: String,
},
+2 -2
View File
@@ -3662,7 +3662,7 @@ mod tests {
LoggedSessionHistoryOrigin::FlowInstruction {
selector: "builtin:coder-review".into(),
definition_id: "flow-definition".into(),
definition_revision: 7,
definition_digest: Some("sha256:flow-source".into()),
instance_id: "flow-instance".into(),
state_id: "implement".into(),
},
@@ -3695,7 +3695,7 @@ mod tests {
origin: LoggedSessionHistoryOrigin::FlowInstruction {
selector: "builtin:coder-review".into(),
definition_id: "flow-definition".into(),
definition_revision: 7,
definition_digest: Some("sha256:flow-source".into()),
instance_id: "flow-instance".into(),
state_id: "implement".into(),
},
+1 -1
View File
@@ -1156,7 +1156,7 @@ mod tests {
)))],
extensions: vec![SessionExtension::new(
"flow.runtime.v1",
serde_json::json!({ "state": "implement", "revision": 0 }),
serde_json::json!({ "state": "implement" }),
)],
};
let json = serde_json::to_string(&entry).unwrap();
+23 -11
View File
@@ -77,7 +77,6 @@ impl SystemReminder {
pub struct PromptRenderProvenance {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
pub config_revision: u64,
pub source_digest: String,
pub projection_digest: String,
pub logical_name: String,
@@ -176,10 +175,14 @@ pub enum SystemItem {
},
/// A Host-fetched change to user-managed Subject behavior. Each changed
/// revision is appended rather than rewriting prior prompt/session content.
/// body is appended rather than rewriting prior prompt/session content.
SubjectBehaviorRefresh {
body: String,
behavior_revision: u64,
/// Exact user-managed behavior acknowledged after durable commit. Old
/// logs omit this value; an empty value is not an acknowledgement of a
/// nonempty behavior and does not replace their stored rendered body.
#[serde(default)]
behavior_md: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
prompt_provenance: Option<PromptRenderProvenance>,
},
@@ -292,22 +295,31 @@ mod tests {
}
#[test]
fn subject_behavior_refresh_round_trips_with_revision_and_exact_body() {
fn subject_behavior_refresh_round_trips_with_exact_body() {
let item = SystemItem::SubjectBehaviorRefresh {
body: "current Subject context".to_string(),
behavior_revision: 7,
behavior_md: "exact behavior".to_string(),
prompt_provenance: None,
};
let raw = serde_json::to_string(&item).unwrap();
let parsed: SystemItem = serde_json::from_str(&raw).unwrap();
assert_eq!(parsed.history_text(), "current Subject context");
assert!(matches!(
parsed,
SystemItem::SubjectBehaviorRefresh {
behavior_revision: 7,
..
assert!(
matches!(parsed, SystemItem::SubjectBehaviorRefresh { behavior_md, .. } if behavior_md == "exact behavior")
);
}
));
#[test]
fn frozen_subject_behavior_log_reads_old_counter_without_republishing_it() {
let old = r#"{"kind":"subject_behavior_refresh","body":"exact archived body","behavior_revision":7}"#;
let item: SystemItem = serde_json::from_str(old).unwrap();
assert_eq!(item.history_text(), "exact archived body");
assert!(
serde_json::to_value(item)
.unwrap()
.get("behavior_revision")
.is_none()
);
}
#[test]
+35 -23
View File
@@ -13,7 +13,10 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
const SCHEMA_VERSION: i64 = 2;
#[path = "job_store/legacy_migrations.rs"]
mod legacy_migrations;
const SCHEMA_VERSION: i64 = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -69,7 +72,10 @@ impl JobSnapshot {
pub fn outcome(&self) -> job::JobOutcome {
job::JobOutcome {
job_id: self.request.job_id.clone(),
input_revision: self.request.input_revision.clone(),
input_digest: self
.request
.input_digest()
.expect("validated immutable Job input"),
attempt_id: self.attempt.attempt_id.clone(),
attempt: self.attempt.number,
state: match self.state {
@@ -135,8 +141,8 @@ pub enum JobStoreError {
ConcurrentLimit,
#[error("result or lifecycle operation is not bound to the current Job attempt")]
AttemptMismatch,
#[error("result input revision does not match the immutable request")]
RevisionMismatch,
#[error("result input digest does not match the immutable request")]
DigestMismatch,
#[error("accepted Job result cannot be changed")]
ResultConflict,
#[error("invalid Job transition: {0}")]
@@ -176,8 +182,11 @@ impl JobStore {
tx.execute_batch(GRANT_SCHEMA)?;
tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
}
1 => {
1 | 2 => {
if version == 1 {
tx.execute_batch(GRANT_SCHEMA)?;
}
legacy_migrations::migrate_legacy_jobs(&tx)?;
tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
}
SCHEMA_VERSION => {}
@@ -370,8 +379,8 @@ impl JobStore {
.transaction_with_behavior(TransactionBehavior::Immediate)?;
let snapshot = require_snapshot(&tx, &bound.job_id)?;
check_attempt(&snapshot, &bound.attempt_id)?;
if snapshot.request.input_revision != bound.input_revision {
return Err(JobStoreError::RevisionMismatch);
if snapshot.request.input_digest()? != bound.input_digest {
return Err(JobStoreError::DigestMismatch);
}
let (digest, encoded) =
job::result_digest(&bound.result, snapshot.request.limits.max_result_bytes)?;
@@ -527,13 +536,13 @@ fn insert_attempt(
number: u8,
) -> Result<(), JobStoreError> {
conn.execute(
"INSERT INTO job_attempts (job_id, number, attempt_id, input_revision, state)
"INSERT INTO job_attempts (job_id, number, attempt_id, input_digest, state)
VALUES (?1, ?2, ?3, ?4, 'reserved')",
params![
request.job_id,
number,
job::attempt_id(&request.job_id, number),
request.input_revision
request.input_digest()?
],
)?;
Ok(())
@@ -669,7 +678,7 @@ CREATE TABLE job_attempts (
job_id TEXT NOT NULL REFERENCES job_intents(job_id),
number INTEGER NOT NULL CHECK (number BETWEEN 1 AND 255),
attempt_id TEXT NOT NULL UNIQUE,
input_revision TEXT NOT NULL,
input_digest TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('reserved', 'dispatched', 'completed', 'failed', 'unknown', 'cancelled')),
result_json TEXT,
result_digest TEXT,
@@ -685,7 +694,7 @@ CREATE TRIGGER job_intent_immutable BEFORE UPDATE OF
job_id, request_json, fingerprint, serialization_key, max_concurrent ON job_intents
BEGIN SELECT RAISE(ABORT, 'immutable Job intent'); END;
CREATE TRIGGER job_attempt_immutable BEFORE UPDATE OF
job_id, number, attempt_id, input_revision ON job_attempts
job_id, number, attempt_id, input_digest ON job_attempts
BEGIN SELECT RAISE(ABORT, 'immutable Job attempt identity'); END;
CREATE TRIGGER job_result_immutable BEFORE UPDATE OF result_json, result_digest ON job_attempts
WHEN OLD.result_json IS NOT NULL AND
@@ -705,7 +714,6 @@ mod tests {
JobRequest {
job_id: id.into(),
purpose: "fixture".into(),
input_revision: "revision-1".into(),
input_ref: "fixture:input".into(),
input: json!({"immutable": [1, 2, 3]}),
instruction: "Produce a structured result".into(),
@@ -725,7 +733,10 @@ mod tests {
JobResultSubmission {
job_id: snapshot.request.job_id.clone(),
attempt_id: snapshot.attempt.attempt_id.clone(),
input_revision: snapshot.request.input_revision.clone(),
input_digest: snapshot
.request
.input_digest()
.expect("validated immutable Job input"),
result,
}
}
@@ -762,10 +773,10 @@ mod tests {
for sql in [
"UPDATE job_intents SET request_json = '{}' WHERE job_id = 'identity'",
"UPDATE job_intents SET fingerprint = 'changed' WHERE job_id = 'identity'",
"UPDATE job_attempts SET input_revision = 'changed' WHERE job_id = 'identity'",
"UPDATE job_attempts SET input_digest = 'changed' WHERE job_id = 'identity'",
"UPDATE job_attempts SET attempt_id = 'changed' WHERE job_id = 'identity'",
"UPDATE job_intents SET current_attempt = 2 WHERE job_id = 'identity'",
"INSERT INTO job_attempts (job_id, number, attempt_id, input_revision, state)
"INSERT INTO job_attempts (job_id, number, attempt_id, input_digest, state)
VALUES ('absent', 1, 'absent:attempt:1', 'r', 'reserved')",
] {
assert!(
@@ -818,9 +829,6 @@ mod tests {
assert_eq!(store.reserve(original.clone()).unwrap(), snapshot);
let mut variants = Vec::new();
let mut changed = original.clone();
changed.input_revision = "revision-2".into();
variants.push(changed);
let mut changed = original.clone();
changed.input = json!({"changed": true});
variants.push(changed);
let mut changed = original.clone();
@@ -917,12 +925,16 @@ mod tests {
Err(JobStoreError::AttemptMismatch)
));
let prior: (String, String, String) = store.conn.query_row(
"SELECT state, failure, input_revision FROM job_attempts WHERE job_id = 'retry' AND number = 1",
"SELECT state, failure, input_digest FROM job_attempts WHERE job_id = 'retry' AND number = 1",
[], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
).unwrap();
assert_eq!(
prior,
("failed".into(), "timeout".into(), "revision-1".into())
(
"failed".into(),
"timeout".into(),
original.input_digest().unwrap()
)
);
store
.finish(
@@ -960,10 +972,10 @@ mod tests {
Err(JobStoreError::AttemptMismatch)
));
let mut invalid = valid.clone();
invalid.input_revision = "revision-2".into();
invalid.input_digest = "wrong-digest".into();
assert!(matches!(
store.accept(&invalid),
Err(JobStoreError::RevisionMismatch)
Err(JobStoreError::DigestMismatch)
));
let mut invalid = valid.clone();
invalid.result = json!("x".repeat(32));
@@ -1537,7 +1549,7 @@ mod tests {
let absent = JobResultSubmission {
job_id: "missing".into(),
attempt_id: "attempt".into(),
input_revision: "r".into(),
input_digest: request("missing").input_digest().unwrap(),
result: json!({}),
};
assert!(matches!(
@@ -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(),
job_id: job_id.into(),
attempt_id: snapshot.attempt.attempt_id.clone(),
input_revision: snapshot.request.input_revision.clone(),
input_digest: snapshot
.request
.input_digest()
.map_err(JobStoreError::from)?,
deadline,
});
let grant = match (
@@ -295,7 +298,10 @@ impl StandaloneJobs {
store: store.clone(),
job_id: id.clone(),
attempt_id: attempt_id.clone(),
input_revision: snapshot.request.input_revision.clone(),
input_digest: snapshot
.request
.input_digest()
.map_err(JobStoreError::from)?,
deadline,
});
let (cancel, mut cancellation) = watch::channel(false);
@@ -451,14 +457,14 @@ struct BoundResultSink {
store: Arc<Mutex<Option<JobStore>>>,
job_id: String,
attempt_id: String,
input_revision: String,
input_digest: String,
deadline: tokio::time::Instant,
}
impl worker::job::JobResultSink for BoundResultSink {
fn submit(&self, submission: JobResultSubmission) -> Result<(), String> {
if submission.job_id != self.job_id
|| submission.attempt_id != self.attempt_id
|| submission.input_revision != self.input_revision
|| submission.input_digest != self.input_digest
{
return Err("Job result capability binding mismatch".into());
}
@@ -482,7 +488,7 @@ struct BoundAttemptFence {
store: Arc<Mutex<Option<JobStore>>>,
job_id: String,
attempt_id: String,
input_revision: String,
input_digest: String,
deadline: tokio::time::Instant,
}
impl JobAttemptFence for BoundAttemptFence {
@@ -499,7 +505,7 @@ impl JobAttemptFence for BoundAttemptFence {
if snapshot.state != JobState::Pending
|| snapshot.attempt.state != JobAttemptState::Dispatched
|| snapshot.attempt.attempt_id != self.attempt_id
|| snapshot.request.input_revision != self.input_revision
|| snapshot.request.input_digest().map_err(|e| e.to_string())? != self.input_digest
{
return Err("Job domain grant is not bound to the current live attempt".into());
}
+9 -10
View File
@@ -11,7 +11,6 @@ fn request(id: &str) -> JobRequest {
JobRequest {
job_id: id.into(),
purpose: "test".into(),
input_revision: "r1".into(),
input_ref: "fixture:input".into(),
input: serde_json::json!({"data":42}),
instruction: "Return a structured result".into(),
@@ -25,7 +24,7 @@ fn sink(jobs: &StandaloneJobs, id: &str, attempt: &str) -> BoundResultSink {
store: jobs.store.clone(),
job_id: id.into(),
attempt_id: attempt.into(),
input_revision: "r1".into(),
input_digest: request(id).input_digest().unwrap(),
deadline: tokio::time::Instant::now() + Duration::from_secs(10),
}
}
@@ -33,7 +32,7 @@ fn result(id: &str, attempt: &str) -> JobResultSubmission {
JobResultSubmission {
job_id: id.into(),
attempt_id: attempt.into(),
input_revision: "r1".into(),
input_digest: request(id).input_digest().unwrap(),
result: serde_json::json!({"valid":true}),
}
}
@@ -99,7 +98,7 @@ type CapturedDomainAttempt = (JobSnapshot, Arc<dyn JobAttemptFence>);
#[derive(Default)]
struct RecordingDomainProvider(Mutex<Vec<CapturedDomainAttempt>>);
fn domain_grant(id: &str) -> Value {
json!({"consumer":"test-domain", "job_id":id, "input_revision":"r1"})
json!({"consumer":"test-domain", "job_id":id, "input_digest":request(id).input_digest().unwrap()})
}
fn domain_request(id: &str) -> JobRequest {
let mut intent = request(id);
@@ -244,7 +243,7 @@ async fn persisted_grant_requires_provider_and_missing_grant_cannot_be_derived_f
}
#[tokio::test]
async fn foreign_job_revision_and_consumer_grants_are_refused_before_model_dispatch() {
async fn foreign_job_digest_and_consumer_grants_are_refused_before_model_dispatch() {
let temp = tempfile::tempdir().unwrap();
let jobs =
StandaloneJobs::open(&temp.path().join("jobs.sqlite3"), temp.path().to_path_buf()).unwrap();
@@ -253,12 +252,12 @@ async fn foreign_job_revision_and_consumer_grants_are_refused_before_model_dispa
for (id, grant) in [
("foreign-job", domain_grant("another-job")),
(
"foreign-revision",
json!({"consumer":"test-domain","job_id":"foreign-revision","input_revision":"r0"}),
"foreign-digest",
json!({"consumer":"test-domain","job_id":"foreign-digest","input_digest":"r0"}),
),
(
"foreign-consumer",
json!({"consumer":"another-domain","job_id":"foreign-consumer","input_revision":"r1"}),
json!({"consumer":"another-domain","job_id":"foreign-consumer","input_digest":request("foreign-consumer").input_digest().unwrap()}),
),
] {
let reserved = jobs.request_granted(domain_request(id), grant).unwrap();
@@ -378,7 +377,7 @@ async fn bound_domain_does_not_change_root_generic_result_only_execution() {
}
#[tokio::test]
async fn host_result_capability_cannot_cross_job_attempt_revision_or_deadline() {
async fn host_result_capability_cannot_cross_job_attempt_digest_or_deadline() {
let temp = tempfile::tempdir().unwrap();
let jobs =
StandaloneJobs::open(&temp.path().join("jobs.sqlite3"), temp.path().to_path_buf()).unwrap();
@@ -398,7 +397,7 @@ async fn host_result_capability_cannot_cross_job_attempt_revision_or_deadline()
);
assert!(bound.submit(result("first", "first:attempt:2")).is_err());
let mut stale = result("first", &first.attempt.attempt_id);
stale.input_revision = "r0".into();
stale.input_digest = "r0".into();
assert!(bound.submit(stale).is_err());
let mut expired = sink(&jobs, "first", &first.attempt.attempt_id);
expired.deadline = tokio::time::Instant::now() - Duration::from_secs(1);
+146 -26
View File
@@ -17,7 +17,7 @@ const LEASE_FILE: &str = "lease.json";
const LEASE_LOCK_FILE: &str = "lease.lock";
const SESSIONS_DIR: &str = "sessions";
const WORKER_DIR: &str = "worker";
const SCHEMA_VERSION: u32 = 1;
const SCHEMA_VERSION: u32 = 2;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StandaloneCwdIdentity {
@@ -79,7 +79,6 @@ pub enum StandaloneShutdownReason {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StandaloneWorkerRecord {
pub schema_version: u32,
pub revision: u64,
pub worker_id: WorkerId,
/// User-facing Worker name resolved from the profile.
pub worker_name: String,
@@ -194,7 +193,6 @@ impl StandaloneWorkerStore {
let now = now_unix_ms()?;
let record = StandaloneWorkerRecord {
schema_version: SCHEMA_VERSION,
revision: 1,
worker_id: allocation.worker_id,
worker_name: manifest.worker.name.clone(),
storage_key,
@@ -326,13 +324,12 @@ impl StandaloneWorkerStore {
active_segment_id: Option<SegmentId>,
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
let mut next = record.clone();
next.revision = next.revision.saturating_add(1);
next.updated_at_unix_ms = now_unix_ms()?;
next.active_session_id = active_session_id;
next.active_segment_id = active_segment_id;
next.status = StandaloneWorkerStatus::Active;
next.shutdown_reason = None;
self.commit_record(Some(record.revision), &next)?;
self.commit_record(Some(record), &next)?;
Ok(next)
}
@@ -344,13 +341,12 @@ impl StandaloneWorkerStore {
reason: StandaloneShutdownReason,
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
let mut next = record.clone();
next.revision = next.revision.saturating_add(1);
next.updated_at_unix_ms = now_unix_ms()?;
next.active_session_id = active_session_id;
next.active_segment_id = active_segment_id;
next.status = StandaloneWorkerStatus::Stopped;
next.shutdown_reason = Some(reason);
self.commit_record(Some(record.revision), &next)?;
self.commit_record(Some(record), &next)?;
Ok(next)
}
@@ -405,7 +401,7 @@ impl StandaloneWorkerStore {
fn commit_record(
&self,
expected_revision: Option<u64>,
expected_record: Option<&StandaloneWorkerRecord>,
next: &StandaloneWorkerRecord,
) -> Result<(), StandaloneStoreError> {
let dir = self.worker_dir(next.worker_id);
@@ -421,19 +417,21 @@ impl StandaloneWorkerStore {
StandaloneStoreError::Io(error)
}
})?;
writeln!(marker_file, "{}", next.revision).map_err(StandaloneStoreError::Io)?;
writeln!(marker_file, "{}", next.worker_id).map_err(StandaloneStoreError::Io)?;
marker_file.sync_all().map_err(StandaloneStoreError::Io)?;
sync_directory(&dir)?;
if let Some(expected) = expected_revision {
if let Some(expected) = expected_record {
let current = self.load_record_while_committing(next.worker_id)?;
if current.revision != expected {
let _ = fs::remove_file(&marker);
return Err(StandaloneStoreError::RevisionConflict {
id: next.worker_id,
expected,
found: current.revision,
});
// The create-new commit marker excludes other writers across this
// comparison and replacement. Compare the observed metadata itself,
// including its manifest, pointer and lifecycle state, not a counter.
if serde_json::to_value(&current).map_err(StandaloneStoreError::Json)?
!= serde_json::to_value(expected).map_err(StandaloneStoreError::Json)?
{
fs::remove_file(&marker).map_err(StandaloneStoreError::Io)?;
sync_directory(&dir)?;
return Err(StandaloneStoreError::MetadataChanged(next.worker_id));
}
}
@@ -681,6 +679,19 @@ fn decode_worker_record(
"standalone Worker record must be an object",
))
})?;
// Frozen schema-1 adapter: the counter was not domain data. Preserve
// every metadata field and use content comparison for future writes.
if object
.get("schema_version")
.and_then(serde_json::Value::as_u64)
== Some(1)
{
object.remove("revision");
object.insert(
"schema_version".to_string(),
serde_json::json!(SCHEMA_VERSION),
);
}
let persisted_manifest = object.remove("manifest").ok_or_else(|| {
serde_json::Error::io(io::Error::new(
io::ErrorKind::InvalidData,
@@ -770,14 +781,8 @@ pub enum StandaloneStoreError {
LeaseOwnershipLost,
#[error("standalone Worker {0} must be stopped before deletion")]
DeleteActive(WorkerId),
#[error(
"standalone Worker {id} metadata revision changed (expected {expected}, found {found})"
)]
RevisionConflict {
id: WorkerId,
expected: u64,
found: u64,
},
#[error("standalone Worker {0} metadata changed since it was read")]
MetadataChanged(WorkerId),
#[error("system clock is before the Unix epoch or out of range")]
Clock,
#[error("standalone metadata serialization failed")]
@@ -816,7 +821,6 @@ permission = "write"
let manifest = test_manifest();
let record = StandaloneWorkerRecord {
schema_version: SCHEMA_VERSION,
revision: 6,
worker_id,
worker_name: manifest.worker.name.clone(),
storage_key: "standalone-test".to_string(),
@@ -859,6 +863,122 @@ permission = "write"
);
}
#[test]
fn metadata_write_compares_observed_content_even_with_equal_timestamps() {
let root = tempfile::tempdir().unwrap();
let store = StandaloneWorkerStore::open(root.path().join("workers")).unwrap();
let allocation = store
.allocate(root.path(), StaleLeasePolicy::Reject)
.unwrap();
let record = store
.commit_created(
&allocation,
test_manifest(),
"metadata-test".into(),
session_store::new_session_id(),
None,
)
.unwrap();
// Simulate another committed pointer change in the same clock tick.
let mut changed = record.clone();
changed.active_session_id = session_store::new_session_id();
store.commit_record(Some(&record), &changed).unwrap();
assert!(matches!(
store.mark_stopped(
&record,
record.active_session_id,
None,
StandaloneShutdownReason::UserExit
),
Err(StandaloneStoreError::MetadataChanged(_))
));
assert_eq!(
store.load(record.worker_id).unwrap().active_session_id,
changed.active_session_id
);
assert!(
!store
.worker_dir(record.worker_id)
.join(COMMIT_MARKER)
.exists()
);
let stopped = store
.mark_stopped(
&changed,
changed.active_session_id,
None,
StandaloneShutdownReason::UserExit,
)
.unwrap();
assert_eq!(stopped.status, StandaloneWorkerStatus::Stopped);
}
#[test]
fn schema_one_metadata_preserves_content_without_an_update_counter() {
let root = tempfile::tempdir().unwrap();
let store = StandaloneWorkerStore::open(root.path().join("workers")).unwrap();
let allocation = store
.allocate(root.path(), StaleLeasePolicy::Reject)
.unwrap();
let record = store
.commit_created_connected(
&allocation,
test_manifest(),
"migration-test".into(),
session_store::new_session_id(),
None,
Some(crate::subjektiv::StandaloneSubjectBinding {
scope_id: "local-scope".into(),
subject_id: "local-subject".into(),
}),
)
.unwrap();
let record = store
.mark_stopped(
&record,
record.active_session_id,
Some(session_store::new_segment_id()),
StandaloneShutdownReason::UserExit,
)
.unwrap();
let mut persisted = Vec::new();
write_worker_record(&mut persisted, &record).unwrap();
let mut legacy: serde_json::Value = serde_json::from_slice(&persisted).unwrap();
legacy["schema_version"] = serde_json::json!(1);
legacy["revision"] = serde_json::json!(42);
let legacy_bytes = serde_json::to_vec(&legacy).unwrap();
fs::write(
store.worker_dir(record.worker_id).join(RECORD_FILE),
&legacy_bytes,
)
.unwrap();
let migrated = store.load(record.worker_id).unwrap();
assert_eq!(
serde_json::to_value(&migrated).unwrap(),
serde_json::to_value(&record).unwrap()
);
let mut encoded = Vec::new();
write_worker_record(&mut encoded, &migrated).unwrap();
let saved: serde_json::Value = serde_json::from_slice(&encoded).unwrap();
assert!(saved.get("revision").is_none());
assert_eq!(saved["schema_version"], 2);
let resumed = store
.update_active_pointer(
&migrated,
migrated.active_session_id,
migrated.active_segment_id,
)
.unwrap();
let disk: serde_json::Value = serde_json::from_slice(
&fs::read(store.worker_dir(record.worker_id).join(RECORD_FILE)).unwrap(),
)
.unwrap();
assert!(disk.get("revision").is_none());
assert_eq!(disk["schema_version"], 2);
assert_eq!(resumed.subject, record.subject);
assert_eq!(resumed.active_segment_id, record.active_segment_id);
}
#[test]
fn lease_liveness_requires_positive_live_or_stale_evidence() {
assert_eq!(
+4 -5
View File
@@ -352,7 +352,7 @@ fn operation_error(error: subjektiv::api::OperationError) -> SubjektivHostError
} else if message.contains("candidate_decision_conflict") {
"candidate_decision_conflict"
} else {
"revision_conflict"
"change_conflict"
}
.into(),
message,
@@ -655,7 +655,7 @@ impl LocalSubjectHost {
worker_id: Some(self.worker_id.to_string()),
flow_selector: None,
flow_definition_id: None,
flow_definition_revision: None,
flow_definition_fingerprint: None,
};
anchor.origin = Some(origin.clone());
source.origin = Some(origin);
@@ -835,8 +835,7 @@ impl SubjektivHost for LocalSubjectHost {
job::DEFAULT_MAX_RESULT_BYTES,
)
.map_err(operation_error)?;
let subject = self
.catalog
self.catalog
.store
.subject(&self.binding.subject_id)
.map_err(domain_error)?
@@ -847,7 +846,7 @@ impl SubjektivHost for LocalSubjectHost {
};
let request=job::JobRequest {
job_id:format!("subjektiv-consolidation:{}",uuid::Uuid::now_v7()),purpose:"subjektiv_consolidation".into(),
input_revision:subject.store_revision.to_string(),input_ref:format!("subjektiv://{}/consolidation",self.binding.subject_id),
input_ref:format!("subjektiv://{}/consolidation",self.binding.subject_id),
input:serde_json::json!({"subject_id":self.binding.subject_id,"candidate_ids":grant.candidate_ids}),
instruction:"Resolve ONLY candidate_ids with MemoryStagingList/Read, recall and MemoryApplyCandidate. Already resolved IDs are durable receipts; do not repeat them. Leave new candidates for a later Job. When the entire immutable batch has a disposition, call SubmitJobResult with result: {subject_id, candidate_ids}. The Host awaits clean-context surface generation and verifies actual receipts/outcome before accepting success; never supply surface claims.".into(),
profile:"builtin:standalone-subjektiv-consolidation".into(),serialization_key:Some(format!("subjektiv:{}",self.binding.subject_id)),
+1 -1
View File
@@ -487,7 +487,7 @@ async fn standalone_restore_recovers_only_a_proven_stale_lease() -> TestResult {
source: manifest::ProfileRegistrySource::User,
name: "user-standalone".to_string(),
path: None,
provenance: Some("user-config-revision-7".to_string()),
provenance: Some("user-config-digest".to_string()),
},
profile: Some(manifest::ProfileMetadata {
name: Some("User standalone".to_string()),
-1
View File
@@ -91,7 +91,6 @@ fn request(id: &str) -> JobRequest {
JobRequest {
job_id: id.into(),
purpose: "generic_test".into(),
input_revision: "r1".into(),
input_ref: "test:immutable".into(),
input: serde_json::json!({"test_input":42}),
instruction: "Return a structured validity result".into(),
+7 -7
View File
@@ -273,7 +273,7 @@ impl ScriptedClient {
"SubmitMemorySurface",
json!({"points":[{
"body_md":format!("- {}", material["body_md"].as_str().unwrap()),
"memory_refs":[{"memory_id":material["memory_id"], "revision":material["revision"]}]
"memory_refs":[{"memory_id":material["memory_id"], "change_id":material["change_id"]}]
}]}),
)
}
@@ -1155,7 +1155,7 @@ async fn automatic_generic_consolidation_applies_candidate_publishes_surface_and
);
let memory = store.list_memories(&subject.id).unwrap().pop().unwrap();
assert_eq!(memory.claim, claim);
assert_eq!(memory.revision, 1);
assert!(!memory.change_id.is_empty());
assert_eq!(memory.source_candidate_ids, vec![ids[0].as_str().unwrap()]);
let surface = store.resident_surface(&subject.id).unwrap();
assert_eq!(surface.availability, subjektiv::SurfaceAvailability::Ready);
@@ -1167,14 +1167,14 @@ async fn automatic_generic_consolidation_applies_candidate_publishes_surface_and
assert!(snapshot.body_md.contains(claim));
assert_eq!(
snapshot.memory_refs,
vec![subjektiv::MemoryRevisionRef {
vec![subjektiv::MemoryChangeRef {
memory_id: memory.id.clone(),
revision: 1
change_id: memory.change_id.clone()
}]
);
assert_eq!(
snapshot.built_from_store_revision,
accepted["surface"]["store_revision"].as_u64().unwrap()
snapshot.built_from_memory_fingerprint,
accepted["surface"]["memory_fingerprint"].as_str().unwrap()
);
memory
});
@@ -1260,7 +1260,7 @@ async fn transport_loss_after_candidate_apply_preserves_memory_and_later_job_onl
let memory = store.list_memories(&subject.id).unwrap();
assert_eq!(memory.len(), 1);
assert_eq!(memory[0].claim, claim);
assert_eq!(memory[0].revision, 1);
assert!(!memory[0].change_id.is_empty());
assert_ne!(
store.resident_surface(&subject.id).unwrap().availability,
subjektiv::SurfaceAvailability::Ready
+1
View File
@@ -13,6 +13,7 @@ manifest.workspace = true
rusqlite.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
sha2.workspace = true
server-api.workspace = true
thiserror.workspace = true
uuid = { workspace = true, features = ["v7"] }
+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
key, never an authorization credential.
Existing SQL schema and migrations are retained. In particular,
Historical migration plans remain frozen. Migration 7 replaces state counters
with opaque Memory change identities and explicit predecessor history; the
original serialized records are retained in an immutable audit archive.
`store_scope.workspace_id` still stores the physical storage scope, under its
historical column name. Existing Backend placement and snapshot manifest keys
are unchanged. Optional historical `EvidenceOrigin.workspace_id` values continue
@@ -29,7 +31,7 @@ Workspace value. `workspace_id()` is a Backend compatibility alias for
`api::execute(store, &HostOperationContext, operation)` is the common dispatcher
used by Backend and local Hosts. It owns query/cursor rules, bounded body and
nested provenance projections, immutable revision reads, proposal validation,
nested provenance projections, immutable change reads, proposal validation,
explicit staging/receipt rules, resident context, candidate decisions and
surface generation/publication/failure projections.
@@ -72,5 +74,24 @@ helpers never run or replay a Job. The store's `prepare_job_surface_generation`,
`require_job_surface_generation`, and `validate_job_surface_outcome` are public
trusted-domain methods for both Backend and local Hosts.
## Content conditions and history
Memory updates require the exact `expected_change_id` read by the caller.
Each committed change receives a new opaque ID and retains `previous_change_id`;
IDs are never sorted or incremented to infer history. Body/evidence continuations
pin that immutable ID, and history cursors pin a predecessor-chain head.
Subject behavior updates compare `expected_behavior_md` byte-for-byte and do not
participate in Memory freshness. Surface runs, publications and query cursors use
`memory_fingerprint`, a SHA-256 digest of the length-framed, ordered current
`(memory_id, change_id)` identities. The Subject stores no redundant freshness
counter. Publication remains first-wins for the captured Memory input set.
Old Surface input sets cannot be proven from historical counters. Migration 7
retains those snapshots and runs as archival history with deliberately detached
input identities, marks existing surfaces stale, and requires regeneration.
Old Flow provenance keeps its exact original bytes in the archive; a definition
fingerprint is not fabricated from an old numeric label.
Dependencies: `feature-storage`, `chrono`, `memory`, `manifest`, `rusqlite`,
`serde`, `serde_json`, `server-api`, `thiserror`, `uuid`; tests use `tempfile`.
`serde`, `serde_json`, `server-api`, `sha2`, `thiserror`, `uuid`; tests use `tempfile`.
+113 -114
View File
@@ -24,7 +24,7 @@ pub const SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES: usize = 40 * 1024;
#[serde(deny_unknown_fields)]
struct SubjektivQueryCursor {
subject_id: String,
store_revision: u64,
memory_fingerprint: String,
query: Option<String>,
kinds: Vec<String>,
states: Vec<String>,
@@ -33,10 +33,10 @@ struct SubjektivQueryCursor {
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct SubjektivRevisionCursor {
struct SubjektivChangeCursor {
subject_id: String,
memory_id: String,
max_revision: u64,
head_change_id: String,
offset: usize,
}
@@ -45,8 +45,8 @@ struct SubjektivRevisionCursor {
struct SubjektivEvidenceCursor {
subject_id: String,
memory_id: String,
revision: u64,
/// Candidate/derivation reference offset within the immutable revision.
change_id: String,
/// Candidate/derivation reference offset within the immutable change_id.
offset: usize,
/// Nested offsets keep compatible historical candidates with more anchors
/// resumable without admitting new over-limit records.
@@ -92,14 +92,14 @@ pub fn subjektiv_surface_prepare_response(
server_api::SubjektivSurfacePrepareResponse {
generation_id: generation.id,
current_snapshot_id: None,
store_revision: generation.store_revision,
memory_fingerprint: generation.memory_fingerprint,
active_memory_count: generation.active_memory_count,
materials: generation
.materials
.into_iter()
.map(|material| server_api::SubjektivSurfaceMaterial {
memory_id: material.memory_id,
revision: material.revision,
change_id: material.change_id.clone(),
kind: material.kind,
body_md: material.body_md,
why_useful: material.why_useful,
@@ -120,7 +120,7 @@ pub fn subjektiv_candidate_summary(
candidate_id: candidate.id,
kind: candidate.kind,
claim: candidate.claim,
revision_proposal: candidate.revision_proposal.map(subjektiv_proposal_metadata),
change_proposal: candidate.change_proposal.map(subjektiv_proposal_metadata),
created_at: candidate.created_at,
}
}
@@ -137,30 +137,26 @@ pub fn subjektiv_candidate(
source: candidate.source,
evidence: candidate.evidence,
source_refs: candidate.source_refs,
revision_proposal: candidate.revision_proposal.map(subjektiv_proposal_metadata),
change_proposal: candidate.change_proposal.map(subjektiv_proposal_metadata),
created_at: candidate.created_at,
}
}
pub fn subjektiv_proposal_metadata(
proposal: crate::RevisionProposal,
) -> server_api::SubjektivMemoryRevisionProposalMetadata {
server_api::SubjektivMemoryRevisionProposalMetadata {
proposal: crate::ChangeProposal,
) -> server_api::SubjektivMemoryChangeProposalMetadata {
server_api::SubjektivMemoryChangeProposalMetadata {
memory_id: proposal.memory_id,
expected_revision: proposal.expected_revision,
expected_change_id: proposal.expected_change_id.clone(),
intent: match proposal.intent {
crate::RevisionProposalIntent::Revise => {
server_api::SubjektivMemoryRevisionIntent::Revise
crate::ChangeProposalIntent::Revise => server_api::SubjektivMemoryChangeIntent::Revise,
crate::ChangeProposalIntent::Resolve => {
server_api::SubjektivMemoryChangeIntent::Resolve
}
crate::RevisionProposalIntent::Resolve => {
server_api::SubjektivMemoryRevisionIntent::Resolve
}
crate::RevisionProposalIntent::Retract => {
server_api::SubjektivMemoryRevisionIntent::Retract
}
crate::RevisionProposalIntent::Reopen => {
server_api::SubjektivMemoryRevisionIntent::Reopen
crate::ChangeProposalIntent::Retract => {
server_api::SubjektivMemoryChangeIntent::Retract
}
crate::ChangeProposalIntent::Reopen => server_api::SubjektivMemoryChangeIntent::Reopen,
},
change_reason: proposal.change_reason,
}
@@ -172,15 +168,13 @@ pub fn subjektiv_candidate_decision(
let decision = match input.decision {
server_api::SubjektivMemoryCandidateDecision::Apply { target, memory } => {
let target = match target {
server_api::SubjektivMemoryApplyTarget::Create => {
crate::MemoryRevisionTarget::Create
}
server_api::SubjektivMemoryApplyTarget::Create => crate::MemoryChangeTarget::Create,
server_api::SubjektivMemoryApplyTarget::Revise {
memory_id,
expected_revision,
} => crate::MemoryRevisionTarget::Revise {
expected_change_id,
} => crate::MemoryChangeTarget::Revise {
memory_id,
expected_revision,
expected_change_id,
},
};
crate::CandidateDecision::Apply {
@@ -196,9 +190,9 @@ pub fn subjektiv_candidate_decision(
derived_from: memory
.derived_from
.into_iter()
.map(|reference| crate::MemoryRevisionRef {
.map(|reference| crate::MemoryChangeRef {
memory_id: reference.memory_id,
revision: reference.revision,
change_id: reference.change_id.clone(),
})
.collect(),
change_reason: memory.change_reason,
@@ -225,9 +219,9 @@ pub fn subjektiv_candidate_decision(
},
affected_memory: affected_memory
.into_iter()
.map(|reference| crate::MemoryRevisionRef {
.map(|reference| crate::MemoryChangeRef {
memory_id: reference.memory_id,
revision: reference.revision,
change_id: reference.change_id.clone(),
})
.collect(),
},
@@ -281,17 +275,17 @@ pub fn subjektiv_candidate_decision_response(
.into_iter()
.map(|reference| server_api::SubjektivMemoryAffectedRef {
memory_id: reference.memory_id,
revision: reference.revision,
change_id: reference.change_id.clone(),
operation,
})
.collect(),
memory: receipt
.memory
.map(|memory| server_api::SubjektivMemoryRevisionRef {
.map(|memory| server_api::SubjektivMemoryChangeRef {
memory_id: memory.id,
revision: memory.revision,
change_id: memory.change_id.clone(),
}),
store_revision: receipt.store_revision,
memory_fingerprint: receipt.memory_fingerprint,
surface_dirty: receipt.surface_dirty,
}
}
@@ -316,10 +310,19 @@ pub fn subjektiv_memory_query(
.filter(|value| !value.is_empty());
let kinds = canonical_subjektiv_kinds(input.kinds)?;
let states = canonical_subjektiv_states(input.states)?;
let subject = store
let _subject = store
.subject(subject_id)
.map_err(subjektiv_store_error)?
.ok_or_else(|| OperationError::SubjectNotFound(subject_id.to_string()))?;
let all_records = store
.list_memories(subject_id)
.map_err(subjektiv_store_error)?;
let memory_fingerprint = crate::memory_identity_fingerprint(
&all_records
.iter()
.map(|r| (r.id.clone(), r.change_id.clone()))
.collect::<Vec<_>>(),
);
let mut offset = 0;
if let Some(cursor) = input.cursor {
let cursor: SubjektivQueryCursor = decode_subjektiv_cursor("query", &cursor)?;
@@ -333,7 +336,7 @@ pub fn subjektiv_memory_query(
)
.into());
}
if cursor.store_revision != subject.store_revision {
if cursor.memory_fingerprint != memory_fingerprint {
return Err(OperationError::Conflict(
"stale_cursor: subjektiv query cursor is stale because Memory changed; restart the query".into(),
)
@@ -344,9 +347,7 @@ pub fn subjektiv_memory_query(
let kind_filter = kinds.iter().cloned().collect::<HashSet<_>>();
let state_filter = states.iter().cloned().collect::<HashSet<_>>();
let records = store
.list_memories(subject_id)
.map_err(subjektiv_store_error)?
let records = all_records
.into_iter()
.filter(|record| {
kind_filter.contains(record.kind.as_str())
@@ -369,7 +370,7 @@ pub fn subjektiv_memory_query(
.iter()
.map(|record| server_api::SubjektivMemoryQueryItem {
id: record.id.clone(),
revision: record.revision,
change_id: record.change_id.clone(),
kind: record.kind.clone(),
state: api_memory_state(record.state),
claim: record.claim.clone(),
@@ -383,7 +384,7 @@ pub fn subjektiv_memory_query(
"query",
&SubjektivQueryCursor {
subject_id: subject_id.to_string(),
store_revision: subject.store_revision,
memory_fingerprint: memory_fingerprint,
query,
kinds,
states,
@@ -407,18 +408,18 @@ pub fn subjektiv_memory_read(
let current = store
.scoped_memory(subject_id, &input.memory_id)
.map_err(subjektiv_store_error)?;
let record = match input.revision {
Some(0) => {
return Err(
OperationError::InvalidInput("revision must be a positive integer".into()).into(),
);
let record = match input.change_id.clone() {
Some(id) if id.trim().is_empty() => {
return Err(OperationError::InvalidInput(
"change_id must be nonempty".into(),
));
}
Some(revision) => store
.scoped_memory_revision(subject_id, &input.memory_id, revision)
Some(change_id) => store
.scoped_memory_change(subject_id, &input.memory_id, change_id.clone())
.map_err(subjektiv_store_error)?
.ok_or_else(|| {
OperationError::InvalidInput(format!(
"memory_revision_not_found: Memory `{}` has no revision {revision}",
"memory_change_not_found: Memory `{}` has no change_id {change_id}",
input.memory_id
))
})?,
@@ -433,9 +434,9 @@ pub fn subjektiv_memory_read(
}
let body_offset = input.offset.unwrap_or(0);
let body_byte_offset = input.byte_offset.unwrap_or(0);
if (body_offset > 0 || body_byte_offset > 0) && input.revision.is_none() {
if (body_offset > 0 || body_byte_offset > 0) && input.change_id.is_none() {
return Err(OperationError::InvalidInput(
"body continuation requires the exact revision returned by the first read".into(),
"body continuation requires the exact change_id returned by the first read".into(),
)
.into());
}
@@ -477,10 +478,10 @@ pub fn subjektiv_memory_read(
let cursor: SubjektivEvidenceCursor = decode_subjektiv_cursor("evidence", &cursor)?;
if cursor.subject_id != subject_id
|| cursor.memory_id != record.id
|| cursor.revision != record.revision
|| cursor.change_id != record.change_id
{
return Err(OperationError::InvalidInput(
"subjektiv evidence cursor does not match subject, Memory, or revision".into(),
"subjektiv evidence cursor does not match subject, Memory, or change_id".into(),
)
.into());
}
@@ -570,9 +571,9 @@ pub fn subjektiv_memory_read(
}
let derived_index = evidence_offset - record.source_candidate_ids.len();
let reference = &record.derived_from[derived_index];
derived_from.push(server_api::SubjektivMemoryRevisionRef {
derived_from.push(server_api::SubjektivMemoryChangeRef {
memory_id: reference.memory_id.clone(),
revision: reference.revision,
change_id: reference.change_id.clone(),
});
next_evidence_offset += 1;
} else if nested_evidence_offset != 0 || nested_source_ref_offset != 0 {
@@ -592,7 +593,7 @@ pub fn subjektiv_memory_read(
&SubjektivEvidenceCursor {
subject_id: subject_id.to_string(),
memory_id: record.id.clone(),
revision: record.revision,
change_id: record.change_id.clone(),
offset: next_evidence_offset,
evidence_offset: next_nested_evidence_offset,
source_ref_offset: next_nested_source_ref_offset,
@@ -603,8 +604,8 @@ pub fn subjektiv_memory_read(
let mut response = server_api::SubjektivMemoryReadResponse {
memory_id: record.id,
revision: record.revision,
current_revision: current.revision,
change_id: record.change_id.clone(),
current_change_id: current.change_id.clone(),
kind: record.kind,
state: api_memory_state(record.state),
claim: record.claim,
@@ -635,36 +636,41 @@ pub fn subjektiv_memory_read(
Ok(response)
}
pub fn subjektiv_memory_list_revisions(
pub fn subjektiv_memory_list_changes(
store: &crate::SubjektivStore,
subject_id: &str,
input: server_api::SubjektivMemoryListRevisionsRequest,
) -> OperationResult<server_api::SubjektivMemoryListRevisionsResponse> {
input: server_api::SubjektivMemoryListChangesRequest,
) -> OperationResult<server_api::SubjektivMemoryListChangesResponse> {
let limit = bounded_subjektiv_limit(input.limit, SUBJEKTIV_QUERY_DEFAULT_LIMIT)?;
let current = store
.scoped_memory(subject_id, &input.memory_id)
.map_err(subjektiv_store_error)?;
let (max_revision, offset) = if let Some(cursor) = input.cursor {
let cursor: SubjektivRevisionCursor = decode_subjektiv_cursor("revisions", &cursor)?;
let (head_change_id, offset) = if let Some(cursor) = input.cursor {
let cursor: SubjektivChangeCursor = decode_subjektiv_cursor("changes", &cursor)?;
if cursor.subject_id != subject_id || cursor.memory_id != input.memory_id {
return Err(OperationError::InvalidInput(
"subjektiv revision cursor does not match subject or Memory".into(),
"subjektiv change_id cursor does not match subject or Memory".into(),
)
.into());
}
(cursor.max_revision, cursor.offset)
(cursor.head_change_id, cursor.offset)
} else {
(current.revision, 0)
(current.change_id.clone(), 0)
};
let records = store
.list_memory_revisions(subject_id, &input.memory_id)
.list_memory_changes(subject_id, &input.memory_id)
.map_err(subjektiv_store_error)?
.into_iter()
.filter(|record| record.revision <= max_revision)
.skip_while(|record| record.change_id != head_change_id)
.collect::<Vec<_>>();
if records.is_empty() {
return Err(OperationError::InvalidInput(
"subjektiv history cursor refers to an unknown change".into(),
));
}
if offset > records.len() {
return Err(OperationError::InvalidInput(
"subjektiv revision cursor offset is invalid".into(),
"subjektiv change_id cursor offset is invalid".into(),
)
.into());
}
@@ -672,8 +678,8 @@ pub fn subjektiv_memory_list_revisions(
let has_more = end < records.len();
let items = records[offset..end]
.iter()
.map(|record| server_api::SubjektivMemoryRevisionItem {
revision: record.revision,
.map(|record| server_api::SubjektivMemoryChangeItem {
change_id: record.change_id.clone(),
kind: record.kind.clone(),
state: api_memory_state(record.state),
claim: record.claim.clone(),
@@ -684,19 +690,19 @@ pub fn subjektiv_memory_list_revisions(
let next_cursor = has_more
.then(|| {
encode_subjektiv_cursor(
"revisions",
&SubjektivRevisionCursor {
"changes",
&SubjektivChangeCursor {
subject_id: subject_id.to_string(),
memory_id: input.memory_id.clone(),
max_revision,
head_change_id,
offset: end,
},
)
})
.transpose()?;
Ok(server_api::SubjektivMemoryListRevisionsResponse {
Ok(server_api::SubjektivMemoryListChangesResponse {
memory_id: input.memory_id,
current_revision: current.revision,
current_change_id: current.change_id.clone(),
items,
next_cursor,
has_more,
@@ -708,25 +714,25 @@ pub fn subjektiv_memory_validate_proposal(
subject_id: &str,
input: server_api::SubjektivMemoryValidateProposalRequest,
) -> OperationResult<server_api::SubjektivMemoryProposalValidationResponse> {
if input.expected_revision == 0 {
if input.expected_change_id.trim().is_empty() {
return Err(
OperationError::InvalidInput("expected_revision must be positive".into()).into(),
OperationError::InvalidInput("expected_change_id must be nonempty".into()).into(),
);
}
let current = store
.scoped_memory(subject_id, &input.memory_id)
.map_err(subjektiv_store_error)?;
if current.revision != input.expected_revision {
if current.change_id != input.expected_change_id {
return Err(OperationError::Conflict(format!(
"revision_conflict: Memory `{}` expected {}, current {}",
input.memory_id, input.expected_revision, current.revision
"change_conflict: Memory `{}` expected {}, current {}",
input.memory_id, input.expected_change_id, current.change_id
))
.into());
}
validate_subjektiv_proposal_transition(current.state, input.intent)?;
Ok(server_api::SubjektivMemoryProposalValidationResponse {
memory_id: input.memory_id,
current_revision: current.revision,
current_change_id: current.change_id.clone(),
kind: current.kind,
state: api_memory_state(current.state),
intent: input.intent,
@@ -821,10 +827,10 @@ pub fn subjektiv_memory_stage_explicit(
.proposal
.as_ref()
.map(|proposal| {
crate::RevisionProposal::new(
crate::ChangeProposal::new(
domain_proposal_intent(proposal.intent),
proposal.memory_id.clone(),
proposal.expected_revision,
proposal.expected_change_id.clone(),
proposal.change_reason.clone(),
)
})
@@ -836,7 +842,7 @@ pub fn subjektiv_memory_stage_explicit(
.map_err(subjektiv_store_error)?;
if current.kind != input.kind {
return Err(OperationError::InvalidInput(
"revision proposal kind must be derived from its target Memory".into(),
"change_id proposal kind must be derived from its target Memory".into(),
)
.into());
}
@@ -859,7 +865,7 @@ pub fn subjektiv_memory_stage_explicit(
let mut staging = crate::SubjectStagingRecord::attach(&attribution.subject_id, staging);
if let Some(proposal) = proposal.clone() {
staging
.attach_revision_proposal(proposal)
.attach_change_proposal(proposal)
.map_err(subjektiv_store_error)?;
}
let (staged, _) = store
@@ -873,7 +879,7 @@ pub fn subjektiv_memory_stage_explicit(
.as_ref()
.map(|proposal| server_api::SubjektivMemoryProposalTarget {
memory_id: proposal.memory_id.clone(),
expected_revision: proposal.expected_revision,
expected_change_id: proposal.expected_change_id.clone(),
}),
intent: proposal
.as_ref()
@@ -883,10 +889,10 @@ pub fn subjektiv_memory_stage_explicit(
pub fn validate_subjektiv_proposal_transition(
state: crate::MemoryState,
intent: server_api::SubjektivMemoryRevisionIntent,
intent: server_api::SubjektivMemoryChangeIntent,
) -> OperationResult<()> {
use crate::MemoryState::{Active, Resolved};
use server_api::SubjektivMemoryRevisionIntent::{Reopen, Resolve, Retract, Revise};
use server_api::SubjektivMemoryChangeIntent::{Reopen, Resolve, Retract, Revise};
let valid = match intent {
Revise => matches!(state, Active | Resolved),
Resolve => state == Active,
@@ -979,32 +985,24 @@ pub fn subjektiv_memory_state_name(state: crate::MemoryState) -> &'static str {
}
pub fn domain_proposal_intent(
intent: server_api::SubjektivMemoryRevisionIntent,
) -> crate::RevisionProposalIntent {
intent: server_api::SubjektivMemoryChangeIntent,
) -> crate::ChangeProposalIntent {
match intent {
server_api::SubjektivMemoryRevisionIntent::Revise => crate::RevisionProposalIntent::Revise,
server_api::SubjektivMemoryRevisionIntent::Resolve => {
crate::RevisionProposalIntent::Resolve
}
server_api::SubjektivMemoryRevisionIntent::Retract => {
crate::RevisionProposalIntent::Retract
}
server_api::SubjektivMemoryRevisionIntent::Reopen => crate::RevisionProposalIntent::Reopen,
server_api::SubjektivMemoryChangeIntent::Revise => crate::ChangeProposalIntent::Revise,
server_api::SubjektivMemoryChangeIntent::Resolve => crate::ChangeProposalIntent::Resolve,
server_api::SubjektivMemoryChangeIntent::Retract => crate::ChangeProposalIntent::Retract,
server_api::SubjektivMemoryChangeIntent::Reopen => crate::ChangeProposalIntent::Reopen,
}
}
pub fn api_proposal_intent(
intent: crate::RevisionProposalIntent,
) -> server_api::SubjektivMemoryRevisionIntent {
intent: crate::ChangeProposalIntent,
) -> server_api::SubjektivMemoryChangeIntent {
match intent {
crate::RevisionProposalIntent::Revise => server_api::SubjektivMemoryRevisionIntent::Revise,
crate::RevisionProposalIntent::Resolve => {
server_api::SubjektivMemoryRevisionIntent::Resolve
}
crate::RevisionProposalIntent::Retract => {
server_api::SubjektivMemoryRevisionIntent::Retract
}
crate::RevisionProposalIntent::Reopen => server_api::SubjektivMemoryRevisionIntent::Reopen,
crate::ChangeProposalIntent::Revise => server_api::SubjektivMemoryChangeIntent::Revise,
crate::ChangeProposalIntent::Resolve => server_api::SubjektivMemoryChangeIntent::Resolve,
crate::ChangeProposalIntent::Retract => server_api::SubjektivMemoryChangeIntent::Retract,
crate::ChangeProposalIntent::Reopen => server_api::SubjektivMemoryChangeIntent::Reopen,
}
}
@@ -1189,6 +1187,7 @@ pub fn bounded_subjektiv_evidence_origin(
&mut origin.worker_id,
&mut origin.flow_selector,
&mut origin.flow_definition_id,
&mut origin.flow_definition_fingerprint,
] {
if let Some(value) = field.take() {
*field = Some(bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES));
@@ -1294,10 +1293,10 @@ pub fn subjektiv_store_error(error: crate::SubjektivError) -> OperationError {
crate::SubjektivError::SubjectScopeMismatch { .. } => {
OperationError::PermissionDenied(format!("subject_scope_mismatch: {error}"))
}
crate::SubjektivError::RevisionConflict { .. }
crate::SubjektivError::MemoryChangeConflict { .. }
| crate::SubjektivError::SubjectBehaviorConflict { .. }
| crate::SubjektivError::SurfaceGenerationConflict(_) => {
OperationError::Conflict(format!("revision_conflict: {error}"))
OperationError::Conflict(format!("change_conflict: {error}"))
}
crate::SubjektivError::CandidateResolved(_)
| crate::SubjektivError::DecisionRequestConflict(_) => {
+9 -10
View File
@@ -28,14 +28,13 @@ pub fn execute(
.map_err(subjektiv_store_error)?;
Response::ResidentContext(server_api::SubjektivResidentContextOutput {
behavior_md: resident.subject.behavior_md,
behavior_revision: resident.subject.behavior_revision,
memory_surface: resident_summary_output(resident.surface),
})
}
Op::Query(input) => Response::Query(subjektiv_memory_query(store, subject_id, input)?),
Op::Read(input) => Response::Read(subjektiv_memory_read(store, subject_id, input)?),
Op::ListRevisions(input) => {
Response::ListRevisions(subjektiv_memory_list_revisions(store, subject_id, input)?)
Op::ListChanges(input) => {
Response::ListChanges(subjektiv_memory_list_changes(store, subject_id, input)?)
}
Op::ValidateProposal(input) => {
context.body()?;
@@ -169,7 +168,7 @@ pub fn execute(
if resident.availability == crate::SurfaceAvailability::Ready {
response.current_snapshot_id = resident
.snapshot
.filter(|s| s.built_from_store_revision == response.store_revision)
.filter(|s| s.built_from_memory_fingerprint == response.memory_fingerprint)
.map(|s| s.id);
}
Response::SurfacePrepared(response)
@@ -194,9 +193,9 @@ pub fn execute(
memory_refs: point
.memory_refs
.into_iter()
.map(|r| crate::MemoryRevisionRef {
.map(|r| crate::MemoryChangeRef {
memory_id: r.memory_id,
revision: r.revision,
change_id: r.change_id,
})
.collect(),
})
@@ -206,7 +205,7 @@ pub fn execute(
.map_err(subjektiv_store_error)?;
Response::SurfacePublished(server_api::SubjektivSurfacePublishResponse {
snapshot_id: snapshot.id,
built_from_store_revision: snapshot.built_from_store_revision,
built_from_memory_fingerprint: snapshot.built_from_memory_fingerprint,
empty: snapshot.body_md.is_empty(),
})
}
@@ -222,7 +221,7 @@ pub fn execute(
)
.map_err(subjektiv_store_error)?;
}
let revision = store
let memory_fingerprint = store
.fail_surface_generation(subject_id, &input.generation_id, &input.reason_code)
.map_err(subjektiv_store_error)?;
let status = if let Some(job) = job {
@@ -230,7 +229,7 @@ pub fn execute(
.validate_job_surface_outcome(
subject_id,
&input.generation_id,
revision,
memory_fingerprint.clone(),
"failed",
None,
Some(&input.reason_code),
@@ -247,7 +246,7 @@ pub fn execute(
"failed"
};
Response::SurfaceFailed(server_api::SubjektivSurfaceFailureResponse {
store_revision: revision,
memory_fingerprint,
status: status.into(),
})
}
+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 {
memory_id: id.into(),
revision,
change_id,
offset,
byte_offset,
limit: Some(1),
@@ -86,7 +91,7 @@ fn neutral_scope_reopen_and_context_attenuation() {
}
#[test]
fn shared_query_cursor_and_utf8_fixed_revision_projection() {
fn shared_query_cursor_and_utf8_fixed_change_projection() {
let temp = tempfile::tempdir().unwrap();
let (_manager, store) = open(temp.path(), "local-random-scope");
let subject = store
@@ -148,7 +153,12 @@ fn shared_query_cursor_and_utf8_fixed_revision_projection() {
let Response::Read(page) = execute(
&store,
&context,
read(&first.id, Some(1), offset, byte_offset),
read(
&first.id,
Some(first.change_id.clone()),
offset,
byte_offset,
),
)
.unwrap() else {
panic!()
@@ -247,7 +257,7 @@ fn explicit_receipts_candidate_batch_and_decision_retries_share_logic() {
entry_range: Some([1, 1]),
kind: memory::schema::EvidenceKind::new("model_output"),
origin: None,
excerpt: Some("Use immutable revisions".into()),
excerpt: Some("Use immutable changes".into()),
summary: None,
}],
source_refs: vec![memory::schema::SourceEvidenceRef {
@@ -319,7 +329,7 @@ fn explicit_receipts_candidate_batch_and_decision_retries_share_logic() {
kind: CandidateKind::Lesson,
state: server_api::SubjektivMemoryState::Active,
claim: "Preserve correction history".into(),
body_md: "Use immutable revisions".into(),
body_md: "Use immutable changes".into(),
why_useful: "Allows auditing".into(),
staleness: None,
derived_from: vec![],
@@ -341,3 +351,104 @@ fn explicit_receipts_candidate_batch_and_decision_retries_share_logic() {
)
.unwrap();
}
#[test]
fn history_pages_pin_ancestry_and_reject_unknown_heads() {
let temp = tempfile::tempdir().unwrap();
let (_manager, store) = open(temp.path(), "scope");
let subject = store
.create_subject(SubjectRole::new("companion").unwrap())
.unwrap();
let draft = |body: &str| {
MemoryDraft::active(CandidateKind::Lesson, "claim", body, "useful", "evidence")
};
let first = store.create_memory(&subject.id, draft("first")).unwrap();
let second = store
.revise_memory(
&subject.id,
&first.id,
first.change_id.clone(),
draft("second"),
)
.unwrap();
let input = |cursor| server_api::SubjektivMemoryListChangesRequest {
memory_id: first.id.clone(),
limit: Some(1),
cursor,
};
let page = subjektiv_memory_list_changes(&store, &subject.id, input(None)).unwrap();
assert_eq!(page.items[0].change_id, second.change_id);
assert!(page.has_more);
let third = store
.revise_memory(
&subject.id,
&first.id,
second.change_id.clone(),
draft("third"),
)
.unwrap();
let next = subjektiv_memory_list_changes(&store, &subject.id, input(page.next_cursor)).unwrap();
assert_eq!(next.items[0].change_id, first.change_id);
assert_eq!(next.current_change_id, third.change_id);
assert!(!next.has_more);
let invalid = encode_subjektiv_cursor(
"changes",
&SubjektivChangeCursor {
subject_id: subject.id.clone(),
memory_id: first.id.clone(),
head_change_id: "not-a-history-entry".into(),
offset: 0,
},
)
.unwrap();
assert!(subjektiv_memory_list_changes(&store, &subject.id, input(Some(invalid))).is_err());
let historic = subjektiv_memory_read(
&store,
&subject.id,
server_api::SubjektivMemoryReadRequest {
memory_id: first.id,
change_id: Some(second.change_id.clone()),
offset: None,
byte_offset: None,
limit: None,
evidence_cursor: None,
},
)
.unwrap();
assert_eq!(historic.body_md, "second");
assert_eq!(historic.change_id, second.change_id);
assert_eq!(historic.current_change_id, third.change_id);
}
#[test]
fn query_content_condition_ignores_behavior_and_detects_aba_memory_writes() {
let temp = tempfile::tempdir().unwrap();
let (_manager, store) = open(temp.path(), "scope");
let subject = store
.create_subject(SubjectRole::new("companion").unwrap())
.unwrap();
let draft = |body: &str| {
MemoryDraft::active(CandidateKind::Lesson, "claim", body, "useful", "evidence")
};
let first = store.create_memory(&subject.id, draft("A")).unwrap();
store.create_memory(&subject.id, draft("other")).unwrap();
let context = HostOperationContext::validated_body(&store, &subject.id, None).unwrap();
let Response::Query(page) = execute(&store, &context, query(None)).unwrap() else {
panic!()
};
let cursor = page.next_cursor.unwrap();
store
.update_subject_behavior(&subject.id, "", "New behavior".into())
.unwrap();
execute(&store, &context, query(Some(cursor.clone()))).unwrap();
let second = store
.revise_memory(&subject.id, &first.id, first.change_id.clone(), draft("B"))
.unwrap();
store
.revise_memory(&subject.id, &first.id, second.change_id, draft("A"))
.unwrap();
assert!(matches!(
execute(&store, &context, query(Some(cursor))),
Err(OperationError::Conflict(_))
));
}
+3 -3
View File
@@ -65,7 +65,7 @@ struct ConsolidationResult {
struct SurfaceResult {
availability: String,
generation_id: String,
store_revision: u64,
memory_fingerprint: String,
#[serde(default)]
snapshot_id: Option<String>,
#[serde(default)]
@@ -105,7 +105,7 @@ pub fn validate_result(
.validate_job_surface_outcome(
subject_id,
&result.surface.generation_id,
result.surface.store_revision,
result.surface.memory_fingerprint,
&result.surface.availability,
result.surface.snapshot_id.as_deref(),
result.surface.reason_code.as_deref(),
@@ -118,7 +118,7 @@ pub fn validate_result(
}
/// Enriches a previously verified result with compact durable dispositions. Full
/// reasons, affected revisions and provenance remain in the receipt store. This
/// reasons, affected changes and provenance remain in the receipt store. This
/// deterministic projection neither applies candidates nor creates a new ledger.
pub fn with_dispositions(
store: &SubjektivStore,
+4 -4
View File
@@ -22,7 +22,7 @@ fn escaped_ids_use_exact_t704_complete_result_budget() {
.collect();
let result = serde_json::json!({
"subject_id": "\\\"".repeat(128), "candidate_ids": batch, "candidate_dispositions": dispositions,
"surface": { "availability": "failed", "generation_id": "g".repeat(100), "store_revision": u64::MAX, "reason_code": "\\\"".repeat(128) }
"surface": { "availability": "failed", "generation_id": "g".repeat(100), "memory_fingerprint": "f".repeat(64), "reason_code": "\\\"".repeat(128) }
});
assert!(serde_json::to_vec(&result).unwrap().len() <= MAX_RESULT_BYTES as usize);
assert!(
@@ -119,7 +119,7 @@ fn result_checks_durable_dispositions_and_exact_job_surface_outcome() {
.publish_surface_generation(&subject.id, &generation.id, vec![])
.unwrap();
let ready = serde_json::json!({"subject_id": subject.id, "candidate_ids": batch,
"surface": {"availability": "ready", "generation_id": generation.id, "store_revision": snapshot.built_from_store_revision, "snapshot_id": snapshot.id}});
"surface": {"availability": "ready", "generation_id": generation.id, "memory_fingerprint": snapshot.built_from_memory_fingerprint, "snapshot_id": snapshot.id}});
assert!(
validate_result(&store, &subject.id, &batch, "job", "attempt", &ready)
.unwrap_err()
@@ -170,11 +170,11 @@ fn result_checks_durable_dispositions_and_exact_job_surface_outcome() {
let failure = store
.prepare_job_surface_generation(&subject.id, Some(("job", "attempt")))
.unwrap();
let revision = store
let memory_fingerprint = store
.fail_surface_generation(&subject.id, &failure.id, "model_failure")
.unwrap();
let failed = serde_json::json!({"subject_id": subject.id, "candidate_ids": batch,
"surface": {"availability": "failed", "generation_id": failure.id, "store_revision": revision, "reason_code": "model_failure"}});
"surface": {"availability": "failed", "generation_id": failure.id, "memory_fingerprint": memory_fingerprint, "reason_code": "model_failure"}});
validate_result(&store, &subject.id, &batch, "job", "attempt", &failed).unwrap();
assert!(validate_result(&store, &subject.id, &batch, "job", "attempt", &ready).is_err());
let mut false_reason = failed;
File diff suppressed because it is too large Load Diff
+709 -1330
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),
#[error("ticket target authority is unavailable")]
TargetAuthorityUnavailable,
#[error("stale ticket item revision: expected `{expected}`, found `{actual}`")]
StaleItemRevision { expected: String, actual: String },
#[error("stale ticket content: expected `{expected}`, found `{actual}`")]
StaleContent { expected: String, actual: String },
#[error("stale ticket workflow state: expected `{expected}`, found `{actual}`")]
StaleWorkflowState { expected: String, actual: String },
#[error("invalid ticket workflow transition `{from}` -> `{to}`")]
@@ -473,7 +473,7 @@ impl TicketStateChange {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TicketCompletion {
pub expected_item_revision: String,
pub expected_content_digest: String,
pub expected_state: TicketWorkflowState,
pub operation_key: String,
pub reason: String,
@@ -483,7 +483,7 @@ pub struct TicketCompletion {
pub author: Option<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
/// reapplying the recorded state, even after later edits or reopening.
/// Setting `state` to Closed also stores `reason` as the resolution; reopening
@@ -491,7 +491,7 @@ pub struct TicketCompletion {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TicketStateUpdate {
pub expected_item_revision: String,
pub expected_content_digest: String,
pub expected_state: TicketWorkflowState,
pub state: TicketWorkflowState,
pub operation_key: String,
@@ -505,7 +505,7 @@ pub struct TicketStateUpdate {
impl From<TicketCompletion> for TicketStateUpdate {
fn from(request: TicketCompletion) -> Self {
Self {
expected_item_revision: request.expected_item_revision,
expected_content_digest: request.expected_content_digest,
expected_state: request.expected_state,
state: TicketWorkflowState::Done,
operation_key: request.operation_key,
@@ -516,17 +516,69 @@ impl From<TicketCompletion> for TicketStateUpdate {
}
}
/// Canonical item revision shared by Ticket authority, item checkers, and Merge
/// Request review records. Only persisted item_edit event indexes advance it.
pub fn ticket_item_revision(ticket: &Ticket) -> String {
let index = ticket
.events
/// SHA-256 of the actual editable Ticket requirements: identity, title, body,
/// and ordered repository targets. Comments, timestamps, workflow state, and
/// event order are not requirements. Restoring identical content restores its
/// digest; state CAS and operation receipts are independent preconditions.
pub fn ticket_content_digest(ticket: &Ticket) -> String {
content_digest(
&ticket.meta.id,
&ticket.meta.title,
ticket.document.body.as_str(),
&ticket.meta.targets,
)
}
fn content_digest(id: &str, title: &str, body: &str, targets: &[TicketTarget]) -> String {
let mut digest = Sha256::new();
digest.update(b"ticket.content.sha256.v1\0");
digest.update(
serde_json::to_vec(&(id, title, body, targets)).expect("Ticket content serializes"),
);
digest
.finalize()
.iter()
.filter(|event| event.kind == TicketEventKind::Other("item_edit".to_owned()))
.filter_map(|event| event.attributes.get("event_sequence")?.parse::<i64>().ok())
.max()
.unwrap_or(0);
format!("{}:{index}", ticket.meta.id)
.map(|byte| format!("{byte:02x}"))
.collect()
}
/// Read the same content inside the caller's SQLite transaction. Review and
/// Ticket mutation authorities must use this instead of event indexes or time.
pub fn sqlite_ticket_content_digest(
connection: &Connection,
workspace_id: &str,
ticket_id: &str,
) -> Result<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)]
@@ -2402,7 +2454,7 @@ impl SqliteTicketBackend {
]
.map(|key| self.event_attributes.get(key));
let mut digest = Sha256::new();
digest.update(b"ticket.state-update.v1\0");
digest.update(b"ticket.state-update.v2\0");
digest.update(
serde_json::to_vec(&(ticket_id, request, body, source))
.map_err(|error| TicketError::Conflict(error.to_string()))?,
@@ -2437,7 +2489,7 @@ impl SqliteTicketBackend {
body: MarkdownText,
) -> Result<Ticket> {
validate_required_event_value("operation_key", &request.operation_key)?;
validate_required_event_value("expected_item_revision", &request.expected_item_revision)?;
validate_required_event_value("expected_content_digest", &request.expected_content_digest)?;
validate_state_change(&TicketStateChange {
from: request.expected_state.to_string(),
to: request.state.to_string(),
@@ -2457,7 +2509,7 @@ impl SqliteTicketBackend {
.attributes
.get("fingerprint_version")
.map(String::as_str)
!= Some("state-update-v1")
!= Some("state-update-v2")
|| event.attributes.get("request_fingerprint") != Some(&fingerprint)
{
return Err(TicketError::OperationFingerprintMismatch {
@@ -2471,10 +2523,10 @@ impl SqliteTicketBackend {
// Exact recorded replay is independent of today's live role/assignment.
// Parent transport authenticates identity before restoring receipt context.
self.require_active_source_assignment(conn, ticket_id)?;
let actual = ticket_item_revision(&previous);
if actual != request.expected_item_revision {
return Err(TicketError::StaleItemRevision {
expected: request.expected_item_revision.clone(),
let actual = ticket_content_digest(&previous);
if actual != request.expected_content_digest {
return Err(TicketError::StaleContent {
expected: request.expected_content_digest.clone(),
actual,
});
}
@@ -2526,11 +2578,11 @@ impl SqliteTicketBackend {
("request_fingerprint".to_owned(), fingerprint),
(
"fingerprint_version".to_owned(),
"state-update-v1".to_owned(),
"state-update-v2".to_owned(),
),
(
"expected_item_revision".to_owned(),
request.expected_item_revision.clone(),
"expected_content_digest".to_owned(),
request.expected_content_digest.clone(),
),
]),
},
@@ -3449,7 +3501,7 @@ impl TicketBackend for SqliteTicketBackend {
let ticket_id = self.resolve_ticket_id(conn, id)?;
let previous = self.load_ticket(conn, &ticket_id)?;
let request = TicketStateUpdate {
expected_item_revision: ticket_item_revision(&previous),
expected_content_digest: ticket_content_digest(&previous),
expected_state: from,
state: to,
operation_key: self.next_legacy_state_operation_key(conn, &ticket_id)?,
@@ -3634,7 +3686,7 @@ impl TicketBackend for SqliteTicketBackend {
let ticket_id = self.resolve_ticket_id(conn, id)?;
let previous = self.load_ticket(conn, &ticket_id)?;
let request = TicketStateUpdate {
expected_item_revision: ticket_item_revision(&previous),
expected_content_digest: ticket_content_digest(&previous),
expected_state: previous.meta.workflow_state,
state: TicketWorkflowState::Closed,
operation_key: self.next_legacy_state_operation_key(conn, &ticket_id)?,
@@ -5626,13 +5678,22 @@ mod tests {
}
#[test]
fn item_revision_uses_canonical_max_item_edit_index_not_edit_count_or_content_hash() {
fn content_digest_matches_sqlite_and_ignores_event_order() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let (reference, ticket) = backend
.create_with_snapshot(NewTicket::new("Revision format"))
.create_with_snapshot(NewTicket::new("Content digest"))
.unwrap();
assert_eq!(ticket_item_revision(&ticket), format!("{}:0", reference.id));
assert_eq!(ticket_content_digest(&ticket).len(), 64);
assert_eq!(
sqlite_ticket_content_digest(
&backend.open_connection().unwrap(),
&backend.workspace_id,
&reference.id
)
.unwrap(),
Some(ticket_content_digest(&ticket))
);
backend
.add_event(
reference.id.clone().into(),
@@ -5648,7 +5709,10 @@ mod tests {
},
)
.unwrap();
assert_eq!(ticket_item_revision(&first), format!("{}:2", reference.id));
assert_ne!(
ticket_content_digest(&first),
ticket_content_digest(&ticket)
);
backend
.add_event(
reference.id.clone().into(),
@@ -5664,13 +5728,23 @@ mod tests {
},
)
.unwrap();
assert_eq!(ticket_item_revision(&second), format!("{}:4", reference.id));
assert_ne!(
ticket_content_digest(&second),
ticket_content_digest(&first)
);
let digest = ticket_content_digest(&second);
assert_eq!(
sqlite_ticket_content_digest(
&backend.open_connection().unwrap(),
&backend.workspace_id,
&reference.id
)
.unwrap(),
Some(digest.clone())
);
let mut reordered = second;
reordered.events.reverse();
assert_eq!(
ticket_item_revision(&reordered),
format!("{}:4", reference.id)
);
assert_eq!(ticket_content_digest(&reordered), digest);
}
#[test]
@@ -5983,18 +6057,18 @@ mod tests {
assert_eq!(event.from.as_deref(), Some("planning"));
assert_eq!(event.reason.as_deref(), Some("finished"));
assert_eq!(
event.attributes.get("expected_item_revision"),
Some(&ticket_item_revision(&ticket))
event.attributes.get("expected_content_digest"),
Some(&ticket_content_digest(&ticket))
);
assert_eq!(
event
.attributes
.get("fingerprint_version")
.map(String::as_str),
Some("state-update-v1")
Some("state-update-v2")
);
let request = TicketStateUpdate {
expected_item_revision: ticket_item_revision(&ticket),
expected_content_digest: ticket_content_digest(&ticket),
expected_state: TicketWorkflowState::Planning,
state: TicketWorkflowState::Done,
operation_key: event.attributes["operation_key"].clone(),
@@ -6023,8 +6097,8 @@ mod tests {
assert_eq!(close.body, resolution);
assert_eq!(closed.resolution, Some(resolution));
assert_eq!(
close.attributes.get("expected_item_revision"),
Some(&ticket_item_revision(&done))
close.attributes.get("expected_content_digest"),
Some(&ticket_content_digest(&done))
);
assert_ne!(
close.attributes["operation_key"],
@@ -6175,7 +6249,7 @@ mod tests {
fn completion_request(ticket: &Ticket, key: &str) -> TicketCompletion {
TicketCompletion {
expected_item_revision: ticket_item_revision(ticket),
expected_content_digest: ticket_content_digest(ticket),
expected_state: ticket.meta.workflow_state,
operation_key: key.to_owned(),
reason: "Acceptance criteria met".to_owned(),
@@ -6184,6 +6258,52 @@ mod tests {
}
}
#[test]
fn frozen_counter_receipts_remain_immutable_and_cannot_be_reinterpreted_as_content_requests() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let (reference, ticket) = backend
.create_with_snapshot(NewTicket::new("Old receipt"))
.unwrap();
let request = completion_request(&ticket, "old-operation");
backend.complete(&reference.id, request.clone()).unwrap();
let connection = backend.open_connection().unwrap();
// Frozen pre-content fixture. Its old fingerprint cannot prove a new
// content-based request, and migration must not rewrite immutable audit.
connection.execute(
"UPDATE typed_ticket_event_attributes SET value='state-update-v1' WHERE ticket_id=?1 AND key='fingerprint_version'",
[&reference.id],
).unwrap();
connection.execute(
"UPDATE typed_ticket_event_attributes SET key='expected_item_revision',value='T:0' WHERE ticket_id=?1 AND key='expected_content_digest'",
[&reference.id],
).unwrap();
let before = backend.show(reference.id.clone().into()).unwrap();
migrate_sqlite_ticket_schema(&connection).unwrap();
assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before);
assert!(matches!(
backend.complete(&reference.id, request),
Err(TicketError::OperationFingerprintMismatch { .. }),
));
assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before);
let fresh = backend
.complete(
&reference.id,
completion_request(&before, "fresh-operation"),
)
.unwrap();
assert_eq!(fresh.events.len(), before.events.len() + 1);
assert_eq!(
fresh
.events
.last()
.unwrap()
.attributes
.get("expected_content_digest"),
Some(&ticket_content_digest(&before))
);
}
#[test]
fn completion_from_all_states_needs_no_git_targets_or_merge_request() {
let tmp = TempDir::new().unwrap();
@@ -6316,7 +6436,7 @@ mod tests {
.unwrap();
assert!(matches!(
backend.complete(&reference.id, request),
Err(TicketError::StaleItemRevision { .. })
Err(TicketError::StaleContent { .. })
));
assert_eq!(backend.show(reference.id.clone().into()).unwrap(), edited);
let mut request = completion_request(&edited, "complete");
@@ -6440,7 +6560,7 @@ mod tests {
..request.clone()
},
TicketCompletion {
expected_item_revision: "other-revision".to_owned(),
expected_content_digest: "other-content".to_owned(),
..request.clone()
},
TicketCompletion {
@@ -6562,13 +6682,13 @@ mod tests {
}
#[test]
fn item_revision_tracks_target_edits_and_aba_but_not_comments_or_state() {
fn content_digest_tracks_targets_and_restored_content_not_comments_or_state() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let (reference, ticket) = backend
.create_with_snapshot(NewTicket::new("Revision"))
.create_with_snapshot(NewTicket::new("Content"))
.unwrap();
let revision = ticket_item_revision(&ticket);
let digest = ticket_content_digest(&ticket);
backend
.add_event(
reference.id.clone().into(),
@@ -6576,11 +6696,11 @@ mod tests {
)
.unwrap();
let commented = backend.show(reference.id.clone().into()).unwrap();
assert_eq!(ticket_item_revision(&commented), revision);
assert_eq!(ticket_content_digest(&commented), digest);
let completed = backend
.complete(&reference.id, completion_request(&ticket, "complete"))
.unwrap();
assert_eq!(ticket_item_revision(&completed), revision);
assert_eq!(ticket_content_digest(&completed), digest);
backend
.edit_item(
reference.id.clone().into(),
@@ -6601,7 +6721,7 @@ mod tests {
},
)
.unwrap();
assert_ne!(ticket_item_revision(&restored), revision);
assert_eq!(ticket_content_digest(&restored), digest);
}
#[test]
@@ -6618,7 +6738,7 @@ mod tests {
..request.clone()
},
TicketCompletion {
expected_item_revision: "".to_owned(),
expected_content_digest: "".to_owned(),
..request.clone()
},
TicketCompletion {
@@ -7081,8 +7201,8 @@ mod tests {
assert_eq!(close.from.as_deref(), Some("planning"));
assert_eq!(close.to.as_deref(), Some("closed"));
assert_eq!(
close.attributes.get("expected_item_revision"),
Some(&format!("{}:0", record.meta.id))
close.attributes.get("expected_content_digest"),
Some(&ticket_content_digest(&record))
);
assert!(close.attributes.contains_key("operation_key"));
}
+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.
Every completion and chip-re-edit query carries a fresh `request_id`. Replies
must echo that nonce and match the active query context, composer edit/cursor
revision, authority snapshot generation, and Worker target. Identical-prefix ABA
edits, cancelled/reopened queries, permission snapshots, and Worker/view switches
cannot reuse older replies or candidates. Uncorrelated replies are ignored, even
for File queries where the wire field remains optional for legacy callers.
must echo that ID and match the active query context and Worker target. The first
input/cursor edit cancels the pending input watch; returning to identical text
cannot revive it. Authority snapshots and Worker/view switches discard pending
queries and visible candidates, rather than advancing another counter.
Uncorrelated replies are ignored, even for File queries where the wire field
remains optional for legacy callers.
The declared attachment adapter stages a client-local file in place, replacing
its staging chip with an `UploadedFile` reference when ready. Local file argument
+107 -138
View File
@@ -35,14 +35,22 @@ pub enum CommandCompletionApply {
/// typing inside a `@` / `#` / `/` token. Cleared whenever the trigger
/// is invalidated (cursor moved out, whitespace landed inside the
/// token, the sigil was deleted, or the candidate was confirmed).
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone)]
struct CompletionSnapshot {
input_revision: u64,
generation: u64,
input: crate::input::CompletionInputWatch,
target: String,
worker_view: Option<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 {
invocation: protocol::FeatureInvocation,
request_id: String,
@@ -229,7 +237,6 @@ impl ActionbarNotice {
pub struct InternalWorkerView {
pub worker: InternalWorkerRef,
pub revision: u64,
pub app: Box<App>,
}
@@ -242,7 +249,7 @@ pub struct WorkerViewTab {
pub struct App {
pub worker_name: String,
pub connected: bool,
/// Latest authoritative revisioned live execution state.
/// Latest authoritative live execution state.
pub worker_state: WorkerStateSnapshot,
next_command_id: u64,
/// Derived Runtime-catalog compatibility projection used by existing UI.
@@ -297,8 +304,9 @@ pub struct App {
/// Turn/protocol errors retained when a real `SegmentStart` replaces the
/// replayable conversation rows during segment rotation.
run_error_messages: Vec<String>,
/// Current compaction identity/revision used to fence snapshot/live updates.
active_compaction: Option<(String, u64)>,
/// Current compaction operation; terminal IDs cannot reopen on delayed events.
active_compaction: Option<String>,
terminal_compactions: std::collections::HashSet<String>,
pub compaction_progress: Option<protocol::InFlightCompaction>,
/// Presentation-only Internal Worker projections keyed by session identity.
/// They are rendered in separate selectable views and never mixed into `blocks`.
@@ -307,7 +315,7 @@ pub struct App {
/// view; the stable session identity survives projection reordering.
selected_internal_worker_session_id: Option<String>,
/// 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 mode: Mode,
pub cache: FileCache,
@@ -323,7 +331,6 @@ pub struct App {
/// typed FeatureInvoke chip.
selected_feature_invocations: HashMap<String, protocol::FeatureInvocationDescriptor>,
pending_feature_edit: Option<PendingFeatureEdit>,
completion_generation: u64,
completion_target: String,
/// Dedicated main-view rewind picker state.
pub rewind_picker: Option<RewindPickerState>,
@@ -397,11 +404,12 @@ impl App {
shutdown_confirm: None,
blocks: Vec::new(),
active_compaction: None,
terminal_compactions: std::collections::HashSet::new(),
compaction_progress: None,
run_error_messages: Vec::new(),
internal_workers: Vec::new(),
selected_internal_worker_session_id: None,
removed_internal_workers: HashMap::new(),
removed_internal_workers: std::collections::HashSet::new(),
scroll: Scroll::default(),
mode: Mode::Normal,
cache: FileCache::new(),
@@ -409,7 +417,6 @@ impl App {
completion: None,
selected_feature_invocations: HashMap::new(),
pending_feature_edit: None,
completion_generation: 0,
completion_target: protocol::new_submission_request_id(),
rewind_picker: None,
rewind_request_pending: false,
@@ -542,7 +549,7 @@ impl App {
/// Cycle the presentation-only transcript/task view. Input and control
/// methods continue to target the parent Worker regardless of selection.
pub fn cycle_worker_view(&mut self) -> bool {
self.invalidate_completion_generation();
self.cancel_completion_requests();
if self.internal_workers.is_empty() {
self.selected_internal_worker_session_id = None;
return false;
@@ -604,8 +611,7 @@ impl App {
fn completion_snapshot(&self) -> CompletionSnapshot {
CompletionSnapshot {
input_revision: self.input.revision(),
generation: self.completion_generation,
input: self.input.watch_completion_input(),
target: self.completion_target.clone(),
worker_view: self.selected_internal_worker_session_id.clone(),
}
@@ -614,14 +620,13 @@ impl App {
pub(crate) fn set_completion_target(&mut self, target: String) {
if self.completion_target != target {
self.completion_target = target;
self.invalidate_completion_generation();
self.cancel_completion_requests();
self.selected_feature_invocations.clear();
}
}
/// Authority/snapshot/target changes revoke in-flight queries and visible candidates.
fn invalidate_completion_generation(&mut self) {
self.completion_generation = self.completion_generation.wrapping_add(1);
fn cancel_completion_requests(&mut self) {
self.completion = None;
self.pending_feature_edit = None;
}
@@ -672,7 +677,7 @@ impl App {
&& state.prefix_start == start
&& state.prefix == prefix
&& state.context == context
&& state.snapshot == snapshot
&& state.snapshot.same_context(&snapshot)
}) {
return None;
}
@@ -722,7 +727,7 @@ impl App {
}
pub fn cancel_completion(&mut self) {
self.invalidate_completion_generation();
self.cancel_completion_requests();
}
/// Tab path: insert the popup-selected entry's value (with a
@@ -736,7 +741,7 @@ impl App {
if self
.completion
.as_ref()
.is_some_and(|state| state.snapshot != self.completion_snapshot())
.is_some_and(|state| !state.snapshot.same_context(&self.completion_snapshot()))
{
self.cancel_completion();
return None;
@@ -885,7 +890,7 @@ impl App {
if self
.completion
.as_ref()
.is_some_and(|state| state.snapshot != self.completion_snapshot())
.is_some_and(|state| !state.snapshot.same_context(&self.completion_snapshot()))
{
self.cancel_completion();
return false;
@@ -942,7 +947,7 @@ impl App {
if self
.completion
.as_ref()
.is_some_and(|state| state.snapshot != self.completion_snapshot())
.is_some_and(|state| !state.snapshot.same_context(&self.completion_snapshot()))
{
self.cancel_completion();
return false;
@@ -1248,22 +1253,23 @@ impl App {
pub fn continue_pending_method(&self) -> Option<Method> {
Some(Method::ContinuePending {
expected_revision: self.pending_submissions.revision,
expected_head_id: self.pending_submissions.head_id.clone()?,
})
}
pub fn clear_pending_method(&self) -> Method {
Method::ClearPendingSubmissions {
expected_revision: self.pending_submissions.revision,
expected_submission_ids: self
.pending_submissions
.submissions
.iter()
.map(|submission| submission.submission_id.clone())
.collect(),
}
}
pub fn cancel_pending_method(&self, submission_id: String) -> Method {
Method::CancelPendingSubmission {
submission_id,
expected_revision: self.pending_submissions.revision,
}
Method::CancelPendingSubmission { submission_id }
}
pub fn next_queued_input_preview(&self) -> Option<&str> {
@@ -1802,14 +1808,13 @@ impl App {
});
}
Event::CompactStart { lifecycle } => {
let should_apply = match &self.active_compaction {
None => true,
Some((id, revision)) => {
id == &lifecycle.compaction_id && lifecycle.revision > *revision
}
};
let should_apply = !self.terminal_compactions.contains(&lifecycle.compaction_id)
&& self
.active_compaction
.as_ref()
.is_none_or(|id| id == &lifecycle.compaction_id);
if should_apply {
self.active_compaction = Some((lifecycle.compaction_id, lifecycle.revision));
self.active_compaction = Some(lifecycle.compaction_id);
if self.last_streaming_compact_mut().is_none() {
self.blocks.push(Block::Compact(CompactEvent::Streaming {
started_at: Instant::now(),
@@ -1818,15 +1823,16 @@ impl App {
}
}
Event::CompactDone { lifecycle } => {
let should_apply = match &self.active_compaction {
None => true,
Some((id, revision)) => {
id == &lifecycle.compaction_id && lifecycle.revision > *revision
}
};
let should_apply = !self.terminal_compactions.contains(&lifecycle.compaction_id)
&& self
.active_compaction
.as_ref()
.is_none_or(|id| id == &lifecycle.compaction_id);
if !should_apply {
return None;
}
self.terminal_compactions
.insert(lifecycle.compaction_id.clone());
self.active_compaction = None;
let new_segment_id = lifecycle
.new_segment_id
@@ -1852,15 +1858,16 @@ impl App {
}
}
Event::CompactFailed { lifecycle } => {
let should_apply = match &self.active_compaction {
None => true,
Some((id, revision)) => {
id == &lifecycle.compaction_id && lifecycle.revision > *revision
}
};
let should_apply = !self.terminal_compactions.contains(&lifecycle.compaction_id)
&& self
.active_compaction
.as_ref()
.is_none_or(|id| id == &lifecycle.compaction_id);
if !should_apply {
return None;
}
self.terminal_compactions
.insert(lifecycle.compaction_id.clone());
self.active_compaction = None;
let error = lifecycle
.error
@@ -1915,14 +1922,10 @@ impl App {
self.replace_internal_worker_snapshots(internal_workers);
return self.refresh_completion();
}
Event::InternalWorker {
worker,
revision,
event,
} => self.apply_internal_worker_event(worker, revision, *event),
Event::InternalWorkerRemoved { worker, revision } => {
self.remove_internal_worker(worker, revision)
Event::InternalWorker { worker, event } => {
self.apply_internal_worker_event(worker, *event)
}
Event::InternalWorkerRemoved { worker } => self.remove_internal_worker(worker),
Event::WorkerState { snapshot } => {
self.rewind_refresh_fence = false;
self.apply_worker_state_snapshot(&snapshot);
@@ -1956,7 +1959,7 @@ impl App {
&& self.pending_feature_edit.as_ref().is_some_and(|edit| {
edit.invocation.name == prefix
&& request_id.as_ref() == Some(&edit.request_id)
&& edit.snapshot == snapshot
&& edit.snapshot.same_context(&snapshot)
})
{
let invocation = self.pending_feature_edit.take().unwrap().invocation;
@@ -1981,7 +1984,7 @@ impl App {
// stale argument/provider replies cannot cross invocation scope.
if let Some(state) = self.completion.as_mut()
&& request_id.as_ref() == Some(&state.request_id)
&& state.snapshot == snapshot
&& state.snapshot.same_context(&snapshot)
&& state.kind == kind
&& state.prefix == prefix
&& state.context == context
@@ -2100,7 +2103,7 @@ impl App {
);
}
Event::Shutdown => {
self.invalidate_completion_generation();
self.cancel_completion_requests();
self.mark_orphan_compacts_incomplete();
self.quit = true;
}
@@ -2309,7 +2312,7 @@ impl App {
}
pub fn enter_command_mode(&mut self) {
self.invalidate_completion_generation();
self.cancel_completion_requests();
self.input_mode = CommandInputMode::Command;
self.completion = None;
self.command_completion_selected = None;
@@ -2317,7 +2320,7 @@ impl App {
}
pub fn exit_command_mode(&mut self) {
self.invalidate_completion_generation();
self.cancel_completion_requests();
self.input_mode = CommandInputMode::Composer;
self.command_input.clear();
self.command_completion_selected = None;
@@ -2445,7 +2448,7 @@ impl App {
}
pub fn request_rewind_picker(&mut self) -> Option<Method> {
self.invalidate_completion_generation();
self.cancel_completion_requests();
// Rewind is a parent Worker control surface. Bring the parent transcript
// back into view before presenting diagnostics or the picker.
self.selected_internal_worker_session_id = None;
@@ -2791,21 +2794,12 @@ impl App {
app.replace_internal_worker_snapshots(snapshot.internal_workers);
InternalWorkerView {
worker: snapshot.worker,
revision: snapshot.revision,
app: Box::new(app),
}
}
fn apply_internal_worker_event(
&mut self,
worker: InternalWorkerRef,
revision: u64,
event: Event,
) {
if self
.removed_internal_workers
.contains_key(&worker.session_id)
{
fn apply_internal_worker_event(&mut self, worker: InternalWorkerRef, event: Event) {
if self.removed_internal_workers.contains(&worker.session_id) {
return;
}
let index = self
@@ -2819,21 +2813,16 @@ impl App {
app.mode = self.mode;
self.internal_workers.push(InternalWorkerView {
worker: worker.clone(),
revision: 0,
app: Box::new(app),
});
self.internal_workers.last_mut().unwrap()
};
if revision <= target.revision {
return;
}
target.worker = worker;
target.revision = revision;
let _ = target.app.handle_worker_event(event);
}
fn remove_internal_worker(&mut self, worker: InternalWorkerRef, revision: u64) {
self.invalidate_completion_generation();
fn remove_internal_worker(&mut self, worker: InternalWorkerRef) {
self.cancel_completion_requests();
let session_id = worker.session_id;
let Some(index) = self
.internal_workers
@@ -2843,20 +2832,14 @@ impl App {
if self.selected_internal_worker_session_id.as_deref() == Some(session_id.as_str()) {
self.selected_internal_worker_session_id = None;
}
self.removed_internal_workers
.entry(session_id)
.and_modify(|current| *current = (*current).max(revision))
.or_insert(revision);
self.removed_internal_workers.insert(session_id);
return;
};
if revision <= self.internal_workers[index].revision {
return;
}
self.internal_workers.remove(index);
if self.selected_internal_worker_session_id.as_deref() == Some(session_id.as_str()) {
self.selected_internal_worker_session_id = None;
}
self.removed_internal_workers.insert(session_id, revision);
self.removed_internal_workers.insert(session_id);
}
fn restore_snapshot(
@@ -2918,7 +2901,7 @@ impl App {
session: &protocol::SessionSnapshot,
greeting: Option<protocol::Greeting>,
) {
self.invalidate_completion_generation();
self.cancel_completion_requests();
self.run_error_messages.clear();
self.turn_index = 0;
self.blocks.clear();
@@ -4102,7 +4085,6 @@ mod completion_flow_tests {
let mut app = App::new("test".into());
app.handle_worker_event(Event::PendingSubmissionsChanged {
pending: protocol::PendingSubmissionsSnapshot {
revision: 3,
notification_count: 0,
notification_previews: vec![],
head_id: Some("submission-1".into()),
@@ -4497,34 +4479,25 @@ mod completion_flow_tests {
};
app.handle_worker_event(Event::InternalWorker {
worker: worker.clone(),
revision: 2,
event: Box::new(Event::TextDelta {
text: "child output".into(),
}),
});
app.handle_worker_event(Event::InternalWorker {
worker,
revision: 1,
event: Box::new(Event::TextDelta {
text: "stale".into(),
text: " next".into(),
}),
});
assert!(app.blocks.is_empty());
assert_eq!(app.internal_workers.len(), 1);
assert_eq!(app.internal_workers[0].revision, 2);
assert!(
app.internal_workers[0].app.blocks.iter().any(
|block| matches!(block, Block::AssistantText { text } if text == "child output")
)
);
assert!(app.internal_workers[0].app.blocks.iter().any(
|block| matches!(block, Block::AssistantText { text } if text == "child output next")
));
}
fn test_internal_worker_snapshot(
session_id: &str,
name: &str,
revision: u64,
) -> InternalWorkerSnapshot {
fn test_internal_worker_snapshot(session_id: &str, name: &str) -> InternalWorkerSnapshot {
InternalWorkerSnapshot {
worker: InternalWorkerRef {
session_id: session_id.into(),
@@ -4532,7 +4505,6 @@ mod completion_flow_tests {
parent_session_id: Some("parent".into()),
kind: protocol::InternalWorkerKind::SubWorker,
},
revision,
status: WorkerStatus::Idle,
greeting: None,
session: protocol::SessionSnapshot {
@@ -4548,7 +4520,7 @@ mod completion_flow_tests {
#[test]
fn internal_worker_snapshot_uses_child_greeting_metadata() {
let mut app = App::new("parent".into());
let mut child = test_internal_worker_snapshot("child", "research", 1);
let mut child = test_internal_worker_snapshot("child", "research");
let mut greeting = test_greeting();
greeting.worker_name = "research".into();
greeting.model = "child-model".into();
@@ -4570,7 +4542,7 @@ mod completion_flow_tests {
#[test]
fn live_internal_worker_snapshot_installs_child_metadata_before_context_updates() {
let mut app = App::new("parent".into());
let worker = test_internal_worker_snapshot("child", "research", 1).worker;
let worker = test_internal_worker_snapshot("child", "research").worker;
let mut greeting = test_greeting();
greeting.worker_name = "research".into();
greeting.model = "child-model".into();
@@ -4582,7 +4554,6 @@ mod completion_flow_tests {
app.handle_worker_event(Event::InternalWorker {
worker: worker.clone(),
revision: 1,
event: Box::new(Event::Snapshot {
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
@@ -4596,7 +4567,6 @@ mod completion_flow_tests {
});
app.handle_worker_event(Event::InternalWorker {
worker,
revision: 2,
event: Box::new(Event::ContextUsage {
usage: Some(protocol::ContextUsage {
tokens: 14_000,
@@ -4626,7 +4596,6 @@ mod completion_flow_tests {
parent_session_id: Some("parent".into()),
kind: protocol::InternalWorkerKind::SubWorker,
},
revision: 1,
app: Box::new(App::new(name.into())),
});
}
@@ -4664,7 +4633,7 @@ mod completion_flow_tests {
let mut app = App::new("parent".into());
app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot(
"child", "child", 1,
"child", "child",
)]);
select_first_row(&mut app, "parent selection");
select_first_row(app.internal_workers[0].app.as_mut(), "child selection");
@@ -4687,7 +4656,6 @@ mod completion_flow_tests {
parent_session_id: Some("parent".into()),
kind: protocol::InternalWorkerKind::SubWorker,
},
revision: 1,
app: Box::new(App::new("old".into())),
});
app.cycle_worker_view();
@@ -4711,7 +4679,7 @@ mod completion_flow_tests {
let mut app = App::new("parent".into());
app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot(
"child", "child", 1,
"child", "child",
)]);
let child = app.internal_workers[0].app.as_mut();
child.scroll.follow_tail = false;
@@ -4733,11 +4701,9 @@ mod completion_flow_tests {
app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot(
"child",
"renamed-child",
2,
)]);
let view = &app.internal_workers[0];
assert_eq!(view.revision, 2);
assert_eq!(view.app.worker_name, "renamed-child");
assert!(!view.app.scroll.follow_tail);
assert_eq!(view.app.scroll.top_offset, 7);
@@ -4749,8 +4715,8 @@ mod completion_flow_tests {
fn task_pane_scroll_is_local_to_selected_worker_view() {
let mut app = App::new("parent".into());
app.replace_internal_worker_snapshots(vec![
test_internal_worker_snapshot("child-a", "alpha", 1),
test_internal_worker_snapshot("child-b", "beta", 1),
test_internal_worker_snapshot("child-a", "alpha"),
test_internal_worker_snapshot("child-b", "beta"),
]);
app.task_pane_scroll = 3;
@@ -4793,10 +4759,8 @@ mod completion_flow_tests {
};
app.handle_worker_event(Event::InternalWorker {
worker: worker.clone(),
revision: 2,
event: Box::new(Event::InternalWorker {
worker: nested,
revision: 1,
event: Box::new(Event::TextDone {
text: "nested".into(),
}),
@@ -4809,11 +4773,9 @@ mod completion_flow_tests {
app.handle_worker_event(Event::InternalWorkerRemoved {
worker: worker.clone(),
revision: 3,
});
app.handle_worker_event(Event::InternalWorker {
worker,
revision: 4,
event: Box::new(Event::TextDone {
text: "late".into(),
}),
@@ -4836,7 +4798,7 @@ mod completion_flow_tests {
}
#[test]
fn stale_internal_worker_removal_keeps_newer_projection() {
fn internal_worker_removal_applies_in_stream_order() {
let mut app = App::new("parent".into());
let worker = InternalWorkerRef {
session_id: "child-session".into(),
@@ -4846,18 +4808,13 @@ mod completion_flow_tests {
};
app.handle_worker_event(Event::InternalWorker {
worker: worker.clone(),
revision: 4,
event: Box::new(Event::TextDone {
text: "current".into(),
}),
});
app.handle_worker_event(Event::InternalWorkerRemoved {
worker,
revision: 3,
});
app.handle_worker_event(Event::InternalWorkerRemoved { worker });
assert_eq!(app.internal_workers.len(), 1);
assert_eq!(app.internal_workers[0].revision, 4);
assert!(app.internal_workers.is_empty());
}
#[test]
@@ -4870,7 +4827,6 @@ mod completion_flow_tests {
parent_session_id: None,
kind: protocol::InternalWorkerKind::SubWorker,
},
revision: 1,
app: Box::new(App::new("old".into())),
});
app.handle_worker_event(Event::Snapshot {
@@ -4888,7 +4844,6 @@ mod completion_flow_tests {
parent_session_id: Some("parent-session".into()),
kind: protocol::InternalWorkerKind::SubWorker,
},
revision: 4,
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
@@ -4903,7 +4858,6 @@ mod completion_flow_tests {
assert_eq!(app.internal_workers.len(), 1);
assert_eq!(app.internal_workers[0].worker.session_id, "replacement");
assert_eq!(app.internal_workers[0].revision, 4);
assert_eq!(
app.internal_workers[0].app.worker_status,
WorkerStatus::Running
@@ -4955,7 +4909,6 @@ mod completion_flow_tests {
protocol::CompactionLifecycle {
schema_version: 2,
compaction_id: "compaction-test".into(),
revision: 1,
internal_worker: None,
state,
started_at_ms: 1,
@@ -4975,7 +4928,6 @@ mod completion_flow_tests {
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
});
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Done);
lifecycle.revision = 2;
lifecycle.new_segment_id = Some(id.to_string());
app.handle_worker_event(Event::CompactDone { lifecycle });
@@ -4989,6 +4941,25 @@ mod completion_flow_tests {
));
}
#[test]
fn terminal_compaction_identity_rejects_duplicate_and_delayed_start() {
let mut app = App::new("test".into());
app.handle_worker_event(Event::CompactStart {
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
});
app.handle_worker_event(Event::CompactDone {
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Done),
});
app.handle_worker_event(Event::CompactStart {
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
});
app.handle_worker_event(Event::CompactDone {
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Done),
});
assert_eq!(compact_block_count(&app), 1);
assert!(app.active_compaction.is_none());
}
#[test]
fn compact_failed_replaces_live_block() {
let mut app = App::new("test".into());
@@ -4997,7 +4968,6 @@ mod completion_flow_tests {
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
});
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Failed);
lifecycle.revision = 2;
lifecycle.error = Some("provider 429".into());
app.handle_worker_event(Event::CompactFailed { lifecycle });
@@ -5662,7 +5632,7 @@ mod completion_correlation_tests {
let first = app.refresh_completion().unwrap();
app.input.insert_char('x');
app.input.delete_before();
// Prefix, context, token location, and cursor are identical; semantic revision is not.
// Returning to the same text cannot revive a request cancelled by the edit.
app.handle_worker_event(reply(&first, "old"));
assert!(app.completion.as_ref().unwrap().entries.is_empty());
let second = app.refresh_completion().unwrap();
@@ -5734,7 +5704,7 @@ mod completion_correlation_tests {
}
#[test]
fn permission_snapshot_generation_aba_revokes_queries_even_when_scope_returns_to_original() {
fn permission_snapshot_aba_revokes_queries_even_when_scope_returns_to_original() {
let mut app = App::new("test".into());
app.handle_worker_event(authority_snapshot("Writable: /tmp"));
crate::invocation_tests::select(&mut app, crate::invocation_tests::descriptor(), "run");
@@ -5748,8 +5718,8 @@ mod completion_correlation_tests {
.unwrap();
assert_ne!(request_id(&first), request_id(&denied));
assert_ne!(request_id(&first), request_id(&current));
app.handle_worker_event(reply(&first, "old permission generation"));
app.handle_worker_event(reply(&denied, "intermediate generation"));
app.handle_worker_event(reply(&first, "old permission context"));
app.handle_worker_event(reply(&denied, "intermediate context"));
assert!(app.completion.as_ref().unwrap().entries.is_empty());
app.handle_worker_event(reply(&current, "資料/current"));
assert_eq!(
@@ -5789,7 +5759,6 @@ mod completion_correlation_tests {
parent_session_id: Some("parent".into()),
kind: protocol::InternalWorkerKind::SubWorker,
},
revision: 1,
event: Box::new(Event::WorkerState {
snapshot: protocol::WorkerStateSnapshot::from(WorkerStatus::Idle),
}),
+3 -4
View File
@@ -674,7 +674,7 @@ mod tests {
fn worker(runtime_id: &str, worker_id: &str, profile: Option<&str>) -> BackendWorkerSummary {
BackendWorkerSummary {
restore_observation_token: Some("observed-worker-generation".into()),
restore_observation_token: Some("observed-worker-state".into()),
runtime_id: runtime_id.to_string(),
worker_id: worker_id.to_string(),
resource_key: "W-1".to_string(),
@@ -953,7 +953,6 @@ mod tests {
workspace_api: None,
memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "local".into(),
settings_revision: 1,
language: "English".into(),
}),
subjektiv_attached: false,
@@ -1107,7 +1106,7 @@ mod tests {
target,
BackendWorkerPickerIntent::Resume,
Some(client::BackendWorkerRestoreRequest {
expected_observation_token: "observed-worker-generation".into(),
expected_observation_token: "observed-worker-state".into(),
request_id: "resume-operation".into(),
}),
|_| Ok(false),
@@ -1142,7 +1141,7 @@ mod tests {
target,
BackendWorkerPickerIntent::Resume,
Some(client::BackendWorkerRestoreRequest {
expected_observation_token: "observed-worker-generation".into(),
expected_observation_token: "observed-worker-state".into(),
request_id: "resume-operation".into(),
}),
|_| Ok(false),
+2 -10
View File
@@ -2625,7 +2625,6 @@ mod tests {
let mut app = App::new("test".into());
app.handle_worker_event(Event::PendingSubmissionsChanged {
pending: protocol::PendingSubmissionsSnapshot {
revision: 2,
notification_count: 0,
notification_previews: vec![],
head_id: Some("submission-1".into()),
@@ -2646,7 +2645,6 @@ mod tests {
assert!(matches!(
continue_next,
Some(Method::ContinuePending {
expected_revision: 2,
ref expected_head_id,
}) if expected_head_id == "submission-1"
));
@@ -2659,7 +2657,6 @@ mod tests {
assert!(matches!(
cancel,
Some(Method::CancelPendingSubmission {
expected_revision: 2,
ref submission_id,
}) if submission_id == "submission-1"
));
@@ -2671,8 +2668,8 @@ mod tests {
assert!(matches!(
clear,
Some(Method::ClearPendingSubmissions {
expected_revision: 2
})
ref expected_submission_ids
}) if expected_submission_ids == &["submission-1"]
));
assert_eq!(app.queued_input_count(), 1);
}
@@ -2682,7 +2679,6 @@ mod tests {
let mut app = App::new("test".into());
app.handle_worker_event(Event::PendingSubmissionsChanged {
pending: protocol::PendingSubmissionsSnapshot {
revision: 2,
notification_count: 0,
notification_previews: vec![],
head_id: Some("submission-1".into()),
@@ -3355,7 +3351,6 @@ mod tests {
parent_session_id: Some("parent-session".into()),
kind: protocol::InternalWorkerKind::SubWorker,
},
revision: 1,
event: Box::new(Event::WorkerState {
snapshot: WorkerStatus::Running.into(),
}),
@@ -3471,7 +3466,6 @@ mod tests {
parent_session_id: Some("parent-session".into()),
kind: protocol::InternalWorkerKind::SubWorker,
},
revision: 1,
event: Box::new(Event::WorkerState {
snapshot: WorkerStatus::Running.into(),
}),
@@ -3497,7 +3491,6 @@ mod tests {
parent_session_id: Some("parent-session".into()),
kind: protocol::InternalWorkerKind::SubWorker,
},
revision: 1,
event: Box::new(Event::WorkerState {
snapshot: WorkerStatus::Running.into(),
}),
@@ -3537,7 +3530,6 @@ mod tests {
parent_session_id: Some("parent-session".into()),
kind: protocol::InternalWorkerKind::SubWorker,
},
revision: 1,
event: Box::new(Event::WorkerState {
snapshot: WorkerStatus::Running.into(),
}),
+56 -26
View File
@@ -11,6 +11,14 @@
//! back to their original captured content so the Worker sees the full
//! pasted text (without the placeholder label).
use std::{
cell::RefCell,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use unicode_width::UnicodeWidthChar;
@@ -281,9 +289,19 @@ struct SelectedInvocation {
invocation_id: String,
}
/// A pending completion is cancelled by the first subsequent input/cursor edit.
/// Returning to identical text does not revive that request.
#[derive(Debug, Clone)]
pub(crate) struct CompletionInputWatch(Arc<AtomicBool>);
impl CompletionInputWatch {
pub(crate) fn is_cancelled(&self) -> bool {
self.0.load(Ordering::Relaxed)
}
}
pub struct InputBuffer {
/// Monotonic semantic edit/cursor revision, including ABA changes.
revision: u64,
pending_completion: RefCell<Option<Arc<AtomicBool>>>,
atoms: Vec<Atom>,
selected_invocations: Vec<SelectedInvocation>,
removed_attachment_stages: Vec<String>,
@@ -300,7 +318,7 @@ pub struct InputBuffer {
impl Default for InputBuffer {
fn default() -> Self {
Self {
revision: 0,
pending_completion: RefCell::new(None),
atoms: Vec::new(),
selected_invocations: Vec::new(),
removed_attachment_stages: Vec::new(),
@@ -312,13 +330,25 @@ impl Default for InputBuffer {
}
}
impl InputBuffer {
pub fn revision(&self) -> u64 {
self.revision
impl Drop for InputBuffer {
fn drop(&mut self) {
self.cancel_completion_on_edit();
}
}
fn bump_revision(&mut self) {
self.revision = self.revision.wrapping_add(1);
impl InputBuffer {
pub(crate) fn watch_completion_input(&self) -> CompletionInputWatch {
CompletionInputWatch(Arc::clone(
self.pending_completion
.borrow_mut()
.get_or_insert_with(|| Arc::new(AtomicBool::new(false))),
))
}
fn cancel_completion_on_edit(&mut self) {
if let Some(pending) = self.pending_completion.get_mut().take() {
pending.store(true, Ordering::Relaxed);
}
}
pub fn new() -> Self {
@@ -326,7 +356,7 @@ impl InputBuffer {
}
pub fn clear(&mut self) {
self.bump_revision();
self.cancel_completion_on_edit();
self.selected_invocations.clear();
self.atoms.clear();
self.cursor = 0;
@@ -349,7 +379,7 @@ impl InputBuffer {
/// by [`submit_segments`](Self::submit_segments), preserving typed chips
/// and placing the cursor at the end of the restored input.
pub fn replace_with_segments(&mut self, segments: &[protocol::Segment]) {
self.bump_revision();
self.cancel_completion_on_edit();
self.selected_invocations.clear();
self.atoms.clear();
for segment in segments {
@@ -400,7 +430,7 @@ impl InputBuffer {
}
pub fn insert_char(&mut self, c: char) {
self.bump_revision();
self.cancel_completion_on_edit();
self.adjust_selection(self.cursor, self.cursor, 1);
self.atoms.insert(self.cursor, Atom::Char(c));
self.cursor += 1;
@@ -445,7 +475,7 @@ impl InputBuffer {
}
pub fn insert_paste(&mut self, content: String) {
self.bump_revision();
self.cancel_completion_on_edit();
let measurement = measure_paste(&content);
if measurement.presentation() == PastePresentation::Text {
self.insert_str(&content);
@@ -469,7 +499,7 @@ impl InputBuffer {
#[cfg(test)]
pub fn insert_uploaded_file(&mut self, file: protocol::UploadedFileRef) {
self.bump_revision();
self.cancel_completion_on_edit();
self.adjust_selection(self.cursor, self.cursor, 1);
self.atoms.insert(self.cursor, Atom::UploadedFile(file));
self.cursor += 1;
@@ -493,7 +523,7 @@ impl InputBuffer {
start: usize,
descriptor: protocol::FeatureInvocationDescriptor,
) {
self.bump_revision();
self.cancel_completion_on_edit();
self.selected_invocations
.retain(|selection| selection.start != start);
self.selected_invocations.push(SelectedInvocation {
@@ -508,7 +538,7 @@ impl InputBuffer {
}
pub fn retry_attachment_stage(&mut self, id: &str) {
self.bump_revision();
self.cancel_completion_on_edit();
for atom in &mut self.atoms {
if let Atom::AttachmentStage {
id: stage_id,
@@ -563,7 +593,7 @@ impl InputBuffer {
}
fn replace_atoms(&mut self, start: usize, end: usize, atoms: Vec<Atom>) {
self.bump_revision();
self.cancel_completion_on_edit();
let inserted = atoms.len();
self.adjust_selection(start, end, inserted);
let removed = self.atoms.splice(start..end, atoms).collect::<Vec<_>>();
@@ -669,7 +699,7 @@ impl InputBuffer {
id: &str,
file: Option<protocol::UploadedFileRef>,
) -> bool {
self.bump_revision();
self.cancel_completion_on_edit();
let Some(index) = self.atoms.iter().position(
|atom| matches!(atom, Atom::AttachmentStage { id: stage_id, .. } if stage_id == id),
) else {
@@ -965,12 +995,12 @@ impl InputBuffer {
}
pub fn move_left(&mut self) {
self.bump_revision();
self.cancel_completion_on_edit();
self.cursor = self.cursor.saturating_sub(1);
}
pub fn move_right(&mut self) {
self.bump_revision();
self.cancel_completion_on_edit();
self.cursor = (self.cursor + 1).min(self.atoms.len());
}
@@ -978,7 +1008,7 @@ impl InputBuffer {
/// atoms sharing the same [`AtomClass`] — so `Word(Hiragana)` next to
/// `Word(Han)` are separate blocks, and a `Paste` atom is its own block.
pub fn move_word_left(&mut self) {
self.bump_revision();
self.cancel_completion_on_edit();
while self.cursor > 0 && atom_class(&self.atoms[self.cursor - 1]) == AtomClass::Sep {
self.cursor -= 1;
}
@@ -993,7 +1023,7 @@ impl InputBuffer {
/// Move forward by one word. Mirror of [`move_word_left`].
pub fn move_word_right(&mut self) {
self.bump_revision();
self.cancel_completion_on_edit();
while self.cursor < self.atoms.len()
&& atom_class(&self.atoms[self.cursor]) == AtomClass::Sep
{
@@ -1009,7 +1039,7 @@ impl InputBuffer {
}
pub fn move_start(&mut self) {
self.bump_revision();
self.cancel_completion_on_edit();
self.cursor = 0;
}
@@ -1058,13 +1088,13 @@ impl InputBuffer {
}
pub fn move_home(&mut self) {
self.bump_revision();
self.cancel_completion_on_edit();
let (ranges, line, _) = self.logical_line_and_col();
self.cursor = ranges[line].0;
}
pub fn move_end(&mut self) {
self.bump_revision();
self.cancel_completion_on_edit();
let (ranges, line, _) = self.logical_line_and_col();
self.cursor = ranges[line].1;
}
@@ -1072,7 +1102,7 @@ impl InputBuffer {
/// Move one logical line up, preserving column (atom count from
/// current line start). No-op if already on the first line.
pub fn move_up(&mut self) {
self.bump_revision();
self.cancel_completion_on_edit();
let (ranges, line, col) = self.logical_line_and_col();
if line == 0 {
return;
@@ -1083,7 +1113,7 @@ impl InputBuffer {
/// Move one logical line down, preserving column.
pub fn move_down(&mut self) {
self.bump_revision();
self.cancel_completion_on_edit();
let (ranges, line, col) = self.logical_line_and_col();
let Some(&(start, end)) = ranges.get(line + 1) else {
return;
-2
View File
@@ -2187,7 +2187,6 @@ mod tests {
fn set_pending_submission(app: &mut App, id: &str) {
app.handle_worker_event(Event::PendingSubmissionsChanged {
pending: protocol::PendingSubmissionsSnapshot {
revision: 1,
notification_count: 0,
notification_previews: vec![],
head_id: Some(id.into()),
@@ -2320,7 +2319,6 @@ mod tests {
parent_session_id: Some("parent-session".into()),
kind: protocol::InternalWorkerKind::SubWorker,
},
revision: 1,
event: Box::new(protocol::Event::TextDelta {
text: "child transcript".into(),
}),
+3 -6
View File
@@ -75,7 +75,6 @@ pub struct WorkingDirectoryRepository {
pub id: String,
pub provider: String,
pub source: server_api::RepositorySource,
pub source_revision: u64,
pub source_fingerprint: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<RepositorySelector>,
@@ -121,7 +120,7 @@ impl std::fmt::Debug for SensitiveString {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositorySshCredentialCandidate {
pub credential_id: String,
pub credential_revision: u64,
pub public_key_fingerprint: String,
#[serde(skip, default)]
pub private_key: SensitiveString,
}
@@ -130,7 +129,7 @@ pub struct RepositorySshCredentialCandidate {
pub struct RepositorySshMaterializationAccess {
pub credential_candidates: Vec<RepositorySshCredentialCandidate>,
pub host_trust_id: String,
pub host_trust_revision: u64,
pub host_key_fingerprint: String,
pub access: server_api::RepositoryAccessMode,
pub expires_at_epoch_seconds: u64,
pub repository_id: String,
@@ -146,7 +145,6 @@ pub struct RepositoryMaterializationContext {
pub workspace_id: String,
pub runtime_id: String,
pub operation_id: String,
pub config_revision: u64,
pub config_projection_digest: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh: Option<RepositorySshMaterializationAccess>,
@@ -191,10 +189,9 @@ pub struct RepositoryRefObservationRequest {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositoryRefObservation {
pub repository_id: String,
pub source_revision: u64,
pub source_fingerprint: String,
pub selector: String,
pub revision_ref: String,
pub resolved_ref: String,
pub observed_at_epoch_seconds: u64,
}
+3 -22
View File
@@ -43,7 +43,6 @@ impl ConfigBundle {
pub fn computed_digest(&self) -> String {
let mut lines = Vec::new();
lines.push(format!("id\0{}", self.metadata.id));
lines.push(format!("revision\0{}", self.metadata.revision));
lines.push(format!("workspace_id\0{}", self.metadata.workspace_id));
lines.push(format!("created_at\0{}", self.metadata.created_at));
lines.push(format!(
@@ -82,7 +81,7 @@ impl ConfigBundle {
if let Some(prompt_catalog) = &self.prompt_catalog {
lines.push(format!(
"prompt_catalog\0{}\0{}\0{}\0{}",
prompt_catalog.config_revision,
prompt_catalog.source_digest,
prompt_catalog.schema_fingerprint,
prompt_catalog.toolchain_fingerprint,
prompt_catalog.catalog_digest
@@ -100,12 +99,8 @@ impl ConfigBundle {
}
if let Some(handle) = &self.profile_source_archive_handle {
lines.push(format!(
"profile_archive_handle\0{}\0{}\0{}\0{}\0{}",
handle.workspace_id,
handle.resource_id,
handle.digest,
handle.revision,
handle.max_bytes
"profile_archive_handle\0{}\0{}\0{}\0{}",
handle.workspace_id, handle.resource_id, handle.digest, handle.max_bytes
));
for (selector, path) in handle
.profile_source_graph
@@ -140,7 +135,6 @@ impl ConfigBundle {
id: self.metadata.id.clone(),
digest: self.metadata.digest.clone(),
digest_algorithm: CONFIG_BUNDLE_DIGEST_ALGORITHM.to_string(),
revision: self.metadata.revision.clone(),
workspace_id: self.metadata.workspace_id.clone(),
created_at: self.metadata.created_at.clone(),
provenance: self.metadata.provenance.clone(),
@@ -182,7 +176,6 @@ impl From<ConfigBundle> for server_api::WorkspaceRuntimeConfigResponse {
prompt_catalog: prompt_catalog.map(|catalog| {
server_api::WorkspaceRuntimePromptCatalog {
templates: catalog.templates,
config_revision: catalog.config_revision,
source_digest: catalog.source_digest,
schema_fingerprint: catalog.schema_fingerprint,
toolchain_fingerprint: catalog.toolchain_fingerprint,
@@ -200,7 +193,6 @@ impl From<ConfigBundleMetadata> for server_api::WorkspaceRuntimeConfigMetadata {
Self {
id: metadata.id,
digest: metadata.digest,
revision: metadata.revision,
workspace_id: metadata.workspace_id,
created_at: metadata.created_at,
provenance: metadata.provenance.into(),
@@ -303,8 +295,6 @@ impl From<BackendResourceHandle> for server_api::WorkspaceRuntimeResourceHandle
operation: handle.operation.into(),
expires_at_unix_seconds: handle.expires_at_unix_seconds,
nonce: handle.nonce,
revision: handle.revision,
generation: handle.generation,
max_bytes: handle.max_bytes,
content_type: handle.content_type,
redaction: handle.redaction.into(),
@@ -344,7 +334,6 @@ impl From<ResourceRedactionPolicy> for server_api::WorkspaceRuntimeResourceRedac
pub struct ConfigBundleMetadata {
pub id: String,
pub digest: String,
pub revision: String,
pub workspace_id: String,
pub created_at: String,
pub provenance: ConfigBundleProvenance,
@@ -402,7 +391,6 @@ pub struct ConfigBundleSummary {
pub id: String,
pub digest: String,
pub digest_algorithm: String,
pub revision: String,
pub workspace_id: String,
pub created_at: String,
pub provenance: ConfigBundleProvenance,
@@ -422,7 +410,6 @@ pub(crate) fn validate_config_bundle(bundle: &ConfigBundle) -> Result<(), Runtim
validate_config_bundle_id(&bundle.metadata.id)?;
validate_non_empty("config bundle digest", &bundle.metadata.digest)?;
validate_digest("config bundle digest", &bundle.metadata.digest)?;
validate_non_empty("config bundle revision", &bundle.metadata.revision)?;
validate_non_empty("config bundle workspace id", &bundle.metadata.workspace_id)?;
validate_non_empty("config bundle created_at", &bundle.metadata.created_at)?;
validate_non_empty(
@@ -430,7 +417,6 @@ pub(crate) fn validate_config_bundle(bundle: &ConfigBundle) -> Result<(), Runtim
&bundle.metadata.provenance.source,
)?;
validate_boundary_text("config bundle id", &bundle.metadata.id)?;
validate_boundary_text("config bundle revision", &bundle.metadata.revision)?;
validate_boundary_text("config bundle workspace id", &bundle.metadata.workspace_id)?;
validate_boundary_text(
"config bundle provenance source",
@@ -489,7 +475,6 @@ pub(crate) fn validate_config_bundle(bundle: &ConfigBundle) -> Result<(), Runtim
("resource handle resource id", handle.resource_id.as_str()),
("resource handle digest", handle.digest.as_str()),
("resource handle nonce", handle.nonce.as_str()),
("resource handle revision", handle.revision.as_str()),
("resource handle content type", handle.content_type.as_str()),
(
"resource handle audit correlation id",
@@ -766,7 +751,6 @@ mod tests {
metadata: ConfigBundleMetadata {
id: "bundle-1".to_string(),
digest: String::new(),
revision: "rev-1".to_string(),
workspace_id: "workspace-1".to_string(),
created_at: "2026-06-26T00:00:00Z".to_string(),
provenance: ConfigBundleProvenance {
@@ -823,7 +807,6 @@ mod tests {
bundle.prompt_catalog = Some(
worker::EffectivePromptCatalog::new(
std::collections::BTreeMap::from([("default".to_string(), "hello".to_string())]),
7,
"schema",
"toolchain",
)
@@ -866,8 +849,6 @@ mod tests {
operation: crate::resource::BackendResourceOperation::FetchArchive,
expires_at_unix_seconds: 4_102_444_800,
nonce: "nonce-not-for-browser".to_string(),
revision: "rev-1".to_string(),
generation: Some(1),
max_bytes: 128,
content_type: crate::resource::PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(),
redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly,
+1 -1
View File
@@ -693,7 +693,7 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
}
/// Observe a newer immutable Workspace Prompt projection. Profile-backed
/// execution uses this as a revision notification; other backends may
/// execution uses this as a content projection notification; other backends may
/// safely ignore it.
fn observe_workspace_prompt_projection(
&self,
+284 -19
View File
@@ -21,7 +21,9 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
const SCHEMA_VERSION: u32 = 9;
const SCHEMA_VERSION: u32 = 10;
// Frozen source schemas: do not move their meaning when adding a migration.
const AGGREGATE_SCHEMA_VERSION: u32 = 9;
const PREVIOUS_SCHEMA_VERSION: u32 = 8;
const LEGACY_SCHEMA_VERSION: u32 = 7;
const OLDEST_SCHEMA_VERSION: u32 = 6;
@@ -609,8 +611,8 @@ fn plan_runtime_store_migration(
format!("Runtime store schema version {schema_version} is out of range"),
)
})?;
let staging = migration_sibling(root, "schema-v9-staging")?;
let backup = migration_sibling(root, "pre-schema-v9-backup")?;
let staging = migration_sibling(root, "schema-v10-staging")?;
let backup = migration_sibling(root, "pre-schema-v10-backup")?;
if staging.exists() || backup.exists() {
return Err(runtime_store_corrupt(
root,
@@ -639,12 +641,15 @@ fn plan_runtime_store_migration(
}
if !matches!(
current_schema_version,
OLDEST_SCHEMA_VERSION | LEGACY_SCHEMA_VERSION | PREVIOUS_SCHEMA_VERSION
OLDEST_SCHEMA_VERSION
| LEGACY_SCHEMA_VERSION
| PREVIOUS_SCHEMA_VERSION
| AGGREGATE_SCHEMA_VERSION
) {
return Err(runtime_store_corrupt(
&runtime_path,
format!(
"unsupported Runtime store schema version {schema_version}; expected {OLDEST_SCHEMA_VERSION}, {LEGACY_SCHEMA_VERSION}, {PREVIOUS_SCHEMA_VERSION}, or {SCHEMA_VERSION}"
"unsupported Runtime store schema version {schema_version}; expected {OLDEST_SCHEMA_VERSION}, {LEGACY_SCHEMA_VERSION}, {PREVIOUS_SCHEMA_VERSION}, {AGGREGATE_SCHEMA_VERSION}, or {SCHEMA_VERSION}"
),
));
}
@@ -969,11 +974,30 @@ fn validate_migrated_worker_documents(
}
fn migrate_worker_record(
identity_document: serde_json::Value,
mut identity_document: serde_json::Value,
source_schema_version: u32,
mapping: Option<&LegacyWorkerIdentityMapping>,
identity_path: &Path,
) -> Result<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 {
let identity: WorkerIdentityRecord =
serde_json::from_value(identity_document).map_err(|error| {
@@ -984,8 +1008,18 @@ fn migrate_worker_record(
})?;
identity.validate_for_schema(identity_path, PREVIOUS_SCHEMA_VERSION)?;
let execution_path = identity_path.with_file_name(WORKER_EXECUTION_FILE);
let execution: WorkerExecutionRecord =
let mut execution_document: serde_json::Value =
read_bounded_json(&execution_path, "read schema-v8 Worker execution record")?;
remove_legacy_job_input_counter(&mut execution_document);
migrate_legacy_request_evidence(&mut execution_document)
.map_err(|message| runtime_store_corrupt(&execution_path, message.into()))?;
let execution: WorkerExecutionRecord =
serde_json::from_value(execution_document).map_err(|error| {
runtime_store_corrupt(
&execution_path,
format!("decode migrated schema-v8 execution: {error}"),
)
})?;
execution.validate_for_schema(&identity, &execution_path, PREVIOUS_SCHEMA_VERSION)?;
return Ok(WorkerAggregateRecord::from_v8(identity, execution));
}
@@ -1016,6 +1050,114 @@ fn migrate_worker_record(
Ok(WorkerAggregateRecord::from_v8(identity, execution))
}
// Frozen schema-v9 conversion. Preserve the exact token and all Restore owners
// and receipts; generating a fresh token here would lose retry/admission authority.
fn migrate_v9_restore_guard(
document: &mut serde_json::Value,
path: &Path,
) -> Result<(), RuntimeError> {
let Some(guard) = document.get_mut("restore_guard") else {
return Ok(());
};
let guard = guard.as_object_mut().ok_or_else(|| {
runtime_store_corrupt(path, "schema-v9 restore_guard must be an object".into())
})?;
let token = guard.remove("generation").ok_or_else(|| {
runtime_store_corrupt(path, "schema-v9 restore_guard is missing generation".into())
})?;
if !token.as_str().is_some_and(|token| !token.is_empty())
|| guard.contains_key("observation_token")
{
return Err(runtime_store_corrupt(
path,
"schema-v9 Restore observation token is invalid or ambiguous".into(),
));
}
guard.insert("observation_token".into(), token);
Ok(())
}
// Legacy Runtime Job bindings never held input JSON, so the old caller-selected
// counter cannot become a content digest. Retain Job/attempt identity and leave
// content identity unknown until the host supplies its immutable intent.
fn remove_legacy_job_input_counter(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(object) => {
if let Some(job) = object
.get_mut("backend_job")
.and_then(serde_json::Value::as_object_mut)
{
job.remove("input_revision");
}
for value in object.values_mut() {
remove_legacy_job_input_counter(value);
}
}
serde_json::Value::Array(values) => {
for value in values {
remove_legacy_job_input_counter(value);
}
}
_ => {}
}
}
// Frozen schema 6..9 request migration. Preserve concrete source/config digests,
// contents, operation ownership and fingerprints. Unknown legacy SSH key identity
// stays empty and cannot pass the materializer's live-authority validation.
fn migrate_legacy_request_evidence(value: &mut serde_json::Value) -> Result<(), &'static str> {
match value {
serde_json::Value::Object(object) => {
if object.contains_key("credential_id") && object.contains_key("credential_revision") {
let counter = object.remove("credential_revision").expect("checked field");
if !counter.as_u64().is_some_and(|value| value > 0) {
return Err("invalid legacy SSH credential evidence");
}
object
.entry("public_key_fingerprint")
.or_insert_with(|| serde_json::json!(""));
}
if object.contains_key("host_trust_id") && object.contains_key("host_trust_revision") {
let counter = object.remove("host_trust_revision").expect("checked field");
if !counter.as_u64().is_some_and(|value| value > 0) {
return Err("invalid legacy SSH host trust evidence");
}
object
.entry("host_key_fingerprint")
.or_insert_with(|| serde_json::json!(""));
}
if let Some(settings) = object
.get_mut("memory_settings")
.and_then(serde_json::Value::as_object_mut)
&& let Some(counter) = settings.remove("settings_revision")
&& !counter.as_u64().is_some_and(|value| value > 0)
{
return Err("invalid legacy Memory settings evidence");
}
if object.contains_key("source_fingerprint") {
object.remove("source_revision");
}
if object.contains_key("config_projection_digest") {
object.remove("config_revision");
}
if object.contains_key("resource_id") && object.contains_key("nonce") {
object.remove("revision");
object.remove("generation");
}
for value in object.values_mut() {
migrate_legacy_request_evidence(value)?;
}
}
serde_json::Value::Array(values) => {
for value in values {
migrate_legacy_request_evidence(value)?;
}
}
_ => {}
}
Ok(())
}
fn runtime_worker_name(worker_id: WorkerId) -> String {
format!("worker-runtime-{worker_id}")
}
@@ -1216,6 +1358,9 @@ fn migrate_runtime_document(
serde_json::Value::from(SCHEMA_VERSION),
);
object.remove("workers");
// This legacy cache is not Runtime lifecycle or restore authority. Bundles
// are reacquired from Workspace content authority, never renumbered/rehash-pinned here.
object.remove("config_bundles");
object.remove("next_worker_sequence");
let snapshot: RuntimeSnapshot = serde_json::from_value(document.clone()).map_err(|error| {
runtime_store_corrupt(
@@ -1325,8 +1470,14 @@ fn remove_runtime_migration_directory(path: &Path) -> Result<(), RuntimeError> {
}
fn recover_runtime_store_migration(root: &Path) -> Result<(), RuntimeError> {
let staging = migration_sibling(root, "schema-v9-staging")?;
let backup = migration_sibling(root, "pre-schema-v9-backup")?;
for suffix in ["schema-v9-staging", "pre-schema-v9-backup"] {
let earlier = migration_sibling(root, suffix)?;
if earlier.exists() {
return Err(runtime_store_corrupt(root, "unfinished schema-v9 migration requires explicit recovery before schema-v10 startup".into()));
}
}
let staging = migration_sibling(root, "schema-v10-staging")?;
let backup = migration_sibling(root, "pre-schema-v10-backup")?;
let root_exists = migration_directory_exists(root)?;
let staging_exists = migration_directory_exists(&staging)?;
let backup_exists = migration_directory_exists(&backup)?;
@@ -1528,8 +1679,8 @@ fn migrate_runtime_store(
if !plan.migration_required {
return Ok(plan);
}
let staging = migration_sibling(root, "schema-v9-staging")?;
let backup = migration_sibling(root, "pre-schema-v9-backup")?;
let staging = migration_sibling(root, "schema-v10-staging")?;
let backup = migration_sibling(root, "pre-schema-v10-backup")?;
if staging.exists() || backup.exists() {
return Err(runtime_store_corrupt(
root,
@@ -2875,7 +3026,7 @@ mod tests {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime-store");
write_empty_schema_v8_store(&root);
let staging = migration_sibling(&root, "schema-v9-staging").unwrap();
let staging = migration_sibling(&root, "schema-v10-staging").unwrap();
fs::create_dir_all(&staging).unwrap();
fs::write(staging.join("partial"), b"partial").unwrap();
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
@@ -2890,7 +3041,7 @@ mod tests {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime-store");
write_empty_schema_v8_store(&root);
let backup = migration_sibling(&root, "pre-schema-v9-backup").unwrap();
let backup = migration_sibling(&root, "pre-schema-v10-backup").unwrap();
fs::rename(&root, &backup).unwrap();
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
@@ -2904,10 +3055,10 @@ mod tests {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime-store");
write_empty_schema_v8_store(&root);
let staging = migration_sibling(&root, "schema-v9-staging").unwrap();
let staging = migration_sibling(&root, "schema-v10-staging").unwrap();
copy_runtime_tree(&root, &root, &staging).unwrap();
migrate_runtime_store_in_place(&staging, "test-runtime").unwrap();
let backup = migration_sibling(&root, "pre-schema-v9-backup").unwrap();
let backup = migration_sibling(&root, "pre-schema-v10-backup").unwrap();
fs::rename(&root, &backup).unwrap();
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
@@ -2922,10 +3073,10 @@ mod tests {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime-store");
write_empty_schema_v8_store(&root);
let staging = migration_sibling(&root, "schema-v9-staging").unwrap();
let staging = migration_sibling(&root, "schema-v10-staging").unwrap();
fs::create_dir_all(&staging).unwrap();
fs::write(staging.join(RUNTIME_FILE), b"{\"schema_version\":8}").unwrap();
let backup = migration_sibling(&root, "pre-schema-v9-backup").unwrap();
let backup = migration_sibling(&root, "pre-schema-v10-backup").unwrap();
fs::rename(&root, &backup).unwrap();
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
@@ -2942,7 +3093,7 @@ mod tests {
let root = temp.path().join("runtime-store");
write_empty_schema_v8_store(&root);
migrate_runtime_store_in_place(&root, "test-runtime").unwrap();
let backup = migration_sibling(&root, "pre-schema-v9-backup").unwrap();
let backup = migration_sibling(&root, "pre-schema-v10-backup").unwrap();
write_empty_schema_v8_store(&backup);
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
@@ -2950,6 +3101,120 @@ mod tests {
assert!(!backup.exists());
}
#[test]
fn earlier_migration_backup_cannot_be_mistaken_for_a_fresh_runtime() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime-store");
let backup = migration_sibling(&root, "pre-schema-v9-backup").unwrap();
write_empty_schema_v8_store(&backup);
let error = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap_err();
assert!(error.to_string().contains("explicit recovery"));
assert!(!root.exists());
assert!(backup.join(RUNTIME_FILE).exists());
}
#[test]
fn legacy_request_migration_preserves_content_and_never_invents_key_identity() {
let mut document = serde_json::json!({"request": {
"memory_settings": {"workspace_id":"workspace-a", "language":"English", "settings_revision":2},
"repository": {"source_revision":1, "source_fingerprint":"source-content"},
"materialization": {"operation_id":"op-a", "config_revision":2, "config_projection_digest":"projection-content", "ssh": {
"host_trust_id":"host-a", "host_trust_revision":3,
"credential_candidates":[{"credential_id":"credential-a", "credential_revision":4}]
}}
}});
migrate_legacy_request_evidence(&mut document).unwrap();
let request = &document["request"];
assert_eq!(request["memory_settings"]["language"], "English");
assert!(
request["memory_settings"]
.get("settings_revision")
.is_none()
);
assert_eq!(
request["repository"]["source_fingerprint"],
"source-content"
);
assert_eq!(
request["materialization"]["config_projection_digest"],
"projection-content"
);
assert_eq!(
request["materialization"]["ssh"]["host_key_fingerprint"],
""
);
assert_eq!(
request["materialization"]["ssh"]["credential_candidates"][0]["public_key_fingerprint"],
""
);
}
#[test]
fn schema_v9_restore_token_migrates_durably_without_rotating_admission_identity() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime-store");
write_empty_schema_v8_store(&root);
let runtime_path = root.join(RUNTIME_FILE);
let mut runtime: serde_json::Value = read_json(&runtime_path, "read test Runtime").unwrap();
runtime["schema_version"] = serde_json::json!(AGGREGATE_SCHEMA_VERSION);
fs::write(&runtime_path, serde_json::to_vec(&runtime).unwrap()).unwrap();
let worker_id = WorkerId::now_v7();
let path = root
.join(WORKERS_DIR)
.join(worker_id.to_string())
.join(WORKER_FILE);
fs::create_dir_all(path.parent().unwrap()).unwrap();
let aggregate = migrate_worker_record(
schema_v7_worker_document(worker_id),
LEGACY_SCHEMA_VERSION,
None,
&path,
)
.unwrap();
let mut legacy = serde_json::to_value(&aggregate).unwrap();
let token = legacy["restore_guard"]["observation_token"].take();
legacy["restore_guard"]
.as_object_mut()
.unwrap()
.remove("observation_token");
legacy["restore_guard"]["generation"] = token.clone();
legacy["schema_version"] = serde_json::json!(AGGREGATE_SCHEMA_VERSION);
fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
store.store.load_runtime_state().unwrap();
drop(store);
let migrated: serde_json::Value = read_json(&path, "read migrated Worker").unwrap();
assert_eq!(migrated["schema_version"], SCHEMA_VERSION);
assert_eq!(migrated["restore_guard"]["observation_token"], token);
assert!(migrated["restore_guard"].get("generation").is_none());
let store = FsRuntimeStore::open_or_create(root, "test-runtime").unwrap();
store.store.load_runtime_state().unwrap();
assert_eq!(
read_json::<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]
fn bounded_worker_record_read_rejects_oversize_before_parse() {
let root = tempfile::tempdir().unwrap();
@@ -3047,7 +3312,7 @@ mod tests {
));
assert!(!worker_dir.join(WORKER_EXECUTION_FILE).exists());
let aggregate: WorkerAggregateRecord =
read_bounded_json(&identity_path, "read schema-v9 aggregate").unwrap();
read_bounded_json(&identity_path, "read schema-v10 aggregate").unwrap();
assert_eq!(aggregate.schema_version, SCHEMA_VERSION);
}
+194 -52
View File
@@ -413,7 +413,8 @@ pub struct RuntimeHttpWorkspacePromptProjectionRequest {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeHttpWorkspacePromptProjectionResponse {
pub workspace_id: String,
pub config_revision: u64,
pub source_digest: String,
pub projection_digest: String,
}
/// Config bundle availability response used by sync/check endpoints.
@@ -576,10 +577,10 @@ async fn post_workspace_verification_challenge(
})?;
if challenge.workspace_id != verified.workspace_id
|| challenge.runtime_id != verified.runtime_id
|| challenge.binding_revision != verified.binding_revision
|| challenge.binding_id != verified.binding_id
|| challenge.workspace_key_id != verified.issuer_key_id
|| challenge.workspace_identity_revision != verified.issuer_identity_revision
|| challenge.workspace_trust_generation != verified.trust_generation
|| challenge.workspace_public_key_fingerprint != verified.issuer_public_key_fingerprint
|| challenge.workspace_trust_id != verified.trust_id
|| challenge.runtime_id != auth.signer.runtime_id()
|| challenge.runtime_public_key_fingerprint != auth.signer.public_key_fingerprint()
{
@@ -599,6 +600,13 @@ async fn post_workspace_verification_challenge(
error.to_string(),
)
})?;
auth.verifications.begin(challenge).map_err(|error| {
RuntimeHttpRestError::new(
StatusCode::SERVICE_UNAVAILABLE,
"workspace_runtime_verification_unavailable",
error.to_string(),
)
})?;
Ok(Json(response))
}
@@ -616,17 +624,17 @@ async fn post_workspace_verification_acknowledgement(
})?;
if acknowledgement.workspace_id != verified.workspace_id
|| acknowledgement.runtime_id != verified.runtime_id
|| acknowledgement.binding_revision != verified.binding_revision
|| acknowledgement.binding_id != verified.binding_id
|| acknowledgement.workspace_key_id != verified.issuer_key_id
|| acknowledgement.workspace_identity_revision != verified.issuer_identity_revision
|| acknowledgement.workspace_trust_generation != verified.trust_generation
|| acknowledgement.workspace_public_key_fingerprint
!= verified.issuer_public_key_fingerprint
|| acknowledgement.workspace_trust_id != verified.trust_id
|| acknowledgement.runtime_id != auth.signer.runtime_id()
|| acknowledgement.runtime_public_key_fingerprint != auth.signer.public_key_fingerprint()
|| acknowledgement.expires_at <= unix_now_i64()
|| acknowledgement.binding_revision == 0
|| acknowledgement.runtime_identity_revision == 0
|| acknowledgement.workspace_identity_revision == 0
|| acknowledgement.workspace_trust_generation == 0
|| acknowledgement.binding_id.is_empty()
|| acknowledgement.workspace_public_key_fingerprint.is_empty()
|| acknowledgement.workspace_trust_id.is_empty()
|| acknowledgement.workspace_nonce.is_empty()
|| acknowledgement.runtime_nonce.is_empty()
|| acknowledgement.response_digest.len() != workspace_request_body_digest(&[]).len()
@@ -641,12 +649,11 @@ async fn post_workspace_verification_acknowledgement(
challenge_id: acknowledgement.challenge_id.clone(),
workspace_id: acknowledgement.workspace_id.clone(),
runtime_id: acknowledgement.runtime_id.clone(),
binding_revision: acknowledgement.binding_revision,
binding_id: acknowledgement.binding_id.clone(),
workspace_key_id: acknowledgement.workspace_key_id.clone(),
workspace_identity_revision: acknowledgement.workspace_identity_revision,
workspace_trust_generation: acknowledgement.workspace_trust_generation,
workspace_public_key_fingerprint: acknowledgement.workspace_public_key_fingerprint.clone(),
workspace_trust_id: acknowledgement.workspace_trust_id.clone(),
runtime_public_key_fingerprint: acknowledgement.runtime_public_key_fingerprint.clone(),
runtime_identity_revision: acknowledgement.runtime_identity_revision,
workspace_nonce: acknowledgement.workspace_nonce.clone(),
expires_at: acknowledgement.expires_at,
};
@@ -689,17 +696,23 @@ async fn post_workspace_verification_acknowledgement(
)
})?;
auth.verifications
.record(WorkspaceRuntimeVerificationRecord {
.acknowledge(
&acknowledgement.challenge_id,
WorkspaceRuntimeVerificationRecord {
workspace_id: acknowledgement.workspace_id.clone(),
runtime_id: acknowledgement.runtime_id.clone(),
binding_revision: acknowledgement.binding_revision,
binding_id: acknowledgement.binding_id.clone(),
workspace_key_id: acknowledgement.workspace_key_id.clone(),
workspace_identity_revision: acknowledgement.workspace_identity_revision,
workspace_trust_generation: acknowledgement.workspace_trust_generation,
runtime_public_key_fingerprint: acknowledgement.runtime_public_key_fingerprint.clone(),
runtime_identity_revision: acknowledgement.runtime_identity_revision,
workspace_public_key_fingerprint: acknowledgement
.workspace_public_key_fingerprint
.clone(),
workspace_trust_id: acknowledgement.workspace_trust_id.clone(),
runtime_public_key_fingerprint: acknowledgement
.runtime_public_key_fingerprint
.clone(),
verified_at: unix_now_i64(),
})
},
)
.map_err(|error| {
RuntimeHttpRestError::new(
StatusCode::SERVICE_UNAVAILABLE,
@@ -711,7 +724,7 @@ async fn post_workspace_verification_acknowledgement(
challenge_id: acknowledgement.challenge_id,
workspace_id: acknowledgement.workspace_id,
runtime_id: acknowledgement.runtime_id,
binding_revision: acknowledgement.binding_revision,
binding_id: acknowledgement.binding_id.clone(),
accepted_at: unix_now_i64(),
}))
}
@@ -810,14 +823,16 @@ async fn observe_workspace_prompt_projection(
));
}
let workspace_id = request.projection.workspace_id.clone();
let config_revision = request.projection.config_revision;
let source_digest = request.projection.source_digest.clone();
let projection_digest = request.projection.projection_digest.clone();
state
.runtime
.observe_workspace_prompt_projection(request.projection)
.map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkspacePromptProjectionResponse {
workspace_id,
config_revision,
source_digest,
projection_digest,
}))
}
@@ -2108,7 +2123,7 @@ async fn require_runtime_auth(
let body_digest = workspace_request_body_digest(&body);
let expected = WorkspaceCapabilityExpectation {
workspace_id: &claims.issuer_workspace_id,
binding_revision: claims.binding_revision,
binding_id: &claims.binding_id,
runtime_id: workspace_auth.signer.runtime_id(),
worker_id: expected_worker_id.as_deref(),
operation: required_permission,
@@ -2145,13 +2160,13 @@ async fn require_runtime_auth(
.into_response();
}
};
if record.binding_revision != verified.binding_revision
if record.binding_id != verified.binding_id
|| record.workspace_key_id != verified.issuer_key_id
|| record.workspace_identity_revision != verified.issuer_identity_revision
|| record.workspace_trust_generation != verified.trust_generation
|| record.workspace_public_key_fingerprint
!= verified.issuer_public_key_fingerprint
|| record.workspace_trust_id != verified.trust_id
|| record.runtime_public_key_fingerprint
!= workspace_auth.signer.public_key_fingerprint()
|| record.runtime_identity_revision == 0
{
return RuntimeHttpRestError::new(
StatusCode::FORBIDDEN,
@@ -2782,9 +2797,8 @@ mod tests {
key_id: "workspace-key".to_string(),
algorithm: "ed25519".to_string(),
public_key: workspace_identity.public_key.clone(),
public_key_fingerprint: workspace_fingerprint,
identity_revision: 1,
trust_generation: 1,
public_key_fingerprint: workspace_fingerprint.clone(),
trust_id: "trust-test".into(),
state: WorkspaceIssuerTrustState::Active,
registered_at_unix: 1,
updated_at_unix: 1,
@@ -2805,12 +2819,11 @@ mod tests {
challenge_id: "challenge-1".to_string(),
workspace_id: "workspace-a".to_string(),
runtime_id: "runtime-test".to_string(),
binding_revision: 4,
binding_id: "binding-test".into(),
workspace_key_id: "workspace-key".to_string(),
workspace_identity_revision: 1,
workspace_trust_generation: 1,
workspace_public_key_fingerprint: workspace_fingerprint.clone(),
workspace_trust_id: "trust-test".into(),
runtime_public_key_fingerprint: runtime_fingerprint,
runtime_identity_revision: 1,
workspace_nonce: "workspace-nonce".to_string(),
expires_at: unix_now_i64() + 60,
};
@@ -2819,9 +2832,9 @@ mod tests {
issuer: "https://backend.test".to_string(),
issuer_workspace_id: "workspace-a".to_string(),
issuer_key_id: "workspace-key".to_string(),
issuer_identity_revision: 1,
trust_generation: 1,
binding_revision: 4,
issuer_public_key_fingerprint: workspace_fingerprint.clone(),
trust_id: "trust-test".into(),
binding_id: "binding-test".into(),
runtime_id: "runtime-test".to_string(),
worker_id: None,
operation: WORKSPACE_VERIFICATION_OPERATION.to_string(),
@@ -2870,14 +2883,15 @@ mod tests {
challenge_id: verification_response.challenge_id.clone(),
workspace_id: verification_response.workspace_id.clone(),
runtime_id: verification_response.runtime_id.clone(),
binding_revision: verification_response.binding_revision,
binding_id: verification_response.binding_id.clone(),
workspace_key_id: verification_response.workspace_key_id.clone(),
workspace_identity_revision: verification_response.workspace_identity_revision,
workspace_trust_generation: verification_response.workspace_trust_generation,
workspace_public_key_fingerprint: verification_response
.workspace_public_key_fingerprint
.clone(),
workspace_trust_id: verification_response.workspace_trust_id.clone(),
runtime_public_key_fingerprint: verification_response
.runtime_public_key_fingerprint
.clone(),
runtime_identity_revision: verification_response.runtime_identity_revision,
workspace_nonce: verification_response.workspace_nonce.clone(),
runtime_nonce: verification_response.runtime_nonce.clone(),
response_digest: workspace_request_body_digest(&response_body),
@@ -2974,13 +2988,11 @@ mod tests {
workspace_id: "workspace-b".to_string(),
source_runtime_id: "runtime-a".to_string(),
worker_id: retention_worker_id,
expected_worker_revision: "revision".to_string(),
source_created_at: "2025-01-01T00:00:00Z".to_string(),
removed_at: "2025-01-02T00:00:00Z".to_string(),
effective_profile: None,
retention_class: None,
policy_id: "policy".to_string(),
policy_revision: 1,
session_disposition: SessionDisposition::Purge,
diagnostics_disposition: DiagnosticsDisposition::Retain,
};
@@ -3018,6 +3030,141 @@ mod tests {
assert_eq!(error.error.code, "worker_not_found");
}
#[tokio::test]
async fn reenrolled_binding_trust_or_keys_require_fresh_signed_verification() {
let workspace_identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
let runtime_identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
let fingerprint = |key: &str| {
format!(
"sha256:{}",
crate::workspace_issuer::hex_lower(&sha2::Sha256::digest(
crate::auth::decode_public_key(key).unwrap()
))
)
};
let old_trust = WorkspaceIssuerTrustRecord::from_bundle(
server_api::WorkspacePublicIdentityBundle {
workspace_id: "workspace-a".into(),
backend_url: "https://backend.test".into(),
key_id: "workspace-key".into(),
algorithm: "ed25519".into(),
public_key: workspace_identity.public_key.clone(),
public_key_fingerprint: fingerprint(&workspace_identity.public_key),
},
"old-trust".into(),
1,
)
.unwrap();
let old_verification = WorkspaceRuntimeVerificationRecord {
workspace_id: old_trust.workspace_id.clone(),
runtime_id: "runtime-test".into(),
binding_id: "old-binding".into(),
workspace_key_id: old_trust.key_id.clone(),
workspace_public_key_fingerprint: old_trust.public_key_fingerprint.clone(),
workspace_trust_id: old_trust.trust_id.clone(),
runtime_public_key_fingerprint: fingerprint(&runtime_identity.public_key),
verified_at: 1,
};
for changed in ["binding", "trust", "workspace_key", "runtime_key"] {
let mut current = old_verification.clone();
let mut trust = old_trust.clone();
let mut workspace_key = workspace_identity.clone();
let mut runtime_key = runtime_identity.clone();
match changed {
"binding" => current.binding_id = "reenrolled-binding".into(),
"trust" => {
trust.trust_id = "reaccepted-trust".into();
current.workspace_trust_id = trust.trust_id.clone();
}
"workspace_key" => {
workspace_key = RuntimeIdentityMaterial::generate("new-workspace-key").unwrap();
trust.key_id = workspace_key.identity_id.clone();
trust.public_key = workspace_key.public_key.clone();
trust.public_key_fingerprint = fingerprint(&workspace_key.public_key);
current.workspace_key_id = trust.key_id.clone();
current.workspace_public_key_fingerprint = trust.public_key_fingerprint.clone();
}
"runtime_key" => {
runtime_key = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
current.runtime_public_key_fingerprint = fingerprint(&runtime_key.public_key);
}
_ => unreachable!(),
}
let authority = Arc::new(InMemoryWorkspaceRuntimeVerificationAuthority::default());
authority.record(old_verification.clone()).unwrap();
let app = runtime_http_router_with_workspace_auth(
Runtime::new_memory(),
None,
WorkspaceRuntimeHttpAuth {
verifier: WorkspaceCapabilityVerifier::new(
vec![trust.clone()],
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
)
.unwrap(),
signer: RuntimeVerificationSigner::from_identity(&runtime_key).unwrap(),
verifications: authority.clone(),
},
);
let claims = WorkspaceCapabilityClaims {
issuer: trust.backend_url.clone(),
issuer_workspace_id: trust.workspace_id.clone(),
issuer_key_id: trust.key_id.clone(),
issuer_public_key_fingerprint: trust.public_key_fingerprint.clone(),
trust_id: trust.trust_id.clone(),
binding_id: current.binding_id.clone(),
runtime_id: current.runtime_id.clone(),
worker_id: None,
operation: RUNTIME_PING_PERMISSION.into(),
method: "GET".into(),
path_and_query: "/v1/ping".into(),
body_digest: workspace_request_body_digest(&[]),
iat: unix_now_i64(),
exp: unix_now_i64() + 60,
jti: "stale-verification".into(),
};
for (expected, token_id) in [
(StatusCode::FORBIDDEN, "stale-verification"),
(StatusCode::OK, "fresh-verification"),
] {
if expected == StatusCode::OK {
authority.record(current.clone()).unwrap();
}
let token = issue_workspace_capability_token(
&workspace_key.signing_key().unwrap(),
&WorkspaceCapabilityClaims {
jti: token_id.into(),
..claims.clone()
},
)
.unwrap();
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/v1/ping")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-a")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(
status,
expected,
"{changed}: {}",
String::from_utf8_lossy(&body)
);
if expected == StatusCode::FORBIDDEN {
let error: RuntimeHttpErrorResponse = serde_json::from_slice(&body).unwrap();
assert_eq!(error.error.code, "workspace_runtime_verification_stale");
}
}
}
}
#[cfg(feature = "ws-server")]
#[test]
fn runtime_protocol_replaces_serialized_tracked_source() {
@@ -3107,7 +3254,6 @@ mod tests {
metadata: ConfigBundleMetadata {
id: "http-test-bundle".to_string(),
digest: String::new(),
revision: "test".to_string(),
workspace_id: "test-workspace".to_string(),
created_at: "test".to_string(),
provenance: ConfigBundleProvenance {
@@ -3164,7 +3310,6 @@ mod tests {
workspace_api: None,
memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "local".to_string(),
settings_revision: 1,
language: "English".to_string(),
}),
subjektiv_attached: false,
@@ -4477,8 +4622,7 @@ mod tests {
algorithm: "ed25519".to_string(),
public_key: workspace_identity.public_key,
public_key_fingerprint: workspace_fingerprint,
identity_revision: 1,
trust_generation: 1,
trust_id: "trust-cleanup-test".to_string(),
state: WorkspaceIssuerTrustState::Active,
registered_at_unix: 1,
updated_at_unix: 1,
@@ -5041,7 +5185,6 @@ mod ws_tests {
metadata: ConfigBundleMetadata {
id: "ws-test-bundle".to_string(),
digest: String::new(),
revision: "test".to_string(),
workspace_id: "test".to_string(),
created_at: "test".to_string(),
provenance: ConfigBundleProvenance {
@@ -5097,7 +5240,6 @@ mod ws_tests {
workspace_api: None,
memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "local".to_string(),
settings_revision: 1,
language: "English".to_string(),
}),
subjektiv_attached: false,
+71 -9
View File
@@ -905,12 +905,11 @@ fn print_workspace_trust_mutation(
WorkspaceIssuerTrustMutation::Unchanged => "unchanged",
};
println!(
"Workspace issuer trust {action}: workspace={} key={} fingerprint={} identity_revision={} trust_generation={} state={:?}",
"Workspace issuer trust {action}: workspace={} key={} fingerprint={} trust_id={} state={:?}",
record.workspace_id,
record.key_id,
record.public_key_fingerprint,
record.identity_revision,
record.trust_generation,
record.trust_id,
record.state,
);
}
@@ -936,10 +935,38 @@ fn read_runtime_auth_file(path: &Path) -> Result<RuntimeAuthFile, ProcessError>
}
let contents = String::from_utf8(contents)
.map_err(|_| ProcessError::auth("runtime auth store is corrupt"))?;
let auth: RuntimeAuthFile = toml::from_str(&contents)
let mut document: toml::Value = toml::from_str(&contents)
.map_err(|_| ProcessError::auth("runtime auth store is corrupt"))?;
let mut migrated = false;
if let Some(records) = document
.get_mut("workspace_issuers")
.and_then(toml::Value::as_array_mut)
{
for value in records {
if value.get("identity_revision").is_some() || value.get("trust_generation").is_some() {
let legacy = serde_json::to_value(&*value)
.map_err(|_| ProcessError::auth("runtime auth store is corrupt"))?;
let record =
worker_runtime::workspace_issuer::migrate_legacy_workspace_issuer_trust_record(
legacy,
)
.map_err(|_| ProcessError::auth("runtime legacy trust store is corrupt"))?;
*value = toml::Value::try_from(record).map_err(|_| {
ProcessError::auth("runtime auth migration serialization failed")
})?;
migrated = true;
}
}
}
let auth: RuntimeAuthFile = document
.try_into()
.map_err(|_| ProcessError::auth("runtime auth store is corrupt"))?;
validate_workspace_issuer_trust_records(&auth.workspace_issuers)
.map_err(|_| ProcessError::auth("runtime Workspace issuer trust store is corrupt"))?;
if migrated {
// Commit the revoked migration before exposing any issuer authority.
write_runtime_auth_file(path, &auth)?;
}
Ok(auth)
}
@@ -1398,7 +1425,6 @@ mod tests {
algorithm: "ed25519".to_string(),
public_key: identity.public_key,
public_key_fingerprint: fingerprint,
revision: 1,
})
.unwrap(),
)
@@ -1419,7 +1445,38 @@ mod tests {
let path = runtime_auth_path(&config);
let first = read_runtime_auth_file(&path).unwrap();
assert_eq!(first.workspace_issuers.len(), 1);
assert_eq!(first.workspace_issuers[0].trust_generation, 1);
assert!(!first.workspace_issuers[0].trust_id.is_empty());
// `trust-workspace show` serializes this persisted record. Owner
// registration must forward that exact enrollment ID, not derive one
// from the key fingerprint or an initial counter.
let shown = serde_json::to_value(&first.workspace_issuers[0]).unwrap();
let runtime_identity = RuntimeIdentityMaterial::generate("remote-runtime").unwrap();
let mut registration_wire = serde_json::json!({
"public_bundle": {
"identity_id": runtime_identity.identity_id,
"public_key": runtime_identity.public_key,
},
"endpoint": "https://runtime.example.test",
"workspace_trust_id": shown["trust_id"],
});
let registration: server_api::CreateRemoteRuntimeRequest =
serde_json::from_value(registration_wire.clone()).unwrap();
assert_eq!(
registration.workspace_trust_id,
first.workspace_issuers[0].trust_id
);
assert_ne!(
registration.workspace_trust_id,
first.workspace_issuers[0].public_key_fingerprint
);
registration_wire
.as_object_mut()
.unwrap()
.remove("workspace_trust_id");
assert!(
serde_json::from_value::<server_api::CreateRemoteRuntimeRequest>(registration_wire)
.is_err()
);
run_trust_workspace_command(VecDeque::from([
"add".to_string(),
@@ -1430,7 +1487,10 @@ mod tests {
]))
.unwrap();
let replay = read_runtime_auth_file(&path).unwrap();
assert_eq!(replay.workspace_issuers[0].trust_generation, 1);
assert_eq!(
replay.workspace_issuers[0].trust_id,
first.workspace_issuers[0].trust_id
);
let replacement = RuntimeIdentityMaterial::generate("WK-2").unwrap();
let public_key = decode_public_key(&replacement.public_key).unwrap();
@@ -1451,7 +1511,6 @@ mod tests {
algorithm: "ed25519".to_string(),
public_key: replacement.public_key,
public_key_fingerprint: fingerprint,
revision: 2,
})
.unwrap(),
)
@@ -1483,7 +1542,10 @@ mod tests {
]))
.unwrap();
let revoked = read_runtime_auth_file(&path).unwrap();
assert_eq!(revoked.workspace_issuers[0].trust_generation, 3);
assert_ne!(
revoked.workspace_issuers[0].trust_id,
first.workspace_issuers[0].trust_id
);
assert_eq!(
revoked.workspace_issuers[0].state,
worker_runtime::workspace_issuer::WorkspaceIssuerTrustState::Revoked
+3 -8
View File
@@ -17,7 +17,7 @@ pub const DEFAULT_BACKEND_RESOURCE_FETCH_TIMEOUT: std::time::Duration =
#[derive(Clone, Serialize, Deserialize)]
pub struct RepositorySshAccessSecretCandidate {
pub credential_id: String,
pub credential_revision: u64,
pub public_key_fingerprint: String,
pub private_key: String,
}
@@ -32,7 +32,7 @@ impl std::fmt::Debug for RepositorySshAccessSecretCandidate {
formatter
.debug_struct("RepositorySshAccessSecretCandidate")
.field("credential_id", &self.credential_id)
.field("credential_revision", &self.credential_revision)
.field("public_key_fingerprint", &self.public_key_fingerprint)
.field("private_key", &"[REDACTED]")
.finish()
}
@@ -89,9 +89,6 @@ pub struct BackendResourceHandle {
pub operation: BackendResourceOperation,
pub expires_at_unix_seconds: i64,
pub nonce: String,
pub revision: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generation: Option<u64>,
pub max_bytes: u64,
pub content_type: String,
pub redaction: ResourceRedactionPolicy,
@@ -425,8 +422,6 @@ mod tests {
operation: BackendResourceOperation::FetchArchive,
expires_at_unix_seconds: 4_102_444_800,
nonce: "nonce-test".to_string(),
revision: sha256_hex(bytes),
generation: Some(1),
max_bytes: 1024,
content_type: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(),
redaction: ResourceRedactionPolicy::RuntimeInternalOnly,
@@ -477,7 +472,7 @@ mod tests {
let secret = RepositorySshAccessSecret {
credential_candidates: vec![RepositorySshAccessSecretCandidate {
credential_id: "credential-1".to_string(),
credential_revision: 2,
public_key_fingerprint: "key-2".into(),
private_key: "PRIVATE KEY secret bytes".to_string(),
}],
known_hosts_entry: "host key secret bytes".to_string(),
-8
View File
@@ -116,13 +116,11 @@ pub struct WorkerRetentionExecutionRequest {
pub workspace_id: String,
pub source_runtime_id: String,
pub worker_id: WorkerId,
pub expected_worker_revision: String,
pub source_created_at: String,
pub removed_at: String,
pub effective_profile: Option<String>,
pub retention_class: Option<String>,
pub policy_id: String,
pub policy_revision: u64,
pub session_disposition: SessionDisposition,
pub diagnostics_disposition: DiagnosticsDisposition,
}
@@ -145,7 +143,6 @@ pub struct WorkerSessionArchiveManifest {
pub content_bytes: u64,
pub content_file_count: u64,
pub policy_id: String,
pub policy_revision: u64,
pub operation_id: String,
pub input_fingerprint: String,
}
@@ -154,7 +151,6 @@ pub struct WorkerSessionArchiveManifest {
pub struct WorkerRetentionExecutionResult {
pub operation_id: String,
pub input_fingerprint: String,
pub expected_worker_revision: String,
pub worker_id: WorkerId,
pub session_disposition: SessionDisposition,
pub diagnostics_disposition: DiagnosticsDisposition,
@@ -524,7 +520,6 @@ impl WorkerRetentionProvider for FsWorkerRetentionProvider {
let mut result = WorkerRetentionExecutionResult {
operation_id: request.operation_id.clone(),
input_fingerprint: request.input_fingerprint.clone(),
expected_worker_revision: request.expected_worker_revision.clone(),
worker_id: request.worker_id,
session_disposition: request.session_disposition,
diagnostics_disposition: request.diagnostics_disposition,
@@ -749,7 +744,6 @@ fn commit_session_archive(
content_bytes: bytes,
content_file_count: count,
policy_id: request.policy_id.clone(),
policy_revision: request.policy_revision,
operation_id: request.operation_id.clone(),
input_fingerprint: request.input_fingerprint.clone(),
};
@@ -1290,7 +1284,6 @@ mod tests {
WorkerRetentionExecutionRequest {
operation_id: "operation-a".to_string(),
input_fingerprint: "fingerprint-a".to_string(),
expected_worker_revision: "revision-a".to_string(),
archive_id: (disposition == SessionDisposition::Archive)
.then(|| "archive-a".to_string()),
workspace_id: "workspace-a".to_string(),
@@ -1301,7 +1294,6 @@ mod tests {
effective_profile: Some("builtin:coder".to_string()),
retention_class: None,
policy_id: "policy-a".to_string(),
policy_revision: 3,
session_disposition: disposition,
diagnostics_disposition: DiagnosticsDisposition::Purge,
}
+57 -173
View File
@@ -457,7 +457,7 @@ impl Runtime {
state
};
state.execution_backend = execution_backend;
// Initialize legacy generations durably before exposing an observation.
// Initialize legacy observation tokens durably before exposing an observation.
state.persist_workers()?;
let runtime = Self {
inner: Arc::new(Mutex::new(state)),
@@ -761,7 +761,7 @@ impl Runtime {
.zip(&ssh.credential_candidates)
.any(|(secret, metadata)| {
secret.credential_id != metadata.credential_id
|| secret.credential_revision != metadata.credential_revision
|| secret.public_key_fingerprint != metadata.public_key_fingerprint
})
{
return Err(RuntimeError::InvalidRequest(
@@ -2157,7 +2157,8 @@ impl Runtime {
&& candidate.pending_stop.is_none()
&& candidate.restore_guard.active_request_id.is_none();
if coordinate && request.preparation.is_none() && !recovering_pending {
if candidate.restore_guard.generation != request.expected_observation_token
if candidate.restore_guard.observation_token
!= request.expected_observation_token
|| candidate.restore_guard.active_request_id.is_some()
|| candidate.has_pending_lifecycle_operation()
{
@@ -2176,7 +2177,7 @@ impl Runtime {
));
}
state.ensure_running()?;
if candidate.restore_guard.generation != request.expected_observation_token
if candidate.restore_guard.observation_token != request.expected_observation_token
|| candidate.restore_guard.active_request_id.is_some()
|| (candidate.has_pending_lifecycle_operation() && !recovering_pending)
{
@@ -2283,7 +2284,7 @@ impl Runtime {
return worker.restore_guard.owners[id].request.clone();
}
runtime_api::WorkerRestoreRequest {
expected_observation_token: worker.restore_guard.generation.clone(),
expected_observation_token: worker.restore_guard.observation_token.clone(),
request_id: uuid::Uuid::now_v7().to_string(),
preparation: worker
.workspace_id
@@ -5531,7 +5532,7 @@ impl RuntimeState {
ProfileSelector::Builtin(name) | ProfileSelector::Named(name) => Some(name.clone()),
};
Ok(SubscriptionWorker {
restore_observation_token: Some(worker.restore_guard.generation.clone()),
restore_observation_token: Some(worker.restore_guard.observation_token.clone()),
worker_id,
runtime_id: None,
resource_key: None,
@@ -5806,7 +5807,6 @@ impl RuntimeState {
InternalWorkerActivity {
status: Some(snapshot.status),
parent_session_id: snapshot.worker.parent_session_id.clone(),
revision: snapshot.revision,
},
);
for child in &snapshot.internal_workers {
@@ -5854,42 +5854,22 @@ impl RuntimeState {
fn remove_internal_worker_subtree(
activity: &mut InternalWorkerActivityProjection,
worker: &protocol::InternalWorkerRef,
revision: u64,
) {
let known_revision = activity
.workers
.get(&worker.session_id)
.map(|worker| worker.revision)
.into_iter()
.chain(
activity
.removed_workers
.get(&worker.session_id)
.map(|worker| worker.revision),
)
.max();
if known_revision.is_some_and(|known_revision| revision <= known_revision) {
return;
}
for session_id in Self::internal_worker_subtree_ids(activity, &worker.session_id) {
let active = activity.workers.remove(&session_id);
let removed = activity.removed_workers.remove(&session_id);
let (worker_revision, parent_session_id) = if session_id == worker.session_id {
(revision, worker.parent_session_id.clone())
let parent_session_id = if session_id == worker.session_id {
worker.parent_session_id.clone()
} else if let Some(active) = active {
(active.revision, active.parent_session_id)
active.parent_session_id
} else if let Some(removed) = removed {
(removed.revision, removed.parent_session_id)
removed.parent_session_id
} else {
continue;
};
activity.removed_workers.insert(
session_id,
RemovedInternalWorkerActivity {
revision: worker_revision,
parent_session_id,
},
RemovedInternalWorkerActivity { parent_session_id },
);
}
}
@@ -5897,37 +5877,25 @@ impl RuntimeState {
fn touch_internal_worker_activity(
activity: &mut InternalWorkerActivityProjection,
worker: &protocol::InternalWorkerRef,
revision: u64,
) {
activity
.workers
.entry(worker.session_id.clone())
.and_modify(|activity| {
activity.parent_session_id = worker.parent_session_id.clone();
activity.revision = revision;
})
.and_modify(|activity| activity.parent_session_id = worker.parent_session_id.clone())
.or_insert_with(|| InternalWorkerActivity {
status: None,
parent_session_id: worker.parent_session_id.clone(),
revision,
});
}
fn project_internal_worker_event(
activity: &mut InternalWorkerActivityProjection,
worker: &protocol::InternalWorkerRef,
revision: u64,
event: &protocol::Event,
) {
if activity.removed_workers.contains_key(&worker.session_id)
|| activity
.workers
.get(&worker.session_id)
.is_some_and(|current| revision <= current.revision)
{
if activity.removed_workers.contains_key(&worker.session_id) {
return;
}
match event {
protocol::Event::Snapshot {
state,
@@ -5940,7 +5908,6 @@ impl RuntimeState {
InternalWorkerActivity {
status: Some(state.catalog_status()),
parent_session_id: worker.parent_session_id.clone(),
revision,
},
);
for child in internal_workers {
@@ -5949,23 +5916,16 @@ impl RuntimeState {
}
protocol::Event::InternalWorker {
worker: nested_worker,
revision: nested_revision,
event,
} => {
Self::touch_internal_worker_activity(activity, worker, revision);
Self::project_internal_worker_event(
activity,
nested_worker,
*nested_revision,
event,
);
Self::touch_internal_worker_activity(activity, worker);
Self::project_internal_worker_event(activity, nested_worker, event);
}
protocol::Event::InternalWorkerRemoved {
worker: removed_worker,
revision: removed_revision,
} => {
Self::touch_internal_worker_activity(activity, worker, revision);
Self::remove_internal_worker_subtree(activity, removed_worker, *removed_revision);
Self::touch_internal_worker_activity(activity, worker);
Self::remove_internal_worker_subtree(activity, removed_worker);
}
protocol::Event::WorkerState { snapshot }
| protocol::Event::CommandAcknowledged {
@@ -5979,11 +5939,10 @@ impl RuntimeState {
InternalWorkerActivity {
status: Some(snapshot.catalog_status()),
parent_session_id: worker.parent_session_id.clone(),
revision,
},
);
}
_ => Self::touch_internal_worker_activity(activity, worker, revision),
_ => Self::touch_internal_worker_activity(activity, worker),
}
}
@@ -6001,15 +5960,11 @@ impl RuntimeState {
Self::internal_worker_snapshot_statuses(activity, child);
}
}
protocol::Event::InternalWorker {
worker,
revision,
event,
} => {
Self::project_internal_worker_event(activity, worker, *revision, event);
protocol::Event::InternalWorker { worker, event } => {
Self::project_internal_worker_event(activity, worker, event);
}
protocol::Event::InternalWorkerRemoved { worker, revision } => {
Self::remove_internal_worker_subtree(activity, worker, *revision);
protocol::Event::InternalWorkerRemoved { worker } => {
Self::remove_internal_worker_subtree(activity, worker);
}
_ => {}
}
@@ -6057,12 +6012,10 @@ impl RuntimeState {
struct InternalWorkerActivity {
status: Option<protocol::WorkerStatus>,
parent_session_id: Option<String>,
revision: u64,
}
#[derive(Debug, Clone)]
struct RemovedInternalWorkerActivity {
revision: u64,
parent_session_id: Option<String>,
}
@@ -6111,8 +6064,9 @@ pub(crate) struct RestoreRequestOwner {
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RestoreGuard {
generation: String,
observation_token: String,
active_request_id: Option<String>,
owners: BTreeMap<String, RestoreRequestOwner>,
}
@@ -6120,7 +6074,7 @@ pub(crate) struct RestoreGuard {
impl Default for RestoreGuard {
fn default() -> Self {
Self {
generation: uuid::Uuid::now_v7().to_string(),
observation_token: uuid::Uuid::now_v7().to_string(),
active_request_id: None,
owners: BTreeMap::new(),
}
@@ -6129,7 +6083,7 @@ impl Default for RestoreGuard {
impl RestoreGuard {
fn rotate(&mut self) {
self.generation = uuid::Uuid::now_v7().to_string();
self.observation_token = uuid::Uuid::now_v7().to_string();
}
fn finish(&mut self, result: RuntimeWorkerRestoreResult) {
@@ -6199,7 +6153,7 @@ impl WorkerRecord {
fn summary(&self) -> WorkerSummary {
WorkerSummary {
restore_observation_token: Some(self.restore_guard.generation.clone()),
restore_observation_token: Some(self.restore_guard.observation_token.clone()),
worker_ref: self.worker_ref.clone(),
worker_id: self.worker_id,
status: self.status,
@@ -6217,7 +6171,7 @@ impl WorkerRecord {
fn detail(&self) -> WorkerDetail {
WorkerDetail {
restore_observation_token: Some(self.restore_guard.generation.clone()),
restore_observation_token: Some(self.restore_guard.observation_token.clone()),
worker_ref: self.worker_ref.clone(),
worker_id: self.worker_id,
status: self.status,
@@ -6740,11 +6694,6 @@ fn validate_create_workspace_scope(
snapshot.workspace_id
)));
}
if snapshot.settings_revision == 0 {
return Err(RuntimeError::InvalidRequest(
"Memory settings revision must be at least 1".to_string(),
));
}
if !manifest::is_normalized_workspace_memory_language(&snapshot.language) {
return Err(RuntimeError::InvalidRequest(
"Memory settings language must be a normalized bounded UTF-8 value".to_string(),
@@ -6953,12 +6902,10 @@ mod tests {
fn internal_worker_status_event(
worker: protocol::InternalWorkerRef,
revision: u64,
status: protocol::WorkerStatus,
) -> protocol::Event {
protocol::Event::InternalWorker {
worker,
revision,
event: Box::new(protocol::Event::WorkerState {
snapshot: status.into(),
}),
@@ -6967,13 +6914,11 @@ mod tests {
fn internal_worker_snapshot(
worker: protocol::InternalWorkerRef,
revision: u64,
status: protocol::WorkerStatus,
internal_workers: Vec<protocol::InternalWorkerSnapshot>,
) -> protocol::InternalWorkerSnapshot {
protocol::InternalWorkerSnapshot {
worker,
revision,
status,
greeting: None,
session: protocol::SessionSnapshot {
@@ -7017,7 +6962,6 @@ mod tests {
&mut activity,
&internal_worker_status_event(
internal_worker_ref("child-a", None),
1,
protocol::WorkerStatus::Running,
),
));
@@ -7025,7 +6969,6 @@ mod tests {
&mut activity,
&internal_worker_status_event(
internal_worker_ref("child-b", None),
1,
protocol::WorkerStatus::Running,
),
));
@@ -7033,7 +6976,6 @@ mod tests {
&mut activity,
&internal_worker_status_event(
internal_worker_ref("child-a", None),
2,
protocol::WorkerStatus::Idle,
),
));
@@ -7041,7 +6983,6 @@ mod tests {
&mut activity,
&internal_worker_status_event(
internal_worker_ref("child-b", None),
2,
protocol::WorkerStatus::Stopped,
),
));
@@ -7053,10 +6994,8 @@ mod tests {
let direct_child = internal_worker_ref("child", None);
let nested_running = protocol::Event::InternalWorker {
worker: direct_child.clone(),
revision: 1,
event: Box::new(internal_worker_status_event(
internal_worker_ref("grandchild", Some("child")),
1,
protocol::WorkerStatus::Running,
)),
};
@@ -7067,10 +7006,8 @@ mod tests {
let nested_idle = protocol::Event::InternalWorker {
worker: direct_child,
revision: 2,
event: Box::new(internal_worker_status_event(
internal_worker_ref("grandchild", Some("child")),
2,
protocol::WorkerStatus::Idle,
)),
};
@@ -7087,7 +7024,6 @@ mod tests {
&mut activity,
&internal_worker_status_event(
internal_worker_ref("child-a", None),
1,
protocol::WorkerStatus::Running,
),
);
@@ -7101,44 +7037,23 @@ mod tests {
}
#[test]
fn internal_worker_removal_is_revision_fenced_until_parent_snapshot() {
fn internal_worker_removal_is_session_fenced_until_parent_snapshot() {
let mut activity = InternalWorkerActivityProjection::default();
let child_a = internal_worker_ref("child-a", None);
let child_b = internal_worker_ref("child-b", None);
assert!(RuntimeState::update_internal_worker_activity(
&mut activity,
&internal_worker_status_event(child_a.clone(), 2, protocol::WorkerStatus::Running),
&internal_worker_status_event(child_a.clone(), protocol::WorkerStatus::Running),
));
assert!(!RuntimeState::update_internal_worker_activity(
&mut activity,
&internal_worker_status_event(child_b.clone(), 1, protocol::WorkerStatus::Running),
&internal_worker_status_event(child_b.clone(), protocol::WorkerStatus::Running),
));
assert!(!RuntimeState::update_internal_worker_activity(
&mut activity,
&protocol::Event::InternalWorkerRemoved {
worker: child_a.clone(),
revision: 1,
},
));
assert!(activity.workers.contains_key("child-a"));
assert!(!RuntimeState::update_internal_worker_activity(
&mut activity,
&protocol::Event::InternalWorkerRemoved {
worker: child_a.clone(),
revision: 2,
},
));
assert!(activity.workers.contains_key("child-a"));
assert!(!RuntimeState::update_internal_worker_activity(
&mut activity,
&protocol::Event::InternalWorkerRemoved {
worker: child_a,
revision: 3,
},
&protocol::Event::InternalWorkerRemoved { worker: child_a },
));
assert!(!activity.workers.contains_key("child-a"));
assert!(activity.removed_workers.contains_key("child-a"));
@@ -7148,14 +7063,13 @@ mod tests {
&mut activity,
&protocol::Event::InternalWorkerRemoved {
worker: child_b.clone(),
revision: 2,
},
));
assert!(!activity.has_running_worker());
assert!(!RuntimeState::update_internal_worker_activity(
&mut activity,
&internal_worker_status_event(child_b.clone(), 3, protocol::WorkerStatus::Running),
&internal_worker_status_event(child_b.clone(), protocol::WorkerStatus::Running),
));
assert!(!activity.workers.contains_key("child-b"));
assert!(!activity.has_running_worker());
@@ -7164,7 +7078,6 @@ mod tests {
&mut activity,
&parent_snapshot(vec![internal_worker_snapshot(
child_b,
4,
protocol::WorkerStatus::Running,
Vec::new(),
)]),
@@ -7182,16 +7095,14 @@ mod tests {
assert!(!RuntimeState::update_internal_worker_activity(
&mut activity,
&internal_worker_status_event(child.clone(), 1, protocol::WorkerStatus::Idle),
&internal_worker_status_event(child.clone(), protocol::WorkerStatus::Idle),
));
assert!(RuntimeState::update_internal_worker_activity(
&mut activity,
&protocol::Event::InternalWorker {
worker: child.clone(),
revision: 2,
event: Box::new(internal_worker_status_event(
grandchild.clone(),
1,
protocol::WorkerStatus::Running,
)),
},
@@ -7200,13 +7111,10 @@ mod tests {
&mut activity,
&protocol::Event::InternalWorker {
worker: child.clone(),
revision: 3,
event: Box::new(protocol::Event::InternalWorker {
worker: grandchild.clone(),
revision: 2,
event: Box::new(internal_worker_status_event(
great_grandchild,
1,
protocol::WorkerStatus::Running,
)),
}),
@@ -7217,10 +7125,8 @@ mod tests {
&mut activity,
&protocol::Event::InternalWorker {
worker: child.clone(),
revision: 4,
event: Box::new(protocol::Event::InternalWorkerRemoved {
worker: grandchild.clone(),
revision: 3,
}),
},
));
@@ -7235,10 +7141,8 @@ mod tests {
&mut activity,
&protocol::Event::InternalWorker {
worker: child,
revision: 5,
event: Box::new(internal_worker_status_event(
grandchild,
4,
protocol::WorkerStatus::Running,
)),
},
@@ -7338,7 +7242,6 @@ mod tests {
workspace_api: None,
memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "local".to_string(),
settings_revision: 1,
language: "English".to_string(),
}),
subjektiv_attached: false,
@@ -7367,7 +7270,7 @@ mod tests {
request.backend_job = Some(crate::catalog::BackendJobExecutionBinding {
job_id: "job-1".into(),
attempt_id: "attempt-1".into(),
input_revision: Some("revision-1".into()),
input_digest: Some(format!("sha256:{}", "a".repeat(64))),
subjektiv_consolidation: false,
});
let owner = scope("local", "server-job-test");
@@ -7416,7 +7319,7 @@ mod tests {
request.backend_job = Some(crate::catalog::BackendJobExecutionBinding {
job_id: "job-1".into(),
attempt_id: "attempt-1".into(),
input_revision: None,
input_digest: None,
subjektiv_consolidation: true,
});
assert!(validate_create_worker_request(&request).is_err());
@@ -7444,7 +7347,6 @@ mod tests {
kind: server_api::RepositorySourceKind::Ssh,
uri: "ssh://git@example.test/repo.git".to_string(),
},
source_revision: 1,
source_fingerprint: "sha256:source".to_string(),
selector: None,
},
@@ -7455,16 +7357,15 @@ mod tests {
workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(),
operation_id: "operation-1".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(),
ssh: Some(RepositorySshMaterializationAccess {
credential_candidates: vec![RepositorySshCredentialCandidate {
credential_id: "credential-1".to_string(),
credential_revision: 1,
public_key_fingerprint: "key-1".into(),
private_key: SensitiveString::new("private-key-bytes"),
}],
host_trust_id: "host-trust-1".to_string(),
host_trust_revision: 1,
host_key_fingerprint: "key-1".into(),
access: server_api::RepositoryAccessMode::ReadOnly,
expires_at_epoch_seconds: u64::MAX,
repository_id: "repository-1".to_string(),
@@ -7512,8 +7413,6 @@ mod tests {
operation: crate::resource::BackendResourceOperation::FetchOnce,
expires_at_unix_seconds: i64::MAX,
nonce: "repository-access-1".to_string(),
revision: "1".to_string(),
generation: None,
max_bytes: crate::resource::DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES,
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly,
@@ -7544,12 +7443,12 @@ mod tests {
credential_candidates: vec![
crate::resource::RepositorySshAccessSecretCandidate {
credential_id: "credential-1".to_string(),
credential_revision: 1,
public_key_fingerprint: "key-1".into(),
private_key: "private-key-bytes-1".to_string(),
},
crate::resource::RepositorySshAccessSecretCandidate {
credential_id: "credential-2".to_string(),
credential_revision: 3,
public_key_fingerprint: "key-3".into(),
private_key: "private-key-bytes-2".to_string(),
},
],
@@ -7567,23 +7466,22 @@ mod tests {
workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(),
operation_id: "operation-1".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(),
ssh: Some(RepositorySshMaterializationAccess {
credential_candidates: vec![
RepositorySshCredentialCandidate {
credential_id: "credential-1".to_string(),
credential_revision: 1,
public_key_fingerprint: "key-1".into(),
private_key: SensitiveString::default(),
},
RepositorySshCredentialCandidate {
credential_id: "credential-2".to_string(),
credential_revision: 3,
public_key_fingerprint: "key-3".into(),
private_key: SensitiveString::default(),
},
],
host_trust_id: "host-trust-1".to_string(),
host_trust_revision: 1,
host_key_fingerprint: "key-1".into(),
access: server_api::RepositoryAccessMode::ReadOnly,
expires_at_epoch_seconds: u64::MAX,
repository_id: "repository-1".to_string(),
@@ -7652,12 +7550,12 @@ mod tests {
credential_candidates: vec![
crate::resource::RepositorySshAccessSecretCandidate {
credential_id: "credential-1".to_string(),
credential_revision: 1,
public_key_fingerprint: "key-1".into(),
private_key: "create-private-key-bytes-1".to_string(),
},
crate::resource::RepositorySshAccessSecretCandidate {
credential_id: "credential-2".to_string(),
credential_revision: 3,
public_key_fingerprint: "key-3".into(),
private_key: "create-private-key-bytes-2".to_string(),
},
],
@@ -7677,7 +7575,6 @@ mod tests {
kind: server_api::RepositorySourceKind::Ssh,
uri: "ssh://git@example.test/repo.git".to_string(),
},
source_revision: 1,
source_fingerprint: "sha256:source".to_string(),
selector: None,
},
@@ -7688,23 +7585,22 @@ mod tests {
workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(),
operation_id: "operation-create".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(),
ssh: Some(RepositorySshMaterializationAccess {
credential_candidates: vec![
RepositorySshCredentialCandidate {
credential_id: "credential-1".to_string(),
credential_revision: 1,
public_key_fingerprint: "key-1".into(),
private_key: SensitiveString::default(),
},
RepositorySshCredentialCandidate {
credential_id: "credential-2".to_string(),
credential_revision: 3,
public_key_fingerprint: "key-3".into(),
private_key: SensitiveString::default(),
},
],
host_trust_id: "host-trust-1".to_string(),
host_trust_revision: 1,
host_key_fingerprint: "key-1".into(),
access: server_api::RepositoryAccessMode::ReadOnly,
expires_at_epoch_seconds: u64::MAX,
repository_id: "repository-1".to_string(),
@@ -7751,7 +7647,6 @@ mod tests {
});
request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: workspace_id.to_string(),
settings_revision: 1,
language: "English".to_string(),
});
request
@@ -7769,14 +7664,12 @@ mod tests {
request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-b".to_string(),
settings_revision: 1,
language: "English".to_string(),
});
assert!(validate_create_workspace_scope(&request, Some("workspace-a")).is_err());
request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-a".to_string(),
settings_revision: 2,
language: " english ".to_string(),
});
assert!(validate_create_workspace_scope(&request, Some("workspace-a")).is_err());
@@ -7791,7 +7684,6 @@ mod tests {
metadata: ConfigBundleMetadata {
id: "bundle-1".to_string(),
digest: String::new(),
revision: "rev-1".to_string(),
workspace_id: "workspace-1".to_string(),
created_at: "2026-06-26T00:00:00Z".to_string(),
provenance: ConfigBundleProvenance {
@@ -8350,7 +8242,6 @@ mod tests {
kind: server_api::RepositorySourceKind::LocalPath,
uri: "/unused-test-repository".into(),
},
source_revision: 1,
source_fingerprint: "sha256:test-source".into(),
selector: None,
},
@@ -8368,7 +8259,6 @@ mod tests {
workspace_id: "workspace-a".into(),
runtime_id: "runtime-1".into(),
operation_id: "operation-1".into(),
config_revision: 1,
config_projection_digest: "sha256:test-projection".into(),
ssh: None,
},
@@ -8842,7 +8732,6 @@ mod tests {
&created.worker_ref,
internal_worker_status_event(
internal_worker_ref("child-live", None),
1,
protocol::WorkerStatus::Running,
),
)
@@ -8861,7 +8750,6 @@ mod tests {
&created.worker_ref,
protocol::Event::InternalWorkerRemoved {
worker: internal_worker_ref("child-live", None),
revision: 2,
},
)
.unwrap();
@@ -8879,7 +8767,6 @@ mod tests {
&created.worker_ref,
internal_worker_status_event(
internal_worker_ref("child-live", None),
3,
protocol::WorkerStatus::Running,
),
)
@@ -8900,7 +8787,6 @@ mod tests {
&created.worker_ref,
parent_snapshot(vec![internal_worker_snapshot(
internal_worker_ref("child-live", None),
4,
protocol::WorkerStatus::Running,
Vec::new(),
)]),
@@ -9427,7 +9313,6 @@ mod tests {
bundle.prompt_catalog = Some(
worker::EffectivePromptCatalog::new(
BTreeMap::from([("default".to_string(), "workspace prompt".to_string())]),
7,
"schema",
"toolchain",
)
@@ -10287,7 +10172,6 @@ mod tests {
workspace_id: "workspace-a".into(),
runtime_id: "peer-runtime".into(),
operation_id: format!("worker-restore:{}", request.request_id),
config_revision: 1,
config_projection_digest: "snapshot".into(),
ssh: None,
},
@@ -10405,7 +10289,7 @@ mod tests {
}
#[test]
fn restore_guard_protocol_state_aba_rotates_generation() {
fn restore_guard_protocol_state_aba_rotates_observation_token() {
let (runtime, backend) = runtime_and_backend();
let worker = runtime.create_worker(task_request("state ABA")).unwrap();
let request = runtime.test_restore_request(&worker.worker_ref);
@@ -11160,7 +11044,7 @@ mod tests {
#[cfg(feature = "fs-store")]
#[test]
fn restore_guard_generation_and_terminal_receipt_survive_restart() {
fn restore_guard_observation_token_and_terminal_receipt_survive_restart() {
let dir = tempfile::tempdir().unwrap();
let options =
FsRuntimeStoreOptions::new(dir.path().join("runtime")).with_runtime_id("guard-test");
@@ -11177,7 +11061,7 @@ mod tests {
.restore_worker_operation(&worker.worker_ref, request.clone())
.unwrap();
runtime.stop_worker(&worker.worker_ref, None).unwrap();
let generation = runtime
let observation_token = runtime
.worker_detail(&worker.worker_ref)
.unwrap()
.restore_observation_token;
@@ -11188,7 +11072,7 @@ mod tests {
.worker_detail(&worker.worker_ref)
.unwrap()
.restore_observation_token,
generation
observation_token
);
assert_eq!(
restarted
@@ -12680,7 +12564,7 @@ mod tests {
assert!(
error
.to_string()
.contains("unsupported Runtime store schema version 2; expected 6, 7, 8, or 9")
.contains("unsupported Runtime store schema version 2; expected 6, 7, 8, 9, or 10")
);
let _ = std::fs::remove_dir_all(root);
@@ -12722,7 +12606,7 @@ mod tests {
let worker_aggregate: serde_json::Value =
serde_json::from_slice(&std::fs::read(worker_store_dir.join("worker.json")).unwrap())
.unwrap();
assert_eq!(worker_aggregate["schema_version"], serde_json::json!(9));
assert_eq!(worker_aggregate["schema_version"], serde_json::json!(10));
assert_eq!(worker_aggregate["status"], serde_json::json!("stopped"));
assert_eq!(
worker_aggregate["execution_state"]["state"],
@@ -13072,7 +12956,7 @@ mod tests {
.unwrap()
.remove("restore_guard")
.unwrap();
assert_ne!(guard["generation"], old_guard["generation"]);
assert_ne!(guard["observation_token"], old_guard["observation_token"]);
assert!(guard["active_request_id"].is_null());
assert_eq!(
guard["owners"]
@@ -13669,7 +13553,7 @@ mod tests {
);
let migrated_aggregate: serde_json::Value =
serde_json::from_slice(&std::fs::read(&worker_path).unwrap()).unwrap();
assert_eq!(migrated_aggregate["schema_version"], serde_json::json!(9));
assert_eq!(migrated_aggregate["schema_version"], serde_json::json!(10));
assert!(migrated_aggregate.get("request").is_none());
assert!(migrated_aggregate.get("execution").is_none());
assert_eq!(
@@ -374,7 +374,6 @@ fn api_archive_manifest(manifest: &DomainArchiveManifest) -> WorkerSessionArchiv
content_bytes: manifest.content_bytes,
content_file_count: manifest.content_file_count,
policy_id: manifest.policy_id.clone(),
policy_revision: manifest.policy_revision,
operation_id: manifest.operation_id.clone(),
input_fingerprint: manifest.input_fingerprint.clone(),
}
+42 -60
View File
@@ -133,7 +133,7 @@ use worker::feature::builtin::{
WorkspaceClientWorkerObservationProvider,
};
#[cfg(feature = "ws-server")]
use worker::ipc::protocol_session::{live_log_entry_event, subscribe_worker_protocol_session};
use worker::ipc::protocol_session::{WorkerProtocolSessionStreams, live_log_entry_event};
use worker::{
PreparedWorker, PromptCatalogSource, SegmentLogSink, SubjektivSessionAttributionLifecycle,
Worker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout,
@@ -142,6 +142,15 @@ use worker::{
bash_output_dir_for_worker_id,
};
#[cfg(feature = "ws-server")]
fn subscribe_worker_protocol_session(handle: &WorkerHandle) -> WorkerProtocolSessionStreams {
// Child state changes and their live events share this publication gate.
// Acquire it before subscribing, and retain it through the parent snapshot
// capture, so a pre-snapshot child event cannot replay after newer state.
let _publish_guard = handle.protocol_snapshot_publish_guard();
worker::ipc::protocol_session::subscribe_worker_protocol_session(handle)
}
const DEFAULT_BACKEND_ID: &str = "worker-crate";
const RUNTIME_TASK_TIMEOUT: Duration = Duration::from_secs(10);
const SPAWN_RESTORE_TASK_TIMEOUT: Duration = Duration::from_secs(60);
@@ -403,26 +412,12 @@ impl WorkspacePromptProjectionCache {
.active
.lock()
.map_err(|_| "Workspace Prompt projection cache lock was poisoned".to_string())?;
if let Some(current) = active.get(&workspace_id) {
if current.projection.config_revision > resolution.projection.config_revision {
return Ok(current.clone());
}
if current.projection.config_revision == resolution.projection.config_revision
&& (current.projection.source_digest != resolution.projection.source_digest
|| current.projection.projection_digest
!= resolution.projection.projection_digest
|| current.projection.catalog.catalog_digest
!= resolution.projection.catalog.catalog_digest
|| current.projection.catalog.schema_fingerprint
!= resolution.projection.catalog.schema_fingerprint
|| current.projection.catalog.toolchain_fingerprint
!= resolution.projection.catalog.toolchain_fingerprint)
// Runtime receives authoritative projections in operation/arrival order.
// Equal content reuses the compiled catalog; counters are not authority.
if let Some(current) = active.get(&workspace_id)
&& current.projection == resolution.projection
{
return Err(format!(
"Workspace Prompt projection identity changed without a config revision transition: workspace={workspace_id} revision={}",
resolution.projection.config_revision
));
}
return Ok(current.clone());
}
active.insert(workspace_id, resolution.clone());
Ok(resolution)
@@ -688,7 +683,7 @@ impl ProfileRuntimeWorkerFactory {
None => fallback,
};
// Saved policy is execution authority. Rebinding current settings here
// would hide revision and attachment mismatches.
// would hide content and attachment mismatches.
validate_worker_memory_settings(&manifest, request)?;
Ok((manifest, loader))
}
@@ -1195,11 +1190,8 @@ fn validate_worker_memory_settings(
"Workspace Worker restored without its bound Memory settings snapshot".to_string()
})?;
return Err(format!(
"Workspace Worker Memory settings snapshot mismatch: expected {} revision {}, restored {} revision {}",
expected.workspace_id,
expected.settings_revision,
actual.workspace_id,
actual.settings_revision
"Workspace Worker Memory settings snapshot mismatch: expected {} language {}, restored {} language {}",
expected.workspace_id, expected.language, actual.workspace_id, actual.language
));
}
let subjektiv_settings = manifest.feature.subjektiv.workspace_settings();
@@ -1216,11 +1208,8 @@ fn validate_worker_memory_settings(
.to_string()
})?;
return Err(format!(
"Workspace Worker subjektiv settings snapshot mismatch: expected {} revision {}, restored {} revision {}",
expected.workspace_id,
expected.settings_revision,
actual.workspace_id,
actual.settings_revision
"Workspace Worker subjektiv settings snapshot mismatch: expected {} language {}, restored {} language {}",
expected.workspace_id, expected.language, actual.workspace_id, actual.language
));
}
} else if subjektiv_settings.is_some() {
@@ -4789,7 +4778,6 @@ mod tests {
request.subjektiv_attached = true;
request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-saved-subjektiv".to_string(),
settings_revision: 17,
language: "English".to_string(),
});
bind_workspace_memory_settings(&mut saved, &request).unwrap();
@@ -5982,7 +5970,6 @@ mod tests {
);
let mut catalog = worker::EffectivePromptCatalog::new(
templates,
17,
projection.schema_fingerprint.clone(),
projection.toolchain_fingerprint.clone(),
)
@@ -6005,7 +5992,6 @@ mod tests {
missing_instruction_bundle.prompt_catalog = Some(
worker::EffectivePromptCatalog::new(
templates,
17,
projection.schema_fingerprint.clone(),
projection.toolchain_fingerprint.clone(),
)
@@ -6115,19 +6101,19 @@ mod tests {
.with_runtime_store_dir(&runtime_store)
.with_runtime_id("runtime-subjektiv-test");
let before = persisted_files(&runtime_store);
let mut wrong_revision = request.clone();
wrong_revision
let mut wrong_settings = request.clone();
wrong_settings
.request
.memory_settings
.as_mut()
.unwrap()
.settings_revision = 18;
.language = "different language".into();
let mut detached = request.clone();
detached.request.subjektiv_attached = false;
let mut missing_settings = request.clone();
missing_settings.request.memory_settings = None;
for (invalid, expected) in [
(wrong_revision, "subjektiv settings snapshot mismatch"),
(wrong_settings, "subjektiv settings snapshot mismatch"),
(detached, "unauthorized subjektiv attachment"),
(missing_settings, "missing its trusted settings snapshot"),
] {
@@ -6660,7 +6646,6 @@ mod tests {
let cache = WorkspacePromptProjectionCache::default();
let catalog_v1 = worker::EffectivePromptCatalog::new(
BTreeMap::from([("default".to_string(), "prompt-v1".to_string())]),
8,
"schema",
"toolchain",
)
@@ -6674,7 +6659,6 @@ mod tests {
.unwrap();
let catalog_v2 = worker::EffectivePromptCatalog::new(
BTreeMap::from([("default".to_string(), "prompt-v2".to_string())]),
9,
"schema",
"toolchain",
)
@@ -6691,7 +6675,7 @@ mod tests {
cache.observe(projection_v2).unwrap();
let active = cache.active("workspace-a").unwrap().unwrap();
assert_eq!(active.projection.config_revision, 9);
assert_eq!(active.projection.source_digest, "source-v2");
assert_eq!(
active.projection.catalog.catalog_digest,
catalog_v2.catalog_digest
@@ -6699,10 +6683,9 @@ mod tests {
}
#[test]
fn workspace_prompt_projection_cache_rejects_same_revision_source_drift() {
fn workspace_prompt_projection_cache_accepts_authoritative_source_change() {
let catalog = worker::EffectivePromptCatalog::new(
BTreeMap::from([("default".to_string(), "prompt".to_string())]),
8,
"schema",
"toolchain",
)
@@ -6724,22 +6707,20 @@ mod tests {
let cache = WorkspacePromptProjectionCache::default();
cache.observe(first).unwrap();
let error = cache.observe(drifted).unwrap_err();
assert!(
error
.to_string()
.contains("without a config revision transition")
assert_eq!(
cache.observe(drifted).unwrap().projection.source_digest,
"source-b"
);
}
#[test]
fn workspace_prompt_projection_cache_rejects_same_revision_schema_drift() {
fn workspace_prompt_projection_cache_accepts_authoritative_toolchain_change() {
let templates = BTreeMap::from([("default".to_string(), "prompt".to_string())]);
let first_catalog =
worker::EffectivePromptCatalog::new(templates.clone(), 8, "schema-a", "toolchain")
worker::EffectivePromptCatalog::new(templates.clone(), "schema-a", "toolchain")
.unwrap();
let drifted_catalog =
worker::EffectivePromptCatalog::new(templates, 8, "schema-b", "toolchain").unwrap();
worker::EffectivePromptCatalog::new(templates, "schema-b", "toolchain").unwrap();
let first = worker::WorkspacePromptProjection::new(
"workspace-a",
"source-a",
@@ -6757,8 +6738,14 @@ mod tests {
let cache = WorkspacePromptProjectionCache::default();
cache.observe(first).unwrap();
let error = cache.observe(drifted).unwrap_err();
assert!(error.contains("without a config revision transition"));
assert_eq!(
cache
.observe(drifted)
.unwrap()
.projection
.schema_fingerprint,
"schema-b"
);
}
#[test]
@@ -7165,7 +7152,6 @@ mod tests {
metadata: crate::config_bundle::ConfigBundleMetadata {
id: "adapter-test-bundle".to_string(),
digest: String::new(),
revision: "test".to_string(),
workspace_id: "adapter-test".to_string(),
created_at: "test".to_string(),
provenance: crate::config_bundle::ConfigBundleProvenance {
@@ -7273,7 +7259,7 @@ mod tests {
request.backend_job = Some(crate::catalog::BackendJobExecutionBinding {
job_id: "job-1".into(),
attempt_id: "attempt-1".into(),
input_revision: None,
input_digest: None,
subjektiv_consolidation: granted,
});
for tools in [false, true] {
@@ -7329,7 +7315,6 @@ mod tests {
};
let settings = manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-subject".to_string(),
settings_revision: 3,
language: "English".to_string(),
};
let mut ordinary_request = create_request("ordinary");
@@ -7987,7 +7972,6 @@ mod tests {
kind: server_api::RepositorySourceKind::LocalPath,
uri: repo.display().to_string(),
},
source_revision: 1,
source_fingerprint: "sha256:test".to_string(),
selector: Some(RepositorySelector::from("HEAD")),
},
@@ -8197,7 +8181,6 @@ mod tests {
);
let mut effective = worker::EffectivePromptCatalog::new(
templates,
7,
projection.schema_fingerprint.clone(),
projection.toolchain_fingerprint.clone(),
)
@@ -8213,7 +8196,7 @@ mod tests {
.unwrap()
.unwrap();
assert_eq!(resolution.projection.config_revision, 7);
assert_eq!(resolution.projection.source_digest, "source-7");
assert_eq!(
resolution.catalog.notify_wrapper("restored").unwrap(),
"PENDING-LAUNCH restored"
@@ -8250,7 +8233,6 @@ mod tests {
});
request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-restore".to_string(),
settings_revision: 1,
language: "English".to_string(),
});
let identity = RuntimeIdentityMaterial::generate("runtime-restore").unwrap();
+7 -32
View File
@@ -736,7 +736,6 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
fn current_prompt_projection(
&self,
minimum_revision: Option<u64>,
) -> Result<Option<WorkspacePromptCatalogResolution>, WorkspaceClientError> {
let Some(cache) = self.prompt_projection_cache.as_ref() else {
return Ok(None);
@@ -744,11 +743,6 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
if let Some(resolution) = cache
.active(&self.workspace_id)
.map_err(WorkspaceClientError::Request)?
.filter(|resolution| {
minimum_revision
.map(|minimum| resolution.projection.config_revision >= minimum)
.unwrap_or(true)
})
{
return Ok(Some((*resolution).clone()));
}
@@ -763,11 +757,6 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
if let Some(resolution) = cache
.active(&self.workspace_id)
.map_err(WorkspaceClientError::Request)?
.filter(|resolution| {
minimum_revision
.map(|minimum| resolution.projection.config_revision >= minimum)
.unwrap_or(true)
})
{
return Ok(Some((*resolution).clone()));
}
@@ -807,14 +796,6 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
let resolution = cache
.observe(projection)
.map_err(WorkspaceClientError::Request)?;
if let Some(minimum_revision) = minimum_revision
&& resolution.projection.config_revision < minimum_revision
{
return Err(WorkspaceClientError::Request(format!(
"active Workspace Prompt projection is stale: required revision {minimum_revision}, got {}",
resolution.projection.config_revision
)));
}
Ok(Some((*resolution).clone()))
}
@@ -1082,7 +1063,6 @@ mod tests {
"default".to_string(),
"workspace prompt".to_string(),
)]),
3,
"schema",
"toolchain",
)
@@ -1103,22 +1083,20 @@ mod tests {
)
.with_prompt_projection_cache(cache);
let projection = client.current_prompt_projection(None).unwrap().unwrap();
let second = client.current_prompt_projection(None).unwrap().unwrap();
let projection = client.current_prompt_projection().unwrap().unwrap();
let second = client.current_prompt_projection().unwrap().unwrap();
assert_eq!(projection.projection.config_revision, 3);
assert_eq!(projection.projection.source_digest, "source-3");
assert!(Arc::ptr_eq(&projection.catalog, &second.catalog));
}
#[test]
fn prompt_projection_minimum_revision_rejects_stale_server_response() {
fn prompt_projection_cache_miss_fetches_content_identity() {
use std::io::{Read, Write};
use std::net::TcpListener;
let catalog = worker::EffectivePromptCatalog::new(
std::collections::BTreeMap::from([("default".to_string(), "stale prompt".to_string())]),
3,
"schema",
"toolchain",
)
@@ -1132,7 +1110,6 @@ mod tests {
.unwrap();
let body = serde_json::to_string(&projection).unwrap();
let cache = Arc::new(WorkspacePromptProjectionCache::default());
cache.observe(projection).unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = std::thread::spawn(move || {
@@ -1151,10 +1128,10 @@ mod tests {
RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a")
.with_prompt_projection_cache(cache);
let error = client.current_prompt_projection(Some(4)).unwrap_err();
let fetched = client.current_prompt_projection().unwrap().unwrap();
server.join().unwrap();
assert!(error.to_string().contains("required revision 4, got 3"));
assert_eq!(fetched.projection.source_digest, "source-3");
}
#[test]
@@ -1168,7 +1145,6 @@ mod tests {
"default".to_string(),
"shared prompt".to_string(),
)]),
5,
"schema",
"toolchain",
)
@@ -1211,7 +1187,7 @@ mod tests {
let barrier = barrier.clone();
std::thread::spawn(move || {
barrier.wait();
client.current_prompt_projection(None).unwrap().unwrap()
client.current_prompt_projection().unwrap().unwrap()
})
})
.collect::<Vec<_>>();
@@ -1239,7 +1215,6 @@ mod tests {
"default".to_string(),
"foreign prompt".to_string(),
)]),
4,
"schema",
"toolchain",
)
@@ -1270,7 +1245,7 @@ mod tests {
RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a")
.with_prompt_projection_cache(Arc::new(WorkspacePromptProjectionCache::default()));
let error = client.current_prompt_projection(None).unwrap_err();
let error = client.current_prompt_projection().unwrap_err();
server.join().unwrap();
assert!(error.to_string().contains("scope mismatch"));
+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);
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "FrozenEvidenceRead")]
pub struct WorkingDirectoryEvidence {
pub repository_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -48,16 +49,61 @@ pub struct WorkingDirectoryEvidence {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_tree: Option<String>,
pub materializer_kind: MaterializerKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository_source_revision: Option<u64>,
/// SSH authority must be reacquired before opening a session, even if an old
/// record has no concrete key fingerprint. Never inferred as false from that omission.
pub repository_access_required: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository_source_fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operation_id: Option<String>,
#[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")]
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)]
@@ -156,7 +202,7 @@ impl WorkingDirectoryBinding {
WorkingDirectoryStatusKind::Active | WorkingDirectoryStatusKind::CleanupPending
) && binding_paths_are_available(self)
{
let (current_selector, current_ref, current_tree) = binding_current_revision(self);
let (current_selector, current_ref, current_tree) = binding_current_commit(self);
summary.current_selector = current_selector;
summary.current_ref = current_ref;
summary.current_tree = current_tree;
@@ -274,7 +320,7 @@ fn binding_paths_are_available(binding: &WorkingDirectoryBinding) -> bool {
root.join(".git").is_dir()
}
fn binding_current_revision(
fn binding_current_commit(
binding: &WorkingDirectoryBinding,
) -> (Option<String>, Option<String>, Option<String>) {
let current_ref = git_stdout(binding.root(), ["rev-parse", "HEAD"])
@@ -305,13 +351,12 @@ fn binding_cleanliness(binding: &WorkingDirectoryBinding) -> String {
#[derive(Debug)]
struct PendingRepositoryAccessLease {
generation: u64,
lease_id: uuid::Uuid,
access: RepositorySshMaterializationAccess,
}
#[derive(Debug, Default)]
struct RepositoryAccessExpiryState {
next_generation: u64,
pending: HashMap<String, PendingRepositoryAccessLease>,
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(
&self,
working_directory_id: &str,
access: RepositorySshMaterializationAccess,
) -> Result<u64, WorkingDirectoryDiagnostic> {
) -> Result<uuid::Uuid, WorkingDirectoryDiagnostic> {
let mut state = self.inner.state.lock().map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_repository_access_unavailable",
@@ -389,14 +422,14 @@ impl RepositoryAccessExpiryScheduler {
"Runtime Repository access state is shutting down",
));
}
let generation = Self::next_generation(&mut state)?;
let lease_id = uuid::Uuid::now_v7();
state.pending.insert(
working_directory_id.to_string(),
PendingRepositoryAccessLease { generation, access },
PendingRepositoryAccessLease { lease_id, access },
);
drop(state);
self.inner.wake.notify_one();
Ok(generation)
Ok(lease_id)
}
fn pending(
@@ -452,14 +485,14 @@ impl RepositoryAccessExpiryScheduler {
Ok(())
}
fn next_expiry(state: &RepositoryAccessExpiryState) -> Option<(String, u64, u64)> {
fn next_expiry(state: &RepositoryAccessExpiryState) -> Option<(String, uuid::Uuid, u64)> {
state
.pending
.iter()
.map(|(working_directory_id, lease)| {
(
working_directory_id.clone(),
lease.generation,
lease.lease_id,
lease.access.expires_at_epoch_seconds,
)
})
@@ -469,14 +502,14 @@ impl RepositoryAccessExpiryScheduler {
fn expire_if_current(
state: &mut RepositoryAccessExpiryState,
working_directory_id: &str,
generation: u64,
lease_id: uuid::Uuid,
now_epoch_seconds: u64,
) {
let should_remove = state
.pending
.get(working_directory_id)
.is_some_and(|lease| {
lease.generation == generation
lease.lease_id == lease_id
&& lease.access.expires_at_epoch_seconds <= now_epoch_seconds
});
if should_remove {
@@ -494,7 +527,7 @@ impl RepositoryAccessExpiryScheduler {
if state.shutdown {
return;
}
let Some((working_directory_id, generation, expires_at_epoch_seconds)) =
let Some((working_directory_id, lease_id, expires_at_epoch_seconds)) =
Self::next_expiry(&state)
else {
state = match inner.wake.wait(state) {
@@ -518,7 +551,7 @@ impl RepositoryAccessExpiryScheduler {
Self::expire_if_current(
&mut state,
&working_directory_id,
generation,
lease_id,
now_epoch_seconds,
);
break;
@@ -527,21 +560,21 @@ impl RepositoryAccessExpiryScheduler {
}
#[cfg(test)]
fn pending_generation(&self, working_directory_id: &str) -> Option<u64> {
fn pending_lease_id(&self, working_directory_id: &str) -> Option<uuid::Uuid> {
self.inner
.state
.lock()
.ok()?
.pending
.get(working_directory_id)
.map(|lease| lease.generation)
.map(|lease| lease.lease_id)
}
#[cfg(test)]
fn expire_pending_for_test(
&self,
working_directory_id: &str,
generation: u64,
lease_id: uuid::Uuid,
now_epoch_seconds: u64,
) {
let Ok(mut state) = self.inner.state.lock() else {
@@ -550,7 +583,7 @@ impl RepositoryAccessExpiryScheduler {
Self::expire_if_current(
&mut state,
working_directory_id,
generation,
lease_id,
now_epoch_seconds,
);
}
@@ -701,6 +734,26 @@ impl RuntimeGitMaterializer {
fn read_binding(
&self,
working_directory_id: &str,
) -> Result<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> {
let working_directory_root = self.working_directory_root(working_directory_id);
let path = working_directory_root.join(MATERIALIZATION_RECORD);
@@ -716,7 +769,7 @@ impl RuntimeGitMaterializer {
"working directory working_directory record is invalid; backend-private path details were omitted",
)
})?;
Ok(WorkingDirectoryBinding {
let binding = WorkingDirectoryBinding {
working_directory: record.working_directory,
root: record.root.clone(),
cwd: record.root,
@@ -726,7 +779,39 @@ impl RuntimeGitMaterializer {
occupancy: self.occupancy.clone(),
occupancy_id: working_directory_id.to_string(),
generation: self.occupancy.generation(&working_directory_id),
};
let canonical = serde_json::to_vec_pretty(&WorkingDirectoryMaterializationRecord {
working_directory: binding.working_directory.clone(),
root: binding.root.clone(),
})
.map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_record_invalid",
"materialization evidence is invalid",
)
})?;
if migrate && canonical != raw {
let temporary = path.with_extension(format!("migration-{}.tmp", uuid::Uuid::now_v7()));
let result = (|| -> std::io::Result<()> {
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temporary)?;
file.write_all(&canonical)?;
file.sync_all()?;
fs::rename(&temporary, &path)?;
fs::File::open(&binding.working_directory_root)?.sync_all()?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(&temporary);
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_record_write_failed",
"materialization evidence migration could not be committed",
));
}
}
Ok(binding)
}
fn store_repository_access(
@@ -750,8 +835,7 @@ impl RuntimeGitMaterializer {
if binding
.working_directory
.evidence
.credential_revision
.is_some()
.repository_access_required
{
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_remote_repository_access_required",
@@ -832,7 +916,6 @@ impl RuntimeGitMaterializer {
if materialization.workspace_id.trim().is_empty()
|| materialization.runtime_id.trim().is_empty()
|| materialization.operation_id.trim().is_empty()
|| materialization.config_revision == 0
|| materialization.config_projection_digest.trim().is_empty()
{
return Err(WorkingDirectoryDiagnostic::new(
@@ -1032,16 +1115,17 @@ impl RuntimeGitMaterializer {
resolved_commit,
resolved_tree,
materializer_kind: MaterializerKind::RuntimeGitClone,
repository_source_revision: Some(request.repository.source_revision),
repository_access_required: request.repository.source.kind
== server_api::RepositorySourceKind::Ssh,
repository_source_fingerprint: Some(request.repository.source_fingerprint.clone()),
operation_id: context.map(|value| value.operation_id.clone()),
credential_revision: context
public_key_fingerprint: context
.and_then(|value| value.ssh.as_ref())
.and_then(|value| value.credential_candidates.first())
.map(|candidate| candidate.credential_revision),
host_trust_revision: context
.map(|candidate| candidate.public_key_fingerprint.clone()),
host_key_fingerprint: context
.and_then(|value| value.ssh.as_ref())
.map(|value| value.host_trust_revision),
.map(|value| value.host_key_fingerprint.clone()),
},
cleanup_target: WorkingDirectoryCleanupTarget {
kind: "runtime_git_clone".to_string(),
@@ -1122,11 +1206,10 @@ impl WorkingDirectoryMaterializer for RuntimeGitMaterializer {
}
Err(error) => return Err(error),
};
if binding
if !binding
.working_directory
.evidence
.credential_revision
.is_none()
.repository_access_required
{
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_repository_access_not_applicable",
@@ -1146,13 +1229,15 @@ impl WorkingDirectoryMaterializer for RuntimeGitMaterializer {
)?;
binding.working_directory.evidence.operation_id =
Some(request.materialization.operation_id.clone());
binding.working_directory.evidence.credential_revision = Some(
binding.working_directory.evidence.public_key_fingerprint = Some(
ssh.credential_candidates
.first()
.expect("validated SSH credential candidate")
.credential_revision,
.public_key_fingerprint
.clone(),
);
binding.working_directory.evidence.host_trust_revision = Some(ssh.host_trust_revision);
binding.working_directory.evidence.host_key_fingerprint =
Some(ssh.host_key_fingerprint.clone());
self.write_record(&binding)?;
self.store_repository_access(&request.working_directory_id, ssh)
}
@@ -1195,15 +1280,15 @@ impl WorkingDirectoryMaterializer for RuntimeGitMaterializer {
"Repository provider returned an ambiguous ref observation",
));
}
let (revision_ref, observed_selector) = line.split_once('\t').ok_or_else(|| {
let (resolved_ref, observed_selector) = line.split_once('\t').ok_or_else(|| {
WorkingDirectoryDiagnostic::new(
"repository_ref_response_invalid",
"Repository provider returned an invalid ref observation",
)
})?;
if observed_selector != selector
|| !matches!(revision_ref.len(), 40 | 64)
|| !revision_ref.bytes().all(|byte| byte.is_ascii_hexdigit())
|| !matches!(resolved_ref.len(), 40 | 64)
|| !resolved_ref.bytes().all(|byte| byte.is_ascii_hexdigit())
{
return Err(WorkingDirectoryDiagnostic::new(
"repository_ref_response_invalid",
@@ -1213,10 +1298,9 @@ impl WorkingDirectoryMaterializer for RuntimeGitMaterializer {
Ok(RepositoryRefObservation {
repository_id: request.repository.id.clone(),
source_revision: request.repository.source_revision,
source_fingerprint: request.repository.source_fingerprint.clone(),
selector: selector.to_string(),
revision_ref: revision_ref.to_ascii_lowercase(),
resolved_ref: resolved_ref.to_ascii_lowercase(),
observed_at_epoch_seconds: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
@@ -2294,11 +2378,11 @@ fn validate_ssh_materialization_access(
if access.credential_candidates.is_empty()
|| access.credential_candidates.iter().any(|candidate| {
candidate.credential_id.trim().is_empty()
|| candidate.credential_revision == 0
|| candidate.public_key_fingerprint.trim().is_empty()
|| !candidate.private_key.expose().contains("PRIVATE KEY")
})
|| access.host_trust_id.trim().is_empty()
|| access.host_trust_revision == 0
|| access.host_key_fingerprint.trim().is_empty()
|| access.known_hosts_entry.expose().trim().is_empty()
{
return Err(WorkingDirectoryDiagnostic::new(
@@ -3013,8 +3097,6 @@ mod tests {
operation: crate::resource::BackendResourceOperation::FetchOnce,
expires_at_unix_seconds: i64::MAX,
nonce: "repository-access-1".to_string(),
revision: "1".to_string(),
generation: None,
max_bytes: crate::resource::DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES,
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly,
@@ -3027,11 +3109,11 @@ mod tests {
crate::catalog::RepositorySshMaterializationAccess {
credential_candidates: vec![crate::catalog::RepositorySshCredentialCandidate {
credential_id: "credential-1".to_string(),
credential_revision: 2,
public_key_fingerprint: "key-2".into(),
private_key: crate::catalog::SensitiveString::new("PRIVATE KEY secret bytes"),
}],
host_trust_id: "trust-1".to_string(),
host_trust_revision: 4,
host_key_fingerprint: "key-4".into(),
access: server_api::RepositoryAccessMode::ReadOnly,
expires_at_epoch_seconds: u64::MAX,
repository_id: "repo-main".to_string(),
@@ -3043,7 +3125,7 @@ mod tests {
}
#[test]
fn repository_access_expiry_fences_a_stale_pending_generation_after_refresh() {
fn repository_access_expiry_fences_a_stale_pending_lease_id_after_refresh() {
let scheduler = RepositoryAccessExpiryScheduler::new();
let working_directory_id = "working-directory-refresh";
let old_expiry = repository_access_now_epoch_seconds() + 60;
@@ -3052,9 +3134,9 @@ mod tests {
scheduler
.store_pending(working_directory_id, old_access)
.unwrap();
let old_generation = scheduler
.pending_generation(working_directory_id)
.expect("old pending generation");
let old_lease_id = scheduler
.pending_lease_id(working_directory_id)
.expect("old pending lease_id");
let barrier = Arc::new(std::sync::Barrier::new(2));
let refresh_barrier = Arc::clone(&barrier);
@@ -3069,12 +3151,12 @@ mod tests {
});
barrier.wait();
refresh.join().unwrap();
let refreshed_generation = scheduler
.pending_generation(working_directory_id)
.expect("refreshed pending generation");
assert_ne!(old_generation, refreshed_generation);
let refreshed_lease_id = scheduler
.pending_lease_id(working_directory_id)
.expect("refreshed pending lease_id");
assert_ne!(old_lease_id, refreshed_lease_id);
scheduler.expire_pending_for_test(working_directory_id, old_generation, old_expiry);
scheduler.expire_pending_for_test(working_directory_id, old_lease_id, old_expiry);
let refreshed = scheduler
.pending(working_directory_id)
.unwrap()
@@ -3175,7 +3257,6 @@ mod tests {
kind: server_api::RepositorySourceKind::LocalPath,
uri: repo.display().to_string(),
},
source_revision: 1,
source_fingerprint: "sha256:test".to_string(),
selector: Some(RepositorySelector::from("HEAD")),
},
@@ -3190,6 +3271,48 @@ mod tests {
WorkerRef::new(WorkerId::from_legacy_u64(sequence))
}
#[test]
fn legacy_ssh_evidence_requires_fresh_access_and_is_rewritten_without_counters() {
let repository = create_clean_repo();
let runtime = tempfile::tempdir().unwrap();
let materializer = RuntimeGitMaterializer::new(runtime.path());
let binding = materializer.create(&request(repository.path())).unwrap();
let id = binding.working_directory.id.clone();
let record_path = binding.working_directory_root.join(MATERIALIZATION_RECORD);
let mut record: serde_json::Value =
serde_json::from_slice(&fs::read(&record_path).unwrap()).unwrap();
let evidence = record["working_directory"]["evidence"]
.as_object_mut()
.unwrap();
evidence.remove("repository_access_required");
evidence.insert("credential_revision".into(), serde_json::json!(3));
evidence.insert("host_trust_revision".into(), serde_json::json!(2));
fs::write(&record_path, serde_json::to_vec(&record).unwrap()).unwrap();
drop(binding);
assert_eq!(
materializer
.bind_working_directory(&id, None)
.unwrap_err()
.code,
"working_directory_remote_repository_access_required"
);
let migrated: serde_json::Value =
serde_json::from_slice(&fs::read(&record_path).unwrap()).unwrap();
let evidence = &migrated["working_directory"]["evidence"];
assert_eq!(evidence["repository_access_required"], true);
assert!(evidence.get("credential_revision").is_none());
assert!(evidence.get("host_trust_revision").is_none());
assert!(evidence.get("public_key_fingerprint").is_none());
drop(materializer);
assert_eq!(
RuntimeGitMaterializer::new(runtime.path())
.bind_working_directory(&id, None)
.unwrap_err()
.code,
"working_directory_remote_repository_access_required"
);
}
#[test]
fn repository_ref_observation_reads_the_provider_fresh() {
let repo = create_clean_repo();
@@ -3207,7 +3330,7 @@ mod tests {
.observe_repository_ref(&observation_request)
.unwrap();
assert_eq!(
first.revision_ref,
first.resolved_ref,
git_stdout(repo.path(), ["rev-parse", "published"]).unwrap()
);
fs::write(repo.path().join("second.txt"), "second\n").unwrap();
@@ -3218,9 +3341,9 @@ mod tests {
let second = materializer
.observe_repository_ref(&observation_request)
.unwrap();
assert_ne!(first.revision_ref, second.revision_ref);
assert_ne!(first.resolved_ref, second.resolved_ref);
assert_eq!(
second.revision_ref,
second.resolved_ref,
git_stdout(repo.path(), ["rev-parse", "published"]).unwrap()
);
}
@@ -3290,8 +3413,8 @@ mod tests {
let still_first = materializer
.observe_repository_ref(&observation_request)
.unwrap();
assert_eq!(still_first.revision_ref, first.revision_ref);
assert_ne!(still_first.revision_ref, unpublished_second);
assert_eq!(still_first.resolved_ref, first.resolved_ref);
assert_ne!(still_first.resolved_ref, unpublished_second);
git(
&workdir,
@@ -3300,11 +3423,11 @@ mod tests {
let second = materializer
.observe_repository_ref(&observation_request)
.unwrap();
assert_eq!(second.revision_ref, unpublished_second);
assert_ne!(second.revision_ref, first.revision_ref);
assert_eq!(second.resolved_ref, unpublished_second);
assert_ne!(second.resolved_ref, first.resolved_ref);
assert_ne!(
git_stdout(&cache, ["rev-parse", "HEAD"]).unwrap(),
second.revision_ref
second.resolved_ref
);
}
@@ -3458,7 +3581,6 @@ mod tests {
let mut file_request = request(repo.path());
file_request.repository.source.kind = server_api::RepositorySourceKind::File;
file_request.repository.source.uri = format!("file://{}", repo.path().display());
file_request.repository.source_revision = 2;
file_request.repository.source_fingerprint = "sha256:file-source".to_string();
let file = materializer
.materialize(&worker_ref(3), &file_request)
@@ -3478,7 +3600,6 @@ mod tests {
workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(),
operation_id: "operation-1".to_string(),
config_revision: 7,
config_projection_digest: "sha256:projection".to_string(),
ssh: None,
});
@@ -3511,8 +3632,8 @@ mod tests {
"credential-1"
);
assert_eq!(
serialized["credential_candidates"][0]["credential_revision"],
2
serialized["credential_candidates"][0]["public_key_fingerprint"],
"key-2"
);
assert!(
serialized["credential_candidates"][0]
@@ -3564,27 +3685,26 @@ mod tests {
workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(),
operation_id: "operation-agent".to_string(),
config_revision: 2,
config_projection_digest: "sha256:projection".to_string(),
ssh: Some(crate::catalog::RepositorySshMaterializationAccess {
credential_candidates: vec![
crate::catalog::RepositorySshCredentialCandidate {
credential_id: "credential-1".to_string(),
credential_revision: 1,
public_key_fingerprint: "key-1".into(),
private_key: crate::catalog::SensitiveString::new(
fs::read_to_string(&key_path).unwrap(),
),
},
crate::catalog::RepositorySshCredentialCandidate {
credential_id: "credential-2".to_string(),
credential_revision: 3,
public_key_fingerprint: "key-3".into(),
private_key: crate::catalog::SensitiveString::new(
fs::read_to_string(&fallback_key_path).unwrap(),
),
},
],
host_trust_id: "trust-1".to_string(),
host_trust_revision: 1,
host_key_fingerprint: "key-1".into(),
access: server_api::RepositoryAccessMode::ReadWrite,
expires_at_epoch_seconds: u64::MAX,
repository_id: "repo-main".to_string(),
@@ -3720,11 +3840,11 @@ mod tests {
ssh_backed_binding
.working_directory
.evidence
.credential_revision = Some(1);
.public_key_fingerprint = Some("key-1".into());
ssh_backed_binding
.working_directory
.evidence
.host_trust_revision = Some(1);
.host_key_fingerprint = Some("key-1".into());
materializer.write_record(&ssh_backed_binding).unwrap();
#[cfg(target_os = "linux")]
fail_workdir_cleanup_once(&materializer, &id);
@@ -3809,7 +3929,8 @@ mod tests {
);
let mut rotated = initial_materialization.clone();
rotated.operation_id = "operation-agent-rotated".to_string();
rotated.ssh.as_mut().unwrap().credential_candidates[0].credential_revision = 2;
rotated.ssh.as_mut().unwrap().credential_candidates[0].public_key_fingerprint =
"key-2".into();
rotated.ssh.as_mut().unwrap().access = server_api::RepositoryAccessMode::ReadOnly;
materializer
.authorize_repository_access(&WorkingDirectoryRepositoryAccessRequest {
@@ -3819,8 +3940,8 @@ mod tests {
.unwrap();
let rebound = materializer.bind_working_directory(&id, None).unwrap();
assert_eq!(
rebound.working_directory.evidence.credential_revision,
Some(2)
rebound.working_directory.evidence.public_key_fingerprint,
Some("key-2".into())
);
let rebound_environment = rebound.command_environment();
assert_eq!(rebound_environment["YOI_REPOSITORY_ACCESS"], "read_only");
@@ -3927,7 +4048,8 @@ mod tests {
let mut read_write = rotated;
read_write.operation_id = "operation-agent-read-write".to_string();
read_write.ssh.as_mut().unwrap().credential_candidates[0].credential_revision = 3;
read_write.ssh.as_mut().unwrap().credential_candidates[0].public_key_fingerprint =
"key-3".into();
read_write.ssh.as_mut().unwrap().access = server_api::RepositoryAccessMode::ReadWrite;
let read_write_command_policy =
RepositorySshCommandPolicy::from_access(read_write.ssh.as_ref().unwrap()).unwrap();
@@ -4006,16 +4128,15 @@ mod tests {
workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(),
operation_id: "operation-read-only".to_string(),
config_revision: 2,
config_projection_digest: "sha256:projection".to_string(),
ssh: Some(crate::catalog::RepositorySshMaterializationAccess {
credential_candidates: vec![crate::catalog::RepositorySshCredentialCandidate {
credential_id: "credential-1".to_string(),
credential_revision: 1,
public_key_fingerprint: "key-1".into(),
private_key: crate::catalog::SensitiveString::new("PRIVATE KEY placeholder"),
}],
host_trust_id: "trust-1".to_string(),
host_trust_revision: 1,
host_key_fingerprint: "key-1".into(),
access: server_api::RepositoryAccessMode::ReadOnly,
expires_at_epoch_seconds: u64::MAX,
repository_id: "repo-main".to_string(),
@@ -4176,7 +4297,6 @@ mod tests {
workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(),
operation_id: "operation-1".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(),
ssh: Some(access),
});
@@ -4205,7 +4325,6 @@ mod tests {
workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(),
operation_id: "operation-1".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(),
ssh,
};
@@ -4283,11 +4402,11 @@ mod tests {
crate::catalog::RepositorySshMaterializationAccess {
credential_candidates: vec![crate::catalog::RepositorySshCredentialCandidate {
credential_id: "credential-1".to_string(),
credential_revision: 1,
public_key_fingerprint: "key-1".into(),
private_key: crate::catalog::SensitiveString::new("PRIVATE KEY placeholder"),
}],
host_trust_id: "trust-1".to_string(),
host_trust_revision: 1,
host_key_fingerprint: "key-1".into(),
access: server_api::RepositoryAccessMode::ReadOnly,
expires_at_epoch_seconds: u64::MAX,
repository_id: "repo-main".to_string(),
@@ -4321,7 +4440,6 @@ mod tests {
workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(),
operation_id: "operation-1".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(),
ssh: None,
});
@@ -4498,7 +4616,6 @@ mod tests {
workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(),
operation_id: "operation-1".to_string(),
config_revision: 7,
config_projection_digest: "sha256:projection".to_string(),
ssh,
}
@@ -4641,6 +4758,45 @@ mod tests {
.unwrap();
}
#[cfg(target_os = "linux")]
#[test]
fn legacy_evidence_normalization_is_excluded_by_cleanup_and_cleanup_reads_do_not_rewrite() {
let repo = create_clean_repo();
let runtime_root = tempfile::tempdir().unwrap();
let materializer = RuntimeGitMaterializer::new(runtime_root.path());
let binding = materializer.create(&request(repo.path())).unwrap();
let id = &binding.working_directory.id;
let path = binding
.working_directory_root()
.join(MATERIALIZATION_RECORD);
let mut legacy: serde_json::Value =
serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
legacy["working_directory"]["evidence"]["repository_source_revision"] =
serde_json::json!(7);
let original = serde_json::to_vec_pretty(&legacy).unwrap();
fs::write(&path, &original).unwrap();
let cleanup = materializer.occupancy.acquire_cleanup(id).unwrap();
assert_eq!(
materializer.read_binding(id).unwrap_err().code,
"working_directory_cleanup_resource_busy"
);
materializer.read_binding_for_cleanup(id).unwrap();
assert_eq!(fs::read(&path).unwrap(), original);
drop(cleanup);
materializer.read_binding(id).unwrap();
let canonical: serde_json::Value =
serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
assert!(
canonical["working_directory"]["evidence"]
.get("repository_source_revision")
.is_none()
);
materializer.cleanup_working_directory(id).unwrap();
assert!(!binding.working_directory_root().exists());
}
#[cfg(target_os = "linux")]
#[test]
fn removed_binding_cannot_activate_against_recreated_same_id() {
@@ -190,7 +190,7 @@ fn remove_with_checks(
}
} else {
let mut binding = materializer
.read_binding(id)
.read_binding_for_cleanup(id)
.map_err(|_| cause("ownership_unknown"))?;
validate_binding(materializer, id, &binding)?;
// Preflight the entire tree without following links or crossing mounts,
@@ -278,8 +278,8 @@ fn same_materialization(saved: &WorkingDirectory, current: &WorkingDirectory) ->
// These fields are refreshed by authorize_repository_access, not a new
// materialization. All repository, creation and cleanup identity stays.
directory.evidence.operation_id = None;
directory.evidence.credential_revision = None;
directory.evidence.host_trust_revision = None;
directory.evidence.public_key_fingerprint = None;
directory.evidence.host_key_fingerprint = None;
directory
};
immutable(saved) == immutable(current)
@@ -298,7 +298,7 @@ fn retry_inventory(
return Ok(before);
}
let binding = materializer
.read_binding(id)
.read_binding_for_cleanup(id)
.map_err(|_| cause("changes_present"))?;
validate_binding(materializer, id, &binding)?;
if !same_materialization(&saved.working_directory, &binding.working_directory)
@@ -1211,6 +1211,13 @@ pub(super) mod tests {
for update in ["access", "repository", "creation"] {
let (_runtime, materializer, binding) = fixture();
let id = &binding.working_directory.id;
// Model an existing SSH materialization before taking the witness.
// Refresh replaces its access proof, not its requirement for SSH authority.
let mut record = materializer.read_binding(id).unwrap();
record.working_directory.evidence.repository_access_required = true;
record.working_directory.evidence.public_key_fingerprint = Some("key-1".into());
record.working_directory.evidence.host_key_fingerprint = Some("host-key-1".into());
materializer.write_record(&record).unwrap();
fail_once(&materializer, id);
let mut record = materializer.read_binding(id).unwrap();
match update {
@@ -1218,8 +1225,8 @@ pub(super) mod tests {
// The same serialized fields changed by authorize_repository_access.
record.working_directory.evidence.operation_id =
Some("reattach-operation".into());
record.working_directory.evidence.credential_revision = Some(2);
record.working_directory.evidence.host_trust_revision = Some(2);
record.working_directory.evidence.public_key_fingerprint = Some("key-2".into());
record.working_directory.evidence.host_key_fingerprint = Some("key-2".into());
}
"repository" => {
record.working_directory.repository_id = "substitute".into();
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.
Client-managed PATH validators are compared against freshly resolved Host
metadata. Each mutation captures the ROOT validator, whole-tree revision/digest,
metadata. Each mutation captures the ROOT validator, whole-tree content digest,
canonical entrypoints and node state from that same observation. Backend resolves
the live grant/link and validates canonical changes/CAS under its execution lock.
No operation accepts LLM-supplied validators or `expected_digest` fields. Returned
post-call path validators are refreshed from the exact committed revision, not
substituted with root validators or later unrelated state. UI/Worker edits and
same-bytes replacement revisions reject stale calls. Old descriptors cannot
post-call path validators are refreshed from the exact committed content, not
substituted with root validators or later unrelated state. Content changes and
reattachment to a new connection lifetime reject stale calls, even when the new
attachment exposes the same bytes. Old descriptors cannot
bypass read-only restrictions, detach, revocation or Workspace boundaries.
Bounds: depth 0..8, 256 observed nodes/changes, 512-byte relative paths, 256 KiB
+3 -3
View File
@@ -10,7 +10,7 @@ pub struct BackendJobExecutionBinding {
pub job_id: String,
pub attempt_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_revision: Option<String>,
pub input_digest: Option<String>,
#[serde(default)]
pub subjektiv_consolidation: bool,
}
@@ -19,10 +19,10 @@ impl BackendJobExecutionBinding {
pub fn validate(&self) -> Result<(), &'static str> {
if [&self.job_id, &self.attempt_id]
.into_iter()
.chain(self.input_revision.iter())
.chain(self.input_digest.iter())
.any(|value| value.trim().is_empty() || value.chars().any(char::is_control))
{
return Err("Backend Job binding contains an invalid identity or input revision");
return Err("Backend Job binding contains an invalid identity or input digest");
}
Ok(())
}
+34 -32
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 {
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>(
pending_submissions: &crate::worker::PendingSubmissionHandle<St>,
notify_buffer: &NotifyBuffer,
fence: Option<(u64, &str)>,
fence: Option<&str>,
allow_notification_run: bool,
) -> Result<Option<PendingRun>, crate::worker::PendingSubmissionError> {
Ok(match pending_submissions.prepare_next_activation(fence)? {
@@ -2619,10 +2625,24 @@ async fn controller_loop<C, St>(
pending: pending_submissions.snapshot(),
});
}
Method::CancelPendingSubmission {
submission_id,
expected_revision,
} => match pending_submissions.cancel(&submission_id, expected_revision) {
Method::CancelPendingSubmission { submission_id } => {
match pending_submissions.cancel(&submission_id) {
Ok(pending_snapshot) => {
let _ = working_event_tx.send(Event::PendingSubmissionsChanged {
pending: pending_snapshot,
});
}
Err(error) => {
let _ = working_event_tx.send(Event::Error {
code: ErrorCode::InvalidRequest,
message: error.to_string(),
});
}
}
}
Method::ClearPendingSubmissions {
expected_submission_ids,
} => match pending_submissions.clear(&expected_submission_ids) {
Ok(pending_snapshot) => {
let _ = working_event_tx.send(Event::PendingSubmissionsChanged {
pending: pending_snapshot,
@@ -2635,25 +2655,7 @@ async fn controller_loop<C, St>(
});
}
},
Method::ClearPendingSubmissions { expected_revision } => {
match pending_submissions.clear(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::ContinuePending {
expected_revision,
expected_head_id,
} => {
Method::ContinuePending { expected_head_id } => {
if shared_state.catalog_status() != WorkerStatus::Idle {
let _ = working_event_tx.send(Event::Error {
code: ErrorCode::InvalidRequest,
@@ -2664,7 +2666,7 @@ async fn controller_loop<C, St>(
match prepare_pending_run(
&pending_submissions,
&notify_buffer,
Some((expected_revision, &expected_head_id)),
Some(&expected_head_id),
true,
) {
Ok(Some(next)) => pending = Some(next),
@@ -3023,8 +3025,9 @@ async fn controller_loop<C, St>(
},
Method::Shutdown { command } => {
// Shutdown ignores the state-revision fence but remains bound to the
// current execution generation and command payload identity.
// Shutdown applies to this controller at admission time, not
// a caller-observed Run. Exact command-id/kind retries remain
// valid so every shutdown path can reach the cleanup barrier.
if let Err(disposition) = validate_shutdown_command(command, &shared_state) {
acknowledge_command(
&working_event_tx,
@@ -3619,9 +3622,8 @@ where
}
Some(Method::CancelPendingSubmission {
submission_id,
expected_revision,
}) => {
match pending_submissions.cancel(&submission_id, expected_revision) {
match pending_submissions.cancel(&submission_id) {
Ok(pending) => {
let _ = working_event_tx.send(Event::PendingSubmissionsChanged { pending });
}
@@ -3633,8 +3635,8 @@ where
}
}
}
Some(Method::ClearPendingSubmissions { expected_revision }) => {
match pending_submissions.clear(expected_revision) {
Some(Method::ClearPendingSubmissions { expected_submission_ids }) => {
match pending_submissions.clear(&expected_submission_ids) {
Ok(pending) => {
let _ = working_event_tx.send(Event::PendingSubmissionsChanged { pending });
}
@@ -4009,7 +4011,7 @@ mod tests {
assert_eq!(request.path, "/api/w/workspace-1/drive/root");
Ok(crate::worker::WorkspaceResponse { status: 200, body: serde_json::json!({
"entry": {"workspace_id": "workspace-1", "node_id": "1"},
"parent": null, "name": "", "kind": "folder", "revision": "1",
"parent": null, "name": "", "kind": "folder", "last_mutation_id": "",
"size": null, "content_type": null, "updated_by": "test",
"updated_at": "2026-10-08T00:00:00Z",
"latest_url": "/api/w/workspace-1/drive/download?entry_workspace_id=workspace-1&id=1"
+1 -1
View File
@@ -756,7 +756,7 @@ mod tests {
workspace_id: Some("workspace".into()),
worker_id: "worker".into(),
session_id: "session".into(),
session_revision: 3,
run_id: Some("run".into()),
turn_index: Some(2),
call_id: None,
@@ -2,7 +2,7 @@
//!
//! The tool intentionally accepts only the result value. Workspace and Worker
//! identity come from the authenticated Workspace client, while Job, attempt,
//! and input revision are resolved from the Backend-owned binding.
//! and input digest are resolved from the Backend-owned binding.
use crate::feature::background::BackgroundTaskCancellation;
use std::collections::HashMap;
@@ -22,7 +22,7 @@ use crate::feature::{
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
const TOOL_NAME: &str = "SubmitBackendJobResult";
const TOOL_DESCRIPTION: &str = "Submit the bounded structured result for this Backend-owned Job attempt. Workspace, Job, attempt, input revision, and source Worker identity are resolved and fenced by Backend authority; normal final prose is not success.";
const TOOL_DESCRIPTION: &str = "Submit the bounded structured result for this Backend-owned Job attempt. Workspace, Job, attempt, input digest, and source Worker identity are resolved and fenced by Backend authority; normal final prose is not success.";
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
@@ -194,7 +194,7 @@ impl Tool for SubmitResultTool {
let sealed = submission.sealed.as_mut().expect("sealed before dispatch");
if definitive_rejection && !was_previously_sealed {
// Only a definitively unaccepted request can be corrected. Reuse
// a ready snapshot after a new preparation confirms its revision;
// a ready snapshot after a new preparation confirms its Memory fingerprint;
// a conflict invalidates even that snapshot. Failed outcomes are
// rebuilt, since another generation can replace their marker.
submission.reusable_surface = if response.status != 409 {
+17 -16
View File
@@ -188,7 +188,8 @@ impl DriveFeature {
return Err(DriveError::Limit);
}
let read = self.backend.read(&args.entry, args.max_bytes).await?;
if pinned.is_some_and(|entry| entry.revision != read.entry.revision) {
if pinned.is_some_and(|entry| entry.last_mutation_id != read.entry.last_mutation_id)
{
return Err(DriveError::Conflict);
}
self.observe(read.entry.clone())?;
@@ -260,7 +261,7 @@ impl DriveFeature {
request_id,
mutation: DriveMutation::UpdateText {
id: entry.entry,
expected_revision: entry.revision,
expected_mutation_id: entry.last_mutation_id,
text,
content_type: entry.content_type.unwrap_or_else(default_media),
},
@@ -281,7 +282,7 @@ impl DriveFeature {
.backend
.read(&entry.entry, DRIVE_TEXT_MAX_BYTES as u32)
.await?;
if read.entry.revision != entry.revision {
if read.entry.last_mutation_id != entry.last_mutation_id {
return Err(DriveError::Conflict);
}
if read.truncated {
@@ -297,7 +298,7 @@ impl DriveFeature {
request_id,
mutation: DriveMutation::UpdateText {
id: entry.entry,
expected_revision: entry.revision,
expected_mutation_id: entry.last_mutation_id,
text,
content_type: entry.content_type.unwrap_or_else(default_media),
},
@@ -316,7 +317,7 @@ impl DriveFeature {
request_id,
mutation: DriveMutation::Relocate {
id: entry.entry,
expected_revision: entry.revision,
expected_mutation_id: entry.last_mutation_id,
parent: args.parent,
name: args.name,
},
@@ -334,7 +335,7 @@ impl DriveFeature {
request_id,
mutation: DriveMutation::Delete {
id: entry.entry,
expected_revision: entry.revision,
expected_mutation_id: entry.last_mutation_id,
},
})
.await?,
@@ -385,7 +386,7 @@ impl DriveFeature {
parent_id: Some(args.parent.node_id),
name: Some(args.name),
id: None,
expected_revision: None,
expected_mutation_id: None,
content_type: args.content_type,
size: bytes.len() as u32,
sha256: digest(&bytes),
@@ -399,7 +400,7 @@ impl DriveFeature {
}
_ => return Err(DriveError::Invalid("unknown operation".into())),
};
// A successful mutation does not silently substitute its new revision
// A successful mutation does not silently substitute its new observation
// into a prior observation. The caller observes explicitly next time.
if let Some(id) = mutated_id {
self.observations
@@ -640,13 +641,13 @@ pub(super) const SPECS: &[Spec] = &[
Spec {
name: "DriveMetadata",
operation: "metadata",
description: "Inspect one Workspace-bound stable entry ID and observe its revision for mutation.",
description: "Inspect one Workspace-bound stable entry ID and retain its last committed request for mutation checks.",
schema: schema::<EntryArgs>,
},
Spec {
name: "DriveList",
operation: "list",
description: "List a bounded folder page (default root), up to 200 entries. Results do not expand the WIP tree or observe revisions for writes.",
description: "List a bounded folder page (default root), up to 200 entries. Results do not expand the WIP tree or establish mutation preconditions.",
schema: schema::<ListArgs>,
},
Spec {
@@ -658,7 +659,7 @@ pub(super) const SPECS: &[Spec] = &[
Spec {
name: "DriveRead",
operation: "read",
description: "Read up to 64 KiB UTF-8 text and observe entry revision. Past Session text remains an immutable observation.",
description: "Read up to 64 KiB UTF-8 text and retain the entry’s last committed request. Past Session text remains an immutable observation.",
schema: schema::<ReadArgs>,
},
Spec {
@@ -676,31 +677,31 @@ pub(super) const SPECS: &[Spec] = &[
Spec {
name: "DriveWrite",
operation: "write",
description: "Replace <=64 KiB text using a previously read/metadata-observed revision. Conflicts are not retried with latest revision.",
description: "Replace <=64 KiB text using a previously observed committed request. Conflicts are not retried with a newer observation.",
schema: schema::<WriteArgs>,
},
Spec {
name: "DriveEdit",
operation: "edit",
description: "Partial text edit using previously observed revision and shared old_string rules. Require nonempty unique match unless replace_all. Truncated preimages cannot be edited.",
description: "Partial text edit using previously observed committed request and shared old_string rules. Require nonempty unique match unless replace_all. Truncated preimages cannot be edited.",
schema: schema::<EditArgs>,
},
Spec {
name: "DriveRelocate",
operation: "relocate",
description: "Rename and/or move an observed entry with revision precondition; stable ID and URL remain unchanged.",
description: "Rename and/or move an observed entry only if its last committed request still matches the observation; stable ID and URL remain unchanged.",
schema: schema::<RelocateArgs>,
},
Spec {
name: "DriveDelete",
operation: "delete",
description: "Delete one observed entry with revision precondition. A recreated name has a different ID; stale observations cannot target it.",
description: "Delete one observed entry only if its last committed request still matches the observation. A recreated name has a different ID; stale observations cannot target it.",
schema: schema::<EntryArgs>,
},
Spec {
name: "DriveViewImage",
operation: "view_image",
description: "Attach revision-fixed PNG/JPEG/GIF/WebP bytes (<=10 MiB) through durable ToolOutput history/capture; a URL alone is not image viewing.",
description: "Attach committed-request-bound PNG/JPEG/GIF/WebP bytes (<=10 MiB) through durable ToolOutput history/capture; a URL alone is not image viewing.",
schema: schema::<EntryArgs>,
},
Spec {
@@ -22,7 +22,9 @@ pub enum DriveError {
Denied,
#[error("Drive resource not found")]
NotFound,
#[error("Drive revision or name conflict; observe explicitly before another operation")]
#[error(
"Drive content, location, or name conflict; observe explicitly before another operation"
)]
Conflict,
#[error("Invalid Drive arguments: {0}")]
Invalid(String),
@@ -54,7 +56,11 @@ impl DriveBackend {
if let Some(parent) = &entry.parent {
self.validate_ref(parent)?;
}
if !decimal(&entry.revision) || entry.size.is_some_and(|n| n > DRIVE_FILE_MAX_BYTES) {
if (entry.last_mutation_id.len() > 128
|| entry.last_mutation_id.chars().any(char::is_control)
|| (entry.parent.is_some() && entry.last_mutation_id.is_empty()))
|| entry.size.is_some_and(|n| n > DRIVE_FILE_MAX_BYTES)
{
return Err(DriveError::Unavailable);
}
Ok(())
@@ -238,14 +244,14 @@ impl DriveBackend {
return Err(DriveError::Limit);
}
let mut bytes = Vec::with_capacity(total as usize);
// Even an empty file gets a revision-fixed read and current authorization.
// Even an empty file gets a read bound to the observed committed mutation and current authorization.
loop {
let offset = bytes.len() as u32;
let length = total.saturating_sub(offset).clamp(1, DRIVE_CHUNK_MAX_BYTES);
let query = query_string(&DriveReadChunkQuery {
entry_workspace_id: entry.entry.workspace_id.clone(),
id: entry.entry.node_id.clone(),
expected_revision: entry.revision.clone(),
expected_mutation_id: entry.last_mutation_id.clone(),
offset,
length,
})?;
@@ -144,9 +144,8 @@ impl WorkspaceClient for InternalChildWorkspaceClient {
fn current_prompt_projection(
&self,
minimum_revision: Option<u64>,
) -> Result<Option<WorkspacePromptCatalogResolution>, WorkspaceClientError> {
self.inner.current_prompt_projection(minimum_revision)
self.inner.current_prompt_projection()
}
fn list_workspace_workers(
@@ -181,7 +180,7 @@ mod tests {
enum Call {
Json(WorkspaceRequest, Option<Duration>),
Binary(WorkspaceBinaryRequest, Option<Duration>),
Projection(Option<u64>),
Projection,
Discovery(WorkspaceWorkerDiscoveryRequest),
Remove(String, String, String),
Server,
@@ -250,9 +249,8 @@ mod tests {
}
fn current_prompt_projection(
&self,
revision: Option<u64>,
) -> 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()))
}
fn list_workspace_workers(
@@ -390,7 +388,7 @@ mod tests {
expected_bytes
);
assert!(
matches!(client.current_prompt_projection(Some(42)), Err(WorkspaceClientError::Request(message)) if message == "projection marker")
matches!(client.current_prompt_projection(), Err(WorkspaceClientError::Request(message)) if message == "projection marker")
);
let discovery = WorkspaceWorkerDiscoveryRequest {
cursor: Some("next".into()),
@@ -422,7 +420,7 @@ mod tests {
Call::Json(request, Some(timeout)),
Call::Binary(bytes.clone(), None),
Call::Binary(bytes, Some(timeout)),
Call::Projection(Some(42)),
Call::Projection,
Call::Discovery(discovery),
Call::Remove("runtime".into(), "worker".into(), "reason".into()),
Call::Server,
@@ -44,7 +44,7 @@ fn response(value: impl Serialize) -> WorkspaceResponse {
body: serde_json::to_string(&value).unwrap(),
}
}
fn entry(id: &str, revision: &str) -> DriveEntry {
fn entry(id: &str, last_mutation_id: &str) -> DriveEntry {
DriveEntry {
entry: DriveEntryRef {
workspace_id: "workspace".into(),
@@ -56,7 +56,7 @@ fn entry(id: &str, revision: &str) -> DriveEntry {
}),
name: "note.md".into(),
kind: DriveEntryKind::File,
revision: revision.into(),
last_mutation_id: last_mutation_id.into(),
size: Some(5),
content_type: Some("text/markdown".into()),
updated_by: "worker".into(),
@@ -83,7 +83,7 @@ async fn execute(
}
#[test]
fn drive_feature_activation_never_creates_authority_and_schemas_hide_revisions() {
fn drive_feature_activation_never_creates_authority_and_schemas_hide_last_mutation_ids() {
let client = Client::new(Vec::new());
let router = Arc::new(WorkdirSessionRouter::new());
assert!(DriveFeature::configured(client.clone(), false, router.clone()).is_none());
@@ -100,7 +100,7 @@ fn drive_feature_activation_never_creates_authority_and_schemas_hide_revisions()
let (meta, _) = definition();
assert_eq!(name, meta.name);
let schema = meta.input_schema.to_string();
assert!(!schema.contains("expected_revision"), "{name}");
assert!(!schema.contains("expected_mutation_id"), "{name}");
assert!(!schema.contains("grant_id"), "{name}");
}
assert_eq!(feature.descriptor().instructions.len(), 1);
@@ -139,7 +139,7 @@ async fn foreign_or_unobserved_entry_cannot_dispatch_mutation() {
}
#[tokio::test]
async fn stale_edit_returns_conflict_without_upgrading_revision_or_dispatching_write() {
async fn stale_edit_returns_conflict_without_upgrading_last_mutation_id_or_dispatching_write() {
let old = entry("2", "1");
let current = entry("2", "2");
let client = Client::new(vec![response(DriveReadTextResponse {
@@ -157,7 +157,10 @@ async fn stale_edit_returns_conflict_without_upgrading_revision_or_dispatching_w
.await;
assert!(matches!(result, Err(DriveError::Conflict)));
assert_eq!(client.requests.lock().unwrap().len(), 1);
assert_eq!(feature.observed(&old.entry, None).unwrap().revision, "1");
assert_eq!(
feature.observed(&old.entry, None).unwrap().last_mutation_id,
"1"
);
}
#[tokio::test]
@@ -192,7 +195,7 @@ async fn shared_edit_rules_reject_ambiguous_and_truncated_preimages_without_muta
}
#[tokio::test]
async fn successful_mutation_uses_observed_decimal_revision_and_requires_next_explicit_observation()
async fn successful_mutation_uses_observed_committed_request_id_and_requires_next_explicit_observation()
{
let old = entry("9007199254740993", "9007199254740993");
let updated = entry("9007199254740993", "9007199254740994");
@@ -220,7 +223,7 @@ async fn successful_mutation_uses_observed_decimal_revision_and_requires_next_ex
.unwrap();
let requests = client.requests.lock().unwrap();
let body: Value = serde_json::from_str(requests[0].body.as_ref().unwrap()).unwrap();
assert_eq!(body["mutation"]["expected_revision"], "9007199254740993");
assert_eq!(body["mutation"]["expected_mutation_id"], "9007199254740993");
assert_eq!(body["mutation"]["id"]["node_id"], "9007199254740993");
drop(requests);
assert!(feature.observed(&old.entry, None).is_err());
@@ -296,7 +299,7 @@ async fn uncommitted_receipt_snapshot_does_not_turn_unknown_into_failure_or_retr
fn invalid_completions(id: &str) -> Vec<DriveMutationResponse> {
let mut invalid_entry = entry("2", "2");
invalid_entry.revision = "not-a-revision".into();
invalid_entry.last_mutation_id = "\ninvalid-request-id".into();
vec![
DriveMutationResponse {
request_id: "another-request".into(),
@@ -340,7 +343,7 @@ async fn invalid_mutation_completion_recovers_from_original_receipt_without_rese
.await
.unwrap();
assert_eq!(output.value["request_id"], id);
assert_eq!(output.value["entry"]["metadata"]["revision"], "2");
assert_eq!(output.value["entry"]["metadata"]["last_mutation_id"], "2");
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
assert_eq!(
@@ -354,7 +357,7 @@ async fn invalid_mutation_completion_recovers_from_original_receipt_without_rese
assert!(requests[1].path.ends_with(&format!("/requests/{id}")));
let body: Value = serde_json::from_str(requests[0].body.as_ref().unwrap()).unwrap();
assert_eq!(body["request_id"], id);
assert_eq!(body["mutation"]["expected_revision"], "1");
assert_eq!(body["mutation"]["expected_mutation_id"], "1");
}
}
@@ -499,7 +502,7 @@ async fn drive_native_entry_is_directly_resolvable_but_never_indexable_and_binds
!operations
.iter()
.flat_map(|op| &op.parameters)
.any(|parameter| parameter.name == "expected_revision")
.any(|parameter| parameter.name == "expected_mutation_id")
);
assert_eq!(client.requests.lock().unwrap().len(), 1);
}
@@ -556,7 +559,7 @@ fn chunk(bytes: Vec<u8>) -> crate::worker::WorkspaceBinaryResponse {
}
}
#[tokio::test]
async fn multi_chunk_image_uses_one_revision_and_never_exposes_binary_in_text_content() {
async fn multi_chunk_image_uses_one_last_mutation_id_and_never_exposes_binary_in_text_content() {
let mut metadata = entry("2", "9007199254740993");
let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
bytes.resize(DRIVE_CHUNK_MAX_BYTES as usize + 13, 42);
@@ -578,7 +581,11 @@ async fn multi_chunk_image_uses_one_revision_and_never_exposes_binary_in_text_co
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
for request in &*requests {
assert!(request.path.contains("expected_revision=9007199254740993"));
assert!(
request
.path
.contains("expected_mutation_id=9007199254740993")
);
assert_eq!(request.max_response_bytes, DRIVE_CHUNK_MAX_BYTES as usize);
}
assert!(requests[0].path.split('&').any(|pair| pair == "offset=0"));
@@ -590,7 +597,7 @@ async fn multi_chunk_image_uses_one_revision_and_never_exposes_binary_in_text_co
);
}
#[tokio::test]
async fn expired_revision_or_partial_chunk_aborts_image_without_rebinding_or_retrying() {
async fn expired_last_mutation_id_or_partial_chunk_aborts_image_without_rebinding_or_retrying() {
for next in [
crate::worker::WorkspaceBinaryResponse {
status: 409,
@@ -618,12 +625,12 @@ async fn expired_revision_or_partial_chunk_aborts_image_without_rebinding_or_ret
assert!(
requests
.iter()
.all(|request| request.path.contains("expected_revision=3&"))
.all(|request| request.path.contains("expected_mutation_id=3&"))
);
assert_eq!(
client.json.requests.lock().unwrap().len(),
1,
"must not fetch a new revision"
"must not fetch a new last_mutation_id"
);
}
}
@@ -642,7 +649,7 @@ async fn oversized_images_and_unknown_arguments_fail_before_binary_transfer() {
execute(
&feature,
"DriveRead",
json!({"entry":metadata.entry,"expected_revision":"1"})
json!({"entry":metadata.entry,"expected_mutation_id":"1"})
)
.await
.is_err()
@@ -1,5 +1,5 @@
//! Native non-indexable Drive entries. Publication resolves just one ID through
//! current Backend authorization; the Client owns identity/revision validators.
//! current Backend authorization; the Client owns entry/committed-request validators.
use super::{
DriveFeature, SPECS, Spec,
backend::{DriveError, decimal},
@@ -101,7 +101,7 @@ impl WipSubtreeProvider for DriveProvider {
};
let descriptor = descriptor(&specs, root);
let reference = contextual_reference(INTERFACE, path);
let validator = entry.revision.as_bytes().to_vec();
let validator = entry.last_mutation_id.as_bytes().to_vec();
let object = Object {
name: if root {
"drive".into()
@@ -183,7 +183,7 @@ fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclarati
}
}
fn descriptor(specs: &[Spec], _root: bool) -> InterfaceDescriptor {
InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation {summary:"Backend-authorized Workspace Drive operations".into(),details:Some("Entry IDs are Workspace-bound. The Host/Client manage revisions; never supply validators or retry conflicts/outcome-unknown with a new revision. Read-only grants reject mutations at Backend. List/search return paths for direct Inspect, never tree inventories. Image bytes attach to durable ToolOutput; URLs are not image viewing. Workdir source read and Drive destination write are independent.".into())}),types:Vec::new(),operations:specs.iter().map(|spec|OperationDeclaration {name:spec.operation.into(),documentation:Some(Documentation {summary:spec.description.into(),details:None}),parameters:match spec.operation {
InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation {summary:"Backend-authorized Workspace Drive operations".into(),details:Some("Entry IDs are Workspace-bound. The Host/Client retain the last committed request observed for an entry. Never supply validators or silently retry a conflict against a newer observation. On outcome-unknown, query the original request receipt. Read-only grants reject mutations at Backend. List/search return paths for direct Inspect, never tree inventories. Image bytes attach to durable ToolOutput; URLs are not image viewing. Workdir source read and Drive destination write are independent.".into())}),types:Vec::new(),operations:specs.iter().map(|spec|OperationDeclaration {name:spec.operation.into(),documentation:Some(Documentation {summary:spec.description.into(),details:None}),parameters:match spec.operation {
"list"=>vec![parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)],
"search"=>vec![parameter("query",true,TypeExpr::String),parameter("include_text",false,TypeExpr::Boolean),parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)],
"read"=>vec![parameter("max_bytes",false,TypeExpr::Integer)],
@@ -230,7 +230,7 @@ impl WipOperationHandler for DriveHandler {
.any(|key| !declaration.parameters.iter().any(|p| p.name == *key))
{
return Err(map_error(DriveError::Invalid(
"unrecognized arguments; revision metadata is Client-managed".into(),
"unrecognized arguments; committed-request observations are Client-managed".into(),
)));
}
let mut input = wip_to_json(&Value::Record(arguments.clone()))
@@ -296,12 +296,14 @@ impl WipOperationHandler for DriveHandler {
// Attachments use the existing ToolOutput side channel and durable Engine
// capture, not transient request injection or URL-only success.
let result = if matches!(operation, "write" | "edit" | "relocate") {
let revision = output.value["entry"]["metadata"]["revision"]
let last_mutation_id = output.value["entry"]["metadata"]["last_mutation_id"]
.as_str()
.ok_or_else(|| {
WipOperationError::OutcomeUnknown("Committed Drive revision unavailable".into())
WipOperationError::OutcomeUnknown(
"Committed Drive request identity unavailable".into(),
)
})?;
WipOperationOutput::native_with_validator(value, revision.as_bytes().to_vec())
WipOperationOutput::native_with_validator(value, last_mutation_id.as_bytes().to_vec())
} else {
WipOperationOutput::native(value)
};
@@ -557,7 +557,7 @@ where
input,
cache_key: Some(format!(
"flow:{}:{}",
attempt.instance_id, attempt.checked_state_revision
attempt.instance_id, attempt.attempt_id
)),
max_turns: Some(12),
engine_configurator: None,
@@ -653,9 +653,7 @@ mod tests {
FlowTransitionAttempt {
attempt_id: id.to_string(),
instance_id: "instance-1".to_string(),
definition_revision: 1,
definition_digest: "sha256:test".to_string(),
checked_state_revision: 0,
from_state: StateId::new("work").unwrap(),
reason: "ready".to_string(),
transitions: vec![
@@ -711,7 +709,6 @@ mod tests {
selector: "workspace:test-flow".parse().unwrap(),
workspace_id: "workspace-1".to_string(),
flow_id: "flow-1".to_string(),
revision: 1,
content_digest: definition.content_digest.clone(),
definition,
},
@@ -419,7 +419,7 @@ impl WipOperationHandler for CollectionHandler {
let mut digest = Sha256::new();
digest.update(self.domain.route());
if matches!(self.domain, Domain::Workdir | Domain::Attachment) {
// The complete-set revision is computed by the existing Backend
// The complete-set digest is computed by the existing Backend
// registry/ledger in the same snapshot as a bounded page. Never infer
// collection freshness from just the first page's items.
let path = if matches!(self.domain, Domain::Workdir) {
@@ -431,13 +431,13 @@ impl WipOperationHandler for CollectionHandler {
.provider
.request(path)
.ok()
.and_then(|p| p.get("revision").and_then(Json::as_str).map(str::to_owned))
.and_then(|p| p.get("digest").and_then(Json::as_str).map(str::to_owned))
{
Some(revision) => {
Some(inventory_digest) => {
digest.update([0]);
digest.update(revision);
digest.update(inventory_digest);
}
None => digest.update(b"inventory-revision-unavailable"),
None => digest.update(b"inventory-digest-unavailable"),
}
return Some(digest.finalize().to_vec());
}
@@ -533,9 +533,9 @@ impl WipOperationHandler for CollectionHandler {
"invalid bounded inventory page".into(),
)
})?;
if page.get("revision").and_then(Json::as_str).is_none() {
if page.get("digest").and_then(Json::as_str).is_none() {
return Err(WipOperationError::OutcomeUnknown(
"inventory page revision missing".into(),
"inventory page digest missing".into(),
));
}
let mut items = Vec::new();
@@ -1162,7 +1162,7 @@ pub(crate) mod tests {
let cursor = query.get("cursor").copied();
let mut all: Vec<_> = state.workdirs.iter().collect();
all.sort_by_key(|v| v["working_directory_id"].as_str().unwrap());
let revision = Sha256::digest(serde_json::to_vec(&all).unwrap())
let digest = Sha256::digest(serde_json::to_vec(&all).unwrap())
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>();
@@ -1179,7 +1179,7 @@ pub(crate) mod tests {
} else {
None
};
json!({"items":items, "next_cursor":next, "revision":revision})
json!({"items":items, "next_cursor":next, "digest":digest})
}
(WorkspaceRequestMethod::Get, "workers/self/workdir-attachments") => {
let query: BTreeMap<_, _> = path
@@ -1191,12 +1191,12 @@ pub(crate) mod tests {
.collect();
let mut all: Vec<_> = state.attachments.iter().collect();
all.sort_by_key(|v| v["alias"].as_str().unwrap());
let revision = Sha256::digest(serde_json::to_vec(&all).unwrap())
let digest = Sha256::digest(serde_json::to_vec(&all).unwrap())
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>();
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 {
let limit = query
.get("limit")
@@ -1209,7 +1209,7 @@ pub(crate) mod tests {
let count = all.len();
let items: Vec<_> = all.into_iter().skip(offset).take(limit).collect();
let next = (offset + limit < count).then_some(offset + limit);
json!({"items":items, "next_offset":next, "revision":revision})
json!({"items":items, "next_offset":next, "digest":digest})
}
}
(WorkspaceRequestMethod::Get, p) if p.starts_with("repositories/") => {
+100 -58
View File
@@ -5,7 +5,6 @@
//! workspace backend instead of resolving `.yoi/memory` from a Worker workdir.
use crate::subjektiv::{SubjektivHostConnection, SubjektivHostError};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
#[cfg(test)]
use std::time::Duration;
@@ -335,21 +334,21 @@ fn subjektiv_consolidation_tools(host: SubjektivHostConnection) -> Vec<ToolDefin
[
(
"SubjektivMemoryQuery",
"Search current Memory revisions for the delegated subject.",
"Search current Memory changes for the delegated subject.",
schema_for::<server_api::SubjektivMemoryQueryRequest>(),
Operation::Query,
),
(
"SubjektivMemoryRead",
"Read one current or historical Memory revision for the delegated subject.",
"Read one current or historical Memory change for the delegated subject.",
schema_for::<server_api::SubjektivMemoryReadRequest>(),
Operation::Read,
),
(
"SubjektivMemoryListRevisions",
"List immutable revisions of one Memory for the delegated subject.",
schema_for::<server_api::SubjektivMemoryListRevisionsRequest>(),
Operation::ListRevisions,
"SubjektivMemoryListChanges",
"List immutable changes of one Memory for the delegated subject.",
schema_for::<server_api::SubjektivMemoryListChangesRequest>(),
Operation::ListChanges,
),
(
"MemoryStagingList",
@@ -359,13 +358,13 @@ fn subjektiv_consolidation_tools(host: SubjektivHostConnection) -> Vec<ToolDefin
),
(
"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>(),
Operation::ReadCandidate,
),
(
"MemoryApplyCandidate",
"Atomically apply or reject one subject candidate. A request_id is idempotent; application revalidates exact revision proposal metadata and returns concrete affected revision refs.",
"Atomically apply or reject one subject candidate. A request_id is idempotent; application revalidates exact change proposal metadata and returns concrete affected change refs.",
schema_for::<server_api::SubjektivMemoryCandidateDecisionRequest>(),
Operation::DecideCandidate,
),
@@ -392,7 +391,7 @@ fn subjektiv_consolidation_tools(host: SubjektivHostConnection) -> Vec<ToolDefin
enum SubjectConsolidationOperation {
Query,
Read,
ListRevisions,
ListChanges,
ListCandidates,
ReadCandidate,
DecideCandidate,
@@ -418,8 +417,8 @@ impl Tool for SubjectConsolidationTool {
Operation::Read => {
server_api::SubjektivMemoryBackendOperation::Read(parse_input(input_json)?)
}
Operation::ListRevisions => {
server_api::SubjektivMemoryBackendOperation::ListRevisions(parse_input(input_json)?)
Operation::ListChanges => {
server_api::SubjektivMemoryBackendOperation::ListChanges(parse_input(input_json)?)
}
Operation::ListCandidates => {
server_api::SubjektivMemoryBackendOperation::ListCandidates(parse_input(
@@ -452,12 +451,12 @@ impl Tool for SubjectConsolidationTool {
}
server_api::SubjektivMemoryBackendResponse::Read(value) => {
format!(
"Read Memory {} revision {}.",
value.memory_id, value.revision
"Read Memory {} change {}.",
value.memory_id, value.change_id
)
}
server_api::SubjektivMemoryBackendResponse::ListRevisions(value) => {
format!("Listed {} revision(s).", value.items.len())
server_api::SubjektivMemoryBackendResponse::ListChanges(value) => {
format!("Listed {} change(s).", value.items.len())
}
server_api::SubjektivMemoryBackendResponse::Candidates(value) => {
format!("Listed {} pending candidate(s).", value.items.len())
@@ -640,8 +639,7 @@ struct WorkspaceResidentSummarySource {
#[derive(Default)]
struct SubjectBehaviorRefreshState {
applied: Option<(u64, String)>,
pending: HashMap<u64, String>,
applied: Option<String>,
}
struct WorkspaceSubjektivResidentSummarySource {
@@ -727,8 +725,7 @@ impl WorkspaceSubjektivResidentSummarySource {
.behavior_state
.lock()
.expect("subject behavior state poisoned");
state.applied = Some((output.behavior_revision, output.behavior_md.clone()));
state.pending.remove(&output.behavior_revision);
state.applied = Some(output.behavior_md.clone());
}
}
@@ -769,33 +766,22 @@ impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource
.expect("subject behavior state poisoned")
.applied
.as_ref()
.is_none_or(|(revision, body)| {
*revision != output.behavior_revision || body != &output.behavior_md
});
.is_none_or(|body| body != &output.behavior_md);
if !changed {
return Ok(None);
}
let rendered = self.render(&output)?;
let revision = output.behavior_revision;
self.behavior_state
.lock()
.expect("subject behavior state poisoned")
.pending
.insert(revision, output.behavior_md);
Ok(Some(ResidentContextRefresh {
body: rendered,
revision,
behavior_md: output.behavior_md,
}))
}
fn confirm_resident_context_revision(&self, revision: u64) {
let mut state = self
.behavior_state
fn confirm_resident_context_behavior(&self, behavior_md: &str) {
self.behavior_state
.lock()
.expect("subject behavior state poisoned");
if let Some(body) = state.pending.remove(&revision) {
state.applied = Some((revision, body));
}
.expect("subject behavior state poisoned")
.applied = Some(behavior_md.to_owned());
}
fn invalidate_resident_context_representation(&self) {
@@ -942,7 +928,7 @@ impl Hook<PreLlmRequest> for SubjektivBehaviorRefreshHook {
"Subject behavior refresh requires durable session append authority",
)
})?;
system_items.append_subject_behavior_refresh(refresh.body, refresh.revision);
system_items.append_subject_behavior_refresh(refresh.body, refresh.behavior_md);
}
Ok(HookPreRequestAction::Continue)
}
@@ -971,7 +957,7 @@ impl Hook<WorkerRestored> for SubjektivResidentRestoreRefreshHook {
body,
session_store::PromptRenderProvenance {
workspace_id: self.workspace_id.clone(),
config_revision: projection.config_revision,
source_digest: projection.source_digest.clone(),
projection_digest: projection.catalog_digest.clone(),
logical_name: WorkerPrompt::ResidentMemoryRestoreSection.key().to_string(),
@@ -1270,7 +1256,7 @@ mod tests {
availability: memory::backend::MemoryResidentSummaryAvailability,
content: Option<String>,
scope_allowed: bool,
behavior: Mutex<(u64, String)>,
behavior: Mutex<String>,
paths: Mutex<Vec<String>>,
timeouts: Mutex<Vec<Duration>>,
}
@@ -1284,7 +1270,7 @@ mod tests {
availability,
content: content.map(str::to_string),
scope_allowed: true,
behavior: Mutex::new((3, "Be deliberate.".to_string())),
behavior: Mutex::new("Be deliberate.".to_string()),
paths: Mutex::new(Vec::new()),
timeouts: Mutex::new(Vec::new()),
}
@@ -1295,7 +1281,7 @@ mod tests {
availability: memory::backend::MemoryResidentSummaryAvailability::Ready,
content: Some("must not be injected".into()),
scope_allowed: false,
behavior: Mutex::new((3, "must not be injected".to_string())),
behavior: Mutex::new("must not be injected".to_string()),
paths: Mutex::new(Vec::new()),
timeouts: Mutex::new(Vec::new()),
}
@@ -1333,14 +1319,13 @@ mod tests {
body: "subject scope unavailable".into(),
});
}
let (behavior_revision, behavior_md) = self.behavior.lock().unwrap().clone();
let behavior_md = self.behavior.lock().unwrap().clone();
Ok(WorkspaceResponse {
status: 200,
body: serde_json::to_string(
&server_api::SubjektivMemoryBackendResponse::ResidentContext(
server_api::SubjektivResidentContextOutput {
behavior_md,
behavior_revision,
memory_surface: memory::backend::MemoryResidentSummaryOutput {
availability: self.availability,
content: self.content.clone(),
@@ -1483,7 +1468,7 @@ mod tests {
enabled
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace".to_string(),
settings_revision: 1,
language: "English".to_string(),
})
.unwrap();
@@ -1543,7 +1528,7 @@ mod tests {
.subjektiv
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace".into(),
settings_revision: 1,
language: "English".into(),
})
.unwrap();
@@ -1586,7 +1571,7 @@ mod tests {
config
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace".to_string(),
settings_revision: 1,
language: "English".to_string(),
})
.unwrap();
@@ -1643,7 +1628,7 @@ permission = "write"
.subjektiv
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: workspace_id.to_string(),
settings_revision: 1,
language: "English".to_string(),
})
.unwrap();
@@ -1712,7 +1697,7 @@ permission = "write"
}
#[tokio::test]
async fn subject_resident_source_emits_only_changed_behavior_revisions() {
async fn subject_resident_source_emits_only_changed_behavior() {
let manifest = subject_manifest("workspace");
let client = Arc::new(SubjectResidentClient::new(
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
@@ -1732,15 +1717,15 @@ permission = "write"
.is_none()
);
*client.behavior.lock().unwrap() = (4, "Ask when uncertain.".to_string());
*client.behavior.lock().unwrap() = "Ask when uncertain.".to_string();
let refresh = source
.load_changed_resident_context()
.await
.unwrap()
.expect("changed behavior must produce one refresh");
assert_eq!(refresh.revision, 4);
assert_eq!(refresh.behavior_md, "Ask when uncertain.");
assert!(refresh.body.contains("Ask when uncertain."));
source.confirm_resident_context_revision(refresh.revision);
source.confirm_resident_context_behavior(&refresh.behavior_md);
assert!(
source
.load_changed_resident_context()
@@ -1749,15 +1734,15 @@ permission = "write"
.is_none()
);
*client.behavior.lock().unwrap() = (5, String::new());
*client.behavior.lock().unwrap() = String::new();
let cleared = source
.load_changed_resident_context()
.await
.unwrap()
.expect("cleared behavior must produce one refresh");
assert_eq!(cleared.revision, 5);
assert_eq!(cleared.behavior_md, "");
assert!(cleared.body.contains("No user-managed behavior is set"));
source.confirm_resident_context_revision(cleared.revision);
source.confirm_resident_context_behavior(&cleared.behavior_md);
assert!(
source
.load_changed_resident_context()
@@ -1772,7 +1757,7 @@ permission = "write"
.await
.unwrap()
.expect("history rewrite must require a durable current representation");
assert_eq!(replay_after_rewrite.revision, 5);
assert_eq!(replay_after_rewrite.behavior_md, "");
assert!(
replay_after_rewrite
.body
@@ -1786,6 +1771,63 @@ permission = "write"
);
}
#[tokio::test]
async fn resident_behavior_ack_tracks_exact_committed_content_not_latest_fetch() {
let manifest = subject_manifest("workspace");
let client = Arc::new(SubjectResidentClient::new(
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
None,
));
let source = backend_resident_source(&manifest, client.clone(), test_prompts())
.unwrap()
.unwrap();
source.load().await;
*client.behavior.lock().unwrap() = "first behavior".into();
let first = source
.load_changed_resident_context()
.await
.unwrap()
.unwrap();
let retry = source
.load_changed_resident_context()
.await
.unwrap()
.unwrap();
assert_eq!(retry, first); // Fetch alone never acknowledges model representation.
*client.behavior.lock().unwrap() = "second behavior".into();
let second = source
.load_changed_resident_context()
.await
.unwrap()
.unwrap();
source.confirm_resident_context_behavior(&first.behavior_md);
assert_eq!(
source
.load_changed_resident_context()
.await
.unwrap()
.unwrap(),
second
);
source.confirm_resident_context_behavior(&second.behavior_md);
assert!(
source
.load_changed_resident_context()
.await
.unwrap()
.is_none()
);
source.invalidate_resident_context_representation();
assert_eq!(
source
.load_changed_resident_context()
.await
.unwrap()
.unwrap(),
second
);
}
#[tokio::test]
async fn subject_resident_source_fails_closed_without_trusted_subject_scope() {
let manifest = subject_manifest("workspace");
@@ -2001,14 +2043,14 @@ permission = "write"
"MemoryApplyCandidate",
"MemoryStagingList",
"MemoryStagingRead",
"SubjektivMemoryListRevisions",
"SubjektivMemoryListChanges",
"SubjektivMemoryQuery",
"SubjektivMemoryRead",
]
);
let decision = tool_meta(tools, "MemoryApplyCandidate").to_string();
assert!(decision.contains("request_id"));
assert!(decision.contains("expected_revision"));
assert!(decision.contains("expected_change_id"));
assert!(decision.contains("already_covered"));
assert!(!decision.contains("subject_id"));
assert!(!decision.contains("workspace_id"));
@@ -972,7 +972,7 @@ impl WorkerAuditBase {
) -> Self {
self.memory_settings = settings.map(|snapshot| memory::audit::MemorySettingsAudit {
workspace_id: snapshot.workspace_id.clone(),
settings_revision: snapshot.settings_revision,
language: snapshot.language.clone(),
});
self
@@ -1292,7 +1292,7 @@ permission = "write"
config
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".to_string(),
settings_revision: 1,
language: "English".to_string(),
})
.unwrap();
@@ -1365,7 +1365,7 @@ permission = "write"
.start_run_committed(HookInvocationContext {
worker_id: "worker-1".to_string(),
session_id: "session-1".to_string(),
session_revision: 1,
run_id: Some("run-1".to_string()),
..Default::default()
})
@@ -1456,7 +1456,7 @@ permission = "write"
config
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".to_string(),
settings_revision: 1,
language: "English".to_string(),
})
.unwrap();
@@ -2002,7 +2002,7 @@ permission = "write"
CommittedSessionCapture {
session_id: "session-1".to_string(),
segment_id: "segment-1".to_string(),
session_revision: history_len.try_into().unwrap(),
entry_count: history_len,
run_exit: CommittedRunExit::Finished,
history: (0..history_len)
@@ -386,7 +386,7 @@ fn evidence_origin(origin: &protocol::SessionEntryProvenance) -> EvidenceOrigin
worker_id: None,
flow_selector: None,
flow_definition_id: None,
flow_definition_revision: None,
flow_definition_fingerprint: None,
}
}
@@ -161,18 +161,23 @@ impl SubjektivSurfaceLifecycleFeature {
impl FeatureModule for SubjektivSurfaceLifecycleFeature {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin("subjektiv-memory-surface-lifecycle", "subjektiv Memory Surface Lifecycle")
.with_description("Rebuilds one bounded subject Memory surface after each committed consolidation run.")
FeatureDescriptor::builtin(
"subjektiv-memory-surface-lifecycle",
"subjektiv Memory Surface Lifecycle",
)
.with_description(
"Rebuilds one bounded subject Memory surface after each committed consolidation run.",
)
.with_background_task(BackgroundTaskDeclaration::worker_managed(
TASK_NAME,
"Generate and publish a current subject Memory surface from bounded confirmed revisions.",
"Generate and publish a current subject Memory surface from bounded confirmed changes.",
))
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
let declaration = BackgroundTaskDeclaration::worker_managed(
TASK_NAME,
"Generate and publish a current subject Memory surface from bounded confirmed revisions.",
"Generate and publish a current subject Memory surface from bounded confirmed changes.",
);
let mut spec = BackgroundTaskSpec::single_flight(declaration, TASK_TIMEOUT);
spec.trigger = BackgroundTaskTrigger::RunCommitted;
@@ -240,16 +245,17 @@ impl SubjektivSurfaceLifecycleTask {
if let Some(snapshot_id) = &generation.current_snapshot_id {
return Ok(serde_json::json!({
"availability": "ready", "generation_id": generation.generation_id,
"store_revision": generation.store_revision, "snapshot_id": snapshot_id,
"memory_fingerprint": generation.memory_fingerprint, "snapshot_id": snapshot_id,
}));
}
}
// Preparation is Backend-fenced to the live attempt and immutable batch.
// Only a ready snapshot is immutable within its store revision. Never
// Only a ready snapshot is immutable within its Memory fingerprint. Never
// reuse a failed marker, which a later generation can replace.
if let Some(surface) = reusable_surface.filter(|surface| {
surface["availability"] == "ready"
&& surface["store_revision"].as_u64() == Some(generation.store_revision)
&& surface["memory_fingerprint"].as_str()
== Some(generation.memory_fingerprint.as_str())
}) {
return Ok(surface.clone());
}
@@ -307,7 +313,7 @@ impl SubjektivSurfaceLifecycleTask {
Ok(output) => {
return Ok(serde_json::json!({
"availability": "ready", "generation_id": generation.generation_id,
"store_revision": output.built_from_store_revision, "snapshot_id": output.snapshot_id,
"memory_fingerprint": output.built_from_memory_fingerprint, "snapshot_id": output.snapshot_id,
}));
}
Err(SubjektivHostError::Conflict { .. })
@@ -418,23 +424,24 @@ impl SubjektivSurfaceLifecycleTask {
.await
.map_err(surface_hook_error)?;
// Success must attest the actual persisted marker, not merely receipt of
// a failure notification. A protected ready surface or moved revision
// a failure notification. A protected ready surface or moved fingerprint
// must yield a Backend rejection/non-failed status, never a failed claim.
let confirmed_status = if job_confirmation_required {
"failed_confirmed"
} else {
"failed"
};
if failure.status != confirmed_status || failure.store_revision != generation.store_revision
if failure.status != confirmed_status
|| failure.memory_fingerprint != generation.memory_fingerprint
{
return Err(HookError::new(
HookErrorCategory::Internal,
"Backend did not confirm a failed surface for this generation's revision; no Job result was submitted",
"Backend did not confirm a failed surface for this generation's fingerprint; no Job result was submitted",
));
}
Ok(serde_json::json!({
"availability": "failed", "generation_id": generation.generation_id,
"store_revision": failure.store_revision, "reason_code": reason_code,
"memory_fingerprint": failure.memory_fingerprint, "reason_code": reason_code,
}))
}
}
@@ -604,7 +611,7 @@ mod tests {
) -> server_api::SubjektivSurfaceMaterial {
server_api::SubjektivSurfaceMaterial {
memory_id: id.into(),
revision: 1,
change_id: "change-1".into(),
kind,
body_md,
why_useful: "budget fixture".into(),
@@ -619,7 +626,7 @@ mod tests {
server_api::SubjektivSurfacePrepareResponse {
generation_id: "generation-1".into(),
current_snapshot_id: None,
store_revision: 1,
memory_fingerprint: "memory-set-1".into(),
active_memory_count: materials.len(),
materials,
body_token_budget: 1_024,
@@ -646,9 +653,9 @@ mod tests {
job_results: Mutex<Vec<serde_json::Value>>,
result_responses:
Mutex<std::collections::VecDeque<Result<WorkspaceResponse, WorkspaceClientError>>>,
current_revision: AtomicUsize,
current_fingerprint: Mutex<Option<String>>,
failure_status: Mutex<String>,
failure_revision: AtomicUsize,
failure_fingerprint: Mutex<Option<String>>,
ready_snapshot: Mutex<Option<String>>,
}
@@ -662,9 +669,9 @@ mod tests {
failure_reasons: Mutex::new(Vec::new()),
job_results: Mutex::new(Vec::new()),
result_responses: Mutex::new(Default::default()),
current_revision: AtomicUsize::new(0),
current_fingerprint: Mutex::new(None),
failure_status: Mutex::new("failed".into()),
failure_revision: AtomicUsize::new(0),
failure_fingerprint: Mutex::new(None),
ready_snapshot: Mutex::new(None),
}
}
@@ -676,13 +683,12 @@ mod tests {
}
}
fn revision(&self, fallback: usize) -> u64 {
let current = self.current_revision.load(Ordering::SeqCst);
if current == 0 {
fallback as u64
} else {
current as u64
}
fn fingerprint(&self, fallback: usize) -> String {
self.current_fingerprint
.lock()
.unwrap()
.clone()
.unwrap_or_else(|| format!("memory-set-{fallback}"))
}
fn response(value: server_api::SubjektivMemoryBackendResponse) -> WorkspaceResponse {
@@ -741,7 +747,7 @@ mod tests {
let mut prepared =
generation(self.input_token_budget, vec![material("memory-1", 32)]);
prepared.generation_id = format!("generation-{call}");
prepared.store_revision = self.revision(call);
prepared.memory_fingerprint = self.fingerprint(call);
prepared.current_snapshot_id = self.ready_snapshot.lock().unwrap().clone();
Ok(Self::response(
server_api::SubjektivMemoryBackendResponse::SurfacePrepared(prepared),
@@ -760,7 +766,7 @@ mod tests {
server_api::SubjektivMemoryBackendResponse::SurfacePublished(
server_api::SubjektivSurfacePublishResponse {
snapshot_id: format!("surface-{call}"),
built_from_store_revision: self.revision(call),
built_from_memory_fingerprint: self.fingerprint(call),
empty: false,
},
),
@@ -771,12 +777,14 @@ mod tests {
Ok(Self::response(
server_api::SubjektivMemoryBackendResponse::SurfaceFailed(
server_api::SubjektivSurfaceFailureResponse {
store_revision: if self.failure_revision.load(Ordering::SeqCst) > 0
{
self.failure_revision.load(Ordering::SeqCst) as u64
} else {
self.revision(self.prepare_calls.load(Ordering::SeqCst))
},
memory_fingerprint: self
.failure_fingerprint
.lock()
.unwrap()
.clone()
.unwrap_or_else(|| {
self.fingerprint(self.prepare_calls.load(Ordering::SeqCst))
}),
status: self.failure_status.lock().unwrap().clone(),
},
),
@@ -814,7 +822,7 @@ mod tests {
serde_json::json!({
"points": [{
"body_md": "- Keep the confirmed constraint.",
"memory_refs": [{"memory_id": "memory-1", "revision": 1}]
"memory_refs": [{"memory_id": "memory-1", "change_id": "change-1"}]
}]
})
.to_string(),
@@ -863,7 +871,7 @@ permission = "write"
.subjektiv
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".into(),
settings_revision: 1,
language: "English".into(),
})
.unwrap();
@@ -899,7 +907,7 @@ permission = "write"
.start_run_committed(HookInvocationContext {
worker_id: "worker-1".into(),
session_id: "session-1".into(),
session_revision: 1,
run_id: Some("run-1".into()),
..Default::default()
})
@@ -1146,7 +1154,10 @@ permission = "write"
results[0]["result"]["surface"]["generation_id"],
"generation-1"
);
assert_eq!(results[0]["result"]["surface"]["store_revision"], 1);
assert_eq!(
results[0]["result"]["surface"]["memory_fingerprint"],
"memory-set-1"
);
assert_eq!(workspace.prepare_calls.load(Ordering::SeqCst), 1);
assert!(
tool.execute(
@@ -1289,7 +1300,7 @@ permission = "write"
async fn job_result_definitive_rejection_allows_correction_and_reuses_current_ready_surface() {
for status in [400, 403, 422] {
let workspace = Arc::new(SurfaceWorkspaceClient::new(0));
workspace.current_revision.store(1, Ordering::SeqCst);
*workspace.current_fingerprint.lock().unwrap() = Some("memory-set-1".into());
workspace
.result_responses
.lock()
@@ -1317,7 +1328,7 @@ permission = "write"
assert_eq!(
workspace.prepare_calls.load(Ordering::SeqCst),
2,
"reuse must verify current revision"
"reuse must verify current Memory fingerprint"
);
assert_eq!(
workspace.publish_calls.load(Ordering::SeqCst),
@@ -1338,7 +1349,7 @@ permission = "write"
async fn job_result_rejection_rebuilds_conflicting_or_stale_surfaces() {
for status in [400, 409] {
let workspace = Arc::new(SurfaceWorkspaceClient::new(0));
workspace.current_revision.store(1, Ordering::SeqCst);
*workspace.current_fingerprint.lock().unwrap() = Some("memory-set-1".into());
workspace
.result_responses
.lock()
@@ -1350,14 +1361,20 @@ permission = "write"
.await
.is_err()
);
workspace.current_revision.store(2, Ordering::SeqCst);
*workspace.current_fingerprint.lock().unwrap() = Some("memory-set-2".into());
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
.await
.unwrap();
let results = workspace.job_results.lock().unwrap();
assert_eq!(results.len(), 2);
assert_eq!(results[0]["result"]["surface"]["store_revision"], 1);
assert_eq!(results[1]["result"]["surface"]["store_revision"], 2);
assert_eq!(
results[0]["result"]["surface"]["memory_fingerprint"],
"memory-set-1"
);
assert_eq!(
results[1]["result"]["surface"]["memory_fingerprint"],
"memory-set-2"
);
assert_ne!(
results[0]["result"]["surface"]["generation_id"],
results[1]["result"]["surface"]["generation_id"]
@@ -1415,18 +1432,18 @@ permission = "write"
}
#[tokio::test]
async fn job_result_never_submits_unconfirmed_failure_or_wrong_revision() {
for (status, revision) in [
("failed", 0),
("ready", 0),
("stale", 0),
("failed_confirmed", 2),
async fn job_result_never_submits_unconfirmed_failure_or_wrong_fingerprint() {
for (status, fingerprint) in [
("failed", "memory-set-1"),
("ready", "memory-set-1"),
("stale", "memory-set-1"),
("failed_confirmed", "memory-set-2"),
] {
let workspace = Arc::new(SurfaceWorkspaceClient::with_input_token_budget(1));
workspace.current_revision.store(1, Ordering::SeqCst);
*workspace.current_fingerprint.lock().unwrap() = Some("memory-set-1".into());
let tool = job_result_tool(workspace.clone());
*workspace.failure_status.lock().unwrap() = status.into();
workspace.failure_revision.store(revision, Ordering::SeqCst);
*workspace.failure_fingerprint.lock().unwrap() = Some(fingerprint.into());
assert!(
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
.await
@@ -1435,7 +1452,7 @@ permission = "write"
assert!(workspace.job_results.lock().unwrap().is_empty());
// Nothing was dispatched/sealed: a real confirmed outcome remains retryable.
*workspace.failure_status.lock().unwrap() = "failed_confirmed".into();
workspace.failure_revision.store(0, Ordering::SeqCst);
*workspace.failure_fingerprint.lock().unwrap() = None;
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
.await
.unwrap();
@@ -64,7 +64,7 @@ impl MemorySurfaceOutputState {
let allowed = self
.materials
.iter()
.map(|material| (material.memory_id.as_str(), material.revision))
.map(|material| (material.memory_id.as_str(), material.change_id.as_str()))
.collect::<HashSet<_>>();
let mut bodies = Vec::with_capacity(points.len());
for point in points {
@@ -81,16 +81,20 @@ impl MemorySurfaceOutputState {
}
let mut point_refs = HashSet::new();
for reference in &point.memory_refs {
if !point_refs.insert((reference.memory_id.as_str(), reference.revision)) {
if !point_refs.insert((reference.memory_id.as_str(), reference.change_id.as_str()))
{
return Err(ToolError::InvalidArgument(format!(
"duplicate surface point ref {}@{}",
reference.memory_id, reference.revision
reference.memory_id,
reference.change_id.as_str()
)));
}
if !allowed.contains(&(reference.memory_id.as_str(), reference.revision)) {
if !allowed.contains(&(reference.memory_id.as_str(), reference.change_id.as_str()))
{
return Err(ToolError::InvalidArgument(format!(
"surface ref {}@{} is not in the generation materials",
reference.memory_id, reference.revision
reference.memory_id,
reference.change_id.as_str()
)));
}
}
@@ -218,7 +222,7 @@ mod tests {
fn material(id: &str) -> server_api::SubjektivSurfaceMaterial {
server_api::SubjektivSurfaceMaterial {
memory_id: id.into(),
revision: 1,
change_id: "change-1".into(),
kind: CandidateKind::Constraint,
body_md: "Keep the boundary.".into(),
why_useful: "Avoid regression.".into(),
@@ -229,9 +233,9 @@ mod tests {
fn point(id: &str) -> server_api::SubjektivSurfacePoint {
server_api::SubjektivSurfacePoint {
body_md: "- Keep the boundary unless it changes.".into(),
memory_refs: vec![server_api::SubjektivMemoryRevisionRef {
memory_refs: vec![server_api::SubjektivMemoryChangeRef {
memory_id: id.into(),
revision: 1,
change_id: "change-1".into(),
}],
}
}
@@ -509,12 +509,11 @@ fn native_merge_request_tools(
}
#[derive(Default)]
struct MergeRequestRevisionState {
revisions: HashMap<String, String>,
mutation_sequence: u64,
struct MergeRequestObservationState {
content_digests: HashMap<String, String>,
}
type MergeRequestRevisions = Arc<Mutex<MergeRequestRevisionState>>;
type MergeRequestObservations = Arc<Mutex<MergeRequestObservationState>>;
/// Mount only the Merge Request operations enabled for this Worker as a native
/// collection plus route-bound item objects. The ordinary Tool definitions remain
@@ -542,7 +541,7 @@ pub fn mount_workspace_http_merge_request_wip(
.filter(|tool| tool.projection.surface == NativeMergeRequestSurface::Item)
.cloned()
.collect::<Vec<_>>();
let revisions = Arc::new(Mutex::new(MergeRequestRevisionState::default()));
let observations = Arc::new(Mutex::new(MergeRequestObservationState::default()));
let collection_descriptor = merge_request_descriptor(
&collection_tools,
@@ -574,7 +573,7 @@ pub fn mount_workspace_http_merge_request_wip(
tools: merge_request_operation_map(collection_tools),
permissions: permissions.clone(),
collection_route: collection_route.clone(),
revisions: Arc::clone(&revisions),
observations: Arc::clone(&observations),
}),
})?;
@@ -593,7 +592,7 @@ pub fn mount_workspace_http_merge_request_wip(
tools: merge_request_operation_map(item_tools),
permissions,
collection_route: collection_route.clone(),
revisions,
observations,
}),
})?;
registry.replace_compatibility_tools(&collection_route, claimed_tools)?;
@@ -613,7 +612,7 @@ struct MergeRequestCollectionWipHandler {
tools: HashMap<String, NativeMergeRequestTool>,
permissions: Option<ToolPermissionConfig>,
collection_route: String,
revisions: MergeRequestRevisions,
observations: MergeRequestObservations,
}
#[async_trait]
@@ -643,7 +642,7 @@ impl WipOperationHandler for MergeRequestCollectionWipHandler {
)
})?
.to_string();
record_merge_request_observation(&self.revisions, &reference, &response, true);
record_merge_request_observation(&self.observations, &reference, &response, true);
if let Some(object) = response.as_object_mut() {
object.insert(
"path".into(),
@@ -663,7 +662,7 @@ struct MergeRequestItemResolver {
tools: HashMap<String, NativeMergeRequestTool>,
permissions: Option<ToolPermissionConfig>,
collection_route: String,
revisions: MergeRequestRevisions,
observations: MergeRequestObservations,
}
impl WipDynamicItemResolver for MergeRequestItemResolver {
@@ -671,14 +670,13 @@ impl WipDynamicItemResolver for MergeRequestItemResolver {
if !is_merge_request_route_reference(item_reference) {
return None;
}
let revision = self
.revisions
let content_digest = self
.observations
.lock()
.unwrap_or_else(|error| error.into_inner())
.revisions
.content_digests
.get(item_reference)
.cloned()
.unwrap_or_else(|| "unobserved".into());
.cloned();
let route = format!("{}/{}", self.collection_route, item_reference);
Some(WipDynamicItem {
object: Object {
@@ -686,13 +684,15 @@ impl WipDynamicItemResolver for MergeRequestItemResolver {
description: Some("Authoritative Merge Request bound to this object route".into()),
interfaces: vec![crate::wip::root_reference(MERGE_REQUEST_ITEM_INTERFACE)],
r#ref: Some(format!("merge-request:{item_reference}")),
validator: Some(merge_request_route_validator(&route, &revision)),
validator: content_digest
.as_deref()
.map(|digest| merge_request_route_validator(&route, digest)),
},
handler: Arc::new(MergeRequestItemWipHandler {
tools: self.tools.clone(),
permissions: self.permissions.clone(),
merge_request_id: item_reference.into(),
revisions: Arc::clone(&self.revisions),
observations: Arc::clone(&self.observations),
}),
})
}
@@ -702,7 +702,7 @@ struct MergeRequestItemWipHandler {
tools: HashMap<String, NativeMergeRequestTool>,
permissions: Option<ToolPermissionConfig>,
merge_request_id: String,
revisions: MergeRequestRevisions,
observations: MergeRequestObservations,
}
#[async_trait]
@@ -737,7 +737,7 @@ impl WipOperationHandler for MergeRequestItemWipHandler {
tool.kind.mutating(),
)?;
record_merge_request_observation(
&self.revisions,
&self.observations,
&self.merge_request_id,
&response,
tool.kind.mutating(),
@@ -911,23 +911,22 @@ fn merge_request_reference(response: &Value) -> Option<&str> {
}
fn record_merge_request_observation(
revisions: &MergeRequestRevisions,
observations: &MergeRequestObservations,
reference: &str,
response: &Value,
mutation: bool,
) {
let mut state = revisions.lock().unwrap_or_else(|error| error.into_inner());
let revision = if mutation {
state.mutation_sequence = state.mutation_sequence.saturating_add(1);
format!(
"{}#mutation-{}",
merge_request_response_fingerprint(response),
state.mutation_sequence
)
let mut state = observations
.lock()
.unwrap_or_else(|error| error.into_inner());
if mutation {
state.content_digests.remove(reference);
} else {
merge_request_response_fingerprint(response)
};
state.revisions.insert(reference.to_string(), revision);
state.content_digests.insert(
reference.to_string(),
merge_request_response_fingerprint(response),
);
}
}
fn merge_request_response_fingerprint(response: &Value) -> String {
@@ -947,11 +946,11 @@ fn is_merge_request_route_reference(reference: &str) -> bool {
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~'))
}
fn merge_request_route_validator(route: &str, revision: &str) -> Vec<u8> {
fn merge_request_route_validator(route: &str, content_digest: &str) -> Vec<u8> {
let mut digest = Sha256::new();
digest.update(route.as_bytes());
digest.update([0]);
digest.update(revision.as_bytes());
digest.update(content_digest.as_bytes());
digest.finalize().to_vec()
}
@@ -1428,7 +1427,7 @@ mod tests {
tools: HashMap::new(),
permissions: None,
collection_route: "/merge-requests".into(),
revisions: Arc::new(Mutex::new(MergeRequestRevisionState::default())),
observations: Arc::new(Mutex::new(MergeRequestObservationState::default())),
};
assert!(
resolver
@@ -1574,16 +1573,16 @@ mod tests {
#[test]
fn successful_observations_and_mutations_stale_only_affected_merge_requests() {
let revisions = Arc::new(Mutex::new(MergeRequestRevisionState::default()));
let observations = Arc::new(Mutex::new(MergeRequestObservationState::default()));
let resolver = MergeRequestItemResolver {
tools: HashMap::new(),
permissions: None,
collection_route: "/merge-requests".into(),
revisions: Arc::clone(&revisions),
observations: Arc::clone(&observations),
};
let first = resolver.resolve("MR-1").unwrap().object.validator;
record_merge_request_observation(
&revisions,
&observations,
"MR-1",
&json!({"merge_request_id": "MR-1", "updated_at": "rev-1"}),
false,
@@ -1592,7 +1591,7 @@ mod tests {
assert_ne!(first, observed);
let other = resolver.resolve("MR-2").unwrap().object.validator;
record_merge_request_observation(
&revisions,
&observations,
"MR-1",
&json!({"event_id": "review-1"}),
true,
@@ -1601,6 +1600,35 @@ mod tests {
assert_eq!(other, resolver.resolve("MR-2").unwrap().object.validator);
}
#[test]
fn merge_request_observation_digest_survives_identical_reads_without_mutation_counters() {
let observations = Arc::new(Mutex::new(MergeRequestObservationState::default()));
let resolver = MergeRequestItemResolver {
tools: HashMap::new(),
permissions: None,
collection_route: "/merge-requests".into(),
observations: Arc::clone(&observations),
};
let response = json!({"merge_request_id": "MR-1", "state": "open", "thread": []});
assert!(resolver.resolve("MR-1").unwrap().object.validator.is_none());
record_merge_request_observation(&observations, "MR-1", &response, false);
let observed = resolver.resolve("MR-1").unwrap().object.validator;
assert!(observed.is_some());
record_merge_request_observation(&observations, "MR-1", &response, false);
assert_eq!(resolver.resolve("MR-1").unwrap().object.validator, observed);
for _ in 0..2 {
record_merge_request_observation(
&observations,
"MR-1",
&json!({"event_id": "same-review"}),
true,
);
assert!(resolver.resolve("MR-1").unwrap().object.validator.is_none());
}
record_merge_request_observation(&observations, "MR-1", &response, false);
assert_eq!(resolver.resolve("MR-1").unwrap().object.validator, observed);
}
#[test]
fn native_errors_preserve_backend_rejections_and_unknown_post_dispatch_outcomes() {
for (status, code) in [
@@ -1681,7 +1709,7 @@ mod tests {
}
#[test]
fn schemas_hide_revision_and_commit_authority() {
fn schemas_hide_content_digest_and_commit_authority() {
let schemas = [
schemars::schema_for!(OpenMergeRequestInput),
schemars::schema_for!(CompleteMergeRequestInput),
@@ -1689,7 +1717,7 @@ mod tests {
for s in schemas {
let j = serde_json::to_string(&s).unwrap();
for banned in [
"revision_id",
"content_digest_id",
"attempt_id",
"base_commit",
"head_commit",
+28 -100
View File
@@ -5,8 +5,10 @@
//! local `.yoi/objectives` paths, so model-visible Objective tools go through
//! the scoped Workspace API.
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::{Arc, Mutex};
use std::collections::BTreeMap;
use std::sync::Arc;
#[cfg(test)]
use std::sync::Mutex;
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
use async_trait::async_trait;
@@ -536,14 +538,6 @@ const OBJECTIVE_TOOL_NAMES: [&str; 7] = [
"ObjectiveUnlinkTicket",
];
#[derive(Default)]
struct ObjectiveRevisionState {
revisions: HashMap<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
/// the Host-owned WIP registry. Ordinary Objective tools remain registered for
/// Tool mode and are claimed only from the WIP compatibility projection.
@@ -555,14 +549,13 @@ pub fn mount_workspace_http_objective_wip(
) -> Result<(), WipMountError> {
let collection_route = namespace_route.root().to_string();
let backend = WorkspaceHttpObjectiveBackend::new(client);
let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default()));
let collection_descriptor = objective_collection_descriptor();
let collection_validator = descriptor_validator(&collection_descriptor);
let collection_handler = Arc::new(ObjectiveCollectionWipHandler {
backend: backend.clone(),
permissions: permissions.clone(),
collection_route: collection_route.clone(),
revisions: Arc::clone(&revisions),
});
registry.mount(WipProjection {
route: collection_route.clone(),
@@ -600,7 +593,6 @@ pub fn mount_workspace_http_objective_wip(
backend,
permissions,
collection_route: collection_route.clone(),
revisions,
}),
})?;
registry.replace_compatibility_tools(&collection_route, OBJECTIVE_TOOL_NAMES)?;
@@ -611,7 +603,6 @@ struct ObjectiveCollectionWipHandler {
backend: WorkspaceHttpObjectiveBackend,
permissions: Option<ToolPermissionConfig>,
collection_route: String,
revisions: ObjectiveRevisions,
}
#[async_trait]
@@ -695,7 +686,7 @@ impl WipOperationHandler for ObjectiveCollectionWipHandler {
.create_value(&input)
.await
.map_err(map_backend_error)?;
record_revision(&self.revisions, None, &response);
if let Some(reference) = response
.get("objective")
.and_then(Json::as_str)
@@ -723,7 +714,6 @@ struct ObjectiveItemResolver {
backend: WorkspaceHttpObjectiveBackend,
permissions: Option<ToolPermissionConfig>,
collection_route: String,
revisions: ObjectiveRevisions,
}
impl WipDynamicItemResolver for ObjectiveItemResolver {
@@ -731,14 +721,6 @@ impl WipDynamicItemResolver for ObjectiveItemResolver {
if !is_objective_route_reference(item_reference) {
return None;
}
let revision = self
.revisions
.lock()
.unwrap_or_else(|error| error.into_inner())
.revisions
.get(item_reference)
.cloned()
.unwrap_or_else(|| "unobserved".into());
let route = format!("{}/{}", self.collection_route, item_reference);
Some(WipDynamicItem {
object: Object {
@@ -746,13 +728,14 @@ impl WipDynamicItemResolver for ObjectiveItemResolver {
description: Some("Authoritative Objective bound to this object route".into()),
interfaces: vec![crate::wip::root_reference(OBJECTIVE_ITEM_INTERFACE)],
r#ref: Some(format!("objective:{item_reference}")),
validator: Some(route_validator(&route, &revision)),
// The Object publishes fixed operation signatures, not Objective content.
// Each operation reads/writes current Backend state under its authority.
validator: Some(route_validator(&route, "objective-item")),
},
handler: Arc::new(ObjectiveItemWipHandler {
backend: self.backend.clone(),
permissions: self.permissions.clone(),
objective_reference: item_reference.to_string(),
revisions: Arc::clone(&self.revisions),
}),
})
}
@@ -762,7 +745,6 @@ struct ObjectiveItemWipHandler {
backend: WorkspaceHttpObjectiveBackend,
permissions: Option<ToolPermissionConfig>,
objective_reference: String,
revisions: ObjectiveRevisions,
}
#[async_trait]
@@ -832,7 +814,7 @@ impl WipOperationHandler for ObjectiveItemWipHandler {
}
_ => return Err(operation_not_found()),
};
record_revision(&self.revisions, Some(&self.objective_reference), &response);
Ok(WipOperationOutput::native(
json_to_wip(&response).map_err(WipOperationError::OutcomeUnknown)?,
))
@@ -1022,36 +1004,6 @@ fn string_list_argument(
}
}
fn record_revision(revisions: &ObjectiveRevisions, bound_reference: Option<&str>, response: &Json) {
let (Some(revision), Some(canonical)) = (
response.get("revision").and_then(Json::as_str),
response.get("objective").and_then(Json::as_str),
) else {
return;
};
let mut state = revisions.lock().unwrap_or_else(|error| error.into_inner());
state
.aliases_by_canonical
.entry(canonical.to_string())
.or_default()
.insert(canonical.to_string());
if let Some(reference) = bound_reference {
state
.aliases_by_canonical
.entry(canonical.to_string())
.or_default()
.insert(reference.to_string());
}
let aliases = state
.aliases_by_canonical
.get(canonical)
.cloned()
.unwrap_or_default();
for alias in aliases {
state.revisions.insert(alias, revision.to_string());
}
}
fn is_objective_route_reference(reference: &str) -> bool {
is_canonical_resource_key(reference, "O-")
|| (reference.len() >= 8
@@ -1059,11 +1011,11 @@ fn is_objective_route_reference(reference: &str) -> bool {
&& reference.bytes().all(|byte| byte.is_ascii_alphanumeric()))
}
fn route_validator(route: &str, revision: &str) -> Vec<u8> {
fn route_validator(route: &str, signature: &str) -> Vec<u8> {
let mut digest = Sha256::new();
digest.update(route.as_bytes());
digest.update([0]);
digest.update(revision.as_bytes());
digest.update(signature.as_bytes());
digest.finalize().to_vec()
}
@@ -1115,7 +1067,7 @@ fn objective_collection_descriptor() -> InterfaceDescriptor {
parameter("state", false, TypeExpr::String),
parameter("linked_tickets", false, list_of(TypeExpr::String)),
],
returns: json_return("Created Objective with revision and bounded context"),
returns: json_return("Created Objective with bounded context"),
},
],
}
@@ -1182,7 +1134,7 @@ fn operation(
details: None,
}),
parameters,
returns: json_return("Authoritative Objective detail with revision and bounded context"),
returns: json_return("Authoritative Objective detail with bounded context"),
}
}
@@ -1293,7 +1245,7 @@ fn parse_input<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 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 =
"Create an Objective record through Backend Workspace API authority.";
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 {
status: 200,
body: json!({
"id": "00001OBJECTIVE",
"resource_key": "O-3",
"title": "Objective",
"title": title,
"body": "Body",
"body_truncated": false,
"state": "active",
"revision": revision,
"created_at": null,
"updated_at": null,
"linked_ticket_summaries": [],
@@ -1635,7 +1586,7 @@ mod tests {
"body": "Body",
"body_truncated": false,
"state": "active",
"revision": "rev-1",
"created_at": null,
"updated_at": null,
"linked_ticket_summaries": [],
@@ -1704,7 +1655,6 @@ mod tests {
"body": "Body",
"body_truncated": false,
"state": "active",
"revision": format!("rev-{mutation}"),
"created_at": null,
"updated_at": null,
"linked_ticket_summaries": [],
@@ -1798,15 +1748,13 @@ mod tests {
}
#[test]
fn dynamic_objective_routes_accept_only_objective_references_and_refresh_revision() {
let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default()));
fn dynamic_objective_routes_accept_only_objective_references() {
let resolver = ObjectiveItemResolver {
backend: WorkspaceHttpObjectiveBackend::new(Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"),
)),
permissions: None,
collection_route: "/objectives".into(),
revisions: Arc::clone(&revisions),
};
let initial = resolver.resolve("O-3").unwrap();
assert_eq!(initial.object.name, "O-3");
@@ -1814,22 +1762,10 @@ mod tests {
assert!(resolver.resolve("T-3").is_none());
assert!(resolver.resolve("O-3/other").is_none());
record_revision(
&revisions,
Some("00001OBJECTIVE"),
&json!({"objective": "O-3", "revision": "rev-1"}),
assert_ne!(
initial.object.validator,
resolver.resolve("O-4").unwrap().object.validator
);
let canonical_rev_1 = resolver.resolve("O-3").unwrap().object.validator;
let internal_rev_1 = resolver.resolve("00001OBJECTIVE").unwrap().object.validator;
record_revision(
&revisions,
Some("O-3"),
&json!({"objective": "O-3", "revision": "rev-2"}),
);
let canonical_rev_2 = resolver.resolve("O-3").unwrap().object.validator;
let internal_rev_2 = resolver.resolve("00001OBJECTIVE").unwrap().object.validator;
assert_ne!(canonical_rev_1, canonical_rev_2);
assert_ne!(internal_rev_1, internal_rev_2);
}
#[test]
@@ -1948,7 +1884,6 @@ mod tests {
rules: Vec::new(),
}),
objective_reference: "O-3".into(),
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
};
let result = denied
.call(
@@ -1972,7 +1907,6 @@ mod tests {
backend,
permissions: None,
collection_route: "/objectives".into(),
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
};
let result = collection
.call(
@@ -2009,12 +1943,11 @@ mod tests {
objective_response("rev-4"),
]));
let backend = WorkspaceHttpObjectiveBackend::new(client.clone());
let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default()));
let collection = ObjectiveCollectionWipHandler {
backend: backend.clone(),
permissions: None,
collection_route: "/objectives".into(),
revisions: Arc::clone(&revisions),
};
let context = || WipCallContext {
execution: ToolExecutionContext::direct(),
@@ -2035,7 +1968,6 @@ mod tests {
backend,
permissions: None,
objective_reference: "O-3".into(),
revisions,
};
assert!(item.call("read", &BTreeMap::new(), context()).await.is_ok());
assert!(
@@ -2100,7 +2032,6 @@ mod tests {
backend: WorkspaceHttpObjectiveBackend::new(client),
permissions: None,
objective_reference: "O-404".into(),
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
};
for (operation, arguments) in [
("read", BTreeMap::new()),
@@ -2171,7 +2102,6 @@ mod tests {
)),
permissions: None,
collection_route: "/objectives".into(),
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
};
let output = match handler
.call(
@@ -2198,7 +2128,7 @@ mod tests {
}
#[tokio::test(flavor = "multi_thread")]
async fn native_edit_binds_target_from_route_and_publishes_new_revision() {
async fn native_edit_returns_updated_content_without_changing_object_signatures() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = thread::spawn(move || {
@@ -2217,7 +2147,7 @@ mod tests {
"body": "Body",
"body_truncated": false,
"state": "active",
"revision": "rev-2",
"created_at": null,
"updated_at": null,
"linked_ticket_summaries": [],
@@ -2233,7 +2163,7 @@ mod tests {
)
.unwrap();
});
let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default()));
let backend = WorkspaceHttpObjectiveBackend::new(Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace", base_url),
));
@@ -2241,14 +2171,12 @@ mod tests {
backend: backend.clone(),
permissions: None,
collection_route: "/objectives".into(),
revisions: Arc::clone(&revisions),
};
let before = resolver.resolve("O-3").unwrap().object.validator;
let handler = ObjectiveItemWipHandler {
backend,
permissions: None,
objective_reference: "O-3".into(),
revisions,
};
let output = match handler
.call(
@@ -2267,9 +2195,9 @@ mod tests {
server.join().unwrap();
let output = crate::wip::wip_to_json(&output.value).unwrap();
assert_eq!(output["objective"], "O-3");
assert_eq!(output["revision"], "rev-2");
assert_eq!(output["title"], "Changed");
let after = resolver.resolve("O-3").unwrap().object.validator;
assert_ne!(before, after);
assert_eq!(before, after);
}
#[test]
@@ -35,7 +35,7 @@ struct ModelTicketQueryItem {
#[derive(Debug, Serialize)]
pub(super) struct ModelTicketDetail {
ticket: String,
item_revision: String,
content_digest: String,
title: String,
body: String,
state: String,
@@ -82,7 +82,6 @@ pub(super) struct ModelObjectiveDetail {
body: String,
body_truncated: bool,
state: String,
revision: String,
created_at: Option<String>,
updated_at: Option<String>,
linked_tickets: Vec<ModelTicketSummary>,
@@ -284,7 +283,7 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
Ok(ModelTicketDetail {
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")?,
body: string_field(root, "body")?,
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_truncated: bool_field(root, "body_truncated")?,
state: string_field(root, "state")?,
revision: string_field(root, "revision")?,
created_at: optional_string(root, "created_at")?,
updated_at: optional_string(root, "updated_at")?,
linked_tickets: array_field(root, "linked_ticket_summaries")?
@@ -705,7 +703,7 @@ mod tests {
"closed",
] {
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"}],
"linked_objectives": [], "assignments": [], "implementation_reports": [],
"merge_requests": [], "merge_request": null,
@@ -735,7 +733,6 @@ mod tests {
"body": "Body",
"body_truncated": false,
"state": "active",
"revision": "rev-3",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-02T00:00:00Z",
"linked_tickets": ["00001M0E82D1V"],
@@ -759,7 +756,6 @@ mod tests {
let text = json.to_string();
assert!(text.contains("O-543"));
assert!(text.contains("T-496"));
assert_eq!(json["revision"], "rev-3");
assert_eq!(json["body_truncated"], false);
assert!(!text.contains("00001M10HW6BV"));
assert!(!text.contains("00001M0E82D1V"));
@@ -845,7 +841,7 @@ mod tests {
let projected = project_ticket_detail(json!({
"id": "internal-ticket",
"resource_key": "T-588",
"item_revision": "revision-1",
"content_digest": "revision-1",
"title": "Queued Submit",
"body": "Body",
"state": "planning",
@@ -896,7 +892,7 @@ mod tests {
.expect("current Ticket blocker shape must project");
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!(
projected["relations"]["blockers"],
json!([{
@@ -948,7 +944,7 @@ mod tests {
});
let projected = project_ticket_detail(json!({
"resource_key": "T-716",
"item_revision": "current-ticket-revision",
"content_digest": "current-ticket-revision",
"title": "Source evidence",
"body": "Body",
"state": if merged { "done" } else { "inprogress" },
@@ -986,7 +982,7 @@ mod tests {
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"]["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
//! adopt a candidate as a confirmed revision.
//! adopt a candidate as a confirmed change.
use std::collections::HashSet;
use std::sync::Arc;
@@ -13,10 +13,9 @@ use memory::extract::CandidateKind;
use schemars::JsonSchema;
use serde::Deserialize;
use server_api::{
SubjektivMemoryBackendOperation, SubjektivMemoryBackendResponse,
SubjektivMemoryListRevisionsRequest, SubjektivMemoryQueryRequest, SubjektivMemoryReadRequest,
SubjektivMemoryReceiptStatus, SubjektivMemoryReceiptStatusRequest,
SubjektivMemoryRevisionIntent, SubjektivMemoryRevisionProposal,
SubjektivMemoryBackendOperation, SubjektivMemoryBackendResponse, SubjektivMemoryChangeIntent,
SubjektivMemoryChangeProposal, SubjektivMemoryListChangesRequest, SubjektivMemoryQueryRequest,
SubjektivMemoryReadRequest, SubjektivMemoryReceiptStatus, SubjektivMemoryReceiptStatusRequest,
SubjektivMemoryStageExplicitRequest, SubjektivMemoryStageExplicitResponse,
SubjektivMemoryValidateProposalRequest,
};
@@ -41,19 +40,19 @@ use server_api::SubjektivMemoryBackendRequest;
const QUERY_TOOL: &str = "SubjektivMemoryQuery";
const READ_TOOL: &str = "SubjektivMemoryRead";
const LIST_REVISIONS_TOOL: &str = "SubjektivMemoryListRevisions";
const LIST_CHANGES_TOOL: &str = "SubjektivMemoryListChanges";
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 RETRY_HOOK: &str = "retry-committed-explicit-memory";
const REWRITE_HOOK: &str = "flush-explicit-memory-before-rewrite";
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 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 LIST_REVISIONS_DESCRIPTION: &str = "List immutable revisions of one connected-subject Memory in descending revision order using snapshot-bound pagination.";
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 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_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 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)]
pub(crate) struct SubjektivMemoryFeature {
@@ -73,7 +72,7 @@ struct PendingExplicit {
claim: String,
why_useful: String,
staleness: Option<String>,
proposal: Option<SubjektivMemoryRevisionProposal>,
proposal: Option<SubjektivMemoryChangeProposal>,
}
impl SubjektivMemoryFeature {
@@ -102,12 +101,12 @@ impl FeatureModule for SubjektivMemoryFeature {
.with_tool(ToolDeclaration::new(QUERY_TOOL, QUERY_DESCRIPTION))
.with_tool(ToolDeclaration::new(READ_TOOL, READ_DESCRIPTION))
.with_tool(ToolDeclaration::new(
LIST_REVISIONS_TOOL,
LIST_REVISIONS_DESCRIPTION,
LIST_CHANGES_TOOL,
LIST_CHANGES_DESCRIPTION,
))
.with_tool(ToolDeclaration::new(REMEMBER_TOOL, REMEMBER_DESCRIPTION))
.with_tool(ToolDeclaration::new(
PROPOSE_REVISION_TOOL,
PROPOSE_CHANGE_TOOL,
PROPOSE_DESCRIPTION,
))
.with_hook(HookDeclaration::new(
@@ -129,9 +128,9 @@ impl FeatureModule for SubjektivMemoryFeature {
(QUERY_TOOL, QUERY_DESCRIPTION, ReadOperation::Query),
(READ_TOOL, READ_DESCRIPTION, ReadOperation::Read),
(
LIST_REVISIONS_TOOL,
LIST_REVISIONS_DESCRIPTION,
ReadOperation::ListRevisions,
LIST_CHANGES_TOOL,
LIST_CHANGES_DESCRIPTION,
ReadOperation::ListChanges,
),
] {
context.tools().register(ToolContribution::new(
@@ -149,9 +148,9 @@ impl FeatureModule for SubjektivMemoryFeature {
),
))?;
context.tools().register(ToolContribution::new(
PROPOSE_REVISION_TOOL,
PROPOSE_CHANGE_TOOL,
explicit_tool_definition(
PROPOSE_REVISION_TOOL,
PROPOSE_CHANGE_TOOL,
PROPOSE_DESCRIPTION,
self.state.clone(),
ExplicitOperation::Propose,
@@ -184,7 +183,7 @@ impl FeatureModule for SubjektivMemoryFeature {
enum ReadOperation {
Query,
Read,
ListRevisions,
ListChanges,
}
fn read_tool_definition(
@@ -197,7 +196,7 @@ fn read_tool_definition(
let schema = match operation {
ReadOperation::Query => schema_for::<SubjektivMemoryQueryRequest>(),
ReadOperation::Read => schema_for::<SubjektivMemoryReadRequest>(),
ReadOperation::ListRevisions => schema_for::<SubjektivMemoryListRevisionsRequest>(),
ReadOperation::ListChanges => schema_for::<SubjektivMemoryListChangesRequest>(),
};
let tool: Arc<dyn Tool> = Arc::new(SubjektivReadTool {
state: state.clone(),
@@ -231,10 +230,9 @@ impl Tool for SubjektivReadTool {
ReadOperation::Read => {
SubjektivMemoryBackendOperation::Read(parse(input_json, READ_TOOL)?)
}
ReadOperation::ListRevisions => SubjektivMemoryBackendOperation::ListRevisions(parse(
input_json,
LIST_REVISIONS_TOOL,
)?),
ReadOperation::ListChanges => {
SubjektivMemoryBackendOperation::ListChanges(parse(input_json, LIST_CHANGES_TOOL)?)
}
};
let response = match self.state.execute(operation) {
Ok(response) => response,
@@ -247,14 +245,14 @@ impl Tool for SubjektivReadTool {
),
SubjektivMemoryBackendResponse::Read(value) => (
format!(
"Read Memory {} revision {}.",
value.memory_id, value.revision
"Read Memory {} change {}.",
value.memory_id, value.change_id
),
serde_json::to_value(value),
),
SubjektivMemoryBackendResponse::ListRevisions(value) => (
SubjektivMemoryBackendResponse::ListChanges(value) => (
format!(
"Listed {} revision(s) for Memory {}.",
"Listed {} change(s) for Memory {}.",
value.items.len(),
value.memory_id
),
@@ -296,7 +294,7 @@ fn explicit_tool_definition(
Arc::new(move || {
let schema = match operation {
ExplicitOperation::Remember => schema_for::<RememberParams>(),
ExplicitOperation::Propose => schema_for::<ProposeRevisionParams>(),
ExplicitOperation::Propose => schema_for::<ProposeChangeParams>(),
};
let tool: Arc<dyn Tool> = Arc::new(SubjektivExplicitTool {
state: state.clone(),
@@ -325,11 +323,11 @@ struct RememberParams {
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ProposeRevisionParams {
struct ProposeChangeParams {
memory_id: String,
#[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))]
expected_revision: u64,
intent: SubjektivMemoryRevisionIntent,
#[schemars(length(min = 1))]
expected_change_id: String,
intent: SubjektivMemoryChangeIntent,
claim: String,
why_useful: String,
change_reason: String,
@@ -371,10 +369,10 @@ impl Tool for SubjektivExplicitTool {
)
}
ExplicitOperation::Propose => {
let params: ProposeRevisionParams = parse(input_json, PROPOSE_REVISION_TOOL)?;
if params.expected_revision == 0 {
let params: ProposeChangeParams = parse(input_json, PROPOSE_CHANGE_TOOL)?;
if params.expected_change_id.trim().is_empty() {
return Err(ToolError::InvalidArgument(
"expected_revision must be a positive integer".into(),
"expected_change_id must not be empty".into(),
));
}
let validation =
@@ -383,7 +381,7 @@ impl Tool for SubjektivExplicitTool {
.execute(SubjektivMemoryBackendOperation::ValidateProposal(
SubjektivMemoryValidateProposalRequest {
memory_id: params.memory_id.clone(),
expected_revision: params.expected_revision,
expected_change_id: params.expected_change_id.clone(),
intent: params.intent,
},
)) {
@@ -398,9 +396,9 @@ impl Tool for SubjektivExplicitTool {
)));
}
};
let proposal = SubjektivMemoryRevisionProposal {
let proposal = SubjektivMemoryChangeProposal {
memory_id: params.memory_id,
expected_revision: params.expected_revision,
expected_change_id: params.expected_change_id.clone(),
intent: params.intent,
change_reason: params.change_reason,
};
@@ -447,7 +445,7 @@ impl Tool for SubjektivExplicitTool {
"status": "pending_commit",
"target": proposal.as_ref().map(|value| serde_json::json!({
"memory_id": value.memory_id,
"expected_revision": value.expected_revision,
"expected_change_id": value.expected_change_id,
})),
"intent": proposal.as_ref().map(|value| value.intent),
"message": "Candidate is not confirmed Memory. It will be staged only after this tool call is durably committed; an uncommitted/cancelled operation is discarded.",
@@ -514,7 +512,7 @@ impl SubjektivMemoryState {
let session_id = capture.session_id.clone();
let view = SessionCapture::from_history_entries(capture.segment_id, capture.history);
let committed = view
.committed_pending_tool_calls(&[REMEMBER_TOOL, PROPOSE_REVISION_TOOL])
.committed_pending_tool_calls(&[REMEMBER_TOOL, PROPOSE_CHANGE_TOOL])
.into_iter()
.any(|call| {
receipt_id(&session_id, &call.call_id)
@@ -537,7 +535,7 @@ impl SubjektivMemoryState {
why_useful: String,
staleness: Option<String>,
entries: Vec<SessionEntryEvidence>,
proposal: Option<SubjektivMemoryRevisionProposal>,
proposal: Option<SubjektivMemoryChangeProposal>,
) -> Result<SubjektivMemoryStageExplicitResponse, ToolError> {
if entries.is_empty() {
return Err(ToolError::ExecutionFailed(
@@ -620,7 +618,7 @@ fn replay_pending_committed_memory_with_policy(
let view =
SessionCapture::from_history_entries(capture.segment_id.clone(), capture.history.clone());
let mut failures = Vec::new();
for call in view.committed_pending_tool_calls(&[REMEMBER_TOOL, PROPOSE_REVISION_TOOL]) {
for call in view.committed_pending_tool_calls(&[REMEMBER_TOOL, PROPOSE_CHANGE_TOOL]) {
let receipt_id = match receipt_id(&capture.session_id, &call.call_id) {
Ok(value) => value,
Err(error) => {
@@ -693,12 +691,12 @@ fn pending_from_committed_call(
None,
)
}
PROPOSE_REVISION_TOOL => {
let params: ProposeRevisionParams = parse(arguments, PROPOSE_REVISION_TOOL)?;
PROPOSE_CHANGE_TOOL => {
let params: ProposeChangeParams = parse(arguments, PROPOSE_CHANGE_TOOL)?;
let response = state.execute(SubjektivMemoryBackendOperation::ValidateProposal(
SubjektivMemoryValidateProposalRequest {
memory_id: params.memory_id.clone(),
expected_revision: params.expected_revision,
expected_change_id: params.expected_change_id.clone(),
intent: params.intent,
},
))?;
@@ -716,9 +714,9 @@ fn pending_from_committed_call(
params.why_useful,
params.staleness,
params.entry_refs,
Some(SubjektivMemoryRevisionProposal {
Some(SubjektivMemoryChangeProposal {
memory_id: params.memory_id,
expected_revision: params.expected_revision,
expected_change_id: params.expected_change_id.clone(),
intent: params.intent,
change_reason: params.change_reason,
}),
@@ -1067,7 +1065,7 @@ mod tests {
Ok(CommittedSessionCapture {
session_id: "session-1".into(),
segment_id: "segment-1".into(),
session_revision: 1,
entry_count: 0,
run_exit: CommittedRunExit::Finished,
history: Vec::new(),
@@ -1096,9 +1094,9 @@ mod tests {
vec![
QUERY_TOOL,
READ_TOOL,
LIST_REVISIONS_TOOL,
LIST_CHANGES_TOOL,
REMEMBER_TOOL,
PROPOSE_REVISION_TOOL,
PROPOSE_CHANGE_TOOL,
]
);
assert_eq!(descriptor.hooks.len(), 3);
@@ -1128,11 +1126,11 @@ mod tests {
.any(|field| field == "entry_refs")
);
let propose = schema_for::<ProposeRevisionParams>();
let propose = schema_for::<ProposeChangeParams>();
let propose_text = serde_json::to_string(&propose).unwrap();
assert!(!propose_text.contains("subject_id"));
assert!(!propose_text.contains("\"kind\""));
assert!(propose_text.contains("expected_revision"));
assert!(propose_text.contains("expected_change_id"));
assert!(propose_text.contains("change_reason"));
let parsed: RememberParams = serde_json::from_str(
@@ -1152,7 +1150,7 @@ mod tests {
worker_id: Some("w".repeat(64)),
flow_selector: Some("f".repeat(64)),
flow_definition_id: Some("d".repeat(64)),
flow_definition_revision: Some(1),
flow_definition_fingerprint: Some("fingerprint".into()),
};
let evidence = (0..MAX_EVIDENCE_REFS)
.map(|index| memory::extract::StagingEvidence {
@@ -1178,8 +1176,8 @@ mod tests {
.collect::<Vec<_>>();
let response = server_api::SubjektivMemoryReadResponse {
memory_id: "memory-1".into(),
revision: 1,
current_revision: 1,
change_id: "change-1".into(),
current_change_id: "change-1".into(),
kind: CandidateKind::Lesson,
state: server_api::SubjektivMemoryState::Active,
claim: "Escape-aware read".into(),
@@ -1252,7 +1250,7 @@ mod tests {
#[tokio::test]
async fn backend_conflict_discriminants_survive_as_structured_tool_output() {
for code in ["revision_conflict", "stale_cursor"] {
for code in ["change_conflict", "stale_cursor"] {
let tool = SubjektivReadTool {
state: SubjektivMemoryState {
host: crate::subjektiv::test_connection(Arc::new(ConflictWorkspaceClient(
@@ -1291,7 +1289,7 @@ mod tests {
operation: ExplicitOperation::Remember,
};
let input =
r#"{"kind":"lesson","claim":"Use fixed revisions","why_useful":"Avoid mixed reads"}"#;
r#"{"kind":"lesson","claim":"Use fixed changes","why_useful":"Avoid mixed reads"}"#;
let output = tool
.execute(input, ToolExecutionContext::new("call-1", "batch-1", 0))
.await
@@ -1321,7 +1319,7 @@ mod tests {
Ok(CommittedSessionCapture {
session_id: "session-1".into(),
segment_id: "segment-1".into(),
session_revision: 1,
entry_count: 3,
run_exit: CommittedRunExit::Interrupted,
history: vec![
@@ -1380,7 +1378,7 @@ mod tests {
Ok(CommittedSessionCapture {
session_id: "session-1".into(),
segment_id: "segment-1".into(),
session_revision: 2,
entry_count: 2,
run_exit: CommittedRunExit::Finished,
history: vec![
@@ -1457,7 +1455,7 @@ mod tests {
Ok(CommittedSessionCapture {
session_id: "session-1".into(),
segment_id: "segment-1".into(),
session_revision: 2,
entry_count: 2,
run_exit: CommittedRunExit::Finished,
history: vec![
+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."
}
Self::Show => {
"Show one authoritative Workspace Ticket with its item revision, paged thread, links, historical implementation reports, and current Merge Request readiness evidence."
"Show one authoritative Workspace Ticket with its content digest, paged thread, links, historical implementation reports, and current Merge Request readiness evidence."
}
}
}
@@ -292,8 +292,8 @@ struct WorkspaceCompleteTicketInput {
ticket: String,
/// Stable key for exact replay of this completion decision.
operation_key: String,
/// Current item revision from ShowTicket.
expected_item_revision: String,
/// SHA-256 of the current editable Ticket content from ShowTicket.
expected_content_digest: String,
expected_state: TicketWorkflowState,
/// Required explanation of the completion decision; MR approval is not a prerequisite.
reason: String,
@@ -308,8 +308,8 @@ struct WorkspaceTicketStateUpdateInput {
/// Ticket reference. Prefer `T-*`.
ticket: String,
operation_key: String,
/// Current item revision from ShowTicket.
expected_item_revision: String,
/// SHA-256 of the current editable Ticket content from ShowTicket.
expected_content_digest: String,
expected_state: TicketWorkflowState,
/// Progress-display state only: this does not start Workers or integrate Merge Requests.
state: TicketWorkflowState,
@@ -326,8 +326,8 @@ struct WorkspaceTicketCloseInput {
ticket: String,
/// Stable key for exact replay of this close decision.
operation_key: String,
/// Current item revision from ShowTicket.
expected_item_revision: String,
/// SHA-256 of the current editable Ticket content from ShowTicket.
expected_content_digest: String,
expected_state: TicketWorkflowState,
/// Required explanation for closing this Ticket.
resolution: String,
@@ -381,13 +381,13 @@ struct WorkspaceTicketDecisionTool {
fn validate_ticket_decision(
ticket: &str,
operation_key: &str,
revision: &str,
content_digest: &str,
reason: &str,
) -> Result<(), ToolError> {
for (name, value) in [
("ticket", ticket),
("operation_key", operation_key),
("expected_item_revision", revision),
("expected_content_digest", content_digest),
("reason", reason),
] {
if value.trim().is_empty() {
@@ -412,7 +412,7 @@ impl Tool for WorkspaceTicketDecisionTool {
validate_ticket_decision(
&value.ticket,
&value.operation_key,
&value.expected_item_revision,
&value.expected_content_digest,
&value.reason,
)?;
let mut body = serde_json::to_value(&value)
@@ -428,12 +428,12 @@ impl Tool for WorkspaceTicketDecisionTool {
validate_ticket_decision(
&value.ticket,
&value.operation_key,
&value.expected_item_revision,
&value.expected_content_digest,
&value.resolution,
)?;
let body = json!({
"operation_key": value.operation_key,
"expected_item_revision": value.expected_item_revision,
"expected_content_digest": value.expected_content_digest,
"expected_state": value.expected_state,
"state": TicketWorkflowState::Closed,
"reason": value.resolution,
@@ -447,7 +447,7 @@ impl Tool for WorkspaceTicketDecisionTool {
validate_ticket_decision(
&value.ticket,
&value.operation_key,
&value.expected_item_revision,
&value.expected_content_digest,
&value.reason,
)?;
let mut body = serde_json::to_value(&value)
@@ -1005,7 +1005,7 @@ impl WorkspaceHttpTicketBackend {
)?;
serde_json::to_value(ticket::TicketStateUpdate {
operation_key: uuid::Uuid::now_v7().to_string(),
expected_item_revision: ticket::ticket_item_revision(&snapshot),
expected_content_digest: ticket::ticket_content_digest(&snapshot),
expected_state,
state,
reason: reason.into(),
@@ -1205,7 +1205,7 @@ impl WorkspaceHttpTicketBackend {
)?;
let body = serde_json::to_value(ticket::TicketCompletion {
operation_key: uuid::Uuid::now_v7().to_string(),
expected_item_revision: ticket::ticket_item_revision(&snapshot),
expected_content_digest: ticket::ticket_content_digest(&snapshot),
expected_state: snapshot.meta.workflow_state,
reason: resolution.as_str().to_owned(),
references: Vec::new(),
@@ -1763,13 +1763,12 @@ fn native_ticket_tools(
}
#[derive(Default)]
struct TicketRevisionState {
revisions: HashMap<String, String>,
struct TicketObservationState {
content_digests: HashMap<String, 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
/// item objects. Only tools already enabled for this Worker are projected and
@@ -1797,7 +1796,7 @@ pub fn mount_workspace_http_ticket_wip(
.filter(|tool| tool.projection.surface == NativeTicketSurface::Item)
.cloned()
.collect::<Vec<_>>();
let revisions = Arc::new(Mutex::new(TicketRevisionState::default()));
let observations = Arc::new(Mutex::new(TicketObservationState::default()));
let collection_descriptor = ticket_descriptor(
&collection_tools,
@@ -1824,7 +1823,7 @@ pub fn mount_workspace_http_ticket_wip(
tools: operation_map(collection_tools),
permissions: permissions.clone(),
collection_route: collection_route.clone(),
revisions: Arc::clone(&revisions),
observations: Arc::clone(&observations),
}),
})?;
@@ -1843,7 +1842,7 @@ pub fn mount_workspace_http_ticket_wip(
tools: operation_map(item_tools),
permissions,
collection_route: collection_route.clone(),
revisions,
observations,
}),
})?;
registry.replace_compatibility_tools(&collection_route, claimed_tools)?;
@@ -1861,7 +1860,7 @@ struct TicketCollectionWipHandler {
tools: HashMap<String, NativeTicketTool>,
permissions: Option<ToolPermissionConfig>,
collection_route: String,
revisions: TicketRevisions,
observations: TicketObservations,
}
#[async_trait]
@@ -1879,7 +1878,7 @@ impl WipOperationHandler for TicketCollectionWipHandler {
match operation {
"query" => add_ticket_paths(&mut response, &self.collection_route),
"create" => {
record_ticket_observation(&self.revisions, None, &response, true);
record_ticket_observation(&self.observations, None, &response, true);
if let Some(reference) = ticket_reference(&response).map(ToOwned::to_owned) {
response.as_object_mut().map(|object| {
object.insert(
@@ -1901,7 +1900,7 @@ struct TicketItemResolver {
tools: HashMap<String, NativeTicketTool>,
permissions: Option<ToolPermissionConfig>,
collection_route: String,
revisions: TicketRevisions,
observations: TicketObservations,
}
impl WipDynamicItemResolver for TicketItemResolver {
@@ -1909,14 +1908,13 @@ impl WipDynamicItemResolver for TicketItemResolver {
if !is_ticket_route_reference(item_reference) {
return None;
}
let revision = self
.revisions
let content_digest = self
.observations
.lock()
.unwrap_or_else(|error| error.into_inner())
.revisions
.content_digests
.get(item_reference)
.cloned()
.unwrap_or_else(|| "unobserved".into());
.cloned();
let route = format!("{}/{}", self.collection_route, item_reference);
Some(WipDynamicItem {
object: Object {
@@ -1924,13 +1922,15 @@ impl WipDynamicItemResolver for TicketItemResolver {
description: Some("Authoritative Ticket bound to this object route".into()),
interfaces: vec![crate::wip::root_reference(TICKET_ITEM_INTERFACE)],
r#ref: Some(format!("ticket:{item_reference}")),
validator: Some(route_validator(&route, &revision)),
validator: content_digest
.as_deref()
.map(|digest| route_validator(&route, digest)),
},
handler: Arc::new(TicketItemWipHandler {
tools: self.tools.clone(),
permissions: self.permissions.clone(),
ticket_reference: item_reference.into(),
revisions: Arc::clone(&self.revisions),
observations: Arc::clone(&self.observations),
}),
})
}
@@ -1940,7 +1940,7 @@ struct TicketItemWipHandler {
tools: HashMap<String, NativeTicketTool>,
permissions: Option<ToolPermissionConfig>,
ticket_reference: String,
revisions: TicketRevisions,
observations: TicketObservations,
}
#[async_trait]
@@ -1960,13 +1960,13 @@ impl WipOperationHandler for TicketItemWipHandler {
let output = execute_native_ticket_tool(tool, &self.permissions, input, context).await?;
let response = ticket_tool_output_json(output)?;
record_ticket_observation(
&self.revisions,
&self.observations,
Some(&self.ticket_reference),
&response,
tool.projection.mutating,
);
if tool.projection.mutating {
record_affected_ticket_mutations(&self.revisions, &self.ticket_reference, &response);
record_affected_ticket_mutations(&self.observations, &self.ticket_reference, &response);
}
Ok(WipOperationOutput::native(
json_to_wip(&response).map_err(WipOperationError::OutcomeUnknown)?,
@@ -2122,23 +2122,16 @@ fn ticket_reference(response: &Value) -> Option<&str> {
.filter(|reference| is_canonical_ticket_resource_key(reference))
}
fn response_revision(response: &Value) -> Option<&str> {
response
.get("item_revision")
.or_else(|| response.get("revision"))
.or_else(|| response.get("updated_at"))
.or_else(|| response.get("meta").and_then(|meta| meta.get("updated_at")))
.and_then(Value::as_str)
}
fn record_ticket_observation(
revisions: &TicketRevisions,
observations: &TicketObservations,
bound_reference: Option<&str>,
response: &Value,
mutation: bool,
) {
let observed_canonical = ticket_reference(response).map(ToOwned::to_owned);
let mut state = revisions.lock().unwrap_or_else(|error| error.into_inner());
let mut state = observations
.lock()
.unwrap_or_else(|error| error.into_inner());
let existing_bound_group = bound_reference.and_then(|reference| {
state
.aliases_by_canonical
@@ -2180,29 +2173,22 @@ fn record_ticket_observation(
aliases.insert(reference.to_string());
}
let revision = if mutation {
state.mutation_sequence = state.mutation_sequence.saturating_add(1);
format!(
"{}#mutation-{}",
response_revision(response).unwrap_or("observed"),
state.mutation_sequence
)
} else if let Some(revision) = response_revision(response) {
revision.to_string()
} else {
aliases
.iter()
.find_map(|alias| state.revisions.get(alias).cloned())
.unwrap_or_else(|| "observed".into())
};
// Mutation receipts are not full representations. Forget the observed
// content rather than manufacturing a local change counter.
for alias in &aliases {
state.revisions.insert(alias.clone(), revision.clone());
if mutation {
state.content_digests.remove(alias);
} else {
let bytes = Sha256::digest(response.to_string().as_bytes());
let digest = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
state.content_digests.insert(alias.clone(), digest);
}
}
state.aliases_by_canonical.insert(group_key, aliases);
}
fn record_affected_ticket_mutations(
revisions: &TicketRevisions,
observations: &TicketObservations,
bound_reference: &str,
response: &Value,
) {
@@ -2228,7 +2214,7 @@ fn record_affected_ticket_mutations(
);
}
for reference in affected.difference(&subject_references) {
record_ticket_observation(revisions, Some(reference), &Value::Null, true);
record_ticket_observation(observations, Some(reference), &Value::Null, true);
}
}
@@ -2239,11 +2225,11 @@ fn is_ticket_route_reference(reference: &str) -> bool {
&& reference.bytes().all(|byte| byte.is_ascii_alphanumeric()))
}
fn route_validator(route: &str, revision: &str) -> Vec<u8> {
fn route_validator(route: &str, content_digest: &str) -> Vec<u8> {
let mut digest = Sha256::new();
digest.update(route.as_bytes());
digest.update([0]);
digest.update(revision.as_bytes());
digest.update(content_digest.as_bytes());
digest.finalize().to_vec()
}
@@ -2427,7 +2413,7 @@ mod tests {
fn decision_input() -> Value {
json!({
"ticket": "T-718", "operation_key": "decision-1",
"expected_item_revision": "revision-1", "expected_state": "planning",
"expected_content_digest": "content_digest-1", "expected_state": "planning",
"reason": "No repository changes are needed"
})
}
@@ -2482,8 +2468,8 @@ mod tests {
let request: ticket::TicketStateUpdate = serde_json::from_value(body.clone()).unwrap();
assert!(uuid::Uuid::parse_str(&request.operation_key).is_ok());
assert_eq!(
request.expected_item_revision,
ticket::ticket_item_revision(&snapshot)
request.expected_content_digest,
ticket::ticket_content_digest(&snapshot)
);
assert_eq!(request.expected_state, TicketWorkflowState::Planning);
assert_eq!(request.state, TicketWorkflowState::Closed);
@@ -2592,8 +2578,8 @@ mod tests {
assert!(uuid::Uuid::parse_str(&request.operation_key).is_ok());
assert!(keys.insert(request.operation_key));
assert_eq!(
request.expected_item_revision,
ticket::ticket_item_revision(&snapshot)
request.expected_content_digest,
ticket::ticket_content_digest(&snapshot)
);
assert_eq!(request.expected_state, TicketWorkflowState::Ready);
assert_eq!(request.reason, "Resolved without repository changes");
@@ -2643,7 +2629,7 @@ mod tests {
);
for field in [
"operation_key",
"expected_item_revision",
"expected_content_digest",
"expected_state",
"resolution",
] {
@@ -2660,13 +2646,19 @@ mod tests {
.iter()
.any(|p| p.name == "references" && !p.required)
);
let revisions = Arc::new(Mutex::new(TicketRevisionState::default()));
let observations = Arc::new(Mutex::new(TicketObservationState::default()));
let resolver = TicketItemResolver {
tools: operation_map(tools),
permissions: None,
collection_route: "/tickets".into(),
revisions,
observations,
};
record_ticket_observation(
&resolver.observations,
Some("T-718"),
&json!({"ticket": "T-718", "body": "observed requirements"}),
false,
);
let bound = resolver.resolve("T-718").unwrap();
let before = bound.object.validator;
let other = resolver.resolve("T-719").unwrap().object.validator;
@@ -2702,7 +2694,7 @@ mod tests {
assert_eq!(
body,
json!({
"operation_key": "decision-1", "expected_item_revision": "revision-1",
"operation_key": "decision-1", "expected_content_digest": "content_digest-1",
"expected_state": "planning", "state": "closed",
"reason": "No repository changes are needed", "references": []
})
@@ -2794,7 +2786,7 @@ mod tests {
assert_eq!(
body,
json!({
"operation_key": "decision-1", "expected_item_revision": "revision-1",
"operation_key": "decision-1", "expected_content_digest": "content_digest-1",
"expected_state": "planning", "reason": "No repository changes are needed", "references": []
})
);
@@ -2859,7 +2851,7 @@ mod tests {
assert!(validator.is_valid(&valid));
for field in [
"operation_key",
"expected_item_revision",
"expected_content_digest",
"expected_state",
reason_field,
] {
@@ -2875,7 +2867,7 @@ mod tests {
for field in [
reason_field,
"operation_key",
"expected_item_revision",
"expected_content_digest",
"ticket",
] {
let mut invalid = valid.clone();
@@ -2968,7 +2960,7 @@ mod tests {
#[tokio::test]
async fn native_ticket_completion_binds_subject_and_stales_only_its_ticket_not_mrs() {
let client = decision_client(200, TicketWorkflowState::Done);
let revisions = Arc::new(Mutex::new(TicketRevisionState::default()));
let observations = Arc::new(Mutex::new(TicketObservationState::default()));
let tools =
native_ticket_tools(client.clone(), TicketFeatureAccess::work_report()).unwrap();
let descriptor = ticket_descriptor(&tools, "Ticket", "bound").unwrap();
@@ -2980,7 +2972,7 @@ mod tests {
assert!(!complete.parameters.iter().any(|p| p.name == "ticket"));
for field in [
"operation_key",
"expected_item_revision",
"expected_content_digest",
"expected_state",
"reason",
] {
@@ -2995,8 +2987,14 @@ mod tests {
tools: operation_map(tools),
permissions: None,
collection_route: "/tickets".into(),
revisions: revisions.clone(),
observations: observations.clone(),
};
record_ticket_observation(
&resolver.observations,
Some("T-718"),
&json!({"ticket": "T-718", "body": "observed requirements"}),
false,
);
let bound = resolver.resolve("T-718").unwrap();
let before = bound.object.validator;
let other = resolver.resolve("T-719").unwrap().object.validator;
@@ -3138,12 +3136,12 @@ mod tests {
.is_err()
);
let revisions = Arc::new(Mutex::new(TicketRevisionState::default()));
let observations = Arc::new(Mutex::new(TicketObservationState::default()));
let resolver = TicketItemResolver {
tools: HashMap::new(),
permissions: None,
collection_route: "/tickets".into(),
revisions,
observations,
};
assert!(resolver.resolve("T-42").is_some());
assert!(resolver.resolve("00001TICKET").is_some());
@@ -3202,36 +3200,36 @@ mod tests {
#[test]
fn ticket_alias_validators_refresh_together_after_mutation() {
let revisions = Arc::new(Mutex::new(TicketRevisionState::default()));
let observations = Arc::new(Mutex::new(TicketObservationState::default()));
let resolver = TicketItemResolver {
tools: HashMap::new(),
permissions: None,
collection_route: "/tickets".into(),
revisions: Arc::clone(&revisions),
observations: Arc::clone(&observations),
};
let unobserved_internal = resolver.resolve("00001TICKET").unwrap().object.validator;
record_ticket_observation(
&revisions,
&observations,
Some("00001TICKET"),
&json!({"ticket": "00001TICKET", "ok": true}),
true,
);
assert_ne!(
assert_eq!(
unobserved_internal,
resolver.resolve("00001TICKET").unwrap().object.validator,
"a first mutation through an internal-id route must stale that route"
"an unobserved route stays unvalidated after mutation"
);
record_ticket_observation(
&revisions,
&observations,
Some("00001TICKET"),
&json!({"ticket": "T-42", "item_revision": "rev-1"}),
&json!({"ticket": "T-42", "content_digest": "rev-1"}),
false,
);
let canonical_v1 = resolver.resolve("T-42").unwrap().object.validator;
let internal_v1 = resolver.resolve("00001TICKET").unwrap().object.validator;
record_ticket_observation(
&revisions,
&observations,
Some("T-42"),
&json!({"ticket": "T-42", "updated_at": "rev-2"}),
true,
@@ -3246,14 +3244,43 @@ mod tests {
);
}
#[test]
fn ticket_observation_digest_is_content_based_and_repeated_mutations_only_invalidate() {
let observations = Arc::new(Mutex::new(TicketObservationState::default()));
let resolver = TicketItemResolver {
tools: HashMap::new(),
permissions: None,
collection_route: "/tickets".into(),
observations: Arc::clone(&observations),
};
let response = json!({"ticket": "T-42", "body": "actual content", "state": "planning"});
assert!(resolver.resolve("T-42").unwrap().object.validator.is_none());
record_ticket_observation(&observations, Some("T-42"), &response, false);
let observed = resolver.resolve("T-42").unwrap().object.validator;
assert!(observed.is_some());
record_ticket_observation(&observations, Some("T-42"), &response, false);
assert_eq!(resolver.resolve("T-42").unwrap().object.validator, observed);
for _ in 0..2 {
record_ticket_observation(
&observations,
Some("T-42"),
&json!({"operation_key": "same-replay"}),
true,
);
assert!(resolver.resolve("T-42").unwrap().object.validator.is_none());
}
record_ticket_observation(&observations, Some("T-42"), &response, false);
assert_eq!(resolver.resolve("T-42").unwrap().object.validator, observed);
}
#[test]
fn related_ticket_mutations_invalidate_target_and_queue_observations() {
let revisions = Arc::new(Mutex::new(TicketRevisionState::default()));
let observations = Arc::new(Mutex::new(TicketObservationState::default()));
for ticket in ["T-42", "T-43", "T-44"] {
record_ticket_observation(
&revisions,
&observations,
Some(ticket),
&json!({"ticket": ticket, "item_revision": "rev-1"}),
&json!({"ticket": ticket, "content_digest": "rev-1"}),
false,
);
}
@@ -3261,12 +3288,12 @@ mod tests {
tools: HashMap::new(),
permissions: None,
collection_route: "/tickets".into(),
revisions: Arc::clone(&revisions),
observations: Arc::clone(&observations),
};
let target_v1 = resolver.resolve("T-43").unwrap().object.validator;
let queued_v1 = resolver.resolve("T-44").unwrap().object.validator;
record_affected_ticket_mutations(
&revisions,
&observations,
"T-42",
&json!({
"ticket": "T-42",
@@ -99,7 +99,6 @@ fn same_snapshot(
) -> Result<(), WipOperationError> {
if snapshot.connection_id != other.connection_id
|| snapshot.validator != other.validator
|| snapshot.revision != other.revision
|| snapshot.digest != other.digest
|| snapshot.entrypoints != other.entrypoints
{
@@ -504,7 +503,7 @@ impl ConfigHandler {
"Workspace config text exceeds limit",
));
}
// Resolve canonical per-file preconditions at the SAME captured tree revision.
// Resolve canonical per-file preconditions at the SAME captured tree content.
let metadata = observe(
&self.backend,
&self.attachment.connection_id,
@@ -716,7 +715,6 @@ impl ConfigHandler {
connection_id: self.attachment.connection_id.clone(),
validator: self.snapshot.validator.clone(),
request: ConfigCommitRequest {
base_revision: self.snapshot.revision,
base_digest: self.snapshot.digest.clone(),
changes,
entrypoints: self.snapshot.entrypoints.clone(),
@@ -735,10 +733,7 @@ impl ConfigHandler {
)
.await
.map_err(|_| unknown())?;
if updated.revision != result.revision
|| updated.digest != result.digest
|| updated.validator != result.validator
{
if updated.digest != result.digest || updated.validator != result.validator {
return Err(unknown());
}
let node = updated
@@ -746,7 +741,7 @@ impl ConfigHandler {
.iter()
.find(|n| n.path == self.node.path)
.ok_or_else(unknown)?;
let value = json_to_wip(&json!({"revision": result.revision, "digest": result.digest,
let value = json_to_wip(&json!({"digest": result.digest,
"activation": "Saved to canonical Workspace configuration; existing Worker manifests are not rewritten"})).map_err(|_| unknown())?;
Ok(WipOperationOutput::native_with_validator(
value,
@@ -14,7 +14,6 @@ use protocol::{FeatureInvocation, FeatureInvocationIdentity};
use serde::Serialize;
use serde_json::{Value as Json, json};
use server_api::*;
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Arc, Mutex};
@@ -24,7 +23,6 @@ struct State {
read_only: bool,
active: Option<WorkspaceConfigAttachment>,
next_connection: u64,
revision: u64,
entries: BTreeMap<String, (ConfigContentType, String)>,
commits: Vec<WorkspaceConfigCommitRequest>,
requests: Vec<WorkspaceRequest>,
@@ -32,6 +30,7 @@ struct State {
fail_current: bool,
lose_commit_reply: bool,
race_commit: bool,
mismatched_commit_reply: Option<&'static str>,
}
#[derive(Debug)]
struct Router {
@@ -47,7 +46,6 @@ impl Router {
read_only: false,
active: None,
next_connection: 0,
revision: 1,
entries: BTreeMap::from([
(
"main.dcdl".into(),
@@ -64,6 +62,7 @@ impl Router {
fail_current: false,
lose_commit_reply: false,
race_commit: false,
mismatched_commit_reply: None,
})),
})
}
@@ -88,20 +87,30 @@ fn denied(status: u16, classification: WorkspaceConfigFailureClassification) ->
}
}
fn digest(content: &str) -> String {
Sha256::digest(content.as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
config_source::digest_bytes(content.as_bytes())
}
impl State {
fn tree_digest(&self) -> String {
digest(&serde_json::to_string(&self.entries).unwrap())
config_source::ConfigTreeSnapshot::from_entries(self.entries.iter().map(
|(path, (content_type, content))| {
config_source::ConfigEntry::new(
config_source::VirtualPath::parse(path).unwrap(),
match content_type {
ConfigContentType::Decodal => config_source::ConfigContentType::Decodal,
ConfigContentType::Text => config_source::ConfigContentType::Text,
},
content.clone(),
)
.unwrap()
},
))
.unwrap()
.digest
}
fn validator(&self, path: &str) -> String {
format!(
"wc:{}:{}:{}:{}",
"wc:{}:{}:{}",
self.active.as_ref().unwrap().connection_id,
self.revision,
self.tree_digest(),
path
)
@@ -262,7 +271,6 @@ impl WorkspaceClient for Router {
Ok(response(&WorkspaceConfigObserveResponse {
connection_id: state.active.as_ref().unwrap().connection_id.clone(),
validator: state.validator(""),
revision: state.revision,
digest: state.tree_digest(),
entrypoints: vec!["main.dcdl".into()],
nodes,
@@ -294,7 +302,7 @@ impl WorkspaceClient for Router {
let request: WorkspaceConfigCommitRequest = serde_json::from_value(input).unwrap();
state.commits.push(request.clone());
if state.race_commit {
state.revision += 1;
state.entries.get_mut("main.dcdl").unwrap().1 = "model = concurrent".into();
state.race_commit = false;
}
if state.read_only {
@@ -305,7 +313,6 @@ impl WorkspaceClient for Router {
}
assert_eq!(request.request.entrypoints, ["main.dcdl"]);
if request.validator != state.validator("")
|| request.request.base_revision != state.revision
|| request.request.base_digest != state.tree_digest()
{
return Ok(denied(
@@ -396,18 +403,23 @@ impl WorkspaceClient for Router {
));
}
state.entries = entries;
state.revision += 1;
if state.lose_commit_reply {
state.lose_commit_reply = false;
return Err(WorkspaceClientError::Request(
"secret-token /private/host/config".into(),
));
}
Ok(response(&WorkspaceConfigCommitResponse {
let mut result = WorkspaceConfigCommitResponse {
validator: state.validator(""),
revision: state.revision,
digest: state.tree_digest(),
}))
};
match state.mismatched_commit_reply.take() {
Some("digest") => result.digest = "different-content".into(),
Some("validator") => result.validator = "different-authority".into(),
None => {}
Some(_) => unreachable!("unknown fixture mismatch"),
}
Ok(response(&result))
}
_ => panic!("unregistered endpoint: {suffix}"),
}
@@ -668,13 +680,20 @@ fn runtime_for_cross(client: Arc<dyn WorkspaceClient>) -> (WorkspaceConfigFeatur
}
#[tokio::test]
async fn workspace_config_production_stale_same_bytes_and_commit_race_never_overwrite() {
async fn workspace_config_production_stale_content_and_commit_race_never_overwrite() {
let client = Router::new();
let (feature, runtime) = runtime(client.clone());
feature.attach("initial", None).await.unwrap();
let path = "/workspace-config/main.dcdl";
observe_interface(&runtime, path, true).await;
client.state.lock().unwrap().revision += 1; // Same bytes, different identity revision.
client
.state
.lock()
.unwrap()
.entries
.get_mut("main.dcdl")
.unwrap()
.1 = "model = external".into();
assert!(
call(&runtime, path, "write", json!({"content":"overwritten"}))
.await
@@ -690,7 +709,7 @@ async fn workspace_config_production_stale_same_bytes_and_commit_race_never_over
);
assert_eq!(
client.state.lock().unwrap().entries["main.dcdl"].1,
"model = old"
"model = concurrent"
);
assert_eq!(client.state.lock().unwrap().commits.len(), 1);
}
@@ -758,6 +777,26 @@ async fn workspace_config_production_invalid_size_save_failure_unknown_are_safe_
);
}
#[tokio::test]
async fn post_commit_observation_mismatch_reports_unknown_without_replaying_write() {
for field in ["digest", "validator"] {
let client = Router::new();
let (feature, runtime) = runtime(client.clone());
feature.attach("initial", None).await.unwrap();
let path = "/workspace-config/main.dcdl";
observe_interface(&runtime, path, true).await;
client.state.lock().unwrap().mismatched_commit_reply = Some(field);
let error = call(&runtime, path, "write", json!({"content":"saved"}))
.await
.unwrap_err()
.to_string();
assert!(error.contains("unknown"), "{field}: {error}");
let state = client.state.lock().unwrap();
assert_eq!(state.commits.len(), 1);
assert_eq!(state.entries["main.dcdl"].1, "saved");
}
}
#[tokio::test]
async fn workspace_config_production_bounds_and_traversal_reject_before_content_calls() {
let client = Router::new();
@@ -876,7 +915,6 @@ async fn workspace_config_edit_shared_argument_rejection_never_commits() {
assert!(error.to_string().contains(expected), "{error:?}");
let state = client.state.lock().unwrap();
assert!(state.commits.is_empty());
assert_eq!(state.revision, 1);
assert_eq!(state.entries["main.dcdl"].1, "model = old");
// Argument-only rejection must not even fetch a preimage.
assert!(

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