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
+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();