fix: complete state-contract naming cleanup

This commit is contained in:
2026-10-10 17:48:59 +09:00
parent 3263d50125
commit 6a12b6cc34
109 changed files with 3696 additions and 576 deletions
+1 -1
View File
@@ -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,
}
+4 -4
View File
@@ -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
View File
@@ -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()
);
+4 -2
View File
@@ -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,
+5 -4
View File
@@ -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
View File
@@ -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!(
+49 -16
View File
@@ -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\""
+7 -5
View File
@@ -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,
+6 -2
View File
@@ -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(&current).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);
}
+11 -10
View File
@@ -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
],
+8
View File
@@ -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())),
+4
View File
@@ -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 {
+3 -5
View File
@@ -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"],
+58 -1
View File
@@ -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()
+18 -17
View File
@@ -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());
+60 -10
View File
@@ -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);
}
-1
View File
@@ -806,7 +806,6 @@ mod tests {
"MergeRequestReview",
"MergeRequestReadinessCheck",
"MergeRequestComplete",
"MergeRequestAddRevision",
] {
assert!(
!combined.contains(stale_operation),
+10 -3
View File
@@ -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());
}
+1 -2
View File
@@ -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);
}
+3
View File
@@ -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() {
+150 -10
View File
@@ -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, &registration, &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, &registration, &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(&registration).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, &registration, &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, &registration, &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, &registration, &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
+4 -1
View File
@@ -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!(
+38 -33
View File
@@ -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
);
+8 -8
View File
@@ -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);
}
+1 -1
View File
@@ -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};
+2 -2
View File
@@ -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",
+114 -63
View File
@@ -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(&current_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,
+2 -2
View File
@@ -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}")),
));
}
+37 -24
View File
@@ -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();