fix: complete state-contract naming cleanup
This commit is contained in:
@@ -65,7 +65,7 @@ impl<T: OpenApiSchema> OpenApiSchema for BTreeMap<String, T> {}
|
||||
pub struct OpenApiInfo<'a> {
|
||||
pub title: &'a str,
|
||||
pub version: &'a str,
|
||||
/// Opaque digest of the source revision used to build the contract.
|
||||
/// Source-content digest used to identify the exported contract's build inputs.
|
||||
pub source_digest: &'a str,
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ fn request() -> JobRequest {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_content_identity_is_computed_and_old_counter_is_not_accepted() {
|
||||
fn input_content_identity_is_computed_and_unknown_fields_are_rejected() {
|
||||
let original = request();
|
||||
let mut changed = original.clone();
|
||||
changed.input = json!({"title": "changed"});
|
||||
@@ -28,9 +28,9 @@ fn input_content_identity_is_computed_and_old_counter_is_not_accepted() {
|
||||
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());
|
||||
let mut unknown = serde_json::to_value(&original).unwrap();
|
||||
unknown["unexpected_state_guard"] = json!(7);
|
||||
assert!(serde_json::from_value::<JobRequest>(unknown).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+15
-11
@@ -1141,7 +1141,8 @@ struct LegacyMemoryConfig {
|
||||
_query_excerpt_lines: Option<usize>,
|
||||
inject_summary: Option<bool>,
|
||||
workspace_id: Option<String>,
|
||||
settings_revision: Option<u64>,
|
||||
#[serde(rename = "settings_revision")]
|
||||
legacy_settings_counter: Option<u64>,
|
||||
language: Option<String>,
|
||||
extract_model: Option<ModelManifest>,
|
||||
extract_threshold: Option<u64>,
|
||||
@@ -1241,8 +1242,9 @@ 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.
|
||||
/// Frozen migration for schema 2/3 resolved manifests. During persisted snapshot
|
||||
/// decoding, reject an invalid old counter; do not compare it for execution freshness.
|
||||
/// Keep actual Workspace identity and language unchanged; the sequence has no execution role.
|
||||
fn migrate_legacy_memory_settings_counter(
|
||||
manifest: &mut serde_json::Value,
|
||||
) -> Result<(), serde_json::Error> {
|
||||
@@ -1342,14 +1344,16 @@ fn migrate_legacy_resolved_manifest_snapshot(
|
||||
serde_json::from_value(legacy_memory.unwrap_or_else(|| serde_json::json!({})))?;
|
||||
let mut workspace_settings = match (
|
||||
legacy_memory.workspace_id,
|
||||
legacy_memory.settings_revision,
|
||||
legacy_memory.legacy_settings_counter,
|
||||
legacy_memory.language,
|
||||
) {
|
||||
(Some(workspace_id), Some(settings_revision), Some(language)) => Some(serde_json::json!({
|
||||
"workspace_id": workspace_id,
|
||||
"settings_revision": settings_revision,
|
||||
"language": language,
|
||||
})),
|
||||
(Some(workspace_id), Some(legacy_settings_counter), Some(language)) => {
|
||||
Some(serde_json::json!({
|
||||
"workspace_id": workspace_id,
|
||||
"settings_revision": legacy_settings_counter,
|
||||
"language": language,
|
||||
}))
|
||||
}
|
||||
(None, None, None) => None,
|
||||
_ => {
|
||||
return Err(serde_json::Error::io(std::io::Error::new(
|
||||
@@ -1891,10 +1895,10 @@ model_id = "claude-sonnet-4-20250514"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_settings_snapshot_rejects_removed_counter_outside_migration() {
|
||||
fn current_settings_snapshot_rejects_unknown_fields_and_invalid_language() {
|
||||
assert!(
|
||||
serde_json::from_value::<WorkspaceMemorySettingsSnapshot>(serde_json::json!({
|
||||
"workspace_id": "workspace-1", "language": "English", "settings_revision": 9
|
||||
"workspace_id": "workspace-1", "language": "English", "unexpected_state_guard": 9
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
|
||||
@@ -606,8 +606,10 @@ 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,
|
||||
/// Frozen historical marker, read only to preserve archived completion payloads.
|
||||
/// It is never checked for readiness, content attestation, or live completion.
|
||||
#[serde(rename = "item_revision")]
|
||||
pub legacy_item_marker: String,
|
||||
pub merge_request_ids: Vec<String>,
|
||||
pub requirement_approval_event_id: String,
|
||||
pub completed_by: WorkerIdentity,
|
||||
|
||||
@@ -164,11 +164,11 @@ fn v12_migration_revokes_old_grants_preserves_integration_and_requires_fresh_con
|
||||
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%'",
|
||||
let invalid_content_attestations: i64 = connection.query_row(
|
||||
"SELECT COUNT(*) FROM merge_request_thread_events WHERE kind IN ('review_requested','review') AND json_extract(payload_json, '$.ticket_content_digest') IS NOT ''",
|
||||
[], |row| row.get(0),
|
||||
).unwrap();
|
||||
assert_eq!(legacy_terms, 0);
|
||||
assert_eq!(invalid_content_attestations, 0);
|
||||
let saved = store.get_by_id("W", "MR").unwrap();
|
||||
assert_eq!(saved.merged_result().unwrap(), &merged);
|
||||
let integration = saved.integration_approval().unwrap();
|
||||
@@ -1314,7 +1314,7 @@ fn historical_ticket_completion_payload_remains_readable_and_unchanged_on_migrat
|
||||
let payload = r#"{
|
||||
"operation_id": "historical-operation",
|
||||
"ticket_id": "T",
|
||||
"item_revision": "historical-revision",
|
||||
"item_revision": "historical-item-marker",
|
||||
"merge_request_ids": ["MR-2", "MR"],
|
||||
"requirement_approval_event_id": "historical-review",
|
||||
"completed_by": {"runtime_id": "runtime", "worker_id": "coder"},
|
||||
@@ -1350,6 +1350,7 @@ fn historical_ticket_completion_payload_remains_readable_and_unchanged_on_migrat
|
||||
assert_eq!(body, "historical body");
|
||||
assert_eq!(state, "done");
|
||||
let historical: TicketCompletionEvent = serde_json::from_str(&saved_payload).unwrap();
|
||||
assert_eq!(historical.legacy_item_marker, "historical-item-marker");
|
||||
assert_eq!(
|
||||
serde_json::to_value(&historical).unwrap(),
|
||||
serde_json::from_str::<serde_json::Value>(payload).unwrap()
|
||||
|
||||
+23
-23
@@ -1142,7 +1142,8 @@ pub enum Event {
|
||||
notification_request_id: String,
|
||||
message: String,
|
||||
},
|
||||
/// Revisioned FIFO replacement following enqueue, activation, cancel, or clear.
|
||||
/// Complete FIFO replacement following enqueue, activation, cancel, or clear.
|
||||
/// Clients replace their pending view in stream order; reconnect uses the snapshot.
|
||||
PendingSubmissionsChanged {
|
||||
pending: PendingSubmissionsSnapshot,
|
||||
},
|
||||
@@ -2035,8 +2036,6 @@ mod tests {
|
||||
}
|
||||
})
|
||||
);
|
||||
assert!(value.get("execution_generation").is_none());
|
||||
assert!(value.get("revision").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2276,7 +2275,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_commands_reject_removed_nested_preconditions() {
|
||||
fn lifecycle_commands_reject_unknown_nested_preconditions() {
|
||||
for method in ["pause", "cancel", "resume", "compact", "shutdown"] {
|
||||
let wire = serde_json::json!({
|
||||
"method": method,
|
||||
@@ -2284,16 +2283,12 @@ mod tests {
|
||||
});
|
||||
// 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}");
|
||||
}
|
||||
let field = "unexpected_state_guard";
|
||||
let mut unknown_command = wire.clone();
|
||||
unknown_command["params"]["command"][field] = serde_json::json!(1);
|
||||
let error = serde_json::from_value::<Method>(unknown_command)
|
||||
.expect_err("unknown command preconditions must not be silently ignored");
|
||||
assert!(error.to_string().contains(field), "{method}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2308,8 +2303,6 @@ mod tests {
|
||||
},
|
||||
] {
|
||||
let json = serde_json::to_string(&method).unwrap();
|
||||
assert!(!json.contains("expected_execution_generation"));
|
||||
assert!(!json.contains("expected_worker_state_revision"));
|
||||
let decoded: Method = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
Method::Pause { command } | Method::Compact { command } => {
|
||||
@@ -2867,14 +2860,21 @@ mod tests {
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed["event"], "worker_state");
|
||||
assert!(
|
||||
parsed["data"]["snapshot"]
|
||||
.get("execution_generation")
|
||||
.is_none()
|
||||
assert_eq!(
|
||||
parsed,
|
||||
serde_json::json!({
|
||||
"event": "worker_state",
|
||||
"data": {
|
||||
"snapshot": {
|
||||
"last_command_id": 9,
|
||||
"state": {
|
||||
"kind": "busy",
|
||||
"state": { "kind": "run", "state": "running" }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
assert!(parsed["data"]["snapshot"].get("revision").is_none());
|
||||
assert_eq!(parsed["data"]["snapshot"]["state"]["kind"], "busy");
|
||||
|
||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(
|
||||
|
||||
@@ -6022,7 +6022,6 @@ pub enum WorkspaceDeletionState {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkspaceDeletionBlockerKind {
|
||||
LastAccessibleWorkspace,
|
||||
RevisionConflict,
|
||||
DirtyWorkdir,
|
||||
WorkerRemovalBlocked,
|
||||
WorkdirRemovalBlocked,
|
||||
@@ -11845,7 +11844,6 @@ mod tests {
|
||||
let source: RepositorySource = serde_json::from_value(serde_json::json!({
|
||||
"kind": "http",
|
||||
"uri": "http://git.example.test/team/project.git",
|
||||
"revision": 1,
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
@@ -11925,7 +11923,10 @@ mod tests {
|
||||
let decoded: SkillCatalogResponse =
|
||||
serde_json::from_str(&json).expect("deserialize Skill catalog");
|
||||
assert_eq!(decoded, response);
|
||||
assert!(!json.contains("\"revision\""));
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&json).unwrap()["projection"],
|
||||
serde_json::json!({"tree_digest": "tree-digest"})
|
||||
);
|
||||
assert!(!json.contains("\"tree_digest\":null"));
|
||||
}
|
||||
|
||||
@@ -12200,6 +12201,26 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn workspace_deletion_wire_contract_is_closed_and_typed() {
|
||||
let categories = serde_json::to_value([
|
||||
WorkspaceDeletionBlockerKind::LastAccessibleWorkspace,
|
||||
WorkspaceDeletionBlockerKind::DirtyWorkdir,
|
||||
WorkspaceDeletionBlockerKind::WorkerRemovalBlocked,
|
||||
WorkspaceDeletionBlockerKind::WorkdirRemovalBlocked,
|
||||
WorkspaceDeletionBlockerKind::RetentionHold,
|
||||
WorkspaceDeletionBlockerKind::CleanupUnavailable,
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
categories,
|
||||
serde_json::json!([
|
||||
"last_accessible_workspace",
|
||||
"dirty_workdir",
|
||||
"worker_removal_blocked",
|
||||
"workdir_removal_blocked",
|
||||
"retention_hold",
|
||||
"cleanup_unavailable"
|
||||
])
|
||||
);
|
||||
let preflight = WorkspaceDeletionPreflightResponse {
|
||||
workspace_id: "workspace-test".to_string(),
|
||||
display_name: "Test".to_string(),
|
||||
@@ -12221,7 +12242,7 @@ mod tests {
|
||||
preflight
|
||||
);
|
||||
let mut stale = value.as_object().unwrap().clone();
|
||||
stale.insert("revision".to_string(), serde_json::json!(7));
|
||||
stale.insert("unexpected".to_string(), serde_json::json!(7));
|
||||
assert!(
|
||||
serde_json::from_value::<WorkspaceDeletionPreflightResponse>(stale.into()).is_err()
|
||||
);
|
||||
@@ -12516,16 +12537,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_response_rejects_stale_field_aliases() {
|
||||
let stale = serde_json::json!({
|
||||
fn repository_response_rejects_unknown_item_fields() {
|
||||
let valid = serde_json::json!({
|
||||
"workspace_id": "workspace-test",
|
||||
"items": [{
|
||||
"repository_key": "main",
|
||||
"display_name": "main",
|
||||
"kind": "git",
|
||||
"provider": "git",
|
||||
"source": {"kind": "local_path", "uri": "/srv/project"},
|
||||
"source_revision": 1,
|
||||
"source_fingerprint": "sha256:test",
|
||||
"observed_status": "ready",
|
||||
"record_authority": "workspace-control-plane"
|
||||
@@ -12533,8 +12552,10 @@ mod tests {
|
||||
"source": "workspace-control-plane",
|
||||
"diagnostics": []
|
||||
});
|
||||
|
||||
assert!(serde_json::from_value::<RepositoryListResponse>(stale).is_err());
|
||||
serde_json::from_value::<RepositoryListResponse>(valid.clone()).unwrap();
|
||||
let mut unknown = valid;
|
||||
unknown["items"][0]["unexpected_state_guard"] = serde_json::json!(1);
|
||||
assert!(serde_json::from_value::<RepositoryListResponse>(unknown).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -12657,21 +12678,23 @@ mod tests {
|
||||
};
|
||||
round_trip(evidence.clone());
|
||||
let mut stale = serde_json::to_value(evidence).unwrap();
|
||||
stale["workspace_identity_revision"] = serde_json::json!(1);
|
||||
stale["unexpected_state_guard"] = serde_json::json!(1);
|
||||
assert!(serde_json::from_value::<RuntimeVerificationEvidenceSummary>(stale).is_err());
|
||||
round_trip(RevokeRuntimeTrustKeyRequest {
|
||||
expected_binding_id: "binding-observed".into(),
|
||||
});
|
||||
assert!(
|
||||
serde_json::from_value::<RevokeRuntimeTrustKeyRequest>(serde_json::json!({
|
||||
"expected_revision": 1
|
||||
"expected_binding_id": "binding-observed",
|
||||
"unexpected_state_guard": 1
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<RemoveRuntimeRequest>(serde_json::json!({
|
||||
"operation_id": "remove-binding",
|
||||
"expected_binding_revision": 1
|
||||
"expected_binding_id": "binding-observed",
|
||||
"unexpected_state_guard": 1
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
@@ -12683,6 +12706,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn repository_key_mutations_require_observed_key_fingerprints() {
|
||||
round_trip(DeleteRepositorySshCredentialRequest {
|
||||
operation_id: "delete-key".into(),
|
||||
expected_public_key_fingerprint: "sha256:observed-key".into(),
|
||||
});
|
||||
round_trip(DeleteRepositorySshHostTrustRequest {
|
||||
operation_id: "delete-trust".into(),
|
||||
expected_fingerprint: "sha256:observed-host-key".into(),
|
||||
});
|
||||
round_trip(RotateRepositorySshCredentialRequest {
|
||||
operation_id: "rotate-key".into(),
|
||||
expected_public_key_fingerprint: "sha256:observed-key".into(),
|
||||
@@ -12700,14 +12731,16 @@ mod tests {
|
||||
assert!(
|
||||
serde_json::from_value::<DeleteRepositorySshCredentialRequest>(serde_json::json!({
|
||||
"operation_id": "delete-key",
|
||||
"expected_revision": 1
|
||||
"expected_public_key_fingerprint": "sha256:observed-key",
|
||||
"unexpected_state_guard": 1
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<DeleteRepositorySshHostTrustRequest>(serde_json::json!({
|
||||
"operation_id": "delete-trust",
|
||||
"expected_revision": 1
|
||||
"expected_fingerprint": "sha256:observed-host-key",
|
||||
"unexpected_state_guard": 1
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
@@ -12763,7 +12796,7 @@ mod tests {
|
||||
assert!(output.contains("export type WorkspaceConfigTreeResponse ="));
|
||||
assert!(output.contains("export type ConfigCommitRequest ="));
|
||||
assert!(output.contains("export type ProfileSettingsResponse ="));
|
||||
assert!(!output.contains("config_revision"));
|
||||
assert!(output.contains("base_digest: string"));
|
||||
assert!(output.contains("provenance: WorkspaceProfileSourceProvenance"));
|
||||
assert!(output.contains(
|
||||
"export type WorkspaceProfileSourceProvenance = \"project_profile_source_tree\""
|
||||
|
||||
@@ -314,11 +314,13 @@ mod tests {
|
||||
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()
|
||||
assert_eq!(
|
||||
serde_json::to_value(item).unwrap(),
|
||||
serde_json::json!({
|
||||
"kind": "subject_behavior_refresh",
|
||||
"body": "exact archived body",
|
||||
"behavior_md": ""
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,8 @@ mod tests {
|
||||
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();
|
||||
// A Job result is opaque domain data, not a legacy attempt binding.
|
||||
let receipt = "{\"accepted\":true,\"receipt_tag\":\"receipt-domain-data\"}".to_string();
|
||||
let digest = job::result_digest(
|
||||
&serde_json::from_str(&receipt).unwrap(),
|
||||
job::ABSOLUTE_MAX_RESULT_BYTES,
|
||||
|
||||
@@ -426,6 +426,10 @@ impl StandaloneWorkerStore {
|
||||
// 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.
|
||||
// Before replacing the file, reject an update based on metadata from
|
||||
// before another writer stopped/resumed the Worker or changed its
|
||||
// session pointer. Those changes may share a timestamp; arrival-order
|
||||
// wins would silently restore an old lifecycle/pointer.
|
||||
if serde_json::to_value(¤t).map_err(StandaloneStoreError::Json)?
|
||||
!= serde_json::to_value(expected).map_err(StandaloneStoreError::Json)?
|
||||
{
|
||||
@@ -961,7 +965,7 @@ permission = "write"
|
||||
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);
|
||||
assert_eq!(saved["schema_version"], SCHEMA_VERSION);
|
||||
let resumed = store
|
||||
.update_active_pointer(
|
||||
&migrated,
|
||||
@@ -974,7 +978,7 @@ permission = "write"
|
||||
)
|
||||
.unwrap();
|
||||
assert!(disk.get("revision").is_none());
|
||||
assert_eq!(disk["schema_version"], 2);
|
||||
assert_eq!(disk["schema_version"], SCHEMA_VERSION);
|
||||
assert_eq!(resumed.subject, record.subject);
|
||||
assert_eq!(resumed.active_segment_id, record.active_segment_id);
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ WHEN EXISTS (
|
||||
AND memory_id = NEW.memory_id
|
||||
AND revision = NEW.revision
|
||||
) BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv revision evidence is sealed');
|
||||
SELECT RAISE(ABORT, 'subjektiv memory history evidence is sealed');
|
||||
END;
|
||||
CREATE TRIGGER memory_revision_derivations_no_late_insert
|
||||
BEFORE INSERT ON memory_revision_derivations
|
||||
@@ -220,11 +220,11 @@ END;
|
||||
|
||||
CREATE TRIGGER memory_revision_seals_no_update
|
||||
BEFORE UPDATE ON memory_revision_seals BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv revision seals are immutable');
|
||||
SELECT RAISE(ABORT, 'subjektiv memory history seals are immutable');
|
||||
END;
|
||||
CREATE TRIGGER memory_revision_seals_no_delete
|
||||
BEFORE DELETE ON memory_revision_seals BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv revision seals are retained');
|
||||
SELECT RAISE(ABORT, 'subjektiv memory history seals are retained');
|
||||
END;
|
||||
CREATE TRIGGER staging_resolution_seals_no_update
|
||||
BEFORE UPDATE ON staging_resolution_seals BEGIN
|
||||
@@ -245,19 +245,19 @@ END;
|
||||
|
||||
CREATE TRIGGER memory_revisions_no_update
|
||||
BEFORE UPDATE ON memory_revisions BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv memory revisions are immutable');
|
||||
SELECT RAISE(ABORT, 'subjektiv memory history entries are immutable');
|
||||
END;
|
||||
CREATE TRIGGER memory_revisions_no_delete
|
||||
BEFORE DELETE ON memory_revisions BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv memory revisions are retained');
|
||||
SELECT RAISE(ABORT, 'subjektiv memory history entries are retained');
|
||||
END;
|
||||
CREATE TRIGGER memory_revision_candidates_no_update
|
||||
BEFORE UPDATE ON memory_revision_candidates BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv revision evidence is immutable');
|
||||
SELECT RAISE(ABORT, 'subjektiv memory history evidence is immutable');
|
||||
END;
|
||||
CREATE TRIGGER memory_revision_candidates_no_delete
|
||||
BEFORE DELETE ON memory_revision_candidates BEGIN
|
||||
SELECT RAISE(ABORT, 'subjektiv revision evidence is retained');
|
||||
SELECT RAISE(ABORT, 'subjektiv memory history evidence is retained');
|
||||
END;
|
||||
CREATE TRIGGER memory_revision_derivations_no_update
|
||||
BEFORE UPDATE ON memory_revision_derivations BEGIN
|
||||
@@ -858,7 +858,8 @@ mod tests {
|
||||
subject_id: String,
|
||||
body_md: String,
|
||||
memory_refs: Vec<MemoryChangeRef>,
|
||||
built_from_store_revision: u64,
|
||||
#[serde(rename = "built_from_store_revision")]
|
||||
legacy_memory_counter: u64,
|
||||
created_at: String,
|
||||
}
|
||||
static V1_MIGRATIONS: &[FeatureMigration] = &[FeatureMigration::new(
|
||||
@@ -983,7 +984,7 @@ mod tests {
|
||||
subject_id: subject_id.clone(),
|
||||
body_md: "Legacy summary without generation-policy evidence.".into(),
|
||||
memory_refs: Vec::new(),
|
||||
built_from_store_revision: 0,
|
||||
legacy_memory_counter: 0,
|
||||
created_at: now(),
|
||||
};
|
||||
store
|
||||
@@ -1010,7 +1011,7 @@ mod tests {
|
||||
params![
|
||||
subject_id,
|
||||
snapshot.id,
|
||||
to_i64(snapshot.built_from_store_revision)?,
|
||||
to_i64(snapshot.legacy_memory_counter)?,
|
||||
serde_json::to_string(&snapshot)?,
|
||||
snapshot.created_at
|
||||
],
|
||||
|
||||
@@ -1628,6 +1628,10 @@ impl SubjektivStore {
|
||||
"unknown generation `{generation_id}` for subject `{subject_id}`"
|
||||
))
|
||||
})?;
|
||||
// Before publication, compare the generation's frozen Memory input
|
||||
// with live input in this same IMMEDIATE transaction. An older job
|
||||
// can finish after Memory changes; first/last arrival and timestamps
|
||||
// cannot make its summary describe the newer input.
|
||||
if current_fingerprint != memory_fingerprint {
|
||||
return Err(SubjektivError::SurfaceGenerationConflict(format!(
|
||||
"generation input {memory_fingerprint} is stale; current Memory input fingerprint is {}",
|
||||
@@ -2167,6 +2171,10 @@ fn write_memory_change(
|
||||
.optional()?;
|
||||
let current = current_raw.map(|raw| parse_memory(&raw)).transpose()?;
|
||||
|
||||
// Compare the observed Memory change before appending history or replacing
|
||||
// current state, under this same IMMEDIATE transaction. A late decision based
|
||||
// on old content must not overwrite a newer accepted change. Timestamps can
|
||||
// tie, and arrival order does not prove which Memory the decision examined.
|
||||
let (change_id, created_at) = match (current.as_ref(), expected_change_id) {
|
||||
(None, None) => (issued_id("memory-change"), now()),
|
||||
(None, Some(_)) => return Err(SubjektivError::MemoryNotFound(memory_id.to_string())),
|
||||
|
||||
@@ -2523,6 +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)?;
|
||||
// Compare content and workflow state before any state/audit write, using
|
||||
// the snapshot read in this same write transaction. Acceptance criteria
|
||||
// may change without a state change, so a late completion must not win
|
||||
// solely by arrival order. Equal timestamps do not prove equal content.
|
||||
let actual = ticket_content_digest(&previous);
|
||||
if actual != request.expected_content_digest {
|
||||
return Err(TicketError::StaleContent {
|
||||
|
||||
@@ -3125,11 +3125,9 @@ mod tests {
|
||||
}});
|
||||
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["memory_settings"],
|
||||
serde_json::json!({"workspace_id": "workspace-a", "language": "English"})
|
||||
);
|
||||
assert_eq!(
|
||||
request["repository"]["source_fingerprint"],
|
||||
|
||||
@@ -640,6 +640,9 @@ struct RuntimeAuthFile {
|
||||
identity: Option<RuntimeIdentityMaterial>,
|
||||
#[serde(default)]
|
||||
workspace_issuers: Vec<WorkspaceIssuerTrustRecord>,
|
||||
/// Frozen pre-ID issuer records, retained as TOML history only, never as authority.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
archived_legacy_issuers: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -938,6 +941,7 @@ fn read_runtime_auth_file(path: &Path) -> Result<RuntimeAuthFile, ProcessError>
|
||||
let mut document: toml::Value = toml::from_str(&contents)
|
||||
.map_err(|_| ProcessError::auth("runtime auth store is corrupt"))?;
|
||||
let mut migrated = false;
|
||||
let mut archived_legacy_issuers = Vec::new();
|
||||
if let Some(records) = document
|
||||
.get_mut("workspace_issuers")
|
||||
.and_then(toml::Value::as_array_mut)
|
||||
@@ -951,6 +955,9 @@ fn read_runtime_auth_file(path: &Path) -> Result<RuntimeAuthFile, ProcessError>
|
||||
legacy,
|
||||
)
|
||||
.map_err(|_| ProcessError::auth("runtime legacy trust store is corrupt"))?;
|
||||
archived_legacy_issuers.push(toml::to_string(&*value).map_err(|_| {
|
||||
ProcessError::auth("runtime auth history serialization failed")
|
||||
})?);
|
||||
*value = toml::Value::try_from(record).map_err(|_| {
|
||||
ProcessError::auth("runtime auth migration serialization failed")
|
||||
})?;
|
||||
@@ -958,9 +965,10 @@ fn read_runtime_auth_file(path: &Path) -> Result<RuntimeAuthFile, ProcessError>
|
||||
}
|
||||
}
|
||||
}
|
||||
let auth: RuntimeAuthFile = document
|
||||
let mut auth: RuntimeAuthFile = document
|
||||
.try_into()
|
||||
.map_err(|_| ProcessError::auth("runtime auth store is corrupt"))?;
|
||||
auth.archived_legacy_issuers.extend(archived_legacy_issuers);
|
||||
validate_workspace_issuer_trust_records(&auth.workspace_issuers)
|
||||
.map_err(|_| ProcessError::auth("runtime Workspace issuer trust store is corrupt"))?;
|
||||
if migrated {
|
||||
@@ -1637,6 +1645,54 @@ mod tests {
|
||||
assert_eq!(error, "invalid trust-workspace list arguments");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_id_issuer_migration_retains_history_without_restoring_authority() {
|
||||
use sha2::{Digest as _, Sha256};
|
||||
|
||||
let identity = RuntimeIdentityMaterial::generate("WK-legacy").unwrap();
|
||||
let public_key = decode_public_key(&identity.public_key).unwrap();
|
||||
let fingerprint = format!(
|
||||
"sha256:{}",
|
||||
Sha256::digest(public_key)
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>()
|
||||
);
|
||||
for state in ["active", "revoked"] {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("runtime-auth.toml");
|
||||
// These frozen field spellings prove the persisted pre-ID boundary.
|
||||
let legacy = serde_json::json!({
|
||||
"workspace_id": "workspace-1",
|
||||
"backend_url": "https://backend.example.test",
|
||||
"key_id": "WK-legacy",
|
||||
"algorithm": "ed25519",
|
||||
"public_key": identity.public_key,
|
||||
"public_key_fingerprint": fingerprint,
|
||||
"identity_revision": 7,
|
||||
"trust_generation": 9,
|
||||
"state": state,
|
||||
"registered_at_unix": 10,
|
||||
"updated_at_unix": 20,
|
||||
});
|
||||
let original_record = toml::Value::try_from(&legacy).unwrap();
|
||||
let document = serde_json::json!({"workspace_issuers": [legacy]});
|
||||
std::fs::write(&path, toml::to_string(&document).unwrap()).unwrap();
|
||||
|
||||
let migrated = read_runtime_auth_file(&path).unwrap();
|
||||
assert_eq!(
|
||||
migrated.workspace_issuers[0].state,
|
||||
worker_runtime::workspace_issuer::WorkspaceIssuerTrustState::Revoked
|
||||
);
|
||||
assert_eq!(migrated.archived_legacy_issuers.len(), 1);
|
||||
let archived: toml::Value =
|
||||
toml::from_str(&migrated.archived_legacy_issuers[0]).unwrap();
|
||||
assert_eq!(archived, original_record);
|
||||
write_runtime_auth_file(&path, &migrated).unwrap();
|
||||
assert_eq!(read_runtime_auth_file(&path).unwrap(), migrated);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_runtime_auth_store_fails_closed_before_parsing() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -1691,6 +1747,7 @@ mod tests {
|
||||
&RuntimeAuthFile {
|
||||
identity: Some(identity),
|
||||
workspace_issuers: Vec::new(),
|
||||
archived_legacy_issuers: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -1480,7 +1480,6 @@ mod tests {
|
||||
assert!(request.starts_with("POST /api/w/workspace-a/workers/remove HTTP/1.1"));
|
||||
assert!(request.contains("\"target_runtime_id\":\"runtime-target\""));
|
||||
assert!(request.contains("\"target_worker_id\":\"worker-target\""));
|
||||
assert!(!request.contains("expected_worker_revision"));
|
||||
assert!(request.contains("\"reason\":\"retire obsolete Worker\""));
|
||||
let request_source_token = request
|
||||
.lines()
|
||||
|
||||
@@ -62,8 +62,9 @@ pub struct WorkingDirectoryEvidence {
|
||||
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.
|
||||
// Frozen persisted evidence reader. At decode time, reject zero legacy counters
|
||||
// and use their presence only to require fresh SSH access before session opening.
|
||||
// They never identify keys or grant access; live authority checks fingerprints.
|
||||
#[derive(Deserialize)]
|
||||
struct FrozenEvidenceRead {
|
||||
repository_id: String,
|
||||
@@ -76,19 +77,21 @@ struct FrozenEvidenceRead {
|
||||
public_key_fingerprint: Option<String>,
|
||||
host_key_fingerprint: Option<String>,
|
||||
repository_access_required: Option<bool>,
|
||||
credential_revision: Option<u64>,
|
||||
host_trust_revision: Option<u64>,
|
||||
#[serde(rename = "credential_revision")]
|
||||
legacy_credential_counter: Option<u64>,
|
||||
#[serde(rename = "host_trust_revision")]
|
||||
legacy_host_trust_counter: 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) {
|
||||
if wire.legacy_credential_counter == Some(0) || wire.legacy_host_trust_counter == 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.legacy_credential_counter.is_some()
|
||||
|| wire.legacy_host_trust_counter.is_some()
|
||||
|| wire.public_key_fingerprint.is_some()
|
||||
|| wire.host_key_fingerprint.is_some();
|
||||
Ok(Self {
|
||||
@@ -3284,6 +3287,8 @@ mod tests {
|
||||
let evidence = record["working_directory"]["evidence"]
|
||||
.as_object_mut()
|
||||
.unwrap();
|
||||
let mut expected_evidence = evidence.clone();
|
||||
expected_evidence.insert("repository_access_required".into(), serde_json::json!(true));
|
||||
evidence.remove("repository_access_required");
|
||||
evidence.insert("credential_revision".into(), serde_json::json!(3));
|
||||
evidence.insert("host_trust_revision".into(), serde_json::json!(2));
|
||||
@@ -3299,10 +3304,7 @@ mod tests {
|
||||
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());
|
||||
assert_eq!(evidence, &serde_json::json!(expected_evidence));
|
||||
drop(materializer);
|
||||
assert_eq!(
|
||||
RuntimeGitMaterializer::new(runtime.path())
|
||||
@@ -4771,8 +4773,8 @@ mod tests {
|
||||
.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 expected_evidence = legacy["working_directory"]["evidence"].clone();
|
||||
legacy["working_directory"]["evidence"]["obsolete_test_attribute"] = serde_json::json!(7);
|
||||
let original = serde_json::to_vec_pretty(&legacy).unwrap();
|
||||
fs::write(&path, &original).unwrap();
|
||||
|
||||
@@ -4788,10 +4790,9 @@ mod tests {
|
||||
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()
|
||||
assert_eq!(
|
||||
canonical["working_directory"]["evidence"],
|
||||
expected_evidence
|
||||
);
|
||||
materializer.cleanup_working_directory(id).unwrap();
|
||||
assert!(!binding.working_directory_root().exists());
|
||||
|
||||
@@ -92,21 +92,25 @@ struct LegacyWorkspaceIssuerTrustRecord {
|
||||
algorithm: String,
|
||||
public_key: String,
|
||||
public_key_fingerprint: String,
|
||||
identity_revision: u64,
|
||||
trust_generation: u64,
|
||||
#[serde(rename = "identity_revision")]
|
||||
legacy_identity_counter: u64,
|
||||
#[serde(rename = "trust_generation")]
|
||||
legacy_trust_counter: u64,
|
||||
state: WorkspaceIssuerTrustState,
|
||||
registered_at_unix: i64,
|
||||
updated_at_unix: i64,
|
||||
}
|
||||
|
||||
/// Migrate a pre-ID enrollment as revoked. Only an explicit operator replacement
|
||||
/// can accept new trust; no legacy capability can authorize its own migration.
|
||||
/// Migrate a pre-ID enrollment as revoked. At persisted-data decode, legacy counters
|
||||
/// must be positive but are never compared with current identity or trust.
|
||||
/// Only an explicit operator replacement can accept new trust; no legacy
|
||||
/// capability can authorize its own migration.
|
||||
pub fn migrate_legacy_workspace_issuer_trust_record(
|
||||
value: serde_json::Value,
|
||||
) -> Result<WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustError> {
|
||||
let legacy: LegacyWorkspaceIssuerTrustRecord =
|
||||
serde_json::from_value(value).map_err(|_| WorkspaceIssuerTrustError::InvalidIdentifier)?;
|
||||
if legacy.identity_revision == 0 || legacy.trust_generation == 0 {
|
||||
if legacy.legacy_identity_counter == 0 || legacy.legacy_trust_counter == 0 {
|
||||
return Err(WorkspaceIssuerTrustError::InvalidIdentifier);
|
||||
}
|
||||
let mut record = WorkspaceIssuerTrustRecord::from_bundle(
|
||||
@@ -474,6 +478,9 @@ struct WorkspaceRuntimeVerificationDocument {
|
||||
records: Vec<WorkspaceRuntimeVerificationRecord>,
|
||||
#[serde(default)]
|
||||
pending: Vec<WorkspaceRuntimeVerificationChallenge>,
|
||||
/// Exact pre-ID document retained only as history; never used to authorize requests.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
archived_legacy_document: Option<String>,
|
||||
}
|
||||
|
||||
// Frozen verification evidence from before trust/binding IDs. Never promote
|
||||
@@ -490,12 +497,16 @@ struct LegacyVerificationDocument {
|
||||
struct LegacyVerificationRecord {
|
||||
workspace_id: String,
|
||||
runtime_id: String,
|
||||
binding_revision: u64,
|
||||
#[serde(rename = "binding_revision")]
|
||||
legacy_binding_counter: u64,
|
||||
workspace_key_id: String,
|
||||
workspace_identity_revision: u64,
|
||||
workspace_trust_generation: u64,
|
||||
#[serde(rename = "workspace_identity_revision")]
|
||||
legacy_workspace_identity_counter: u64,
|
||||
#[serde(rename = "workspace_trust_generation")]
|
||||
legacy_workspace_trust_counter: u64,
|
||||
runtime_public_key_fingerprint: String,
|
||||
runtime_identity_revision: u64,
|
||||
#[serde(rename = "runtime_identity_revision")]
|
||||
legacy_runtime_identity_counter: u64,
|
||||
verified_at: i64,
|
||||
}
|
||||
|
||||
@@ -507,6 +518,9 @@ impl FileWorkspaceRuntimeVerificationAuthority {
|
||||
}
|
||||
}
|
||||
|
||||
// No pre-ID acknowledgement is live authority. Keep its exact source bytes
|
||||
// in the persisted archive while new challenges populate only current records.
|
||||
// Neither read(), get(), nor capability verification consults that archive.
|
||||
fn read(
|
||||
&self,
|
||||
) -> Result<WorkspaceRuntimeVerificationDocument, WorkspaceCapabilityVerificationError> {
|
||||
@@ -531,6 +545,9 @@ impl FileWorkspaceRuntimeVerificationAuthority {
|
||||
version: 2,
|
||||
records: Vec::new(),
|
||||
pending: Vec::new(),
|
||||
archived_legacy_document: Some(String::from_utf8(bytes).map_err(|_| {
|
||||
WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable
|
||||
})?),
|
||||
};
|
||||
self.write(&migrated)?;
|
||||
return Ok(migrated);
|
||||
@@ -575,6 +592,7 @@ impl FileWorkspaceRuntimeVerificationAuthority {
|
||||
version: 2,
|
||||
records: Vec::new(),
|
||||
pending: Vec::new(),
|
||||
archived_legacy_document: None,
|
||||
})
|
||||
}
|
||||
Err(_) => Err(WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable),
|
||||
@@ -1993,7 +2011,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_verification_file_is_invalidated_durably() {
|
||||
fn frozen_verification_file_is_invalidated_durably_without_losing_archived_evidence() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("verification.json");
|
||||
fs::write(&path, serde_json::to_vec(&serde_json::json!({"version":1,"records":[{
|
||||
@@ -2001,18 +2019,50 @@ mod tests {
|
||||
"workspace_key_id":"WK-1","workspace_identity_revision":1,"workspace_trust_generation":1,
|
||||
"runtime_public_key_fingerprint":"sha256:old","runtime_identity_revision":1,"verified_at":10
|
||||
}]})).unwrap()).unwrap();
|
||||
let original_bytes = fs::read(&path).unwrap();
|
||||
let authority = FileWorkspaceRuntimeVerificationAuthority::new(&path);
|
||||
assert!(authority.get("workspace-1", "runtime-1").unwrap().is_none());
|
||||
let migrated: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!(migrated["version"], 2);
|
||||
assert_eq!(migrated["records"], serde_json::json!([]));
|
||||
assert_eq!(
|
||||
migrated["archived_legacy_document"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.as_bytes(),
|
||||
original_bytes
|
||||
);
|
||||
assert!(
|
||||
FileWorkspaceRuntimeVerificationAuthority::new(&path)
|
||||
.get("workspace-1", "runtime-1")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let current = WorkspaceRuntimeVerificationRecord {
|
||||
workspace_id: "workspace-1".into(),
|
||||
runtime_id: "runtime-1".into(),
|
||||
binding_id: "accepted-binding".into(),
|
||||
workspace_key_id: "WK-1".into(),
|
||||
workspace_public_key_fingerprint: format!("sha256:{}", "a".repeat(64)),
|
||||
workspace_trust_id: "accepted-trust".into(),
|
||||
runtime_public_key_fingerprint: format!("sha256:{}", "b".repeat(64)),
|
||||
verified_at: 20,
|
||||
};
|
||||
authority.record(current.clone()).unwrap();
|
||||
let restored = FileWorkspaceRuntimeVerificationAuthority::new(&path);
|
||||
assert_eq!(
|
||||
restored.get("workspace-1", "runtime-1").unwrap(),
|
||||
Some(current)
|
||||
);
|
||||
let updated: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
updated["archived_legacy_document"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.as_bytes(),
|
||||
original_bytes
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2111,15 +2111,7 @@ mod tests {
|
||||
for field in ["runtime_id", "worker_id", "reason"] {
|
||||
assert!(schema.contains(field));
|
||||
}
|
||||
for forbidden in [
|
||||
"expected_worker_revision",
|
||||
"proof",
|
||||
"actor",
|
||||
"workspace_id",
|
||||
"policy",
|
||||
"plan",
|
||||
"stage",
|
||||
] {
|
||||
for forbidden in ["proof", "actor", "workspace_id", "policy", "plan", "stage"] {
|
||||
assert!(!schema.contains(forbidden), "schema leaked {forbidden}");
|
||||
}
|
||||
}
|
||||
@@ -2149,23 +2141,28 @@ mod tests {
|
||||
.await
|
||||
.unwrap_err();
|
||||
}
|
||||
let _error = tool
|
||||
.execute(
|
||||
&serde_json::json!({
|
||||
"subject": {
|
||||
"kind": "runtime_worker",
|
||||
"runtime_id": "runtime-1",
|
||||
"worker_id": "worker-7",
|
||||
},
|
||||
"expected_worker_revision": "revision-1",
|
||||
"reason": "retire",
|
||||
"source_proof": "caller-controlled"
|
||||
})
|
||||
.to_string(),
|
||||
ToolExecutionContext::new("call-spoof", "batch-remove", 0),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
// Unknown authority fields must be rejected individually, before mutation.
|
||||
for (field, value) in [
|
||||
("unexpected_state_guard", "caller-controlled"),
|
||||
("source_proof", "caller-controlled"),
|
||||
] {
|
||||
let mut input = serde_json::json!({
|
||||
"subject": {
|
||||
"kind": "runtime_worker",
|
||||
"runtime_id": "runtime-1",
|
||||
"worker_id": "worker-7",
|
||||
},
|
||||
"reason": "retire",
|
||||
});
|
||||
input[field] = serde_json::json!(value);
|
||||
let _error = tool
|
||||
.execute(
|
||||
&input.to_string(),
|
||||
ToolExecutionContext::new("call-spoof", "batch-remove", 0),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
}
|
||||
assert!(client.removals.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
|
||||
@@ -841,7 +841,7 @@ mod tests {
|
||||
let projected = project_ticket_detail(json!({
|
||||
"id": "internal-ticket",
|
||||
"resource_key": "T-588",
|
||||
"content_digest": "revision-1",
|
||||
"content_digest": "ticket-content-digest",
|
||||
"title": "Queued Submit",
|
||||
"body": "Body",
|
||||
"state": "planning",
|
||||
@@ -892,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["content_digest"], "revision-1");
|
||||
assert_eq!(projected["content_digest"], "ticket-content-digest");
|
||||
assert_eq!(
|
||||
projected["relations"]["blockers"],
|
||||
json!([{
|
||||
@@ -944,7 +944,7 @@ mod tests {
|
||||
});
|
||||
let projected = project_ticket_detail(json!({
|
||||
"resource_key": "T-716",
|
||||
"content_digest": "current-ticket-revision",
|
||||
"content_digest": "current-ticket-content-digest",
|
||||
"title": "Source evidence",
|
||||
"body": "Body",
|
||||
"state": if merged { "done" } else { "inprogress" },
|
||||
@@ -982,7 +982,7 @@ mod tests {
|
||||
Some(&json!(integration_error))
|
||||
);
|
||||
}
|
||||
assert_eq!(projected["content_digest"], "current-ticket-revision");
|
||||
assert_eq!(projected["content_digest"], "current-ticket-content-digest");
|
||||
assert_eq!(projected["evidence"]["review_after_rescope"], attested);
|
||||
assert_eq!(projected["evidence"]["complete_for_integration"], merged);
|
||||
}
|
||||
|
||||
@@ -806,7 +806,6 @@ mod tests {
|
||||
"MergeRequestReview",
|
||||
"MergeRequestReadinessCheck",
|
||||
"MergeRequestComplete",
|
||||
"MergeRequestAddRevision",
|
||||
] {
|
||||
assert!(
|
||||
!combined.contains(stale_operation),
|
||||
|
||||
@@ -393,9 +393,16 @@ mod tests {
|
||||
)));
|
||||
let parsed: serde_json::Value = serde_json::from_str(&state.status_json()).unwrap();
|
||||
assert_eq!(parsed["state"], "running");
|
||||
assert!(parsed["worker_state"].get("execution_generation").is_none());
|
||||
assert!(parsed["worker_state"].get("revision").is_none());
|
||||
assert_eq!(parsed["worker_state"]["state"]["kind"], "busy");
|
||||
assert_eq!(
|
||||
parsed["worker_state"],
|
||||
json!({
|
||||
"last_command_id": 0,
|
||||
"state": {
|
||||
"kind": "busy",
|
||||
"state": { "kind": "maintenance", "state": "compacting" }
|
||||
}
|
||||
})
|
||||
);
|
||||
assert_eq!(parsed["worker_name"], "test-worker");
|
||||
assert!(parsed["segment_id"].is_string());
|
||||
}
|
||||
|
||||
@@ -10793,7 +10793,7 @@ mod build_summary_prompt_tests {
|
||||
let binding = crate::BackendJobExecutionBinding {
|
||||
job_id: "job-1".into(),
|
||||
attempt_id: "attempt-1".into(),
|
||||
input_digest: Some("revision-1".into()),
|
||||
input_digest: Some("input-content-digest".into()),
|
||||
subjektiv_consolidation: true,
|
||||
};
|
||||
worker.bind_backend_job(binding.clone()).unwrap();
|
||||
@@ -14244,7 +14244,6 @@ permission = "write"
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(payload.get("revision").is_none());
|
||||
let restored: PendingActivationState = serde_json::from_value(payload.clone()).unwrap();
|
||||
assert_eq!(restored.snapshot(), cleared);
|
||||
}
|
||||
|
||||
@@ -690,6 +690,9 @@ fn directory(c: &Connection, id: NodeId) -> Result<()> {
|
||||
// Called only inside the same IMMEDIATE transaction that publishes the mutation.
|
||||
// Compare the last successful request, not time: even identical bytes or a move
|
||||
// away and back cannot revive an old request. Receipts prevent request ID reuse.
|
||||
// A queued update/delete based on an older observation must not overwrite/delete
|
||||
// a newer commit merely because it arrives last; same-ms commits also rule out
|
||||
// timestamp comparison. The check precedes blob writes and node publication.
|
||||
fn changeable(c: &Transaction<'_>, id: NodeId, expected_mutation_id: &str) -> Result<Node> {
|
||||
let n = node(c, id)?;
|
||||
if n.parent_id.is_none() {
|
||||
|
||||
@@ -12,6 +12,7 @@ pub(super) static MIGRATIONS: &[FeatureMigration] = &[
|
||||
"bind_nodes_to_committed_requests",
|
||||
bind_committed_requests,
|
||||
),
|
||||
FeatureMigration::new(3, "name_legacy_replay_counter", name_legacy_replay_counter),
|
||||
];
|
||||
fn create_original_schema(tx: &Transaction<'_>) -> feature_storage::Result<()> {
|
||||
tx.execute_batch("\
|
||||
@@ -61,7 +62,7 @@ fn bind_committed_requests(tx: &Transaction<'_>) -> feature_storage::Result<()>
|
||||
WHERE json_extract(result_json, '$.deleted') = 0 GROUP BY 1,2;
|
||||
CREATE TABLE drive_legacy_replays (
|
||||
request_id TEXT PRIMARY KEY REFERENCES drive_receipts(request_id) ON DELETE CASCADE,
|
||||
expected_mutation_id TEXT NOT NULL, expected_revision TEXT NOT NULL);
|
||||
expected_mutation_id TEXT NOT NULL, legacy_expected_counter TEXT NOT NULL);
|
||||
INSERT INTO drive_legacy_replays
|
||||
SELECT receipt.request_id, observed.request_id, CAST(observed.previous_counter AS TEXT)
|
||||
FROM drive_receipts AS receipt JOIN drive_observed_requests AS observed
|
||||
@@ -85,6 +86,23 @@ fn bind_committed_requests(tx: &Transaction<'_>) -> feature_storage::Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn name_legacy_replay_counter(tx: &Transaction<'_>) -> feature_storage::Result<()> {
|
||||
// Already-deployed v2 databases used this column name. New v2 applications
|
||||
// create the concrete name directly; both paths retain the same replay-only
|
||||
// decimal strings and leave the original request digests untouched.
|
||||
let old_column: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM pragma_table_info('drive_legacy_replays') WHERE name='expected_revision')",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if old_column {
|
||||
tx.execute_batch(
|
||||
"ALTER TABLE drive_legacy_replays RENAME COLUMN expected_revision TO legacy_expected_counter;",
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Frozen pre-cutover typed payload encoding, including field order and decimal
|
||||
// strings. Never reconstruct a fingerprint from the result: compare the caller's
|
||||
// complete actor/payload against the original digest. Historical counters below
|
||||
@@ -94,19 +112,22 @@ fn bind_committed_requests(tx: &Transaction<'_>) -> feature_storage::Result<()>
|
||||
enum LegacyMutation<'a> {
|
||||
Update {
|
||||
id: NodeId,
|
||||
expected_revision: &'a str,
|
||||
#[serde(rename = "expected_revision")]
|
||||
legacy_expected_counter: &'a str,
|
||||
content_type: &'a str,
|
||||
bytes: &'a [u8],
|
||||
},
|
||||
Relocate {
|
||||
id: NodeId,
|
||||
expected_revision: &'a str,
|
||||
#[serde(rename = "expected_revision")]
|
||||
legacy_expected_counter: &'a str,
|
||||
parent: NodeId,
|
||||
name: &'a str,
|
||||
},
|
||||
Delete {
|
||||
id: NodeId,
|
||||
expected_revision: &'a str,
|
||||
#[serde(rename = "expected_revision")]
|
||||
legacy_expected_counter: &'a str,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -119,7 +140,7 @@ pub(super) fn matches_legacy_replay(
|
||||
) -> crate::Result<bool> {
|
||||
let evidence = tx
|
||||
.query_row(
|
||||
"SELECT expected_mutation_id,expected_revision FROM drive_legacy_replays WHERE request_id=?1",
|
||||
"SELECT expected_mutation_id,legacy_expected_counter FROM drive_legacy_replays WHERE request_id=?1",
|
||||
[request_id],
|
||||
|r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)),
|
||||
)
|
||||
@@ -139,7 +160,7 @@ pub(super) fn matches_legacy_replay(
|
||||
expected_mutation_id,
|
||||
LegacyMutation::Update {
|
||||
id: *id,
|
||||
expected_revision: &counter,
|
||||
legacy_expected_counter: &counter,
|
||||
content_type,
|
||||
bytes,
|
||||
},
|
||||
@@ -153,7 +174,7 @@ pub(super) fn matches_legacy_replay(
|
||||
expected_mutation_id,
|
||||
LegacyMutation::Relocate {
|
||||
id: *id,
|
||||
expected_revision: &counter,
|
||||
legacy_expected_counter: &counter,
|
||||
parent: *parent,
|
||||
name,
|
||||
},
|
||||
@@ -165,7 +186,7 @@ pub(super) fn matches_legacy_replay(
|
||||
expected_mutation_id,
|
||||
LegacyMutation::Delete {
|
||||
id: *id,
|
||||
expected_revision: &counter,
|
||||
legacy_expected_counter: &counter,
|
||||
},
|
||||
),
|
||||
Mutation::CreateFolder { .. } | Mutation::CreateFile { .. } => return Ok(false),
|
||||
@@ -254,7 +275,10 @@ mod tests {
|
||||
let storage = FeatureStorage::new(&path).workspace("ws").unwrap();
|
||||
let registration = Drive::register(&storage).unwrap();
|
||||
let drive = Drive::open(&storage, ®istration, &temp.path().join("blobs")).unwrap();
|
||||
assert_eq!(drive.database.schema_version().unwrap(), 2);
|
||||
assert_eq!(
|
||||
drive.database.schema_version().unwrap(),
|
||||
MIGRATIONS.last().unwrap().version()
|
||||
);
|
||||
assert_eq!(drive.root().unwrap().last_mutation_id, "");
|
||||
let file = drive.metadata(NodeId(4)).unwrap();
|
||||
assert_eq!(file.last_mutation_id, "file-create");
|
||||
@@ -394,6 +418,119 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_schema_uses_only_the_concrete_replay_counter_column() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let storage = FeatureStorage::new(temp.path().join("metadata"))
|
||||
.workspace("ws")
|
||||
.unwrap();
|
||||
let registration = Drive::register(&storage).unwrap();
|
||||
let drive = Drive::open(&storage, ®istration, &temp.path().join("blobs")).unwrap();
|
||||
assert_current_replay_schema(&drive.database);
|
||||
}
|
||||
|
||||
fn assert_current_replay_schema(database: &feature_storage::FeatureDatabase) {
|
||||
assert_eq!(
|
||||
database.schema_version().unwrap(),
|
||||
MIGRATIONS.last().unwrap().version()
|
||||
);
|
||||
database
|
||||
.try_with_connection::<_, feature_storage::FeatureStorageError>(|c| {
|
||||
let mut columns = c.prepare(
|
||||
"SELECT name FROM pragma_table_info('drive_legacy_replays') ORDER BY cid",
|
||||
)?;
|
||||
let names = columns
|
||||
.query_map([], |row| row.get::<_, String>(0))?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"request_id",
|
||||
"expected_mutation_id",
|
||||
"legacy_expected_counter"
|
||||
]
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_replay_column_upgrade_preserves_evidence_digests_and_replay_after_reopen() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("metadata");
|
||||
seed_update(&path, false);
|
||||
let original = {
|
||||
let storage = FeatureStorage::new(&path).workspace("ws").unwrap();
|
||||
let registration = storage
|
||||
.register(FeatureRegistration::new(
|
||||
"workspace-drive",
|
||||
&MIGRATIONS[..2],
|
||||
))
|
||||
.unwrap();
|
||||
let database = storage.open(®istration).unwrap();
|
||||
// Actual deployed v2 schema: the old name is a frozen DB boundary,
|
||||
// not the DDL used when applying v2 to a newly created database.
|
||||
database
|
||||
.transaction(|tx| {
|
||||
tx.execute_batch(
|
||||
"ALTER TABLE drive_legacy_replays RENAME COLUMN legacy_expected_counter TO expected_revision;",
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
fingerprints(&database)
|
||||
};
|
||||
let storage = FeatureStorage::new(&path).workspace("ws").unwrap();
|
||||
let registration = Drive::register(&storage).unwrap();
|
||||
let drive = Drive::open(&storage, ®istration, &temp.path().join("blobs")).unwrap();
|
||||
assert_current_replay_schema(&drive.database);
|
||||
assert_eq!(fingerprints(&drive.database), original);
|
||||
drive
|
||||
.database
|
||||
.try_with_connection::<_, feature_storage::FeatureStorageError>(|c| {
|
||||
let mut rows = c.prepare(
|
||||
"SELECT request_id,expected_mutation_id,legacy_expected_counter FROM drive_legacy_replays ORDER BY request_id",
|
||||
)?;
|
||||
let evidence = rows
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
assert_eq!(
|
||||
evidence,
|
||||
vec![
|
||||
("delete".into(), "create-deleted".into(), "1".into()),
|
||||
("file-update".into(), "file-create".into(), "1".into()),
|
||||
("move-0".into(), "create".into(), "1".into()),
|
||||
]
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let nodes = drive.list(NodeId(1), None, 100).unwrap().nodes;
|
||||
for (request, mutation) in changed_replays() {
|
||||
let RequestStatus::Committed { result } = drive.request_status(request).unwrap() else {
|
||||
panic!("lost {request}")
|
||||
};
|
||||
assert_eq!(drive.mutate(request, "actor", mutation).unwrap(), result);
|
||||
}
|
||||
assert_eq!(drive.list(NodeId(1), None, 100).unwrap().nodes, nodes);
|
||||
assert_eq!(fingerprints(&drive.database), original);
|
||||
drop(drive);
|
||||
let drive = Drive::open(&storage, ®istration, &temp.path().join("blobs")).unwrap();
|
||||
assert_current_replay_schema(&drive.database);
|
||||
assert_eq!(fingerprints(&drive.database), original);
|
||||
assert_eq!(drive.list(NodeId(1), None, 100).unwrap().nodes, nodes);
|
||||
for (request, mutation) in changed_replays() {
|
||||
assert!(drive.mutate(request, "actor", mutation).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgrade_preserves_original_digests_and_never_uses_old_counters_for_new_mutations() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -445,7 +582,10 @@ mod tests {
|
||||
let storage = FeatureStorage::new(&path).workspace("ws").unwrap();
|
||||
let registration = Drive::register(&storage).unwrap();
|
||||
let drive = Drive::open(&storage, ®istration, &temp.path().join("blobs")).unwrap();
|
||||
assert_eq!(drive.database.schema_version().unwrap(), 2);
|
||||
assert_eq!(
|
||||
drive.database.schema_version().unwrap(),
|
||||
MIGRATIONS.last().unwrap().version()
|
||||
);
|
||||
// Historical replay must not overwrite a newer committed mutation.
|
||||
for (id, expected) in [(NodeId(2), "move-0"), (NodeId(4), "file-update")] {
|
||||
drive
|
||||
|
||||
@@ -20,7 +20,10 @@ fn create(parent: NodeId, name: &str) -> Mutation {
|
||||
fn migration_enforces_workspace_parent_and_unique_names() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let d = fixture(&t);
|
||||
assert_eq!(d.database.schema_version().unwrap(), 2);
|
||||
assert_eq!(
|
||||
d.database.schema_version().unwrap(),
|
||||
MIGRATIONS.last().unwrap().version()
|
||||
);
|
||||
let root = d.root().unwrap();
|
||||
d.mutate("a", "actor", create(root.id, "a")).unwrap();
|
||||
assert!(matches!(
|
||||
|
||||
@@ -174,8 +174,11 @@ impl merge_request::RepositorySource for AuthorityMergeRequestSource {
|
||||
}
|
||||
}
|
||||
|
||||
pub trait TicketMergeRevisionSource: Send + Sync {
|
||||
fn resolve_subject_ref(
|
||||
/// Resolve an open Merge Request source selector to its provider-published Git commit.
|
||||
/// Ticket detail/query calls this for open requests only; merged requests use their
|
||||
/// immutable recorded source/result commits and need no mutable branch observation.
|
||||
pub trait TicketSourceCommitResolver: Send + Sync {
|
||||
fn resolve_source_commit(
|
||||
&self,
|
||||
ticket_id: &str,
|
||||
repository_id: &str,
|
||||
@@ -183,10 +186,10 @@ pub trait TicketMergeRevisionSource: Send + Sync {
|
||||
) -> std::result::Result<String, server_api::MergeRequestRefDiagnostic>;
|
||||
}
|
||||
|
||||
struct UnresolvedTicketMergeRevisionSource;
|
||||
struct UnresolvedTicketSourceCommitResolver;
|
||||
|
||||
impl TicketMergeRevisionSource for UnresolvedTicketMergeRevisionSource {
|
||||
fn resolve_subject_ref(
|
||||
impl TicketSourceCommitResolver for UnresolvedTicketSourceCommitResolver {
|
||||
fn resolve_source_commit(
|
||||
&self,
|
||||
_ticket_id: &str,
|
||||
_repository_id: &str,
|
||||
@@ -205,7 +208,7 @@ pub struct SqliteWorkspaceAuthority {
|
||||
store: SqliteWorkspaceStore,
|
||||
ticket_backend: SqliteTicketBackend,
|
||||
merge_request_store: Arc<MergeRequestStore>,
|
||||
merge_revision_source: Arc<dyn TicketMergeRevisionSource>,
|
||||
source_commit_resolver: Arc<dyn TicketSourceCommitResolver>,
|
||||
}
|
||||
|
||||
impl SqliteWorkspaceAuthority {
|
||||
@@ -231,15 +234,15 @@ impl SqliteWorkspaceAuthority {
|
||||
)
|
||||
.map_err(|error| Error::Store(error.to_string()))?,
|
||||
),
|
||||
merge_revision_source: Arc::new(UnresolvedTicketMergeRevisionSource),
|
||||
source_commit_resolver: Arc::new(UnresolvedTicketSourceCommitResolver),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_merge_revision_source(
|
||||
pub fn with_source_commit_resolver(
|
||||
mut self,
|
||||
merge_revision_source: Arc<dyn TicketMergeRevisionSource>,
|
||||
source_commit_resolver: Arc<dyn TicketSourceCommitResolver>,
|
||||
) -> Self {
|
||||
self.merge_revision_source = merge_revision_source;
|
||||
self.source_commit_resolver = source_commit_resolver;
|
||||
self
|
||||
}
|
||||
|
||||
@@ -843,7 +846,7 @@ impl SqliteWorkspaceAuthority {
|
||||
})
|
||||
},
|
||||
|selector| {
|
||||
self.merge_revision_source.resolve_subject_ref(
|
||||
self.source_commit_resolver.resolve_source_commit(
|
||||
id,
|
||||
&request.repository_id,
|
||||
selector,
|
||||
@@ -1883,7 +1886,9 @@ fn ticket_evidence_summary(
|
||||
server_api::TicketSourceRefObservation::Unavailable { .. }
|
||||
)
|
||||
});
|
||||
// Exact revision/snapshot attestation, not a timestamp heuristic.
|
||||
// Evaluated on every Ticket detail/query: approval must bind the current Ticket
|
||||
// content digest and every relevant MR source/result commit. Later timestamps
|
||||
// or last-arriving approvals cannot attest to different content.
|
||||
let review_after_rescope = approved_current_subject && requirement_approved;
|
||||
let review_status = if !has_merge_request {
|
||||
None
|
||||
@@ -2778,7 +2783,7 @@ mod tests {
|
||||
event_id: "request-1".to_string(),
|
||||
sequence: 1,
|
||||
subject_ref: "commit-1".to_string(),
|
||||
ticket_content_digest: "revision-1".to_string(),
|
||||
ticket_content_digest: "ticket-content-1".to_string(),
|
||||
ticket_merge_request_subjects: vec![merge_request::MergeRequestReviewSubject {
|
||||
merge_request_id: "mr-1".to_string(),
|
||||
subject_ref: "commit-1".to_string(),
|
||||
@@ -2792,7 +2797,7 @@ mod tests {
|
||||
sequence: 2,
|
||||
request_event_id: "request-1".to_string(),
|
||||
subject_ref: "commit-1".to_string(),
|
||||
ticket_content_digest: "revision-1".to_string(),
|
||||
ticket_content_digest: "ticket-content-1".to_string(),
|
||||
ticket_merge_request_subjects: vec![merge_request::MergeRequestReviewSubject {
|
||||
merge_request_id: "mr-1".to_string(),
|
||||
subject_ref: "commit-1".to_string(),
|
||||
@@ -2884,7 +2889,7 @@ mod tests {
|
||||
|
||||
fn project_evidence(
|
||||
requests: &[MergeRequest],
|
||||
revision: &str,
|
||||
content_digest: &str,
|
||||
observations: &[std::result::Result<String, server_api::MergeRequestRefDiagnostic>],
|
||||
) -> (Vec<TicketMergeRequestSummary>, TicketEvidenceSummary) {
|
||||
let summaries = requests
|
||||
@@ -2898,7 +2903,7 @@ mod tests {
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let approved = ticket_requirement_approved(requests, &summaries, revision);
|
||||
let approved = ticket_requirement_approved(requests, &summaries, content_digest);
|
||||
let evidence = ticket_evidence_summary(&["main", "other"], &summaries, approved);
|
||||
(summaries, evidence)
|
||||
}
|
||||
@@ -2919,7 +2924,7 @@ mod tests {
|
||||
] {
|
||||
let (summaries, evidence) = project_evidence(
|
||||
&[reviewed_merge_request(decision, revoked)],
|
||||
"revision-1",
|
||||
"ticket-content-1",
|
||||
&[Ok(source.into())],
|
||||
);
|
||||
assert_eq!(evidence.complete_for_integration, approved);
|
||||
@@ -2941,7 +2946,7 @@ mod tests {
|
||||
)];
|
||||
let (summaries, evidence) = project_evidence(
|
||||
&[reviewed_merge_request(ReviewDecision::Approve, false)],
|
||||
"revision-2",
|
||||
"ticket-content-2",
|
||||
&[unavailable_source("source_ref_provider_timeout")],
|
||||
);
|
||||
assert!(
|
||||
@@ -2976,7 +2981,7 @@ mod tests {
|
||||
unavailable_source("source_ref_runtime_unavailable"),
|
||||
] {
|
||||
let (summaries, evidence) =
|
||||
project_evidence(&[merged_request()], "revision-1", &[observation]);
|
||||
project_evidence(&[merged_request()], "ticket-content-1", &[observation]);
|
||||
assert_eq!(
|
||||
summaries[0].current_subject_ref.as_deref(),
|
||||
Some("commit-1")
|
||||
@@ -3032,7 +3037,7 @@ mod tests {
|
||||
}
|
||||
let (summaries, evidence) = project_evidence(
|
||||
&[request],
|
||||
"revision-1",
|
||||
"ticket-content-1",
|
||||
&[unavailable_source("source_ref_not_found")],
|
||||
);
|
||||
assert!(summaries[0].integration_evidence_error.is_some(), "{case}");
|
||||
@@ -3047,7 +3052,7 @@ mod tests {
|
||||
|
||||
fn approve_snapshot(
|
||||
request: &mut MergeRequest,
|
||||
revision: &str,
|
||||
content_digest: &str,
|
||||
subjects: Vec<merge_request::MergeRequestReviewSubject>,
|
||||
) {
|
||||
let sequence = request
|
||||
@@ -3063,14 +3068,14 @@ mod tests {
|
||||
MergeRequestThreadEvent::ReviewRequested(review) => {
|
||||
review.event_id = format!("request-{sequence}");
|
||||
review.sequence = sequence;
|
||||
review.ticket_content_digest = revision.into();
|
||||
review.ticket_content_digest = content_digest.into();
|
||||
review.ticket_merge_request_subjects = subjects.clone();
|
||||
}
|
||||
MergeRequestThreadEvent::Review(review) => {
|
||||
review.event_id = format!("review-{sequence}");
|
||||
review.request_event_id = format!("request-{sequence}");
|
||||
review.sequence = sequence + 1;
|
||||
review.ticket_content_digest = revision.into();
|
||||
review.ticket_content_digest = content_digest.into();
|
||||
review.ticket_merge_request_subjects = subjects.clone();
|
||||
}
|
||||
_ => unreachable!(),
|
||||
@@ -3080,7 +3085,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_revision_and_multi_result_snapshot_require_fresh_attestation_not_later_dates() {
|
||||
fn exact_ticket_content_and_multi_result_snapshot_require_fresh_attestation_not_later_dates() {
|
||||
let mut requests = vec![
|
||||
merged_request(),
|
||||
reviewed_merge_request(ReviewDecision::Approve, false),
|
||||
@@ -3091,7 +3096,7 @@ mod tests {
|
||||
unavailable_source("source_ref_not_found"),
|
||||
Ok("commit-1".into()),
|
||||
];
|
||||
let (_, stale) = project_evidence(&requests, "revision-1", &observations);
|
||||
let (_, stale) = project_evidence(&requests, "ticket-content-1", &observations);
|
||||
assert!(stale.approved_current_subject);
|
||||
assert!(!stale.review_after_rescope); // linked set grew, even though dates did not change
|
||||
assert!(!ticket_evidence_matches(&stale, "approved_review"));
|
||||
@@ -3105,12 +3110,12 @@ mod tests {
|
||||
subject_ref: "commit-1".into(),
|
||||
},
|
||||
];
|
||||
approve_snapshot(&mut requests[0], "revision-2", snapshot);
|
||||
let (summaries, fresh) = project_evidence(&requests, "revision-2", &observations);
|
||||
approve_snapshot(&mut requests[0], "ticket-content-2", snapshot);
|
||||
let (summaries, fresh) = project_evidence(&requests, "ticket-content-2", &observations);
|
||||
assert!(fresh.complete_for_integration);
|
||||
assert_eq!(summaries[0].review_excerpt.as_deref(), Some("review body"));
|
||||
assert!(
|
||||
!project_evidence(&requests, "revision-3", &observations)
|
||||
!project_evidence(&requests, "ticket-content-3", &observations)
|
||||
.1
|
||||
.complete_for_integration
|
||||
);
|
||||
@@ -3123,12 +3128,12 @@ mod tests {
|
||||
}
|
||||
requests[1].state = MergeRequestState::Merged;
|
||||
assert!(
|
||||
project_evidence(&requests, "revision-2", &observations)
|
||||
project_evidence(&requests, "ticket-content-2", &observations)
|
||||
.1
|
||||
.complete_for_integration
|
||||
);
|
||||
assert!(
|
||||
!project_evidence(&requests[..1], "revision-2", &observations[..1])
|
||||
!project_evidence(&requests[..1], "ticket-content-2", &observations[..1])
|
||||
.1
|
||||
.complete_for_integration
|
||||
);
|
||||
@@ -3137,7 +3142,7 @@ mod tests {
|
||||
"2026-01-01T00:00:00Z",
|
||||
Some("body"),
|
||||
)];
|
||||
let stale = project_evidence(&requests, "revision-3", &observations).1;
|
||||
let stale = project_evidence(&requests, "ticket-content-3", &observations).1;
|
||||
assert!(ticket_attention_matches(
|
||||
"done",
|
||||
&stale,
|
||||
@@ -3163,7 +3168,7 @@ mod tests {
|
||||
);
|
||||
let (summaries, _) = project_evidence(
|
||||
&[merged_request()],
|
||||
"revision-1",
|
||||
"ticket-content-1",
|
||||
&[unavailable_source("unused")],
|
||||
);
|
||||
assert!(
|
||||
@@ -3173,7 +3178,7 @@ mod tests {
|
||||
let mut closed = reviewed_merge_request(ReviewDecision::Approve, false);
|
||||
closed.state = MergeRequestState::Closed;
|
||||
assert!(
|
||||
!project_evidence(&[closed], "revision-1", &[Ok("commit-1".into())])
|
||||
!project_evidence(&[closed], "ticket-content-1", &[Ok("commit-1".into())])
|
||||
.1
|
||||
.complete_for_integration
|
||||
);
|
||||
|
||||
@@ -564,9 +564,9 @@ mod tests {
|
||||
|
||||
fn request() -> BackendJobRequest {
|
||||
BackendJobRequest {
|
||||
job_id: "ticket-check:T-1:r7".to_string(),
|
||||
job_id: "ticket-check:T-1:content-7".to_string(),
|
||||
purpose: "ticket_item_check".to_string(),
|
||||
input_ref: "ticket://T-1/revisions/7".to_string(),
|
||||
input_ref: "ticket://T-1/contents/content-7".to_string(),
|
||||
input: serde_json::json!({"title": "Check me"}),
|
||||
instruction: "Check this immutable Ticket snapshot.".to_string(),
|
||||
profile: "builtin:backend-job".to_string(),
|
||||
@@ -591,8 +591,8 @@ mod tests {
|
||||
},
|
||||
"attempt": {
|
||||
"workspace_id": "workspace-a",
|
||||
"job_id": "ticket-check:T-1:r7",
|
||||
"attempt_id": "ticket-check:T-1:r7:attempt:2",
|
||||
"job_id": "ticket-check:T-1:content-7",
|
||||
"attempt_id": "ticket-check:T-1:content-7:attempt:2",
|
||||
"attempt": 2,
|
||||
"input_digest": "7",
|
||||
"state": "reserved",
|
||||
@@ -687,7 +687,7 @@ mod tests {
|
||||
job::JobOutcome {
|
||||
job_id: reservation.job.request.job_id.clone(),
|
||||
input_digest: "7".into(),
|
||||
attempt_id: "ticket-check:T-1:r7:attempt:2".into(),
|
||||
attempt_id: "ticket-check:T-1:content-7:attempt:2".into(),
|
||||
attempt: 2,
|
||||
state: job::JobState::Pending,
|
||||
attempt_state: job::JobAttemptState::Reserved,
|
||||
@@ -766,11 +766,11 @@ mod tests {
|
||||
let encoded = serde_json::to_string(&request).unwrap();
|
||||
assert_eq!(
|
||||
encoded,
|
||||
r#"{"job_id":"ticket-check:T-1:r7","purpose":"ticket_item_check","input_ref":"ticket://T-1/revisions/7","input":{"title":"Check me"},"instruction":"Check this immutable Ticket snapshot.","profile":"builtin:backend-job","source_worker":{"runtime_id":"runtime-a","worker_id":"worker-a"},"notification_target":{"runtime_id":"runtime-a","worker_id":"worker-a"},"limits":{"max_concurrent_jobs":8,"timeout_seconds":120,"max_result_bytes":16384,"max_attempts":2}}"#
|
||||
r#"{"job_id":"ticket-check:T-1:content-7","purpose":"ticket_item_check","input_ref":"ticket://T-1/contents/content-7","input":{"title":"Check me"},"instruction":"Check this immutable Ticket snapshot.","profile":"builtin:backend-job","source_worker":{"runtime_id":"runtime-a","worker_id":"worker-a"},"notification_target":{"runtime_id":"runtime-a","worker_id":"worker-a"},"limits":{"max_concurrent_jobs":8,"timeout_seconds":120,"max_result_bytes":16384,"max_attempts":2}}"#
|
||||
);
|
||||
assert_eq!(
|
||||
request.fingerprint().unwrap(),
|
||||
"sha256:250b626572e954d2aa2228364274c946d9f6b176eb0f0a205157e55031581147"
|
||||
"sha256:d7a3eb420eb1404e1a0aacd198726ce7f23b84a00b5d81a1b15d002b3075b8b0"
|
||||
);
|
||||
let neutral = request.to_job_request();
|
||||
neutral.validate().unwrap();
|
||||
@@ -784,7 +784,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
request.worker_input("attempt-1").unwrap(),
|
||||
"Check this immutable Ticket snapshot.\n\nBackend Job envelope (immutable):\njob_id: ticket-check:T-1:r7\nattempt_id: attempt-1\ninput_digest: sha256:7d0f26b7fc612e982fd7acf2b64f29d2e8f65c7e1292683e36d7dcac38905c50\ninput_ref: ticket://T-1/revisions/7\ninput_json: {\"title\":\"Check me\"}\n\nReturn success only through the structured Backend Job result capability. Final prose and Worker Idle/Stopped state are not result authority."
|
||||
"Check this immutable Ticket snapshot.\n\nBackend Job envelope (immutable):\njob_id: ticket-check:T-1:content-7\nattempt_id: attempt-1\ninput_digest: sha256:7d0f26b7fc612e982fd7acf2b64f29d2e8f65c7e1292683e36d7dcac38905c50\ninput_ref: ticket://T-1/contents/content-7\ninput_json: {\"title\":\"Check me\"}\n\nReturn success only through the structured Backend Job result capability. Final prose and Worker Idle/Stopped state are not result authority."
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ fn migrate_content_state_v85_to_v86(conn: &Connection) -> Result<()> {
|
||||
"worker_create_reservations",
|
||||
"memory_settings_revision",
|
||||
)? {
|
||||
remove_legacy_reservation_memory_revision(&tx)?;
|
||||
remove_legacy_reservation_memory_counter(&tx)?;
|
||||
}
|
||||
// Older retained chains rebuilt this table without the lookup index.
|
||||
// Converge on the canonical schema for both legacy creation paths.
|
||||
@@ -169,7 +169,7 @@ fn migrate_content_state_v85_to_v86(conn: &Connection) -> Result<()> {
|
||||
result
|
||||
}
|
||||
|
||||
fn remove_legacy_reservation_memory_revision(conn: &Connection) -> Result<()> {
|
||||
fn remove_legacy_reservation_memory_counter(conn: &Connection) -> Result<()> {
|
||||
// This is the exact table CHECK introduced by the frozen v52 -> v53
|
||||
// migration. Fresh v85 databases do not have it. Do not rewrite arbitrary
|
||||
// constraints: SQLite's DROP COLUMN must still reject unknown dependencies.
|
||||
|
||||
@@ -371,7 +371,7 @@ fn content_cutover_keeps_each_evaluation_provenance_and_deduplicates_only_identi
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("content.db");
|
||||
let (conn, _) = legacy_content_database(&path);
|
||||
for (revision, toolchain, projection, schema) in [
|
||||
for (legacy_counter, toolchain, projection, schema) in [
|
||||
(4, "new-toolchain", "projection", "schema-2"),
|
||||
(5, "toolchain", "new-projection", "schema-1"),
|
||||
] {
|
||||
@@ -379,7 +379,7 @@ fn content_cutover_keeps_each_evaluation_provenance_and_deduplicates_only_identi
|
||||
"INSERT INTO workspace_config_tree_revisions
|
||||
SELECT workspace_id,?1,tree_digest,?2,?3,manifest_json,'later',?4
|
||||
FROM workspace_config_tree_revisions WHERE revision=1",
|
||||
params![revision, toolchain, projection, schema],
|
||||
params![legacy_counter, toolchain, projection, schema],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -18,11 +18,11 @@ fn migrate_flow_content_v84_to_v85(conn: &Connection) -> Result<()> {
|
||||
while let Some(row) = rows.next()? {
|
||||
let workspace_id: String = row.get(0)?;
|
||||
let flow_id: String = row.get(1)?;
|
||||
let revision: i64 = row.get(2)?;
|
||||
let legacy_counter: i64 = row.get(2)?;
|
||||
let content: String = row.get(3)?;
|
||||
let stored_digest: String = row.get(4)?;
|
||||
let definition_json: String = row.get(5)?;
|
||||
let identity = format!("{workspace_id}/{flow_id} revision {revision}");
|
||||
let identity = format!("{workspace_id}/{flow_id} legacy counter {legacy_counter}");
|
||||
let source_digest = format!(
|
||||
"sha256:{}",
|
||||
Sha256::digest(content.as_bytes())
|
||||
|
||||
@@ -56,7 +56,7 @@ fn legacy_flow_database(path: &Path) -> Connection {
|
||||
params![alpha, alpha_definition.content_digest],
|
||||
)
|
||||
.unwrap();
|
||||
for (revision, content, definition, created_at) in [
|
||||
for (legacy_counter, content, definition, created_at) in [
|
||||
(1, &alpha, &alpha_definition, "first"),
|
||||
(2, &beta, &beta_definition, "second"),
|
||||
(3, &alpha, &alpha_definition, "third"),
|
||||
@@ -64,7 +64,7 @@ fn legacy_flow_database(path: &Path) -> Connection {
|
||||
conn.execute(
|
||||
"INSERT INTO flow_source_revisions VALUES('space','flow',?1,?2,?3,?4,?5)",
|
||||
params![
|
||||
revision,
|
||||
legacy_counter,
|
||||
content,
|
||||
definition.content_digest,
|
||||
serde_json::to_string_pretty(definition).unwrap(),
|
||||
|
||||
@@ -32,8 +32,8 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
tx.execute_batch(
|
||||
"CREATE TEMP TABLE repository_key_cutover (
|
||||
workspace_id TEXT NOT NULL, kind TEXT NOT NULL, resource_id TEXT NOT NULL,
|
||||
legacy_revision INTEGER NOT NULL, operation_id TEXT NOT NULL, fingerprint TEXT NOT NULL, created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(workspace_id,kind,resource_id,legacy_revision));",
|
||||
legacy_counter INTEGER NOT NULL, operation_id TEXT NOT NULL, fingerprint TEXT NOT NULL, created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(workspace_id,kind,resource_id,legacy_counter));",
|
||||
)?;
|
||||
for (table, kind, id, fingerprint) in [
|
||||
(
|
||||
@@ -65,10 +65,10 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
for (workspace, resource, revision, fingerprint, created) in rows {
|
||||
for (workspace, resource, legacy_counter, fingerprint, created) in rows {
|
||||
let mut statement = tx.prepare("SELECT operation_id FROM repository_secret_operations WHERE workspace_id=?1 AND resource_kind=?2 AND resource_id=?3 AND result_revision=?4 AND created_at=?5")?;
|
||||
let matches = statement
|
||||
.query_map(params![workspace, kind, resource, revision, created], |r| {
|
||||
.query_map(params![workspace, kind, resource, legacy_counter, created], |r| {
|
||||
r.get::<_, String>(0)
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
@@ -81,7 +81,7 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
workspace,
|
||||
kind,
|
||||
resource,
|
||||
revision,
|
||||
legacy_counter,
|
||||
matches[0],
|
||||
fingerprint,
|
||||
created
|
||||
@@ -126,12 +126,12 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
tx.execute_batch("DROP INDEX idx_workdir_create_credential_candidates_revision;")?;
|
||||
tx.execute_batch(include_str!("frozen_repository_key_schema.sql"))?;
|
||||
tx.execute_batch("INSERT INTO repository_secret_operations SELECT workspace_id,operation_id,request_fingerprint,resource_kind,resource_id,operation_id,created_at FROM repository_secret_operations_v86;
|
||||
INSERT INTO repository_ssh_credentials SELECT c.workspace_id,c.credential_id,c.name,c.public_key_algorithm,c.public_key_fingerprint,m.operation_id,c.status,c.created_at,c.rotated_at FROM repository_ssh_credentials_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_revision=c.current_revision;
|
||||
INSERT INTO repository_ssh_credential_keys SELECT c.workspace_id,c.credential_id,m.operation_id,c.public_key_algorithm,c.public_key_fingerprint,c.created_at FROM repository_ssh_credential_revisions_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_revision=c.revision;
|
||||
INSERT INTO repository_ssh_host_trusts SELECT c.workspace_id,c.host_trust_id,c.hostname,c.port,c.key_algorithm,c.host_key,c.fingerprint,m.operation_id,c.created_at,c.updated_at FROM repository_ssh_host_trusts_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='host_trust' AND m.resource_id=c.host_trust_id AND m.legacy_revision=c.current_revision;
|
||||
INSERT INTO repository_ssh_host_trust_keys SELECT c.workspace_id,c.host_trust_id,m.operation_id,c.hostname,c.port,c.key_algorithm,c.host_key,c.fingerprint,c.created_at FROM repository_ssh_host_trust_revisions_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='host_trust' AND m.resource_id=c.host_trust_id AND m.legacy_revision=c.revision;")?;
|
||||
INSERT INTO repository_ssh_credentials SELECT c.workspace_id,c.credential_id,c.name,c.public_key_algorithm,c.public_key_fingerprint,m.operation_id,c.status,c.created_at,c.rotated_at FROM repository_ssh_credentials_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_counter=c.current_revision;
|
||||
INSERT INTO repository_ssh_credential_keys SELECT c.workspace_id,c.credential_id,m.operation_id,c.public_key_algorithm,c.public_key_fingerprint,c.created_at FROM repository_ssh_credential_revisions_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_counter=c.revision;
|
||||
INSERT INTO repository_ssh_host_trusts SELECT c.workspace_id,c.host_trust_id,c.hostname,c.port,c.key_algorithm,c.host_key,c.fingerprint,m.operation_id,c.created_at,c.updated_at FROM repository_ssh_host_trusts_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='host_trust' AND m.resource_id=c.host_trust_id AND m.legacy_counter=c.current_revision;
|
||||
INSERT INTO repository_ssh_host_trust_keys SELECT c.workspace_id,c.host_trust_id,m.operation_id,c.hostname,c.port,c.key_algorithm,c.host_key,c.fingerprint,c.created_at FROM repository_ssh_host_trust_revisions_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='host_trust' AND m.resource_id=c.host_trust_id AND m.legacy_counter=c.revision;")?;
|
||||
let secrets = {
|
||||
let mut statement = tx.prepare("SELECT s.workspace_id,s.secret_id,s.revision,s.purpose,s.encryption_algorithm,s.nonce,s.ciphertext,s.created_at,m.operation_id FROM server_secret_versions_v86 s LEFT JOIN repository_key_cutover m ON m.workspace_id=s.workspace_id AND m.kind='credential' AND m.resource_id=s.secret_id AND m.legacy_revision=s.revision")?;
|
||||
let mut statement = tx.prepare("SELECT s.workspace_id,s.secret_id,s.revision,s.purpose,s.encryption_algorithm,s.nonce,s.ciphertext,s.created_at,m.operation_id FROM server_secret_versions_v86 s LEFT JOIN repository_key_cutover m ON m.workspace_id=s.workspace_id AND m.kind='credential' AND m.resource_id=s.secret_id AND m.legacy_counter=s.revision")?;
|
||||
statement
|
||||
.query_map([], |r| {
|
||||
Ok((
|
||||
@@ -148,7 +148,7 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
for (workspace, id, revision, purpose, algorithm, nonce, ciphertext, created, operation) in
|
||||
for (workspace, id, legacy_counter, purpose, algorithm, nonce, ciphertext, created, operation) in
|
||||
secrets
|
||||
{
|
||||
if algorithm != "aes-256-gcm-v1" {
|
||||
@@ -164,7 +164,7 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
database,
|
||||
&workspace,
|
||||
&id,
|
||||
revision,
|
||||
legacy_counter,
|
||||
&operation,
|
||||
&purpose,
|
||||
&nonce,
|
||||
@@ -201,7 +201,7 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
for (workspace, event, kind, resource, revision, actor, created) in audits {
|
||||
for (workspace, event, kind, resource, legacy_counter, actor, created) in audits {
|
||||
let resource_kind = if kind.starts_with("credential_") {
|
||||
"credential"
|
||||
} else if kind.starts_with("host_trust_") {
|
||||
@@ -212,7 +212,7 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
let mut statement=tx.prepare("SELECT operation_id FROM repository_secret_operations_v86 WHERE workspace_id=?1 AND resource_kind=?2 AND resource_id=?3 AND result_revision=?4 AND created_at=?5")?;
|
||||
let matches = statement
|
||||
.query_map(
|
||||
params![workspace, resource_kind, resource, revision, created],
|
||||
params![workspace, resource_kind, resource, legacy_counter, created],
|
||||
|r| r.get::<_, String>(0),
|
||||
)?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
@@ -246,7 +246,7 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
HAVING count(*)=1) AS mutation_kind FROM repository_secret_operations_v86 o) e
|
||||
LEFT JOIN repository_key_cutover m ON m.workspace_id=e.workspace_id AND m.kind=e.resource_kind AND m.resource_id=e.resource_id
|
||||
AND m.created_at<=e.created_at
|
||||
AND m.legacy_revision=CASE WHEN e.mutation_kind IN ('credential_rotated','host_trust_rotated') THEN e.result_revision-1
|
||||
AND m.legacy_counter=CASE WHEN e.mutation_kind IN ('credential_rotated','host_trust_rotated') THEN e.result_revision-1
|
||||
WHEN e.mutation_kind IN ('credential_deleted','host_trust_deleted') THEN e.result_revision END;
|
||||
INSERT INTO workdir_create_operations
|
||||
SELECT o.workspace_id,o.operation_id,'workdir-create-v86:' || COALESCE(CAST(o.source_revision AS TEXT),'unknown') || ':' || o.request_fingerprint,
|
||||
@@ -257,14 +257,14 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
CASE WHEN a.operation_id IS NULL THEN o.repository_access_mode END
|
||||
FROM workdir_create_operations_v86 o
|
||||
LEFT JOIN workdir_terminal_archive_cutover a ON a.workspace_id=o.workspace_id AND a.operation_id=o.operation_id
|
||||
LEFT JOIN repository_key_cutover c ON c.workspace_id=o.workspace_id AND c.kind='credential' AND c.resource_id=o.credential_id AND c.legacy_revision=o.credential_revision AND c.created_at<=o.updated_at
|
||||
LEFT JOIN repository_key_cutover h ON h.workspace_id=o.workspace_id AND h.kind='host_trust' AND h.resource_id=o.host_trust_id AND h.legacy_revision=o.host_trust_revision AND h.created_at<=o.updated_at;
|
||||
LEFT JOIN repository_key_cutover c ON c.workspace_id=o.workspace_id AND c.kind='credential' AND c.resource_id=o.credential_id AND c.legacy_counter=o.credential_revision AND c.created_at<=o.updated_at
|
||||
LEFT JOIN repository_key_cutover h ON h.workspace_id=o.workspace_id AND h.kind='host_trust' AND h.resource_id=o.host_trust_id AND h.legacy_counter=o.host_trust_revision AND h.created_at<=o.updated_at;
|
||||
INSERT INTO workdir_create_credential_candidates
|
||||
SELECT c.workspace_id,c.operation_id,c.ordinal,c.role,c.credential_id,m.fingerprint
|
||||
FROM workdir_create_credential_candidates_v86 c JOIN workdir_create_operations_v86 o ON o.workspace_id=c.workspace_id AND o.operation_id=c.operation_id LEFT JOIN repository_key_cutover m
|
||||
ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_revision=c.credential_revision AND m.created_at<=o.updated_at
|
||||
ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_counter=c.credential_revision AND m.created_at<=o.updated_at
|
||||
WHERE NOT EXISTS(SELECT 1 FROM workdir_terminal_archive_cutover a WHERE a.workspace_id=c.workspace_id AND a.operation_id=c.operation_id);
|
||||
INSERT INTO workdir_create_credential_retentions SELECT c.workspace_id,c.operation_id,c.ordinal,c.credential_id,m.fingerprint,m.operation_id FROM workdir_create_credential_revision_retentions_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_revision=c.credential_revision;")?;
|
||||
INSERT INTO workdir_create_credential_retentions SELECT c.workspace_id,c.operation_id,c.ordinal,c.credential_id,m.fingerprint,m.operation_id FROM workdir_create_credential_revision_retentions_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_counter=c.credential_revision;")?;
|
||||
let missing:i64=tx.query_row("SELECT count(*) FROM workdir_create_operations WHERE (credential_id IS NOT NULL AND credential_fingerprint IS NULL) OR (host_trust_id IS NOT NULL AND host_trust_fingerprint IS NULL)",[],|r|r.get(0))?;
|
||||
if missing != 0 {
|
||||
return Err(Error::Store(
|
||||
|
||||
@@ -177,6 +177,8 @@ fn migrated_workdir_request_replays_and_retries_without_accepting_changed_displa
|
||||
)
|
||||
.unwrap();
|
||||
migrate_repository_keys_v86_to_v87(&conn).unwrap();
|
||||
// The service consumes current receipt columns; unrelated schemas are not this fixture.
|
||||
rename_saved_secret_receipt_counters(&conn).unwrap();
|
||||
let store = SqliteWorkspaceStore {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
};
|
||||
@@ -358,6 +360,8 @@ fn deleted_successful_workdir_archives_verbatim_history_without_authorizing_retr
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
// The service consumes current receipt columns; unrelated schemas are not this fixture.
|
||||
rename_saved_secret_receipt_counters(&conn).unwrap();
|
||||
let store = Arc::new(SqliteWorkspaceStore {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
});
|
||||
@@ -655,7 +659,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
|
||||
let rotated_digest = frozen_credential_request_digest("rotate", "", 1, &next_private);
|
||||
let host_created_digest = frozen_host_request_digest("old.example.test", 0, &old_fp);
|
||||
let host_rotated_digest = frozen_host_request_digest("new.example.test", 1, &old_fp);
|
||||
for (operation, digest, kind, resource, revision, timestamp, audit) in [
|
||||
for (operation, digest, kind, resource, legacy_counter, timestamp, audit) in [
|
||||
(
|
||||
"create-key",
|
||||
&created_digest,
|
||||
@@ -695,25 +699,25 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
|
||||
] {
|
||||
conn.execute(
|
||||
"INSERT INTO repository_secret_operations VALUES('space',?1,?2,?3,?4,?5,?6)",
|
||||
params![operation, digest, kind, resource, revision, timestamp],
|
||||
params![operation, digest, kind, resource, legacy_counter, timestamp],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO repository_secret_audit_events VALUES('space',?1,?2,?3,?4,'actor',?5)",
|
||||
params![operation, audit, resource, revision, timestamp],
|
||||
params![operation, audit, resource, legacy_counter, timestamp],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
conn.execute("INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519',?1,2,'active','2026-01-01','2026-01-02')",[&next_fp]).unwrap();
|
||||
let encryption_key = LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &[42; 32]).unwrap());
|
||||
for (revision, fingerprint, private, timestamp) in [
|
||||
for (legacy_counter, fingerprint, private, timestamp) in [
|
||||
(1u64, &old_fp, &old_private, "2026-01-01"),
|
||||
(2, &next_fp, &next_private, "2026-01-02"),
|
||||
] {
|
||||
conn.execute("INSERT INTO repository_ssh_credential_revisions VALUES('space','key',?1,'ssh-ed25519',?2,?3)",params![revision,fingerprint,timestamp]).unwrap();
|
||||
let nonce = [revision as u8; 12];
|
||||
conn.execute("INSERT INTO repository_ssh_credential_revisions VALUES('space','key',?1,'ssh-ed25519',?2,?3)",params![legacy_counter,fingerprint,timestamp]).unwrap();
|
||||
let nonce = [legacy_counter as u8; 12];
|
||||
let mut ciphertext = private.as_bytes().to_vec();
|
||||
let aad = format!("yoi/repository-secret/v1/space/key/{revision}/private_key");
|
||||
let aad = format!("yoi/repository-secret/v1/space/key/{legacy_counter}/private_key");
|
||||
encryption_key
|
||||
.seal_in_place_append_tag(
|
||||
Nonce::assume_unique_for_key(nonce),
|
||||
@@ -721,16 +725,18 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
|
||||
&mut ciphertext,
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',?1,'private_key','aes-256-gcm-v1',?2,?3,?4)",params![revision,nonce.as_slice(),ciphertext,timestamp]).unwrap();
|
||||
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',?1,'private_key','aes-256-gcm-v1',?2,?3,?4)",params![legacy_counter,nonce.as_slice(),ciphertext,timestamp]).unwrap();
|
||||
}
|
||||
conn.execute("INSERT INTO repository_ssh_host_trusts VALUES('space','host','new.example.test',22,'ssh-ed25519',?1,?2,2,'2026-01-01','2026-01-02')",params![public,old_fp]).unwrap();
|
||||
for (revision, hostname, timestamp) in [
|
||||
for (legacy_counter, hostname, timestamp) in [
|
||||
(1, "old.example.test", "2026-01-01"),
|
||||
(2, "new.example.test", "2026-01-02"),
|
||||
] {
|
||||
conn.execute("INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',?1,?2,22,'ssh-ed25519',?3,?4,?5)",params![revision,hostname,public,old_fp,timestamp]).unwrap();
|
||||
conn.execute("INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',?1,?2,22,'ssh-ed25519',?3,?4,?5)",params![legacy_counter,hostname,public,old_fp,timestamp]).unwrap();
|
||||
}
|
||||
migrate_repository_keys_v86_to_v87(&conn).unwrap();
|
||||
// The service consumes current receipt columns; unrelated schemas are not this fixture.
|
||||
rename_saved_secret_receipt_counters(&conn).unwrap();
|
||||
let store = Arc::new(SqliteWorkspaceStore {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
});
|
||||
@@ -881,6 +887,8 @@ fn deleted_legacy_receipt_does_not_bind_to_later_reused_key_ordinal() {
|
||||
INSERT INTO repository_secret_operations VALUES('space','old-delete','intent','credential','key',7,'2025-01-01');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','old-delete-audit','credential_deleted','key',7,'actor','2025-01-01');").unwrap();
|
||||
migrate_repository_keys_v86_to_v87(&conn).unwrap();
|
||||
// The service consumes current receipt columns; unrelated schemas are not this fixture.
|
||||
rename_saved_secret_receipt_counters(&conn).unwrap();
|
||||
let store = Arc::new(SqliteWorkspaceStore {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
});
|
||||
|
||||
@@ -62,7 +62,7 @@ fn migrate_runtime_bindings_v87_to_v88(conn: &Connection) -> Result<()> {
|
||||
)?;
|
||||
if unsupported {
|
||||
return Err(Error::Store(
|
||||
"unsupported legacy Workspace signing identity revision".into(),
|
||||
"legacy Workspace signing identity counter must equal one".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ CREATE TABLE "artifacts" (
|
||||
worker_display TEXT,
|
||||
repository_id TEXT,
|
||||
source_kind TEXT,
|
||||
source_revision TEXT,
|
||||
source_reference TEXT,
|
||||
PRIMARY KEY (workspace_id, artifact_id),
|
||||
CHECK ((worker_ref_kind IS NULL) = (worker_ref_key IS NULL)),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
@@ -1679,10 +1679,10 @@ CREATE TABLE repository_secret_legacy_receipts (
|
||||
request_fingerprint TEXT NOT NULL,
|
||||
resource_kind TEXT NOT NULL,
|
||||
resource_id TEXT NOT NULL,
|
||||
result_revision INTEGER NOT NULL,
|
||||
legacy_result_counter INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
mutation_kind TEXT,
|
||||
expected_revision INTEGER,
|
||||
legacy_expected_counter INTEGER,
|
||||
expected_operation_id TEXT,
|
||||
expected_key_fingerprint TEXT,
|
||||
PRIMARY KEY (workspace_id, operation_id),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// These are provenance and frozen replay evidence, not live update guards.
|
||||
// Keep the original data while removing the misleading names from current DDL.
|
||||
fn migrate_evidence_column_names_v88_to_v89(conn: &Connection) -> Result<()> {
|
||||
if current_schema_version(conn)? != 88 {
|
||||
return Err(Error::Store(
|
||||
"Evidence column migration requires schema 88".into(),
|
||||
));
|
||||
}
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch("ALTER TABLE artifacts RENAME COLUMN source_revision TO source_reference;")?;
|
||||
rename_saved_secret_receipt_counters(&tx)?;
|
||||
tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES(89,'descriptive legacy evidence columns')", [])?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Also used by focused legacy SSH fixtures that do not contain unrelated tables.
|
||||
fn rename_saved_secret_receipt_counters(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE repository_secret_legacy_receipts RENAME COLUMN result_revision TO legacy_result_counter;
|
||||
ALTER TABLE repository_secret_legacy_receipts RENAME COLUMN expected_revision TO legacy_expected_counter;",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// These fixtures use actual schema-88 column names as input, not as current APIs.
|
||||
fn old_evidence_database(path: &Path) -> Connection {
|
||||
prepare_retained_schema(path, 88);
|
||||
let conn = Connection::open(path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
seed_migration_workspace(&conn);
|
||||
for (index, source) in [Some("immutable-source-commit"), None]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
conn.execute("INSERT INTO artifacts(workspace_id,artifact_id,kind,uri,created_at,created_by_kind,created_by_key,created_by_display,source_revision)
|
||||
VALUES('space',?1,'file','artifact://saved','created','user','owner','Owner',?2)",params![format!("artifact-{index}"),source]).unwrap();
|
||||
}
|
||||
for (operation, result, expected) in [
|
||||
("old-update", 7, Some(6)),
|
||||
("old-create", 1, None),
|
||||
("old-zero", 0, Some(0)),
|
||||
] {
|
||||
conn.execute("INSERT INTO repository_secret_operations VALUES('space',?1,'saved-request-digest','credential','deleted-key',?1,'created')",[operation]).unwrap();
|
||||
conn.execute("INSERT INTO repository_secret_legacy_receipts VALUES('space',?1,'saved-request-digest','credential','deleted-key',?2,'created','credential_deleted',?3,'prior-operation','key-fingerprint')",params![operation,result,expected]).unwrap();
|
||||
}
|
||||
conn
|
||||
}
|
||||
|
||||
fn evidence_rows(conn: &Connection, table: &str) -> Vec<Vec<rusqlite::types::Value>> {
|
||||
let mut stmt = conn
|
||||
.prepare(&format!("SELECT * FROM {table} ORDER BY 1,2"))
|
||||
.unwrap();
|
||||
let count = stmt.column_count();
|
||||
stmt.query_map([], |row| {
|
||||
(0..count)
|
||||
.map(|i| row.get(i))
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
})
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evidence_column_cutover_preserves_all_values_and_current_schema_names() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("server.db");
|
||||
let conn = old_evidence_database(&path);
|
||||
let artifacts = evidence_rows(&conn, "artifacts");
|
||||
let receipts = evidence_rows(&conn, "repository_secret_legacy_receipts");
|
||||
drop(conn);
|
||||
let upgraded = SqliteWorkspaceStore::open(&path).unwrap();
|
||||
upgraded
|
||||
.with_conn(|conn| {
|
||||
assert_eq!(current_schema_version(conn)?, 89);
|
||||
assert_eq!(evidence_rows(conn, "artifacts"), artifacts);
|
||||
assert_eq!(
|
||||
evidence_rows(conn, "repository_secret_legacy_receipts"),
|
||||
receipts
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})?,
|
||||
0
|
||||
);
|
||||
for (table, column) in [
|
||||
("artifacts", "source_reference"),
|
||||
("repository_secret_legacy_receipts", "legacy_result_counter"),
|
||||
(
|
||||
"repository_secret_legacy_receipts",
|
||||
"legacy_expected_counter",
|
||||
),
|
||||
] {
|
||||
assert!(column_exists(conn, table, column)?);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
for store in [upgraded, SqliteWorkspaceStore::in_memory().unwrap()] {
|
||||
store.with_conn(|conn| {
|
||||
let mut stmt = conn.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'")?;
|
||||
let objects = stmt.query_map([],|row|Ok((row.get::<_,String>(0)?,row.get::<_,String>(1)?)))?.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
for (name,sql) in objects {
|
||||
assert!(!name.to_ascii_lowercase().contains("revision") && !sql.to_ascii_lowercase().contains("revision"),"old name in current schema: {name}");
|
||||
}
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evidence_column_cutover_rolls_back_every_rename_before_retry() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conn = old_evidence_database(&dir.path().join("server.db"));
|
||||
conn.execute_batch("CREATE TRIGGER reject_schema_89 BEFORE INSERT ON __yoi_schema_migrations WHEN NEW.version=89 BEGIN SELECT RAISE(ABORT,'reject schema 89'); END;").unwrap();
|
||||
let schema = evidence_rows(&conn, "sqlite_schema");
|
||||
let receipts = evidence_rows(&conn, "repository_secret_legacy_receipts");
|
||||
let artifacts = evidence_rows(&conn, "artifacts");
|
||||
let history = evidence_rows(&conn, "__yoi_schema_migrations");
|
||||
let pragmas = |conn: &Connection| {
|
||||
(
|
||||
conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
conn.pragma_query_value(None, "legacy_alter_table", |row| row.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
)
|
||||
};
|
||||
let before = pragmas(&conn);
|
||||
assert!(
|
||||
migrate_evidence_column_names_v88_to_v89(&conn)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("reject schema 89")
|
||||
);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 88);
|
||||
assert_eq!(evidence_rows(&conn, "sqlite_schema"), schema);
|
||||
assert_eq!(
|
||||
evidence_rows(&conn, "repository_secret_legacy_receipts"),
|
||||
receipts
|
||||
);
|
||||
assert_eq!(evidence_rows(&conn, "artifacts"), artifacts);
|
||||
assert_eq!(evidence_rows(&conn, "__yoi_schema_migrations"), history);
|
||||
assert_eq!(pragmas(&conn), before);
|
||||
conn.execute_batch("DROP TRIGGER reject_schema_89").unwrap();
|
||||
migrate_evidence_column_names_v88_to_v89(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 89);
|
||||
}
|
||||
@@ -42,7 +42,7 @@ mod workspace_subscription;
|
||||
|
||||
pub use authority::{
|
||||
InternalMemoryStagingEntry, MemoryAuthority, MemoryDocument, MemoryStagingResolution,
|
||||
ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource,
|
||||
ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, TicketSourceCommitResolver,
|
||||
WorkspaceAuthority,
|
||||
};
|
||||
pub use config::{ResolvedWorkspaceBackendConfig, ServerHostConfigFile};
|
||||
|
||||
@@ -160,7 +160,7 @@ fn load_skill_workspace_config(
|
||||
let store = SqliteWorkspaceStore::open(ServerConfig::default_server_database_path())?;
|
||||
store.load_workspace_config(workspace_id)?.ok_or_else(|| {
|
||||
Box::new(CliError(format!(
|
||||
"Workspace `{workspace_id}` has no active config revision"
|
||||
"Workspace `{workspace_id}` has no active config snapshot"
|
||||
))) as Box<dyn std::error::Error>
|
||||
})
|
||||
}
|
||||
@@ -655,7 +655,7 @@ fn print_migrate_help() {
|
||||
|
||||
fn print_skills_help() {
|
||||
println!(
|
||||
"yoi-server skills\n\nUsage:\n yoi-server skills list --workspace <WORKSPACE_ID>\n yoi-server skills lint --workspace <WORKSPACE_ID>\n yoi-server skills show <NAME> --workspace <WORKSPACE_ID>\n\nDescription:\n Reads the active Server DB virtual-config revision. Catalog output is lightweight and omits imported Markdown content; detail output includes that content. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace <WORKSPACE_ID> Workspace id in the Server DB (required)\n -h, --help Print help"
|
||||
"yoi-server skills\n\nUsage:\n yoi-server skills list --workspace <WORKSPACE_ID>\n yoi-server skills lint --workspace <WORKSPACE_ID>\n yoi-server skills show <NAME> --workspace <WORKSPACE_ID>\n\nDescription:\n Reads the active Server DB virtual-config snapshot. Catalog output is lightweight and omits imported Markdown content; detail output includes that content. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace <WORKSPACE_ID> Workspace id in the Server DB (required)\n -h, --help Print help"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -308,7 +308,7 @@ impl RepositorySecretService {
|
||||
let Some((_,resource_kind,resource_id,result_operation_id))=read_operation(conn,workspace_id,&operation_id)? else { return Ok(None); };
|
||||
let created_at=conn.query_row("SELECT created_at FROM repository_secret_operations WHERE workspace_id=?1 AND operation_id=?2",params![workspace_id,operation_id],|r|r.get(0))?;
|
||||
let legacy: Option<(Option<String>,Option<i64>,Option<String>,Option<String>)>=conn.query_row(
|
||||
"SELECT mutation_kind,expected_revision,expected_operation_id,expected_key_fingerprint FROM repository_secret_legacy_receipts WHERE workspace_id=?1 AND operation_id=?2",
|
||||
"SELECT mutation_kind,legacy_expected_counter,expected_operation_id,expected_key_fingerprint FROM repository_secret_legacy_receipts WHERE workspace_id=?1 AND operation_id=?2",
|
||||
params![workspace_id,operation_id],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?)),
|
||||
).optional()?;
|
||||
let (mutation_kind,legacy_precondition_proven)=if let Some((kind,counter,prior,key))=legacy {
|
||||
@@ -1626,7 +1626,7 @@ fn matches_legacy_secret_replay(
|
||||
request: LegacySecretRequest<'_>,
|
||||
) -> Result<bool> {
|
||||
let evidence: Option<(String,Option<String>,Option<i64>,Option<String>,Option<String>)> = conn.query_row(
|
||||
"SELECT request_fingerprint,mutation_kind,expected_revision,expected_operation_id,expected_key_fingerprint FROM repository_secret_legacy_receipts WHERE workspace_id=?1 AND operation_id=?2 AND resource_id=?3",
|
||||
"SELECT request_fingerprint,mutation_kind,legacy_expected_counter,expected_operation_id,expected_key_fingerprint FROM repository_secret_legacy_receipts WHERE workspace_id=?1 AND operation_id=?2 AND resource_id=?3",
|
||||
params![workspace_id,operation_id,resource_id], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?)),
|
||||
).optional()?;
|
||||
let Some((original, Some(mutation_kind), Some(counter), prior_operation, prior_key)) = evidence
|
||||
@@ -2074,7 +2074,7 @@ pub(crate) fn migrate_legacy_repository_secret_envelope(
|
||||
database_path: &Path,
|
||||
workspace_id: &str,
|
||||
credential_id: &str,
|
||||
legacy_revision: u64,
|
||||
legacy_counter: u64,
|
||||
operation_id: &str,
|
||||
purpose: &str,
|
||||
nonce: &[u8],
|
||||
@@ -2092,7 +2092,7 @@ pub(crate) fn migrate_legacy_repository_secret_envelope(
|
||||
Error::RegistryInconsistency("Repository secret migration nonce is invalid".to_string())
|
||||
})?;
|
||||
let old_aad = format!(
|
||||
"yoi/repository-secret/v1/{workspace_id}/{credential_id}/{legacy_revision}/{purpose}"
|
||||
"yoi/repository-secret/v1/{workspace_id}/{credential_id}/{legacy_counter}/{purpose}"
|
||||
);
|
||||
let mut plaintext = zeroize::Zeroizing::new(ciphertext.to_vec());
|
||||
let plaintext_len = key
|
||||
|
||||
@@ -42,6 +42,7 @@ fn fresh_and_upgraded_state_contract_schemas_enforce_the_same_constraints() {
|
||||
let fresh = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
let mut mismatches = Vec::new();
|
||||
for table in [
|
||||
"artifacts",
|
||||
"workspace_config_trees",
|
||||
"workspace_config_tree_history",
|
||||
"workspace_memory_settings",
|
||||
|
||||
@@ -155,7 +155,7 @@ use crate::auth::{
|
||||
};
|
||||
use crate::authority::{
|
||||
MemoryAuthority, ObjectiveAuthority, ObjectiveCreateInput, ObjectiveEditInput,
|
||||
SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource, merge_request_summary,
|
||||
SqliteWorkspaceAuthority, TicketAuthority, TicketSourceCommitResolver, merge_request_summary,
|
||||
};
|
||||
use crate::backend_job::{
|
||||
ABSOLUTE_MAX_CONCURRENT_JOBS, BackendJobAttemptRecord, BackendJobAttemptState,
|
||||
@@ -3607,11 +3607,9 @@ impl WorkspaceApi {
|
||||
// The observer snapshot retains the unconfigured authority, not itself:
|
||||
// source observation uses Repository access + Runtime authority without a cycle.
|
||||
let observer_api = api.clone();
|
||||
api.authority =
|
||||
api.authority
|
||||
.with_merge_revision_source(Arc::new(RuntimeTicketMergeRevisionSource {
|
||||
api: observer_api,
|
||||
}));
|
||||
api.authority = api.authority.with_source_commit_resolver(Arc::new(
|
||||
RuntimeTicketSourceCommitResolver { api: observer_api },
|
||||
));
|
||||
if let Some(dispatcher) = worker_remove_dispatcher {
|
||||
dispatcher
|
||||
.install_executor(Arc::new(
|
||||
@@ -4635,7 +4633,7 @@ impl WorkspaceApi {
|
||||
&self.config.workspace_id,
|
||||
&delivery_id,
|
||||
BackendJobDeliveryState::Failed,
|
||||
Some("current_revision_unavailable"),
|
||||
Some("current_ticket_content_unavailable"),
|
||||
Some(&error.to_string()),
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
);
|
||||
@@ -4657,7 +4655,7 @@ impl WorkspaceApi {
|
||||
}
|
||||
Ok(ticket_item_checker::TicketItemCheckNotification::Stale) => {
|
||||
let detail = format!(
|
||||
"checked revision {} is no longer current ({})",
|
||||
"checked Ticket content digest {} is no longer current ({})",
|
||||
checker_input.content_digest,
|
||||
ticket_item_checker::content_digest(¤t_ticket)
|
||||
);
|
||||
@@ -4665,7 +4663,7 @@ impl WorkspaceApi {
|
||||
&self.config.workspace_id,
|
||||
&delivery_id,
|
||||
BackendJobDeliveryState::Completed,
|
||||
Some("notification_suppressed_stale_revision"),
|
||||
Some("notification_suppressed_stale_ticket_content"),
|
||||
Some(&detail),
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
);
|
||||
@@ -15190,12 +15188,12 @@ struct MergeRequestRepositorySource {
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RuntimeTicketMergeRevisionSource {
|
||||
struct RuntimeTicketSourceCommitResolver {
|
||||
api: WorkspaceApi,
|
||||
}
|
||||
|
||||
impl TicketMergeRevisionSource for RuntimeTicketMergeRevisionSource {
|
||||
fn resolve_subject_ref(
|
||||
impl TicketSourceCommitResolver for RuntimeTicketSourceCommitResolver {
|
||||
fn resolve_source_commit(
|
||||
&self,
|
||||
ticket_id: &str,
|
||||
repository_id: &str,
|
||||
@@ -15357,6 +15355,9 @@ fn remap_source_ref_error(error: ApiError) -> ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Observe the provider-published source commit through the assigned Runtime, not
|
||||
/// a Server-local checkout. Open/list/show/review/complete paths call this at the
|
||||
/// point they need current source evidence; provider failure remains unavailable.
|
||||
fn observe_published_source_ref(
|
||||
api: &WorkspaceApi,
|
||||
workspace_id: &str,
|
||||
@@ -15368,6 +15369,10 @@ fn observe_published_source_ref(
|
||||
.map_err(remap_source_ref_error)
|
||||
}
|
||||
|
||||
/// Before opening an MR or granting review of an open MR, re-read the current
|
||||
/// Coder attachments and require one clean checkout of the same Repository,
|
||||
/// normalized branch selector and provider-published HEAD commit. A newer
|
||||
/// observation time alone says nothing about which code is being reviewed.
|
||||
fn require_assigned_workdir_source(
|
||||
api: &WorkspaceApi,
|
||||
assignment: &crate::store::TicketWorkerAssignmentRecord,
|
||||
@@ -15457,7 +15462,7 @@ fn validate_assigned_workdir_source(
|
||||
if !workdir_selector_matches || workdir.current_ref.as_deref() != Some(resolved_ref) {
|
||||
return Err(
|
||||
worker_runtime::working_directory::WorkingDirectoryDiagnostic {
|
||||
code: "source_ref_revision_mismatch".to_string(),
|
||||
code: "source_workdir_ref_mismatch".to_string(),
|
||||
message: "Current Coder Workdir selector and HEAD do not match the provider-published source ref".to_string(),
|
||||
},
|
||||
);
|
||||
@@ -18017,7 +18022,7 @@ fn list_current_worker_workdir_catalog_for_worker(
|
||||
workspace_id: api.config.workspace_id.clone(),
|
||||
items,
|
||||
next_cursor: page.next_cursor,
|
||||
digest: page.revision,
|
||||
digest: page.catalog_digest,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18033,7 +18038,7 @@ fn list_current_worker_workdir_attachments(
|
||||
Error::InvalidInput("attachment page limit must be 1..=100".to_string()).into(),
|
||||
);
|
||||
}
|
||||
let (mut links, digest) = api.store.list_worker_workdir_links_page_with_revision(
|
||||
let (mut links, digest) = api.store.list_worker_workdir_links_page_with_digest(
|
||||
&api.config.workspace_id,
|
||||
worker,
|
||||
limit + 1,
|
||||
@@ -20839,7 +20844,7 @@ async fn scoped_subjektiv_memory_backend(
|
||||
),
|
||||
Op::ValidateProposal(_) => (
|
||||
Some(SubjektivWorkerAuthority::Subject),
|
||||
"revision proposal validation",
|
||||
"Memory proposal validation",
|
||||
),
|
||||
Op::ReceiptStatus(_) => (
|
||||
Some(SubjektivWorkerAuthority::Subject),
|
||||
@@ -21289,7 +21294,7 @@ async fn scoped_list_skills(
|
||||
.load_workspace_config(&path.workspace_id)?
|
||||
.ok_or_else(|| {
|
||||
Error::RegistryInconsistency(format!(
|
||||
"Workspace {} has no active config revision",
|
||||
"Workspace {} has no active config snapshot",
|
||||
path.workspace_id
|
||||
))
|
||||
})?;
|
||||
@@ -21306,7 +21311,7 @@ async fn scoped_lint_skills(
|
||||
.load_workspace_config(&path.workspace_id)?
|
||||
.ok_or_else(|| {
|
||||
Error::RegistryInconsistency(format!(
|
||||
"Workspace {} has no active config revision",
|
||||
"Workspace {} has no active config snapshot",
|
||||
path.workspace_id
|
||||
))
|
||||
})?;
|
||||
@@ -21323,7 +21328,7 @@ async fn scoped_get_skill(
|
||||
.load_workspace_config(&path.workspace_id)?
|
||||
.ok_or_else(|| {
|
||||
Error::RegistryInconsistency(format!(
|
||||
"Workspace {} has no active config revision",
|
||||
"Workspace {} has no active config snapshot",
|
||||
path.workspace_id
|
||||
))
|
||||
})?;
|
||||
@@ -21342,7 +21347,7 @@ async fn scoped_activate_skill(
|
||||
.load_workspace_config(&path.workspace_id)?
|
||||
.ok_or_else(|| {
|
||||
Error::RegistryInconsistency(format!(
|
||||
"Workspace {} has no active config revision",
|
||||
"Workspace {} has no active config snapshot",
|
||||
path.workspace_id
|
||||
))
|
||||
})?;
|
||||
@@ -21611,7 +21616,7 @@ async fn scoped_confirm_repository_ssh_host_trust(
|
||||
})?;
|
||||
if request.expected_host_key_fingerprint != probe.expected_host_key_fingerprint {
|
||||
return Err(ApiError::from(Error::WorkspaceConfigConflict(
|
||||
"SSH host trust revision changed after the connection test".to_string(),
|
||||
"SSH host trust fingerprint changed after the connection test".to_string(),
|
||||
)));
|
||||
}
|
||||
let host_trust = api.repository_secrets.put_host_trust(
|
||||
@@ -32899,9 +32904,7 @@ fn api_error_status(error: &Error) -> StatusCode {
|
||||
StatusCode::NOT_IMPLEMENTED
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. }
|
||||
if code == "profile_registry_revision_conflict"
|
||||
|| code == "profile_source_revision_conflict"
|
||||
|| code == "workspace_metadata_updated_at_conflict"
|
||||
if code == "workspace_metadata_updated_at_conflict"
|
||||
|| code == "workspace_cleanup_plan_stale"
|
||||
|| code == "workspace_cleanup_worker_blocked"
|
||||
|| code == "workspace_cleanup_workdir_blocked"
|
||||
@@ -35929,6 +35932,10 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// Architectural source check, run by the ordinary Server unit suite: route
|
||||
// wiring must use Runtime/provider authority and capability validation must
|
||||
// precede provider access. This is not a freshness proof; the boundary tests
|
||||
// below and ticket_evidence_tests exercise commit/content equality directly.
|
||||
#[test]
|
||||
fn merge_request_http_paths_observe_refs_through_runtime_provider_authority() {
|
||||
let source = include_str!("server.rs");
|
||||
@@ -36034,8 +36041,57 @@ mod tests {
|
||||
"work/T-549",
|
||||
"abc123"
|
||||
),
|
||||
Err(diagnostic) if diagnostic.code == "source_ref_revision_mismatch"
|
||||
Err(diagnostic) if diagnostic.code == "source_workdir_ref_mismatch"
|
||||
));
|
||||
|
||||
// A recent observation cannot excuse the wrong branch, a missing HEAD,
|
||||
// another Repository or unobserved cleanliness. Each input isolates one
|
||||
// externally observed source requirement.
|
||||
workdir.current_ref = Some("abc123".into());
|
||||
workdir.observed_at_epoch_seconds = Some(u64::MAX);
|
||||
for (repository_id, selector, head, cleanliness, expected_code) in [
|
||||
(
|
||||
"other-repository",
|
||||
"work/T-549",
|
||||
Some("abc123"),
|
||||
Some("clean"),
|
||||
"source_workdir_repository_mismatch",
|
||||
),
|
||||
(
|
||||
"repository-1",
|
||||
"other-branch",
|
||||
Some("abc123"),
|
||||
Some("clean"),
|
||||
"source_workdir_ref_mismatch",
|
||||
),
|
||||
(
|
||||
"repository-1",
|
||||
"work/T-549",
|
||||
None,
|
||||
Some("clean"),
|
||||
"source_workdir_ref_mismatch",
|
||||
),
|
||||
(
|
||||
"repository-1",
|
||||
"work/T-549",
|
||||
Some("abc123"),
|
||||
None,
|
||||
"source_workdir_dirty",
|
||||
),
|
||||
] {
|
||||
workdir.repository_id = repository_id.into();
|
||||
workdir.current_selector = Some(selector.into());
|
||||
workdir.current_ref = head.map(str::to_owned);
|
||||
workdir.cleanliness = cleanliness.map(str::to_owned);
|
||||
let diagnostic = validate_assigned_workdir_source(
|
||||
&workdir,
|
||||
"repository-1",
|
||||
"refs/heads/work/T-549",
|
||||
"abc123",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(diagnostic.code, expected_code);
|
||||
}
|
||||
}
|
||||
|
||||
fn completed_upload_file(sha256: &str) -> protocol::UploadedFileRef {
|
||||
@@ -40003,9 +40059,7 @@ mod tests {
|
||||
created["behavior_md"],
|
||||
"Prefer explicit evidence.\nAsk when uncertain."
|
||||
);
|
||||
assert!(created.get("behavior_revision").is_none());
|
||||
assert_eq!(created["state"], "active");
|
||||
assert!(created.get("store_revision").is_none());
|
||||
assert!(created.get("current_worker").is_none());
|
||||
let created_id = created["id"].as_str().unwrap();
|
||||
let persisted = store.subject(created_id).unwrap().unwrap();
|
||||
@@ -40040,7 +40094,6 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
assert_eq!(updated["behavior_md"], "Updated without rewriting history.");
|
||||
assert!(updated.get("behavior_revision").is_none());
|
||||
request_json_authenticated(
|
||||
app.clone(),
|
||||
"PATCH",
|
||||
@@ -40066,7 +40119,6 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
assert_eq!(cleared["behavior_md"], "");
|
||||
assert!(cleared.get("behavior_revision").is_none());
|
||||
assert!(
|
||||
api.store
|
||||
.current_worker_singleton_owner(
|
||||
@@ -42897,7 +42949,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let unknown_identity = RuntimeIdentityMaterial::generate("unknown-runtime").unwrap();
|
||||
let unknown_revision = create_remote_runtime(
|
||||
let unknown_key_generation = create_remote_runtime(
|
||||
State(api.clone()),
|
||||
Extension(test_owner_actor()),
|
||||
Json(CreateRemoteRuntimeRequest {
|
||||
@@ -42914,7 +42966,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
unknown_revision.into_response().status(),
|
||||
unknown_key_generation.into_response().status(),
|
||||
StatusCode::CONFLICT
|
||||
);
|
||||
|
||||
@@ -44249,7 +44301,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_checker_burst_keeps_excess_revisions_durable_and_drains_them() {
|
||||
async fn ticket_checker_burst_keeps_excess_content_checks_durable_and_drains_them() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (api, _execution) = test_api_with_recording_backend(temp.path()).await;
|
||||
let source = spawn_ticket_check_source(&api, "ticket-check-burst-author");
|
||||
@@ -44318,7 +44370,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// Restart reconciliation is idempotent for running attempts and retains
|
||||
// the queued revisions until a slot becomes available.
|
||||
// the queued content checks until a slot becomes available.
|
||||
api.recover_backend_jobs().unwrap();
|
||||
let mut completed = std::collections::BTreeSet::new();
|
||||
for _ in 0..300 {
|
||||
@@ -44421,7 +44473,7 @@ mod tests {
|
||||
];
|
||||
let mut checked_inputs = Vec::new();
|
||||
for (ticket, source, expected_body) in snapshots {
|
||||
let revision = ticket_item_checker::content_digest(&ticket);
|
||||
let content_digest = ticket_item_checker::content_digest(&ticket);
|
||||
let mut jobs = ticket_check_jobs_for_snapshot(&api, &ticket);
|
||||
assert_eq!(
|
||||
jobs.len(),
|
||||
@@ -44430,7 +44482,7 @@ mod tests {
|
||||
);
|
||||
let job = jobs.pop().unwrap();
|
||||
let input = ticket_item_checker::parse_input(&job.request.input).unwrap();
|
||||
assert_eq!(input.content_digest, revision);
|
||||
assert_eq!(input.content_digest, content_digest);
|
||||
assert_eq!(input.body, expected_body);
|
||||
assert_eq!(job.request.source_worker.as_ref(), Some(&source));
|
||||
assert_eq!(job.request.notification_target.as_ref(), Some(&source));
|
||||
@@ -44468,7 +44520,7 @@ mod tests {
|
||||
let source = spawn_ticket_check_source(&api, "ticket-check-editor");
|
||||
let backend = browser_ticket_backend(&api).unwrap();
|
||||
let ticket_ref = backend
|
||||
.create(ticket::NewTicket::new("Checker revision fencing"))
|
||||
.create(ticket::NewTicket::new("Checker content freshness"))
|
||||
.unwrap();
|
||||
|
||||
let edit = |body: &str| TicketBackendOperation::EditItem {
|
||||
@@ -44563,7 +44615,7 @@ mod tests {
|
||||
assert_eq!(stale_delivery.state, BackendJobDeliveryState::Completed);
|
||||
assert_eq!(
|
||||
stale_delivery.failure_category.as_deref(),
|
||||
Some("notification_suppressed_stale_revision")
|
||||
Some("notification_suppressed_stale_ticket_content")
|
||||
);
|
||||
|
||||
api.accept_backend_job_result(
|
||||
@@ -48559,11 +48611,11 @@ mod tests {
|
||||
);
|
||||
let Json(replayed) = call(request.clone()).await.unwrap();
|
||||
assert_eq!(replayed.events, completed.events);
|
||||
for field in ["reason", "revision", "state", "references"] {
|
||||
for field in ["reason", "content_digest", "state", "references"] {
|
||||
let mut changed = request.clone();
|
||||
match field {
|
||||
"reason" => changed.reason = "different judgment".into(),
|
||||
"revision" => changed.expected_content_digest = "stale".into(),
|
||||
"content_digest" => changed.expected_content_digest = "stale".into(),
|
||||
"state" => changed.expected_state = TicketWorkflowState::Planning,
|
||||
"references" => changed.references.push(ticket::TicketReference {
|
||||
kind: "document".into(),
|
||||
@@ -49989,7 +50041,7 @@ mod tests {
|
||||
Json(server_api::BrowserTransitionTicketStateRequest {
|
||||
state: server_api::BrowserTicketWorkflowState::Done,
|
||||
operation_key: "stale".into(),
|
||||
expected_content_digest: "stale-revision".into(),
|
||||
expected_content_digest: "stale-content-digest".into(),
|
||||
expected_state: server_api::BrowserTicketWorkflowState::Planning,
|
||||
reason: "Stale decision".into(),
|
||||
body: None,
|
||||
@@ -52440,7 +52492,7 @@ mod tests {
|
||||
.uri(format!("/api/w/{TEST_WORKSPACE_ID}/workers/remove"))
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"target_runtime_id":"runtime-target","target_worker_id":"target-worker","expected_worker_revision":"revision-1","reason":"retire target Worker","source_proof":"browser-controlled","actor":"orchestrator","policy":"purge"}"#,
|
||||
r#"{"target_runtime_id":"runtime-target","target_worker_id":"target-worker","reason":"retire target Worker","source_proof":"browser-controlled","actor":"orchestrator","policy":"purge"}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
@@ -54116,7 +54168,7 @@ mod tests {
|
||||
restarted
|
||||
.workdir_catalog_page(TEST_WORKSPACE_ID, 100, None)
|
||||
.unwrap()
|
||||
.revision
|
||||
.catalog_digest
|
||||
);
|
||||
let no_worker = runtime_source_request(&identity, None, "GET", &base, Vec::new());
|
||||
assert_eq!(
|
||||
@@ -54559,7 +54611,7 @@ mod tests {
|
||||
assert_eq!(page.next_offset, None);
|
||||
assert_eq!(
|
||||
page.digest, first.digest,
|
||||
"lookup does not narrow the collection revision"
|
||||
"lookup does not narrow the collection digest"
|
||||
);
|
||||
|
||||
let foreign_worker_id = seed_cleanup_worker(api, 902, "normal");
|
||||
@@ -54621,7 +54673,7 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
unchanged, first,
|
||||
"another caller's connection cannot change this revision"
|
||||
"another caller's connection cannot change this digest"
|
||||
);
|
||||
api.store
|
||||
.detach_worker_workdir_connection(
|
||||
@@ -54657,8 +54709,8 @@ mod tests {
|
||||
assert_eq!(capability_changed.items, reattached.items);
|
||||
assert_ne!(capability_changed.digest, reattached.digest);
|
||||
let restarted = SqliteWorkspaceStore::open(&api.config.database_path).unwrap();
|
||||
let (restarted_page, restarted_revision) = restarted
|
||||
.list_worker_workdir_links_page_with_revision(
|
||||
let (restarted_page, restarted_digest) = restarted
|
||||
.list_worker_workdir_links_page_with_digest(
|
||||
TEST_WORKSPACE_ID,
|
||||
&fixture.worker,
|
||||
51,
|
||||
@@ -54667,8 +54719,8 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(restarted_page.len(), 51);
|
||||
assert_eq!(restarted_revision, capability_changed.digest);
|
||||
// A SQLite backup restores the same authoritative content/revision, without a sync store.
|
||||
assert_eq!(restarted_digest, capability_changed.digest);
|
||||
// A SQLite backup restores the same authoritative content/digest, without a sync store.
|
||||
let source = rusqlite::Connection::open(&api.config.database_path).unwrap();
|
||||
let mut snapshot = rusqlite::Connection::open_in_memory().unwrap();
|
||||
rusqlite::backup::Backup::new(&source, &mut snapshot)
|
||||
@@ -54678,7 +54730,7 @@ mod tests {
|
||||
let restored = SqliteWorkspaceStore::from_connection(snapshot).unwrap();
|
||||
assert_eq!(
|
||||
restored
|
||||
.list_worker_workdir_links_page_with_revision(
|
||||
.list_worker_workdir_links_page_with_digest(
|
||||
TEST_WORKSPACE_ID,
|
||||
&fixture.worker,
|
||||
51,
|
||||
@@ -54686,10 +54738,10 @@ mod tests {
|
||||
None
|
||||
)
|
||||
.unwrap(),
|
||||
(restarted_page, restarted_revision)
|
||||
(restarted_page, restarted_digest)
|
||||
);
|
||||
// Independent SQLite connections race a last-row capability update against exact lookup.
|
||||
// The page and complete revision must always describe the same snapshot, not two reads.
|
||||
// The page and complete collection digest must always describe the same snapshot, not two reads.
|
||||
let mut read_write_links = api
|
||||
.store
|
||||
.list_worker_workdir_links(TEST_WORKSPACE_ID, &fixture.worker)
|
||||
@@ -54710,8 +54762,8 @@ mod tests {
|
||||
&read_write_links,
|
||||
)
|
||||
.unwrap();
|
||||
let (_, read_write_revision) = restarted
|
||||
.list_worker_workdir_links_page_with_revision(
|
||||
let (_, read_write_digest) = restarted
|
||||
.list_worker_workdir_links_page_with_digest(
|
||||
TEST_WORKSPACE_ID,
|
||||
&fixture.worker,
|
||||
1,
|
||||
@@ -54719,7 +54771,7 @@ mod tests {
|
||||
Some(&replacement.connection_id),
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(read_write_revision, capability_changed.digest);
|
||||
assert_ne!(read_write_digest, capability_changed.digest);
|
||||
let reader = SqliteWorkspaceStore::open(&api.config.database_path).unwrap();
|
||||
let barrier = Arc::new(std::sync::Barrier::new(2));
|
||||
std::thread::scope(|scope| {
|
||||
@@ -54743,8 +54795,8 @@ mod tests {
|
||||
});
|
||||
barrier.wait();
|
||||
for _ in 0..100 {
|
||||
let (page, revision) = reader
|
||||
.list_worker_workdir_links_page_with_revision(
|
||||
let (page, digest) = reader
|
||||
.list_worker_workdir_links_page_with_digest(
|
||||
TEST_WORKSPACE_ID,
|
||||
worker,
|
||||
1,
|
||||
@@ -54753,13 +54805,13 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(page.len(), 1);
|
||||
if revision == read_write_revision {
|
||||
if digest == read_write_digest {
|
||||
assert_eq!(
|
||||
page[0].capabilities,
|
||||
workdir::WorkdirSessionCapabilities::READ_WRITE
|
||||
);
|
||||
} else {
|
||||
assert_eq!(revision, capability_changed.digest);
|
||||
assert_eq!(digest, capability_changed.digest);
|
||||
assert_eq!(
|
||||
page[0].capabilities,
|
||||
workdir::WorkdirSessionCapabilities::READ_ONLY
|
||||
@@ -57895,7 +57947,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_removal_config_and_revision_guards_preserve_active_trust() {
|
||||
async fn runtime_removal_config_and_binding_identity_guards_preserve_active_trust() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let api = test_api(root.path()).await;
|
||||
let original = register_test_runtime(&api, "guarded-runtime").await;
|
||||
@@ -61450,14 +61502,14 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3);
|
||||
.await;
|
||||
let committed_digest = committed["snapshot"]["digest"].as_str().unwrap();
|
||||
|
||||
let revision_snapshot = get_json(
|
||||
let config_snapshot = get_json(
|
||||
app.clone(),
|
||||
&format!("{source_tree_path}/history/{committed_digest}"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(revision_snapshot["digest"], committed_digest);
|
||||
assert_eq!(config_snapshot["digest"], committed_digest);
|
||||
assert_eq!(
|
||||
revision_snapshot["entries"]["notes/readme.txt"]["content"],
|
||||
config_snapshot["entries"]["notes/readme.txt"]["content"],
|
||||
"nested entry"
|
||||
);
|
||||
|
||||
@@ -61484,7 +61536,6 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3);
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/settings/profiles");
|
||||
let settings = get_json(app.clone(), &path).await;
|
||||
assert_eq!(settings["default_profile"], "builtin:companion");
|
||||
assert!(settings.get("config_revision").is_none());
|
||||
assert!(settings["tree_digest"].as_str().is_some());
|
||||
assert!(settings["projection_digest"].as_str().is_some());
|
||||
assert!(
|
||||
|
||||
@@ -282,7 +282,7 @@ mod result_budget_tests {
|
||||
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),"reason_code":"\\\"".repeat(128)},
|
||||
});
|
||||
assert!(
|
||||
serde_json::to_vec(&result).unwrap().len()
|
||||
|
||||
@@ -19,7 +19,7 @@ const EVIDENCE_SOURCE: &str = "immutable-approved-source";
|
||||
const EVIDENCE_TARGET: &str = "immutable-target-result";
|
||||
|
||||
// Deliberately freeze review dates: freshness must follow the exact item
|
||||
// revision and source/result snapshot, not a comparison of wall-clock dates.
|
||||
// content digest and source/result snapshot, not a comparison of wall-clock dates.
|
||||
fn evidence_time() -> chrono::DateTime<Utc> {
|
||||
"2000-01-01T00:00:00Z".parse().unwrap()
|
||||
}
|
||||
@@ -121,7 +121,7 @@ impl EvidenceApiFixture {
|
||||
}
|
||||
}
|
||||
|
||||
fn revision(&self) -> String {
|
||||
fn content_digest(&self) -> String {
|
||||
ticket_item_checker::content_digest(
|
||||
&self.backend.show(self.ticket_id.clone().into()).unwrap(),
|
||||
)
|
||||
@@ -150,7 +150,7 @@ impl EvidenceApiFixture {
|
||||
.request_review(RequestMergeRequestReview {
|
||||
merge_request_id: EVIDENCE_MR.into(),
|
||||
ticket_id: self.ticket_id.clone(),
|
||||
ticket_content_digest: self.revision(),
|
||||
ticket_content_digest: self.content_digest(),
|
||||
ticket_merge_request_subjects: self.subjects(),
|
||||
subject_ref: EVIDENCE_SOURCE.into(),
|
||||
child_session_id,
|
||||
@@ -194,7 +194,7 @@ impl EvidenceApiFixture {
|
||||
fn completion(&self, _approval: &ReviewEvent) -> ticket::TicketCompletion {
|
||||
ticket::TicketCompletion {
|
||||
operation_key: "evidence-ticket-completion".into(),
|
||||
expected_content_digest: self.revision(),
|
||||
expected_content_digest: self.content_digest(),
|
||||
expected_state: TicketWorkflowState::InProgress,
|
||||
reason: "Implementation judged complete independently of MR attestation".into(),
|
||||
references: Vec::new(),
|
||||
@@ -287,7 +287,7 @@ impl EvidenceApiFixture {
|
||||
self.ticket_id.clone().into(),
|
||||
TicketItemEdit {
|
||||
body: Some(MarkdownText::new(
|
||||
"Revised requirements require a fresh exact-revision approval.",
|
||||
"Revised requirements require a fresh exact-content approval.",
|
||||
)),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -301,12 +301,12 @@ async fn done_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_
|
||||
let fixture = EvidenceApiFixture::new().await;
|
||||
let approval = fixture.approve("integration");
|
||||
let merge = fixture.integrate(&approval);
|
||||
let revision = fixture.revision();
|
||||
let content_digest = fixture.content_digest();
|
||||
let completion = fixture
|
||||
.backend
|
||||
.complete(&fixture.ticket_id, fixture.completion(&approval))
|
||||
.unwrap();
|
||||
assert_eq!(ticket::ticket_content_digest(&completion), revision);
|
||||
assert_eq!(ticket::ticket_content_digest(&completion), content_digest);
|
||||
assert!(
|
||||
fixture
|
||||
.api
|
||||
@@ -326,7 +326,7 @@ async fn done_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_
|
||||
|
||||
let shown = fixture.show().await;
|
||||
assert_eq!(shown.state, "done");
|
||||
assert_eq!(shown.content_digest, revision);
|
||||
assert_eq!(shown.content_digest, content_digest);
|
||||
assert!(
|
||||
shown.evidence.complete_for_integration,
|
||||
"{:?}",
|
||||
@@ -376,13 +376,13 @@ async fn done_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn latest_postmerge_approval_requires_exact_revision_and_stored_source_result_snapshot() {
|
||||
async fn latest_postmerge_approval_requires_exact_content_and_stored_source_result_snapshot() {
|
||||
let fixture = EvidenceApiFixture::new().await;
|
||||
let integration = fixture.approve("integration");
|
||||
let merge = fixture.integrate(&integration);
|
||||
fixture.rescope();
|
||||
let revision = fixture.revision();
|
||||
assert_ne!(integration.ticket_content_digest, revision);
|
||||
let content_digest = fixture.content_digest();
|
||||
assert_ne!(integration.ticket_content_digest, content_digest);
|
||||
|
||||
let stale = fixture.show().await;
|
||||
assert!(stale.evidence.approved_current_subject); // integration proof survives
|
||||
@@ -405,9 +405,9 @@ async fn latest_postmerge_approval_requires_exact_revision_and_stored_source_res
|
||||
);
|
||||
assert_eq!(fixture.show().await.state, "inprogress");
|
||||
|
||||
let latest = fixture.approve("latest-revision");
|
||||
let latest = fixture.approve("latest-content");
|
||||
assert_eq!(latest.created_at, integration.created_at);
|
||||
assert_eq!(latest.ticket_content_digest, revision);
|
||||
assert_eq!(latest.ticket_content_digest, content_digest);
|
||||
assert_eq!(latest.ticket_merge_request_subjects, fixture.subjects());
|
||||
let fresh = fixture.show().await;
|
||||
assert!(
|
||||
@@ -420,7 +420,7 @@ async fn latest_postmerge_approval_requires_exact_revision_and_stored_source_res
|
||||
Some("Requirement approval integration")
|
||||
);
|
||||
fixture.assert_filters(true, false, false).await;
|
||||
// A current revision alone is insufficient: simulate a stored attestation
|
||||
// A current Ticket content digest alone is insufficient: simulate a stored attestation
|
||||
// against a different result set using typed serialized payloads in this
|
||||
// temporary fixture database, never a live Workspace database.
|
||||
let mut wrong_snapshot = latest.clone();
|
||||
@@ -490,7 +490,7 @@ async fn latest_postmerge_approval_requires_exact_revision_and_stored_source_res
|
||||
.backend
|
||||
.complete(&fixture.ticket_id, fixture.completion(&repaired))
|
||||
.unwrap();
|
||||
assert_eq!(ticket::ticket_content_digest(&completion), revision);
|
||||
assert_eq!(ticket::ticket_content_digest(&completion), content_digest);
|
||||
assert_eq!(fixture.show().await.state, "done");
|
||||
fixture.assert_filters(true, false, false).await;
|
||||
}
|
||||
@@ -649,7 +649,7 @@ async fn closing_ticket_with_open_unreviewed_mr_does_not_change_its_proof_or_sta
|
||||
&fixture.ticket_id,
|
||||
ticket::TicketStateUpdate {
|
||||
operation_key: "close-with-open-mr".into(),
|
||||
expected_content_digest: fixture.revision(),
|
||||
expected_content_digest: fixture.content_digest(),
|
||||
expected_state: TicketWorkflowState::InProgress,
|
||||
state: TicketWorkflowState::Closed,
|
||||
reason: "The request was withdrawn; no integration or approval is asserted".into(),
|
||||
|
||||
@@ -458,7 +458,7 @@ async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let admitted_revision = api.authority.ticket(&ticket_id).unwrap().content_digest;
|
||||
let admitted_content_digest = api.authority.ticket(&ticket_id).unwrap().content_digest;
|
||||
let request = server_api::SetTicketRoleAssignmentRequest {
|
||||
operation_id: "claim-runtime-reconciliation".into(),
|
||||
principal: server_api::TicketAssignmentPrincipal::Worker {
|
||||
@@ -515,7 +515,7 @@ async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits
|
||||
.get_ticket_claim_recovery(TEST_WORKSPACE_ID, &request.operation_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(recovery.content_digest, admitted_revision);
|
||||
assert_eq!(recovery.content_digest, admitted_content_digest);
|
||||
assert!(recovery.selected.is_empty());
|
||||
assert!(recovery.original_links.is_empty());
|
||||
assert!(recovery.effective_links.is_empty());
|
||||
@@ -572,7 +572,7 @@ async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits
|
||||
);
|
||||
assert_eq!(
|
||||
api.authority.ticket(&ticket_id).unwrap().content_digest,
|
||||
admitted_revision
|
||||
admitted_content_digest
|
||||
);
|
||||
assert_eq!(
|
||||
api.config_store
|
||||
|
||||
@@ -438,7 +438,7 @@ async fn value_profiles_save_project_and_runtime_consume_the_same_content() {
|
||||
"omission must remain omission in the Profile value"
|
||||
);
|
||||
let mut snapshot = serde_json::to_value(&resolved).unwrap();
|
||||
snapshot.as_object_mut().unwrap().remove("profile"); // revision-specific provenance only
|
||||
snapshot.as_object_mut().unwrap().remove("profile"); // snapshot-specific provenance only
|
||||
manifests.push(snapshot);
|
||||
if index == 0 {
|
||||
saved_manifest =
|
||||
@@ -785,7 +785,7 @@ async fn value_profiles_legacy_source_config_requires_explicit_lossless_migratio
|
||||
old_schema,
|
||||
)
|
||||
.unwrap();
|
||||
// Fixture simulates a previously persisted v1 schema/revision.
|
||||
// Fixture simulates a previously persisted v1 schema/config snapshot.
|
||||
let legacy = api
|
||||
.config_store
|
||||
.commit_evaluated_workspace_config(TEST_WORKSPACE_ID, &candidate)
|
||||
|
||||
@@ -434,7 +434,7 @@ impl WorkspaceWorker {
|
||||
})
|
||||
}
|
||||
|
||||
/// The caller owns the full cleanup-plan revision/digest check. The removal service
|
||||
/// The caller owns the full cleanup-plan digest check. The removal service
|
||||
/// rechecks live assignment/retention/use at its existing commit boundary.
|
||||
pub(super) async fn delete_from_plan(
|
||||
&self,
|
||||
|
||||
@@ -217,7 +217,7 @@ fn merged_skills(
|
||||
source_diagnostic = Some(SkillDiagnostic::error(
|
||||
"skill_source_mismatch",
|
||||
format!(
|
||||
"Skill `{name}` must be the imported value of `{canonical_path}` in the active config revision"
|
||||
"Skill `{name}` must be the imported value of `{canonical_path}` in the active config snapshot"
|
||||
),
|
||||
Some(format!("workspace:{name}")),
|
||||
));
|
||||
@@ -233,7 +233,7 @@ fn merged_skills(
|
||||
} else {
|
||||
source_diagnostic = Some(SkillDiagnostic::error(
|
||||
"missing_skill_source",
|
||||
format!("Skill `{name}` requires `{canonical_path}` in the active config revision"),
|
||||
format!("Skill `{name}` requires `{canonical_path}` in the active config snapshot"),
|
||||
Some(format!("workspace:{name}")),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore;
|
||||
use crate::{Error, Result};
|
||||
|
||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 88;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 89;
|
||||
const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
|
||||
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
|
||||
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||
@@ -276,6 +276,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: "Runtime binding identities and retention content contracts",
|
||||
apply: migrate_runtime_bindings_v87_to_v88,
|
||||
},
|
||||
Migration {
|
||||
version: 89,
|
||||
name: "descriptive legacy evidence columns",
|
||||
apply: migrate_evidence_column_names_v88_to_v89,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -956,7 +961,7 @@ pub struct WorkdirRegistryRecord {
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// Projection inputs captured together with the catalog revision in one SQLite snapshot.
|
||||
/// Projection inputs captured together with the catalog digest in one SQLite snapshot.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkdirCatalogEntry {
|
||||
pub record: WorkdirRegistryRecord,
|
||||
@@ -970,7 +975,9 @@ pub struct WorkdirCatalogEntry {
|
||||
pub struct WorkdirCatalogPage {
|
||||
pub entries: Vec<WorkdirCatalogEntry>,
|
||||
pub next_cursor: Option<String>,
|
||||
pub revision: String,
|
||||
/// SHA-256 of the full safe public catalog projection, independent of this page.
|
||||
/// The entries and digest are read in one SQLite transaction.
|
||||
pub catalog_digest: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -2126,7 +2133,7 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
||||
offset: u32,
|
||||
connection_id: Option<&str>,
|
||||
) -> Result<Vec<WorkerWorkdirLinkRecord>>;
|
||||
fn list_worker_workdir_links_page_with_revision(
|
||||
fn list_worker_workdir_links_page_with_digest(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
worker: &RuntimeWorkerRef,
|
||||
@@ -6760,7 +6767,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
.ok_or_else(|| Error::InvalidInput(format!("unknown Backend Job attempt `{attempt_id}`")))?;
|
||||
if attempt.attempt != job.current_attempt || attempt.input_digest != job.request.input_digest()? {
|
||||
return Err(Error::InvalidInput(
|
||||
"Backend Job attempt is stale for the current input revision".to_string(),
|
||||
"Backend Job attempt is stale for the current input digest".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(bound) = &attempt.worker {
|
||||
@@ -6875,7 +6882,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
|| attempt.attempt != job.current_attempt
|
||||
{
|
||||
return Err(Error::InvalidInput(
|
||||
"Backend Job result is stale for the current attempt or input revision".to_string(),
|
||||
"Backend Job result is stale for the current attempt or input digest".to_string(),
|
||||
));
|
||||
}
|
||||
if attempt.worker.as_ref() != Some(source_worker) {
|
||||
@@ -9369,7 +9376,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
Ok(WorkdirCatalogPage {
|
||||
entries,
|
||||
next_cursor,
|
||||
revision: hasher.finalize().iter().map(|byte| format!("{byte:02x}")).collect::<String>(),
|
||||
catalog_digest: hasher.finalize().iter().map(|byte| format!("{byte:02x}")).collect::<String>(),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -10421,7 +10428,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
offset: u32,
|
||||
connection_id: Option<&str>,
|
||||
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
|
||||
self.list_worker_workdir_links_page_with_revision(
|
||||
self.list_worker_workdir_links_page_with_digest(
|
||||
workspace_id,
|
||||
worker,
|
||||
limit,
|
||||
@@ -10431,7 +10438,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
.map(|(links, _)| links)
|
||||
}
|
||||
|
||||
fn list_worker_workdir_links_page_with_revision(
|
||||
fn list_worker_workdir_links_page_with_digest(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
worker: &RuntimeWorkerRef,
|
||||
@@ -12636,7 +12643,7 @@ struct FrozenWorkspaceRuntimeBinding {
|
||||
base_url: String,
|
||||
public_key: String,
|
||||
public_key_fingerprint: String,
|
||||
binding_revision: u64,
|
||||
legacy_counter: u64,
|
||||
state: WorkspaceRuntimeBindingState,
|
||||
authentication_mode: WorkspaceRuntimeAuthenticationMode,
|
||||
workspace_key_id: Option<String>,
|
||||
@@ -12666,7 +12673,7 @@ fn read_frozen_workspace_runtime_binding(
|
||||
base_url: row.get(3)?,
|
||||
public_key: row.get(4)?,
|
||||
public_key_fingerprint: row.get(5)?,
|
||||
binding_revision: row.get(6)?,
|
||||
legacy_counter: row.get(6)?,
|
||||
state,
|
||||
authentication_mode,
|
||||
workspace_key_id: row.get(9)?,
|
||||
@@ -12681,6 +12688,7 @@ include!("frozen_flow_content_migration.rs");
|
||||
include!("frozen_content_state_migration.rs");
|
||||
include!("frozen_repository_key_migration.rs");
|
||||
include!("frozen_runtime_binding_migration.rs");
|
||||
include!("legacy_evidence_column_migration.rs");
|
||||
|
||||
fn prepare_connection(conn: &Connection) -> Result<()> {
|
||||
prepare_connection_with_secret_source(
|
||||
@@ -12971,15 +12979,14 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> {
|
||||
));
|
||||
}
|
||||
}
|
||||
let revision_default = conn.query_row(
|
||||
let legacy_counter_default = conn.query_row(
|
||||
"SELECT dflt_value FROM pragma_table_info('workspace_runtime_bindings') WHERE name = 'binding_revision'",
|
||||
[],
|
||||
|row| row.get::<_, Option<String>>(0),
|
||||
)?;
|
||||
if revision_default.as_deref() != Some("1") {
|
||||
if legacy_counter_default.as_deref() != Some("1") {
|
||||
return Err(Error::Store(
|
||||
"workspace_runtime_bindings binding_revision default does not match schema-52"
|
||||
.to_string(),
|
||||
"legacy Runtime binding counter default must equal one for schema-52".to_string(),
|
||||
));
|
||||
}
|
||||
let sql = conn.query_row(
|
||||
@@ -13072,7 +13079,7 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> {
|
||||
base_url: row.get(3)?,
|
||||
public_key: row.get(4)?,
|
||||
public_key_fingerprint: row.get(5)?,
|
||||
binding_revision: row.get(6)?,
|
||||
legacy_counter: row.get(6)?,
|
||||
state: if row.get::<_, Option<String>>(9)?.is_some() {
|
||||
WorkspaceRuntimeBindingState::Revoked
|
||||
} else {
|
||||
@@ -14647,7 +14654,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn schema_v74_migrates_historical_worker_projection_rows_without_revision_state() {
|
||||
fn schema_v74_migrates_historical_worker_projection_rows_without_counter_state() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("workspace.db");
|
||||
drop(SqliteWorkspaceStore::open(&path).unwrap());
|
||||
@@ -16326,6 +16333,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
|
||||
include!("schema_equivalence_tests.rs");
|
||||
include!("migration_entrypoint_tests.rs");
|
||||
include!("legacy_evidence_column_tests.rs");
|
||||
|
||||
fn prepare_retained_schema(path: &Path, version: i64) {
|
||||
prepare_schema_v52(path);
|
||||
@@ -16638,6 +16646,10 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
version: 88,
|
||||
name: "Runtime binding identities and retention content contracts".to_string()
|
||||
},
|
||||
WorkspaceSchemaMigrationStep {
|
||||
version: 89,
|
||||
name: "descriptive legacy evidence columns".to_string()
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
@@ -16736,6 +16748,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
(86, "content-fenced config, Memory settings and Jobs".to_string()),
|
||||
(87, "Repository SSH key identities and encrypted operation objects".to_string()),
|
||||
(88, "Runtime binding identities and retention content contracts".to_string()),
|
||||
(89, "descriptive legacy evidence columns".to_string()),
|
||||
]
|
||||
);
|
||||
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||
@@ -16954,14 +16967,14 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
workspace_id, job_id, purpose, input_revision, input_ref, request_json,
|
||||
intent_fingerprint, state, current_attempt, created_at, updated_at
|
||||
) VALUES (
|
||||
'workspace-a', 'job-a', 'check', 'revision-a', 'test://job-a', '{}',
|
||||
'workspace-a', 'job-a', 'check', 'legacy-input-token', 'test://job-a', '{}',
|
||||
'sha256:test', 'pending', 1, '1', '1'
|
||||
);
|
||||
INSERT INTO backend_job_attempts (
|
||||
workspace_id, job_id, attempt_id, attempt, input_revision, state,
|
||||
deadline_at, created_at, updated_at
|
||||
) VALUES (
|
||||
'workspace-a', 'job-a', 'job-a:attempt:1', 1, 'revision-a', 'reserved',
|
||||
'workspace-a', 'job-a', 'job-a:attempt:1', 1, 'legacy-input-token', 'reserved',
|
||||
'2', '1', '1'
|
||||
);
|
||||
INSERT INTO backend_job_deliveries (
|
||||
@@ -17659,7 +17672,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_runtime_verification_is_revision_bound_and_restart_safe() {
|
||||
fn workspace_runtime_verification_is_key_bound_and_restart_safe() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("server.db");
|
||||
let store = SqliteWorkspaceStore::open(&path).unwrap();
|
||||
@@ -18063,7 +18076,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_binding_key_mutations_are_revisioned_idempotent_and_audited() {
|
||||
fn runtime_binding_key_mutations_are_identity_guarded_idempotent_and_audited() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
@@ -18444,7 +18457,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_display_name_update_is_revision_guarded_and_preserves_identity() {
|
||||
async fn workspace_display_name_update_is_timestamp_guarded_and_preserves_identity() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let database_path = dir.path().join("server.db");
|
||||
let store = SqliteWorkspaceStore::open(&database_path).unwrap();
|
||||
@@ -23188,7 +23201,7 @@ INSERT INTO worker_registry (
|
||||
let mut refresh = resource_job_request(
|
||||
&format!("surface-{surface_state}"),
|
||||
&subject,
|
||||
"surface-revision",
|
||||
"surface-generation",
|
||||
);
|
||||
refresh
|
||||
.grants
|
||||
@@ -23435,7 +23448,7 @@ INSERT INTO worker_registry (
|
||||
let request = BackendJobRequest {
|
||||
job_id: "check:T-1:r1".to_string(),
|
||||
purpose: "ticket_item_check".to_string(),
|
||||
input_ref: "ticket://T-1/revisions/1".to_string(),
|
||||
input_ref: "ticket://T-1/contents/content-1".to_string(),
|
||||
input: serde_json::json!({"title": "check"}),
|
||||
instruction: "Check the immutable input.".to_string(),
|
||||
profile: "builtin:backend-job".to_string(),
|
||||
|
||||
@@ -99,8 +99,8 @@ pub fn request_fingerprint_for_replay(
|
||||
return Ok(current);
|
||||
}
|
||||
if let Some(legacy) = persisted.strip_prefix("workdir-create-v86:") {
|
||||
if let Some((revision, digest)) = legacy.split_once(':') {
|
||||
if let Ok(revision) = revision.parse::<u64>() {
|
||||
if let Some((legacy_counter, digest)) = legacy.split_once(':') {
|
||||
if let Ok(legacy_counter) = legacy_counter.parse::<u64>() {
|
||||
let mut hasher = request_hasher(
|
||||
repository_id,
|
||||
selector,
|
||||
@@ -108,7 +108,7 @@ pub fn request_fingerprint_for_replay(
|
||||
display_name,
|
||||
repository_source_fingerprint,
|
||||
);
|
||||
hasher.update(revision.to_be_bytes());
|
||||
hasher.update(legacy_counter.to_be_bytes());
|
||||
if encode_request_digest(hasher) == digest {
|
||||
return Ok(persisted.to_string());
|
||||
}
|
||||
|
||||
@@ -357,7 +357,8 @@ mod frozen_material_v1 {
|
||||
version: u32,
|
||||
workspace_id: String,
|
||||
key_id: String,
|
||||
revision: u64,
|
||||
#[serde(rename = "revision")]
|
||||
legacy_counter: u64,
|
||||
private_key: String,
|
||||
}
|
||||
|
||||
@@ -369,7 +370,7 @@ mod frozen_material_v1 {
|
||||
|
||||
pub(super) fn decode(bytes: &[u8]) -> Option<WorkspaceSigningPrivateMaterial> {
|
||||
let old: Material = serde_json::from_slice(bytes).ok()?;
|
||||
if old.version != 1 || old.revision != 1 {
|
||||
if old.version != 1 || old.legacy_counter != 1 {
|
||||
return None;
|
||||
}
|
||||
Some(WorkspaceSigningPrivateMaterial {
|
||||
@@ -415,8 +416,8 @@ mod frozen_material_v1 {
|
||||
let material = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap();
|
||||
let mut old = serde_json::to_value(material).unwrap();
|
||||
old["version"] = 1.into();
|
||||
for revision in [0, 2, u64::MAX] {
|
||||
old["revision"] = revision.into();
|
||||
for legacy_counter in [0, 2, u64::MAX] {
|
||||
old["revision"] = legacy_counter.into();
|
||||
assert!(decode_private_material(&serde_json::to_vec(&old).unwrap()).is_err());
|
||||
}
|
||||
old["revision"] = 1.into();
|
||||
|
||||
Reference in New Issue
Block a user