feat: replace revision counters with domain state contracts

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

Preserve historical replay and secret material through frozen migrations; preflight uncertain removals before cutover, resume provisioning receipts, validate encrypted migration candidates, and align fresh/upgraded schemas. Include reviewed regression fixes, generated artifacts, and validation evidence.
This commit is contained in:
2026-10-10 15:55:20 +09:00
parent 72ce817de4
commit c78f7f7d80
358 changed files with 25965 additions and 12069 deletions
+11 -28
View File
@@ -310,21 +310,12 @@ impl SqliteWorkspaceAuthority {
created_at: event.created_at.clone(),
})
.collect::<Vec<_>>();
let revision = format!(
"{}:{}",
record.updated_at,
all_events
.last()
.map(|event| event.event_id.as_str())
.unwrap_or("none")
);
Ok(ObjectiveDetail {
resource_key: self
.resource_key(WorkspaceResourceKind::Objective, &record.objective_id)?,
id: record.objective_id,
title: record.title,
state: record.state,
revision,
created_at: Some(record.created_at),
updated_at: Some(record.updated_at),
linked_tickets,
@@ -878,16 +869,9 @@ impl SqliteWorkspaceAuthority {
})
.collect::<Result<Vec<_>>>()?;
let merge_request = (merge_requests.len() == 1).then(|| merge_requests[0].clone());
let item_revision = ticket
.events
.iter()
.rev()
.find(|event| matches!(event.kind.as_str(), "create" | "item_edit"))
.and_then(|event| event.attributes.get("event_id").cloned())
.or_else(|| ticket.meta.updated_at.clone())
.unwrap_or_else(|| format!("{}:0", ticket.meta.id));
let content_digest = ticket::ticket_content_digest(&ticket);
let requirement_approved =
ticket_requirement_approved(&requests, &merge_requests, &item_revision);
ticket_requirement_approved(&requests, &merge_requests, &content_digest);
let evidence = ticket_evidence_summary(
&write_repository_keys,
&merge_requests,
@@ -934,7 +918,7 @@ impl SqliteWorkspaceAuthority {
priority: ticket.meta.priority,
created_at: ticket.meta.created_at,
updated_at: ticket.meta.updated_at,
item_revision,
content_digest,
queued_by: ticket.meta.queued_by,
queued_at: ticket.meta.queued_at,
targets,
@@ -1846,7 +1830,7 @@ fn ticket_write_target_repository_keys(targets: &[ticket::TicketTarget]) -> Vec<
fn ticket_requirement_approved(
requests: &[MergeRequest],
summaries: &[TicketMergeRequestSummary],
item_revision: &str,
content_digest: &str,
) -> bool {
let subjects = summaries
.iter()
@@ -1860,7 +1844,7 @@ fn ticket_requirement_approved(
})
.collect::<Option<Vec<_>>>();
subjects.is_some_and(|subjects| {
merge_request::requirement_approval(requests, item_revision, &subjects, None).is_ok()
merge_request::requirement_approval(requests, content_digest, &subjects, None).is_ok()
})
}
@@ -2333,7 +2317,7 @@ fn ticket_query_item(
priority: summary.priority,
created_at: detail.created_at.clone(),
updated_at: summary.updated_at,
item_revision: detail.item_revision.clone(),
content_digest: detail.content_digest.clone(),
workspace_action_priority: summary.workspace_action_priority,
matched_fields,
snippet: snippet.map(|value| truncate_body(&value, 512).0),
@@ -2794,7 +2778,7 @@ mod tests {
event_id: "request-1".to_string(),
sequence: 1,
subject_ref: "commit-1".to_string(),
ticket_item_revision: "revision-1".to_string(),
ticket_content_digest: "revision-1".to_string(),
ticket_merge_request_subjects: vec![merge_request::MergeRequestReviewSubject {
merge_request_id: "mr-1".to_string(),
subject_ref: "commit-1".to_string(),
@@ -2808,7 +2792,7 @@ mod tests {
sequence: 2,
request_event_id: "request-1".to_string(),
subject_ref: "commit-1".to_string(),
ticket_item_revision: "revision-1".to_string(),
ticket_content_digest: "revision-1".to_string(),
ticket_merge_request_subjects: vec![merge_request::MergeRequestReviewSubject {
merge_request_id: "mr-1".to_string(),
subject_ref: "commit-1".to_string(),
@@ -3079,14 +3063,14 @@ mod tests {
MergeRequestThreadEvent::ReviewRequested(review) => {
review.event_id = format!("request-{sequence}");
review.sequence = sequence;
review.ticket_item_revision = revision.into();
review.ticket_content_digest = revision.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_item_revision = revision.into();
review.ticket_content_digest = revision.into();
review.ticket_merge_request_subjects = subjects.clone();
}
_ => unreachable!(),
@@ -3521,7 +3505,7 @@ VALUES ('workspace-test', 'ticket', 4);
assert!(ticket.body.contains("Ticket body"));
assert!(ticket.body_truncated);
assert!(!ticket.body.contains("Deep Ticket marker"));
assert!(!ticket.item_revision.is_empty());
assert!(!ticket.content_digest.is_empty());
assert_eq!(ticket.linked_objectives[0].id, "00000000001J3");
assert_eq!(ticket.event_page.returned, ticket.events.len());
let ticket_query = authority
@@ -3704,7 +3688,6 @@ VALUES ('workspace-test', 'ticket', 4);
let objective = authority.objective("00000000001J3").unwrap();
assert!(objective.body.contains("Objective body"));
assert!(!objective.revision.is_empty());
assert_eq!(objective.linked_ticket_summaries[0].id, "00000000001J2");
assert_eq!(objective.linked_ticket_summaries[0].state, "ready");
let linked_ticket_key = objective.linked_ticket_summaries[0].resource_key.clone();
+17 -16
View File
@@ -261,8 +261,6 @@ pub struct BackendJobRequest {
pub job_id: String,
/// Stable machine-readable use-site name, for example `ticket_item_check`.
pub purpose: String,
/// Revision fenced by the structured result.
pub input_revision: String,
/// Durable domain reference used to reload or explain the input.
pub input_ref: String,
/// Bounded immutable input snapshot. This must not contain credentials.
@@ -295,7 +293,6 @@ impl BackendJobRequest {
Self {
job_id: request.job_id,
purpose: request.purpose,
input_revision: request.input_revision,
input_ref: request.input_ref,
input: request.input,
instruction: request.instruction,
@@ -347,7 +344,6 @@ impl BackendJobRequest {
job::JobRequest {
job_id: self.job_id.clone(),
purpose: self.purpose.clone(),
input_revision: self.input_revision.clone(),
input_ref: self.input_ref.clone(),
input: self.input.clone(),
instruction: self.instruction.clone(),
@@ -371,6 +367,10 @@ impl BackendJobRequest {
.or_else(|| self.serialization_key.clone())
}
pub fn input_digest(&self) -> Result<String> {
self.to_job_request().input_digest().map_err(backend_error)
}
pub fn fingerprint(&self) -> Result<String> {
self.validate()?;
job::fingerprint(self).map_err(backend_error)
@@ -436,7 +436,7 @@ pub struct BackendJobAttemptRecord {
pub job_id: String,
pub attempt_id: String,
pub attempt: u8,
pub input_revision: String,
pub input_digest: String,
pub state: BackendJobAttemptState,
pub worker: Option<RuntimeWorkerRef>,
pub runtime_run_id: Option<String>,
@@ -492,7 +492,7 @@ impl BackendJobResultAcceptance {
fn job_outcome(job: &BackendJobRecord, attempt: &BackendJobAttemptRecord) -> job::JobOutcome {
job::JobOutcome {
job_id: job.request.job_id.clone(),
input_revision: job.request.input_revision.clone(),
input_digest: attempt.input_digest.clone(),
attempt_id: attempt.attempt_id.clone(),
attempt: attempt.attempt,
state: job.state.into(),
@@ -566,7 +566,6 @@ mod tests {
BackendJobRequest {
job_id: "ticket-check:T-1:r7".to_string(),
purpose: "ticket_item_check".to_string(),
input_revision: "7".to_string(),
input_ref: "ticket://T-1/revisions/7".to_string(),
input: serde_json::json!({"title": "Check me"}),
instruction: "Check this immutable Ticket snapshot.".to_string(),
@@ -595,7 +594,7 @@ mod tests {
"job_id": "ticket-check:T-1:r7",
"attempt_id": "ticket-check:T-1:r7:attempt:2",
"attempt": 2,
"input_revision": "7",
"input_digest": "7",
"state": "reserved",
"worker": {"runtime_id": "runtime-a", "worker_id": "worker-a"},
"runtime_run_id": "run-a",
@@ -687,7 +686,7 @@ mod tests {
reservation.outcome(),
job::JobOutcome {
job_id: reservation.job.request.job_id.clone(),
input_revision: "7".into(),
input_digest: "7".into(),
attempt_id: "ticket-check:T-1:r7:attempt:2".into(),
attempt: 2,
state: job::JobState::Pending,
@@ -767,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_revision":"7","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: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}}"#
);
assert_eq!(
request.fingerprint().unwrap(),
"sha256:920187fa9efa111a97c4d6869d134943e45b52edb2861cad19a0a173233c8f60"
"sha256:250b626572e954d2aa2228364274c946d9f6b176eb0f0a205157e55031581147"
);
let neutral = request.to_job_request();
neutral.validate().unwrap();
@@ -785,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_revision: 7\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: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."
);
}
@@ -794,13 +793,13 @@ mod tests {
let submission = BackendJobResultSubmission {
job_id: "job-1".into(),
attempt_id: "job-1:attempt:1".into(),
input_revision: "7".into(),
input_digest: "7".into(),
result: serde_json::json!({"ok": true}),
};
let encoded = serde_json::to_string(&submission).unwrap();
assert_eq!(
encoded,
r#"{"job_id":"job-1","attempt_id":"job-1:attempt:1","input_revision":"7","result":{"ok":true}}"#
r#"{"job_id":"job-1","attempt_id":"job-1:attempt:1","input_digest":"7","result":{"ok":true}}"#
);
let neutral: job::JobResultSubmission = serde_json::from_str(&encoded).unwrap();
assert_eq!(neutral, submission);
@@ -832,12 +831,14 @@ mod tests {
}
#[test]
fn fingerprint_covers_revision_profile_limits_and_notification_provenance() {
fn fingerprint_covers_input_profile_limits_and_notification_provenance() {
let request = request();
let first = request.fingerprint().unwrap();
assert_eq!(first, request.fingerprint().unwrap());
for mutate in [
|request: &mut BackendJobRequest| request.input_revision = "8".to_string(),
|request: &mut BackendJobRequest| {
request.input = serde_json::json!({"title":"changed"})
},
|request: &mut BackendJobRequest| request.purpose = "alternate_check".to_string(),
|request: &mut BackendJobRequest| request.limits.max_attempts = 3,
|request: &mut BackendJobRequest| request.notification_target = None,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,241 @@
// Frozen schema-85 cutover. Legacy ordinal/wire names belong only here.
fn migrate_content_state_v85_to_v86(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 85 {
return Err(Error::Store(
"content state migration requires schema 85".into(),
));
}
if !conn.is_autocommit() {
return Err(Error::Store(
"content state migration requires an autocommit connection".into(),
));
}
let foreign_keys: bool = conn.query_row("PRAGMA foreign_keys", [], |r| r.get(0))?;
let legacy_alter: bool = conn.query_row("PRAGMA legacy_alter_table", [], |r| r.get(0))?;
// Replacing a parent table must not cascade-delete its referencing rows or
// rewrite their FK targets. Restore both connection settings on every exit.
conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;")?;
let result = (|| -> Result<()> {
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Immediate)?;
if table_exists(&tx, "workspace_config_tree_revisions")? {
tx.execute_batch(
r#"
CREATE TABLE workspace_config_tree_history (
workspace_id TEXT NOT NULL,
content_digest TEXT NOT NULL,
toolchain_fingerprint TEXT NOT NULL,
projection_digest TEXT NOT NULL,
manifest_json TEXT NOT NULL,
created_at TEXT NOT NULL,
schema_bundle_json TEXT NOT NULL DEFAULT '{"contributions":[],"source":"{}","fingerprint":""}',
PRIMARY KEY (workspace_id, content_digest, toolchain_fingerprint, projection_digest),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
"#,
)?;
let history = {
let mut stmt = tx.prepare("SELECT workspace_id, tree_digest, toolchain_fingerprint, projection_digest, manifest_json, created_at, schema_bundle_json FROM workspace_config_tree_revisions ORDER BY revision")?;
stmt.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
r.get::<_, String>(3)?,
r.get::<_, String>(4)?,
r.get::<_, String>(5)?,
r.get::<_, String>(6)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?
};
for (workspace, digest, toolchain, projection, manifest, created, schema) in history {
let entries: std::collections::BTreeMap<
config_source::VirtualPath,
config_source::ConfigEntry,
> = serde_json::from_str(&manifest)
.map_err(|e| Error::Store(format!("invalid legacy config manifest: {e}")))?;
if entries.iter().any(|(path, entry)| path != &entry.path) {
return Err(Error::Store("legacy config manifest path mismatch".into()));
}
let snapshot =
config_source::ConfigTreeSnapshot::from_entries(entries.into_values())
.map_err(|e| Error::Store(format!("invalid legacy config content: {e}")))?;
if snapshot.digest != digest {
return Err(Error::Store("legacy config content digest mismatch".into()));
}
tx.execute("INSERT OR IGNORE INTO workspace_config_tree_history(workspace_id,content_digest,toolchain_fingerprint,projection_digest,manifest_json,created_at,schema_bundle_json) VALUES(?1,?2,?3,?4,?5,?6,?7)",
params![workspace,digest,toolchain,projection,manifest,created,schema])?;
}
tx.execute_batch(
"DROP TABLE workspace_config_tree_revisions;
ALTER TABLE workspace_config_trees DROP COLUMN revision;
ALTER TABLE workspace_config_trees RENAME COLUMN tree_digest TO content_digest;",
)?;
}
let missing_current: i64 = tx.query_row("SELECT COUNT(*) FROM workspace_config_trees t WHERE NOT EXISTS (
SELECT 1 FROM workspace_config_tree_history h WHERE h.workspace_id=t.workspace_id AND h.content_digest=t.content_digest)", [], |r| r.get(0))?;
if missing_current != 0 {
return Err(Error::Store(
"config cutover found missing current content".into(),
));
}
if column_exists(&tx, "workspace_memory_settings", "settings_revision")? {
tx.execute_batch(
"ALTER TABLE workspace_memory_settings DROP COLUMN settings_revision",
)?;
}
if column_exists(
&tx,
"worker_create_reservations",
"memory_settings_revision",
)? {
remove_legacy_reservation_memory_revision(&tx)?;
}
// Older retained chains rebuilt this table without the lookup index.
// Converge on the canonical schema for both legacy creation paths.
tx.execute_batch("CREATE INDEX IF NOT EXISTS worker_create_reservations_worker ON worker_create_reservations(workspace_id, worker_id);")?;
if column_exists(&tx, "backend_jobs", "input_revision")? {
let jobs = {
let mut stmt =
tx.prepare("SELECT workspace_id,job_id,request_json FROM backend_jobs")?;
stmt.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?
};
tx.execute_batch(
"ALTER TABLE backend_jobs RENAME COLUMN input_revision TO input_digest;
ALTER TABLE backend_job_attempts RENAME COLUMN input_revision TO input_digest;",
)?;
for (workspace, id, encoded) in jobs {
let mut value: serde_json::Value = serde_json::from_str(&encoded)
.map_err(|e| Error::Store(format!("invalid legacy Job intent: {e}")))?;
value
.as_object_mut()
.ok_or_else(|| Error::Store("legacy Job intent is not an object".into()))?
.remove("input_revision");
let request: BackendJobRequest = serde_json::from_value(value)
.map_err(|e| Error::Store(format!("invalid legacy Job intent: {e}")))?;
let digest = request.input_digest()?;
let encoded =
serde_json::to_string(&request).map_err(|e| Error::Store(e.to_string()))?;
// A Reserved-only ledger proves no dispatch was admitted. Fail it
// definitively and record vacuous cleanup so its resource is released.
let reserved_only: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM backend_job_attempts WHERE workspace_id=?1 AND job_id=?2)
AND NOT EXISTS(SELECT 1 FROM backend_job_attempts WHERE workspace_id=?1 AND job_id=?2
AND (state != 'reserved' OR runtime_id IS NOT NULL OR worker_cleanup_state IS NOT NULL))",
params![workspace, id], |r| r.get(0),
)?;
// Outstanding old capabilities must not gain authority under a new input fence.
// Preserve completed results and cleanup receipts; uncertain execution is not retryable.
tx.execute("UPDATE backend_jobs SET input_digest=?3,request_json=?4,intent_fingerprint=?5,
state=CASE WHEN state='pending' THEN ?6 ELSE state END,
failure_category=CASE WHEN state='pending' THEN 'content_contract_cutover' ELSE failure_category END,
completed_at=CASE WHEN state='pending' THEN COALESCE(completed_at,updated_at) ELSE completed_at END
WHERE workspace_id=?1 AND job_id=?2", params![workspace,id,digest,encoded,request.fingerprint()?, if reserved_only { "failed" } else { "unknown" }])?;
tx.execute("UPDATE backend_job_attempts SET input_digest=?3,
state=CASE WHEN state='reserved' AND ?4 THEN 'failed' WHEN state IN ('reserved','dispatching','dispatched') THEN 'unknown' ELSE state END,
failure_category=CASE WHEN state IN ('reserved','dispatching','dispatched') THEN 'content_contract_cutover' ELSE failure_category END,
worker_cleanup_state=CASE WHEN state='reserved' AND ?4 THEN 'completed' WHEN runtime_id IS NOT NULL AND state IN ('dispatching','dispatched') AND worker_cleanup_state IS NULL THEN 'pending' ELSE worker_cleanup_state END,
completed_at=CASE WHEN state IN ('reserved','dispatching','dispatched') THEN COALESCE(completed_at,updated_at) ELSE completed_at END
WHERE workspace_id=?1 AND job_id=?2", params![workspace,id,digest,reserved_only])?;
tx.execute("UPDATE backend_job_deliveries SET state='unknown', failure_category='content_contract_cutover'
WHERE workspace_id=?1 AND job_id=?2 AND state IN ('pending','sending')", params![workspace,id])?;
}
}
// A legacy pending manual claim cannot be admitted against a newly inferred digest.
tx.execute("UPDATE ticket_assignment_operations SET claim_state='failed',failure_reason='content_contract_cutover'
WHERE claim_state='pending' AND binding_recovery_json IS NOT NULL AND assignment_id IS NULL", [])?;
let broken: i64 =
tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| {
r.get(0)
})?;
if broken != 0 {
return Err(Error::Store(
"content state migration foreign-key verification failed".into(),
));
}
tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES(86,'content-fenced config, Memory settings and Jobs')", [])?;
tx.commit()?;
Ok(())
})();
conn.pragma_update(None, "legacy_alter_table", legacy_alter)?;
conn.pragma_update(None, "foreign_keys", foreign_keys)?;
result
}
fn remove_legacy_reservation_memory_revision(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.
const LEGACY_CHECK: &str = ",\n CHECK (\n (request_fingerprint IS NULL AND memory_settings_revision IS NULL AND memory_language IS NULL)\n OR\n (request_fingerprint IS NOT NULL AND memory_settings_revision IS NOT NULL AND memory_settings_revision > 0 AND memory_language IS NOT NULL AND length(trim(memory_language)) > 0)\n )";
let ddl: String = conn.query_row(
"SELECT sql FROM sqlite_schema WHERE type='table' AND name='worker_create_reservations'",
[],
|r| r.get(0),
)?;
if !ddl.contains(LEGACY_CHECK) {
conn.execute_batch(
"ALTER TABLE worker_create_reservations DROP COLUMN memory_settings_revision",
)?;
return Ok(());
}
let ddl = ddl.replacen(LEGACY_CHECK, "", 1);
let definition = ddl
.find('(')
.ok_or_else(|| Error::Store("invalid legacy reservation DDL".into()))?;
// Retain the actual table definition, not a baseline copy: historical
// column order, additional constraints and outbound FKs must survive.
conn.execute_batch(&format!(
"CREATE TABLE worker_create_reservations_v86 {}",
&ddl[definition..]
))?;
conn.execute_batch(
"ALTER TABLE worker_create_reservations_v86 DROP COLUMN memory_settings_revision",
)?;
let columns = {
let mut stmt = conn.prepare(
"SELECT name FROM pragma_table_info('worker_create_reservations_v86') ORDER BY cid",
)?;
stmt.query_map([], |r| r.get::<_, String>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?
};
let columns = columns
.into_iter()
.map(|name| format!("\"{}\"", name.replace('"', "\"\"")))
.collect::<Vec<_>>()
.join(",");
let indexes = {
let mut stmt = conn.prepare("SELECT sql FROM sqlite_schema WHERE type='index' AND tbl_name='worker_create_reservations' AND sql IS NOT NULL")?;
stmt.query_map([], |r| r.get::<_, String>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?
};
// Cross-table triggers can also reference this table during replacement.
let triggers = {
let mut stmt = conn.prepare("SELECT name,sql FROM sqlite_schema WHERE type='trigger'")?;
stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?
.collect::<rusqlite::Result<Vec<_>>>()?
};
for (name, _) in &triggers {
conn.execute_batch(&format!("DROP TRIGGER \"{}\"", name.replace('"', "\"\"")))?;
}
// No state, fingerprint, language, lease, timestamp or uncertain admission
// is inferred from the removed ordinal. Copy every remaining column as-is.
conn.execute_batch(&format!(
"INSERT INTO worker_create_reservations_v86 ({columns}) SELECT {columns} FROM worker_create_reservations;
DROP TABLE worker_create_reservations;
ALTER TABLE worker_create_reservations_v86 RENAME TO worker_create_reservations;"
))?;
for sql in indexes {
conn.execute_batch(&sql)?;
}
for (_, sql) in triggers {
conn.execute_batch(&sql)?;
}
Ok(())
}
@@ -0,0 +1,633 @@
use super::*;
fn legacy_content_database(path: &Path) -> (Connection, String) {
let conn = Connection::open(path).unwrap();
conn.execute_batch(r#"
PRAGMA foreign_keys=ON;
CREATE TABLE __yoi_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL);
INSERT INTO __yoi_schema_migrations VALUES(85,'workspace schema baseline');
CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);
INSERT INTO workspaces VALUES('space');
CREATE TABLE workspace_config_trees(workspace_id TEXT PRIMARY KEY,revision INTEGER,tree_digest TEXT);
CREATE TABLE workspace_config_tree_revisions(workspace_id TEXT,revision INTEGER,tree_digest TEXT,
toolchain_fingerprint TEXT,projection_digest TEXT,manifest_json TEXT,created_at TEXT,schema_bundle_json TEXT,
PRIMARY KEY(workspace_id,revision));
CREATE TABLE workspace_memory_settings(workspace_id TEXT PRIMARY KEY,settings_revision INTEGER,
language TEXT,created_at TEXT,updated_at TEXT);
INSERT INTO workspace_memory_settings VALUES('space',9,'Japanese','created','updated');
CREATE TABLE ticket_assignment_operations(claim_state TEXT,failure_reason TEXT,binding_recovery_json TEXT,assignment_id TEXT);
INSERT INTO ticket_assignment_operations VALUES('pending',NULL,'legacy recovery',NULL);
"#).unwrap();
// Reproduce the real upgrade path: v53's table CHECK survives v73's
// singleton ALTERs, unlike the simplified fresh-v85 baseline definition.
let legacy = include_str!("frozen_legacy_migrations.rs");
let start = legacy
.find("CREATE TABLE worker_create_reservations_v53 (")
.unwrap();
let end = start
+ legacy[start..]
.find("INSERT INTO worker_create_reservations_v53 (")
.unwrap();
conn.execute_batch(&legacy[start..end].replace(
"worker_create_reservations_v53",
"worker_create_reservations",
))
.unwrap();
conn.execute_batch("ALTER TABLE worker_create_reservations ADD COLUMN singleton_key TEXT;
ALTER TABLE worker_create_reservations ADD COLUMN singleton_generation INTEGER
CHECK (singleton_generation IS NULL OR singleton_generation > 0);
INSERT INTO worker_create_reservations(workspace_id,allocation_key,worker_id,runtime_id,create_fingerprint,state,
request_fingerprint,memory_settings_revision,memory_language,created_at,updated_at)
VALUES('space','create','worker','runtime','legacy-create','reserved','legacy-request',8,'English','created','updated');").unwrap();
let frozen = include_str!("frozen_schema_v85.sql");
let start = frozen
.find("CREATE TRIGGER worker_create_reservation_singleton_lease_insert")
.unwrap();
let end = frozen
.find("CREATE TABLE worker_singleton_owners (")
.unwrap();
conn.execute_batch(&frozen[start..end]).unwrap();
// The frozen Job DDL protects the persisted checks and public replay/resource contract.
let jobs = frozen.find("CREATE TABLE backend_jobs (").unwrap();
let end = frozen
.find("CREATE TABLE worker_registry_observations (")
.unwrap();
conn.execute_batch(&frozen[jobs..end]).unwrap();
let snapshot =
config_source::ConfigTreeSnapshot::from_entries([config_source::ConfigEntry::new(
config_source::VirtualPath::parse("main.dcdl").unwrap(),
config_source::ConfigContentType::Decodal,
"{} as WorkspaceConfigSchema",
)
.unwrap()])
.unwrap();
let manifest = serde_json::to_string(&snapshot.entries).unwrap();
conn.execute(
"INSERT INTO workspace_config_trees VALUES('space',3,?1)",
[&snapshot.digest],
)
.unwrap();
for (ordinal, created) in [(1, "first"), (3, "third")] {
conn.execute("INSERT INTO workspace_config_tree_revisions VALUES('space',?1,?2,'toolchain','projection',?3,?4,'{}')",
params![ordinal,snapshot.digest,manifest,created]).unwrap();
}
let request = BackendJobRequest {
job_id: "legacy-job".into(),
purpose: "migration_test".into(),
input_ref: "test://input".into(),
input: serde_json::json!({"content":"immutable"}),
instruction: "Inspect immutable input.".into(),
profile: "builtin:backend-job".into(),
grants: Default::default(),
serialization_key: Some("migration-resource".into()),
source_worker: None,
notification_target: None,
limits: Default::default(),
};
let mut value = serde_json::to_value(&request).unwrap();
value["input_revision"] = "old-number".into();
conn.execute("INSERT INTO backend_jobs(workspace_id,job_id,purpose,input_ref,resource_key,request_json,input_revision,intent_fingerprint,state,current_attempt,created_at,updated_at)
VALUES('space','legacy-job','migration_test','test://input',?2,?1,'old-number','old-fingerprint','pending',1,'2026-08-13T00:00:00Z','2026-08-13T00:00:00Z')",
params![serde_json::to_string(&value).unwrap(),request.resource_key()]).unwrap();
conn.execute_batch("INSERT INTO backend_job_attempts(workspace_id,job_id,attempt_id,attempt,input_revision,state,runtime_id,worker_id,deadline_at,created_at,updated_at)
VALUES('space','legacy-job','legacy-job:attempt:1',1,'old-number','dispatched','runtime','worker','2026-08-14T00:00:00Z','2026-08-13T00:00:00Z','2026-08-13T00:00:00Z');
INSERT INTO backend_job_deliveries(workspace_id,delivery_id,job_id,attempt_id,target_runtime_id,target_worker_id,state,created_at,updated_at)
VALUES('space','delivery','legacy-job','legacy-job:attempt:1','runtime','target','pending','created','updated');") .unwrap();
(conn, snapshot.digest)
}
#[test]
fn legacy_reservation_check_cutover_preserves_rows_constraints_indexes_triggers_and_foreign_keys() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("content.db");
let (conn, _) = legacy_content_database(&path);
let error = conn
.execute_batch(
"ALTER TABLE worker_create_reservations DROP COLUMN memory_settings_revision",
)
.unwrap_err();
assert!(
error.to_string().contains("memory_settings_revision"),
"{error}"
);
conn.execute_batch(r#"
CREATE TABLE reservation_owners(owner_id TEXT PRIMARY KEY);
INSERT INTO reservation_owners VALUES('owner');
ALTER TABLE worker_create_reservations ADD COLUMN extension_owner TEXT
REFERENCES reservation_owners(owner_id) ON DELETE RESTRICT;
UPDATE worker_create_reservations SET extension_owner='owner';
INSERT INTO worker_create_reservations(workspace_id,allocation_key,worker_id,runtime_id,create_fingerprint,state,
request_fingerprint,memory_settings_revision,memory_language,created_at,updated_at,singleton_key,singleton_generation)
VALUES ('space','uncertain','uncertain-worker','runtime','old-uncertain','reserved',NULL,NULL,NULL,'old','old',NULL,NULL),
('space','created','created-worker','runtime','old-created','created','request',2,'Japanese','old','new','singleton',3),
('space','removed','removed-worker','runtime','old-removed','removed','request',7,'French','old','new',NULL,NULL);
CREATE INDEX reservation_pending_idx ON worker_create_reservations(workspace_id,updated_at) WHERE state='reserved';
CREATE TABLE reservation_receipts(workspace_id TEXT,allocation_key TEXT,
FOREIGN KEY(workspace_id,allocation_key) REFERENCES worker_create_reservations(workspace_id,allocation_key) ON DELETE CASCADE);
INSERT INTO reservation_receipts VALUES('space','create');
CREATE TABLE reservation_events(allocation_key TEXT,state TEXT);
CREATE TRIGGER reservation_state_audit AFTER UPDATE OF state ON worker_create_reservations
BEGIN INSERT INTO reservation_events VALUES(NEW.allocation_key,NEW.state); END;
CREATE TRIGGER reservation_receipt_touch AFTER INSERT ON reservation_receipts
BEGIN UPDATE worker_create_reservations SET updated_at=updated_at
WHERE workspace_id=NEW.workspace_id AND allocation_key=NEW.allocation_key; END;
"#).unwrap();
let schema_objects = |conn: &Connection| -> Vec<(String, String)> {
conn.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL AND
(tbl_name='worker_create_reservations' AND type IN ('index','trigger') OR name IN ('reservation_receipts','reservation_receipt_touch')) ORDER BY name")
.unwrap().query_map([], |r| Ok((r.get(0)?,r.get(1)?))).unwrap()
.collect::<rusqlite::Result<_>>().unwrap()
};
let original_objects = schema_objects(&conn);
let columns: Vec<String> = conn.prepare("SELECT name FROM pragma_table_info('worker_create_reservations') WHERE name != 'memory_settings_revision' ORDER BY cid")
.unwrap().query_map([], |r| r.get(0)).unwrap().collect::<rusqlite::Result<_>>().unwrap();
let select = format!(
"SELECT {} FROM worker_create_reservations ORDER BY allocation_key",
columns.join(",")
);
let rows = |conn: &Connection| -> Vec<Vec<rusqlite::types::Value>> {
conn.prepare(&select)
.unwrap()
.query_map([], |r| {
(0..columns.len())
.map(|i| r.get(i))
.collect::<rusqlite::Result<Vec<_>>>()
})
.unwrap()
.collect::<rusqlite::Result<_>>()
.unwrap()
};
let original_rows = rows(&conn);
let foreign_keys = |conn: &Connection| -> Vec<(String, String, String, String)> {
conn.prepare("SELECT \"table\",\"from\",\"to\",on_delete FROM pragma_foreign_key_list('worker_create_reservations') ORDER BY id,seq")
.unwrap().query_map([], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?))).unwrap()
.collect::<rusqlite::Result<_>>().unwrap()
};
let original_foreign_keys = foreign_keys(&conn);
migrate_content_state_v85_to_v86(&conn).unwrap();
assert!(
conn.pragma_query_value(None, "foreign_keys", |r| r.get::<_, bool>(0))
.unwrap()
);
assert!(
!conn
.pragma_query_value(None, "legacy_alter_table", |r| r.get::<_, bool>(0))
.unwrap()
);
drop(conn);
let conn = Connection::open(path).unwrap();
conn.execute_batch("PRAGMA foreign_keys=ON").unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 86);
assert!(
!column_exists(
&conn,
"worker_create_reservations",
"memory_settings_revision"
)
.unwrap()
);
assert_eq!(rows(&conn), original_rows);
let mut expected_objects = original_objects;
expected_objects.push((
"worker_create_reservations_worker".into(),
"CREATE INDEX worker_create_reservations_worker ON worker_create_reservations(workspace_id, worker_id)".into(),
));
expected_objects.sort();
assert_eq!(schema_objects(&conn), expected_objects);
assert_eq!(foreign_keys(&conn), original_foreign_keys);
assert_eq!(
conn.query_row("SELECT COUNT(*) FROM reservation_receipts", [], |r| r
.get::<_, i64>(0))
.unwrap(),
1
);
assert_eq!(
conn.query_row("SELECT COUNT(*) FROM reservation_events", [], |r| r
.get::<_, i64>(0))
.unwrap(),
0
);
assert_eq!(
conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| r
.get::<_, i64>(
0
))
.unwrap(),
0
);
for sql in [
"UPDATE worker_create_reservations SET singleton_generation=NULL WHERE allocation_key='created'",
"UPDATE worker_create_reservations SET singleton_generation=0 WHERE allocation_key='created'",
"UPDATE worker_create_reservations SET state='pending' WHERE allocation_key='create'",
"UPDATE worker_create_reservations SET worker_id='worker' WHERE allocation_key='uncertain'",
"UPDATE worker_create_reservations SET extension_owner='missing' WHERE allocation_key='create'",
"UPDATE worker_create_reservations SET workspace_id='missing' WHERE allocation_key='create'",
] {
assert!(conn.execute(sql, []).is_err(), "{sql}");
}
conn.execute(
"UPDATE worker_create_reservations SET state='removed' WHERE allocation_key='create'",
[],
)
.unwrap();
let audit: (String, String) = conn
.query_row(
"SELECT allocation_key,state FROM reservation_events",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(audit, ("create".into(), "removed".into()));
}
#[test]
fn fresh_v85_reservations_cut_over_without_legacy_check_and_restore_connection_settings() {
for (foreign_keys, legacy_alter) in [(false, false), (false, true), (true, false), (true, true)]
{
let dir = tempfile::tempdir().unwrap();
let (conn, _) = legacy_content_database(&dir.path().join("content.db"));
conn.execute_batch("DROP TABLE worker_create_reservations")
.unwrap();
let frozen = include_str!("frozen_schema_v85.sql");
let start = frozen
.find("CREATE TABLE worker_create_reservations (")
.unwrap();
let end = frozen
.find("CREATE TABLE worker_singleton_owners (")
.unwrap();
conn.execute_batch(&frozen[start..end]).unwrap();
conn.execute_batch("INSERT INTO worker_create_reservations(workspace_id,allocation_key,worker_id,runtime_id,
create_fingerprint,state,created_at,updated_at,memory_settings_revision,memory_language)
VALUES('space','fresh','worker','runtime','fingerprint','reserved','created','updated',9,'Japanese')").unwrap();
conn.pragma_update(None, "foreign_keys", foreign_keys)
.unwrap();
conn.pragma_update(None, "legacy_alter_table", legacy_alter)
.unwrap();
migrate_content_state_v85_to_v86(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 86);
assert!(
!column_exists(
&conn,
"worker_create_reservations",
"memory_settings_revision"
)
.unwrap()
);
let saved: (String, String) = conn
.query_row(
"SELECT state,memory_language FROM worker_create_reservations",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(saved, ("reserved".into(), "Japanese".into()));
assert_eq!(
conn.pragma_query_value(None, "foreign_keys", |r| r.get::<_, bool>(0))
.unwrap(),
foreign_keys
);
assert_eq!(
conn.pragma_query_value(None, "legacy_alter_table", |r| r.get::<_, bool>(0))
.unwrap(),
legacy_alter
);
}
}
#[test]
fn content_cutover_preserves_content_and_languages_but_fences_old_job_capabilities() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("content.db");
let (conn, digest) = legacy_content_database(&path);
migrate_content_state_v85_to_v86(&conn).unwrap();
drop(conn);
let conn = Connection::open(path).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 86);
assert!(!table_exists(&conn, "workspace_config_tree_revisions").unwrap());
let history: (i64, String, String) = conn
.query_row(
"SELECT COUNT(*),content_digest,created_at FROM workspace_config_tree_history",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
assert_eq!(history, (1, digest, "first".into()));
assert!(!column_exists(&conn, "workspace_memory_settings", "settings_revision").unwrap());
assert!(
!column_exists(
&conn,
"worker_create_reservations",
"memory_settings_revision"
)
.unwrap()
);
let language: String = conn
.query_row("SELECT language FROM workspace_memory_settings", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(language, "Japanese");
let captured: String = conn
.query_row(
"SELECT memory_language FROM worker_create_reservations",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(captured, "English");
let (encoded, state): (String, String) = conn
.query_row("SELECT request_json,state FROM backend_jobs", [], |r| {
Ok((r.get(0)?, r.get(1)?))
})
.unwrap();
let request: BackendJobRequest = serde_json::from_str(&encoded).unwrap();
assert_eq!(state, "unknown");
let (input_digest, state, cleanup): (String, String, String) = conn
.query_row(
"SELECT input_digest,state,worker_cleanup_state FROM backend_job_attempts",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
assert_eq!(input_digest, request.input_digest().unwrap());
assert_eq!(state, "unknown");
assert_eq!(cleanup, "pending");
let delivery: String = conn
.query_row("SELECT state FROM backend_job_deliveries", [], |r| r.get(0))
.unwrap();
assert_eq!(delivery, "unknown");
let claim: String = conn
.query_row(
"SELECT claim_state FROM ticket_assignment_operations",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(claim, "failed");
}
#[test]
fn content_cutover_keeps_each_evaluation_provenance_and_deduplicates_only_identical_evaluations() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("content.db");
let (conn, _) = legacy_content_database(&path);
for (revision, toolchain, projection, schema) in [
(4, "new-toolchain", "projection", "schema-2"),
(5, "toolchain", "new-projection", "schema-1"),
] {
conn.execute(
"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],
)
.unwrap();
}
let expected: Vec<(String,String,String,String,String,String)> = conn.prepare(
"SELECT tree_digest,toolchain_fingerprint,projection_digest,manifest_json,created_at,schema_bundle_json
FROM workspace_config_tree_revisions WHERE revision != 3 ORDER BY toolchain_fingerprint,projection_digest"
).unwrap().query_map([], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?)))
.unwrap().collect::<rusqlite::Result<_>>().unwrap();
migrate_content_state_v85_to_v86(&conn).unwrap();
drop(conn);
let conn = Connection::open(path).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 86);
let saved: Vec<(String,String,String,String,String,String)> = conn.prepare(
"SELECT content_digest,toolchain_fingerprint,projection_digest,manifest_json,created_at,schema_bundle_json
FROM workspace_config_tree_history ORDER BY toolchain_fingerprint,projection_digest"
).unwrap().query_map([], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?)))
.unwrap().collect::<rusqlite::Result<_>>().unwrap();
assert_eq!(saved, expected);
let default: String = conn.query_row(
"SELECT dflt_value FROM pragma_table_info('workspace_config_tree_history') WHERE name='schema_bundle_json'",
[], |r| r.get(0),
).unwrap();
assert_eq!(
default,
"'{\"contributions\":[],\"source\":\"{}\",\"fingerprint\":\"\"}'"
);
}
#[test]
fn content_cutover_reserved_only_releases_resource_but_uncertain_dispatch_keeps_it() {
for (state, expected_job, expected_attempt, expected_cleanup) in [
(
"reserved",
BackendJobState::Failed,
BackendJobAttemptState::Failed,
Some(BackendJobWorkerCleanupState::Completed),
),
(
"dispatching",
BackendJobState::Unknown,
BackendJobAttemptState::Unknown,
Some(BackendJobWorkerCleanupState::Pending),
),
(
"dispatched",
BackendJobState::Unknown,
BackendJobAttemptState::Unknown,
Some(BackendJobWorkerCleanupState::Pending),
),
] {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("content.db");
let (conn, _) = legacy_content_database(&path);
conn.execute(
"UPDATE backend_job_attempts SET state=?1,
runtime_id=CASE WHEN ?1='reserved' THEN NULL ELSE runtime_id END,
worker_id=CASE WHEN ?1='reserved' THEN NULL ELSE worker_id END",
[state],
)
.unwrap();
migrate_content_state_v85_to_v86(&conn).unwrap();
drop(conn);
let store = SqliteWorkspaceStore {
conn: Arc::new(Mutex::new(Connection::open(path).unwrap())),
};
let job = store
.get_backend_job("space", "legacy-job")
.unwrap()
.unwrap();
assert_eq!(job.state, expected_job, "{state}");
let replay = store
.reserve_backend_job("space", &job.request, "2026-08-15T00:00:00Z")
.unwrap();
assert!(replay.replayed);
assert_eq!(replay.attempt.state, expected_attempt);
assert_eq!(replay.attempt.worker_cleanup_state, expected_cleanup);
let mut next = job.request.clone();
next.job_id = "next-job".into();
let next = store
.reserve_backend_job("space", &next, "2026-08-15T00:00:00Z")
.unwrap();
assert_eq!(next.resource_reused, state != "reserved");
assert_eq!(
next.job.request.job_id,
if state == "reserved" {
"next-job"
} else {
"legacy-job"
}
);
}
}
#[test]
fn content_cutover_completed_result_cleanup_and_replay_remain_unchanged() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("content.db");
let (conn, _) = legacy_content_database(&path);
let result = serde_json::json!({"finding":"preserved"});
let (digest, encoded) = job::result_digest(&result, 4096).unwrap();
conn.execute("UPDATE backend_jobs SET state='completed',result_json=?1,result_digest=?2,completed_at='finished'",params![encoded,digest]).unwrap();
conn.execute("UPDATE backend_job_attempts SET state='completed',result_json=?1,result_digest=?2,
worker_cleanup_state='completed',worker_cleanup_completed_at='cleaned',completed_at='finished'",params![encoded,digest]).unwrap();
conn.execute(
"UPDATE backend_job_deliveries SET state='completed',delivered_at='delivered'",
[],
)
.unwrap();
migrate_content_state_v85_to_v86(&conn).unwrap();
drop(conn);
let store = SqliteWorkspaceStore {
conn: Arc::new(Mutex::new(Connection::open(path).unwrap())),
};
let job = store
.get_backend_job("space", "legacy-job")
.unwrap()
.unwrap();
let replay = store
.reserve_backend_job("space", &job.request, "2026-08-15T00:00:00Z")
.unwrap();
assert!(replay.replayed);
assert_eq!(replay.job.state, BackendJobState::Completed);
assert_eq!(replay.job.result, Some(result.clone()));
assert_eq!(replay.job.result_digest, Some(digest.clone()));
assert_eq!(replay.job.completed_at.as_deref(), Some("finished"));
assert_eq!(replay.attempt.state, BackendJobAttemptState::Completed);
assert_eq!(replay.attempt.result, Some(result));
assert_eq!(replay.attempt.result_digest, Some(digest));
assert_eq!(
replay.attempt.worker_cleanup_state,
Some(BackendJobWorkerCleanupState::Completed)
);
assert_eq!(
replay.attempt.worker_cleanup_completed_at.as_deref(),
Some("cleaned")
);
let delivery: (String, String) = store
.with_conn(|conn| {
conn.query_row(
"SELECT state,delivered_at FROM backend_job_deliveries",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.map_err(Error::from)
})
.unwrap();
assert_eq!(delivery, ("completed".into(), "delivered".into()));
}
#[test]
fn late_job_failure_rolls_back_config_memory_jobs_and_migration_version() {
let dir = tempfile::tempdir().unwrap();
let (conn, _) = legacy_content_database(&dir.path().join("content.db"));
let original_reservation_schema: Vec<(String,String)> = conn.prepare(
"SELECT name,sql FROM sqlite_schema WHERE tbl_name='worker_create_reservations' AND sql IS NOT NULL ORDER BY name"
).unwrap().query_map([],|r| Ok((r.get(0)?,r.get(1)?))).unwrap().collect::<rusqlite::Result<_>>().unwrap();
conn.execute_batch(
"CREATE TRIGGER fail_delivery_update BEFORE UPDATE ON backend_job_deliveries
BEGIN SELECT RAISE(ABORT,'late cutover failure'); END;",
)
.unwrap();
let original: (String, String) = conn
.query_row(
"SELECT request_json,intent_fingerprint FROM backend_jobs",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
let error = migrate_content_state_v85_to_v86(&conn).unwrap_err();
assert!(
error.to_string().contains("late cutover failure"),
"{error}"
);
let restored_reservation_schema: Vec<(String,String)> = conn.prepare(
"SELECT name,sql FROM sqlite_schema WHERE tbl_name='worker_create_reservations' AND sql IS NOT NULL ORDER BY name"
).unwrap().query_map([],|r| Ok((r.get(0)?,r.get(1)?))).unwrap().collect::<rusqlite::Result<_>>().unwrap();
assert_eq!(restored_reservation_schema, original_reservation_schema);
assert!(!table_exists(&conn, "worker_create_reservations_v86").unwrap());
assert!(
conn.pragma_query_value(None, "foreign_keys", |r| r.get::<_, bool>(0))
.unwrap()
);
assert!(
!conn
.pragma_query_value(None, "legacy_alter_table", |r| r.get::<_, bool>(0))
.unwrap()
);
assert_eq!(current_schema_version(&conn).unwrap(), 85);
assert!(table_exists(&conn, "workspace_config_tree_revisions").unwrap());
assert!(!table_exists(&conn, "workspace_config_tree_history").unwrap());
assert!(column_exists(&conn, "workspace_config_trees", "revision").unwrap());
assert!(column_exists(&conn, "workspace_memory_settings", "settings_revision").unwrap());
assert!(
column_exists(
&conn,
"worker_create_reservations",
"memory_settings_revision"
)
.unwrap()
);
assert!(column_exists(&conn, "backend_jobs", "input_revision").unwrap());
assert!(column_exists(&conn, "backend_job_attempts", "input_revision").unwrap());
let saved: (String, String, String) = conn
.query_row(
"SELECT request_json,intent_fingerprint,state FROM backend_jobs",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
assert_eq!(saved, (original.0, original.1, "pending".into()));
let attempt: (String, Option<String>) = conn
.query_row(
"SELECT state,worker_cleanup_state FROM backend_job_attempts",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(attempt, ("dispatched".into(), None));
let delivery: String = conn
.query_row("SELECT state FROM backend_job_deliveries", [], |r| r.get(0))
.unwrap();
assert_eq!(delivery, "pending");
let claim: String = conn
.query_row(
"SELECT claim_state FROM ticket_assignment_operations",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(claim, "pending");
}
#[test]
fn corrupt_config_history_rolls_back_entire_content_cutover() {
let dir = tempfile::tempdir().unwrap();
let (conn, _) = legacy_content_database(&dir.path().join("content.db"));
conn.execute(
"UPDATE workspace_config_tree_revisions SET tree_digest='corrupt'",
[],
)
.unwrap();
assert!(migrate_content_state_v85_to_v86(&conn).is_err());
assert_eq!(current_schema_version(&conn).unwrap(), 85);
assert!(table_exists(&conn, "workspace_config_tree_revisions").unwrap());
assert!(!table_exists(&conn, "workspace_config_tree_history").unwrap());
assert!(column_exists(&conn, "backend_jobs", "input_revision").unwrap());
let state: String = conn
.query_row("SELECT state FROM backend_jobs", [], |r| r.get(0))
.unwrap();
assert_eq!(state, "pending");
}
@@ -0,0 +1,98 @@
// Frozen schema-84 conversion. Legacy ordinal names are confined to this migration.
fn migrate_flow_content_v84_to_v85(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 84 {
return Err(Error::Store(
"Flow content migration requires schema 84".into(),
));
}
let tx = conn.unchecked_transaction()?;
if table_exists(&tx, "flow_source_revisions")? {
// Validate every saved row before deduplicating or changing the schema.
// Do not recompile: compiler changes must not rewrite historical meaning.
{
let mut statement = tx.prepare(
"SELECT workspace_id, flow_id, revision, content, content_digest, definition_json
FROM flow_source_revisions",
)?;
let mut rows = statement.query([])?;
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 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 source_digest = format!(
"sha256:{}",
Sha256::digest(content.as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
);
if source_digest != stored_digest {
return Err(Error::Store(format!(
"Flow content migration found source digest mismatch for {identity}"
)));
}
let definition: CompiledFlowDefinition =
serde_json::from_str(&definition_json).map_err(|error| {
Error::Store(format!(
"Flow content migration found invalid stored definition for {identity}: {error}"
))
})?;
if definition.content_digest != stored_digest {
return Err(Error::Store(format!(
"Flow content migration found definition digest mismatch for {identity}"
)));
}
}
}
let conflicts: i64 = tx.query_row(
"SELECT COUNT(*) FROM (SELECT 1 FROM flow_source_revisions GROUP BY workspace_id,flow_id,content_digest
HAVING COUNT(DISTINCT content)>1 OR COUNT(DISTINCT definition_json)>1)", [], |row| row.get(0))?;
if conflicts != 0 {
return Err(Error::Store(
"Flow content migration found inconsistent content identity".into(),
));
}
tx.execute_batch(
r#"
CREATE TABLE flow_source_contents (
workspace_id TEXT NOT NULL,
flow_id TEXT NOT NULL,
content TEXT NOT NULL,
content_digest TEXT NOT NULL,
definition_json TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, flow_id, content_digest),
FOREIGN KEY (workspace_id, flow_id)
REFERENCES flow_sources(workspace_id, flow_id) ON DELETE CASCADE
);
INSERT INTO flow_source_contents
(workspace_id, flow_id, content, content_digest, definition_json, created_at)
SELECT workspace_id, flow_id, content, content_digest, definition_json, MIN(created_at)
FROM flow_source_revisions
GROUP BY workspace_id, flow_id, content_digest;
DROP TABLE flow_source_revisions;
ALTER TABLE flow_sources DROP COLUMN revision;
"#,
)?;
}
let missing: i64 = tx.query_row(
"SELECT COUNT(*) FROM flow_sources s WHERE NOT EXISTS (
SELECT 1 FROM flow_source_contents c WHERE c.workspace_id=s.workspace_id
AND c.flow_id=s.flow_id AND c.content_digest=s.content_digest
AND c.content=s.content)",
[],
|row| row.get(0),
)?;
if missing != 0 {
return Err(Error::Store(
"Flow content migration found missing current content".into(),
));
}
tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES(85,'content-addressed Flow sources')", [])?;
tx.commit()?;
Ok(())
}
@@ -0,0 +1,231 @@
use super::*;
fn legacy_flow_source(instructions: &str) -> String {
format!(
r#"{{
schema_version = 1;
name = "flow";
initial = "work";
states = {{
work = {{
instructions = "{instructions}";
transitions = {{ done = {{ target = "done"; condition = "Finished."; }}; }};
}};
done = {{ instructions = ""; terminal = true; }};
}};
}}"#
)
}
fn legacy_flow_definition(content: &str) -> CompiledFlowDefinition {
let mut definition = compile_flow_source(content).unwrap();
// Model a saved definition from a previous compiler. Migration must preserve
// its runtime-supplied meaning, not replace it with today's compiler output.
for state in definition.states.values_mut() {
for transition in &mut state.transitions {
if transition.synthetic {
transition.condition = "Frozen compiler cancellation condition.".into();
}
}
}
definition
}
// Frozen schema-84 fixture: old ordinals are migration input only.
fn legacy_flow_database(path: &Path) -> Connection {
let conn = Connection::open(path).unwrap();
conn.execute_batch(
r#"
PRAGMA foreign_keys=ON;
CREATE TABLE __yoi_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL);
INSERT INTO __yoi_schema_migrations VALUES(84,'workspace schema baseline');
CREATE TABLE flow_sources(workspace_id TEXT,flow_id TEXT,content TEXT,content_digest TEXT,
revision INTEGER,PRIMARY KEY(workspace_id,flow_id));
CREATE TABLE flow_source_revisions(workspace_id TEXT,flow_id TEXT,revision INTEGER,
content TEXT,content_digest TEXT,definition_json TEXT,created_at TEXT,
PRIMARY KEY(workspace_id,flow_id,revision));
"#,
)
.unwrap();
let alpha = legacy_flow_source("alpha");
let beta = legacy_flow_source("beta");
let alpha_definition = legacy_flow_definition(&alpha);
let beta_definition = legacy_flow_definition(&beta);
conn.execute(
"INSERT INTO flow_sources VALUES('space','flow',?1,?2,3)",
params![alpha, alpha_definition.content_digest],
)
.unwrap();
for (revision, content, definition, created_at) in [
(1, &alpha, &alpha_definition, "first"),
(2, &beta, &beta_definition, "second"),
(3, &alpha, &alpha_definition, "third"),
] {
conn.execute(
"INSERT INTO flow_source_revisions VALUES('space','flow',?1,?2,?3,?4,?5)",
params![
revision,
content,
definition.content_digest,
serde_json::to_string_pretty(definition).unwrap(),
created_at
],
)
.unwrap();
}
conn
}
#[test]
fn flow_content_migration_preserves_current_and_historical_content_after_reopen() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("flows.db");
let conn = legacy_flow_database(&path);
let expected: Vec<(String, String, String, String)> = conn
.prepare("SELECT content_digest,content,definition_json,created_at FROM flow_source_revisions WHERE revision IN (1,2) ORDER BY content_digest")
.unwrap()
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))
.unwrap()
.collect::<rusqlite::Result<_>>()
.unwrap();
for (_, content, definition_json, _) in &expected {
let saved: CompiledFlowDefinition = serde_json::from_str(definition_json).unwrap();
assert_ne!(saved, compile_flow_source(content).unwrap());
}
migrate_flow_content_v84_to_v85(&conn).unwrap();
drop(conn);
let conn = Connection::open(path).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 85);
let rows: Vec<(String, String, String, String)> = conn
.prepare("SELECT content_digest,content,definition_json,created_at FROM flow_source_contents ORDER BY content_digest")
.unwrap()
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))
.unwrap()
.collect::<rusqlite::Result<_>>()
.unwrap();
assert_eq!(rows, expected);
let current: String = conn
.query_row("SELECT c.content FROM flow_sources s JOIN flow_source_contents c USING(workspace_id,flow_id,content_digest)", [], |r| r.get(0))
.unwrap();
assert_eq!(current, legacy_flow_source("alpha"));
assert!(!table_exists(&conn, "flow_source_revisions").unwrap());
let failures: i64 = conn
.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(failures, 0);
}
// Snapshot all fixture data and schema so rejection proves durable rollback,
// including the corrupted input (which migration must not silently repair).
fn legacy_flow_snapshot(conn: &Connection) -> Vec<Vec<Vec<rusqlite::types::Value>>> {
[
"SELECT name,sql FROM sqlite_schema ORDER BY name",
"SELECT * FROM __yoi_schema_migrations ORDER BY version",
"SELECT * FROM flow_sources ORDER BY workspace_id,flow_id",
"SELECT * FROM flow_source_revisions ORDER BY workspace_id,flow_id,revision",
]
.into_iter()
.map(|sql| {
let mut statement = conn.prepare(sql).unwrap();
let count = statement.column_count();
statement
.query_map([], |row| (0..count).map(|column| row.get(column)).collect())
.unwrap()
.collect::<rusqlite::Result<_>>()
.unwrap()
})
.collect()
}
fn assert_flow_migration_rolls_back(conn: Connection, path: &Path, expected_error: &str) {
let before = legacy_flow_snapshot(&conn);
let error = migrate_flow_content_v84_to_v85(&conn).unwrap_err();
assert!(error.to_string().contains(expected_error), "{error}");
assert_eq!(legacy_flow_snapshot(&conn), before);
drop(conn);
let reopened = Connection::open(path).unwrap();
assert_eq!(current_schema_version(&reopened).unwrap(), 84);
assert!(table_exists(&reopened, "flow_source_revisions").unwrap());
assert!(!table_exists(&reopened, "flow_source_contents").unwrap());
assert_eq!(legacy_flow_snapshot(&reopened), before);
}
#[test]
fn flow_content_migration_missing_current_content_rolls_back_data_and_marker() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("flows.db");
let conn = legacy_flow_database(&path);
conn.execute(
"DELETE FROM flow_source_revisions WHERE revision IN (1,3)",
[],
)
.unwrap();
assert_flow_migration_rolls_back(conn, &path, "missing current content");
}
#[test]
fn flow_content_migration_single_historical_wrong_source_digest_rolls_back_data_and_marker() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("flows.db");
let conn = legacy_flow_database(&path);
let mut definition = legacy_flow_definition(&legacy_flow_source("beta"));
let wrong_digest = format!("sha256:{}", "0".repeat(64));
definition.content_digest = wrong_digest.clone();
conn.execute(
"UPDATE flow_source_revisions SET content_digest=?1,definition_json=?2 WHERE revision=2",
params![wrong_digest, serde_json::to_string(&definition).unwrap()],
)
.unwrap();
assert_flow_migration_rolls_back(conn, &path, "source digest mismatch");
}
#[test]
fn flow_content_migration_invalid_historical_definition_json_rolls_back_data_and_marker() {
for invalid_definition in ["{", "{}"] {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("flows.db");
let conn = legacy_flow_database(&path);
conn.execute(
"UPDATE flow_source_revisions SET definition_json=?1 WHERE revision=2",
[invalid_definition],
)
.unwrap();
assert_flow_migration_rolls_back(conn, &path, "invalid stored definition");
}
}
#[test]
fn flow_content_migration_historical_definition_digest_mismatch_rolls_back_data_and_marker() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("flows.db");
let conn = legacy_flow_database(&path);
let mut definition = legacy_flow_definition(&legacy_flow_source("beta"));
definition.content_digest = format!("sha256:{}", "0".repeat(64));
conn.execute(
"UPDATE flow_source_revisions SET definition_json=?1 WHERE revision=2",
[serde_json::to_string(&definition).unwrap()],
)
.unwrap();
assert_flow_migration_rolls_back(conn, &path, "definition digest mismatch");
}
#[test]
fn flow_content_migration_inconsistent_saved_definitions_rolls_back_without_data_loss() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("flows.db");
let conn = legacy_flow_database(&path);
let mut definition = legacy_flow_definition(&legacy_flow_source("alpha"));
definition
.states
.get_mut(&flow::StateId::new("work").unwrap())
.unwrap()
.instructions = "Different saved meaning.".into();
conn.execute(
"UPDATE flow_source_revisions SET definition_json=?1 WHERE revision=3",
[serde_json::to_string_pretty(&definition).unwrap()],
)
.unwrap();
assert_flow_migration_rolls_back(conn, &path, "inconsistent content identity");
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,400 @@
// Frozen schema-86 SSH cutover. No ordinal is used as a new authority token.
fn migrate_repository_keys_v86_to_v87(conn: &Connection) -> Result<()> {
migrate_repository_keys_v86_to_v87_with_secret_source(
conn,
conn.path().filter(|path| !path.is_empty()).map(Path::new),
)
}
// Validation may run against an in-memory backup. Its secret authority remains
// the original database's master key, not the candidate connection's location.
fn migrate_repository_keys_v86_to_v87_with_secret_source(
conn: &Connection,
secret_source: Option<&Path>,
) -> Result<()> {
if current_schema_version(conn)? != 86 {
return Err(Error::Store(
"Repository key migration requires schema 86".into(),
));
}
if !conn.is_autocommit() {
return Err(Error::Store(
"Repository key migration requires an autocommit connection".into(),
));
}
let foreign_keys: bool = conn.query_row("PRAGMA foreign_keys", [], |r| r.get(0))?;
let legacy_alter: bool = conn.query_row("PRAGMA legacy_alter_table", [], |r| r.get(0))?;
conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;")?;
let result = (|| {
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Immediate)?;
// Each stored key must resolve to one actual mutation receipt. Ambiguous or
// missing evidence is not repaired by manufacturing an ID from its ordinal.
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));",
)?;
for (table, kind, id, fingerprint) in [
(
"repository_ssh_credential_revisions",
"credential",
"credential_id",
"public_key_fingerprint",
),
(
"repository_ssh_host_trust_revisions",
"host_trust",
"host_trust_id",
"fingerprint",
),
] {
let rows = {
let mut statement = tx.prepare(&format!(
"SELECT workspace_id,{id},revision,{fingerprint},created_at FROM {table}"
))?;
statement
.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, i64>(2)?,
r.get::<_, String>(3)?,
r.get::<_, String>(4)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?
};
for (workspace, resource, revision, 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| {
r.get::<_, String>(0)
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
if matches.len() != 1 || matches[0].is_empty() {
return Err(Error::Store("Repository key cutover requires one unambiguous mutation receipt per stored key".into()));
}
tx.execute(
"INSERT INTO repository_key_cutover VALUES(?1,?2,?3,?4,?5,?6,?7)",
params![
workspace,
kind,
resource,
revision,
matches[0],
fingerprint,
created
],
)?;
}
}
// Current rows must agree with their pointed-to historical key, not merely
// resolve to a nonempty receipt. FK checks alone cannot prove this identity.
let inconsistent: bool = tx.query_row("SELECT EXISTS (
SELECT 1 FROM repository_ssh_credentials c LEFT JOIN repository_ssh_credential_revisions k
ON k.workspace_id=c.workspace_id AND k.credential_id=c.credential_id AND k.revision=c.current_revision
WHERE k.revision IS NULL OR k.public_key_algorithm<>c.public_key_algorithm OR k.public_key_fingerprint<>c.public_key_fingerprint
UNION ALL
SELECT 1 FROM repository_ssh_host_trusts c LEFT JOIN repository_ssh_host_trust_revisions k
ON k.workspace_id=c.workspace_id AND k.host_trust_id=c.host_trust_id AND k.revision=c.current_revision
WHERE k.revision IS NULL OR k.hostname<>c.hostname OR k.port<>c.port OR k.key_algorithm<>c.key_algorithm OR k.host_key<>c.host_key OR k.fingerprint<>c.fingerprint
UNION ALL
SELECT 1 FROM workdir_create_credential_revision_retentions r LEFT JOIN workdir_create_credential_candidates c
ON c.workspace_id=r.workspace_id AND c.operation_id=r.operation_id AND c.ordinal=r.ordinal
WHERE c.ordinal IS NULL OR c.credential_id<>r.credential_id OR c.credential_revision<>r.credential_revision
)", [], |r| r.get(0))?;
if inconsistent {
return Err(Error::Store(
"Repository key cutover found inconsistent current or retained key evidence".into(),
));
}
for table in [
"repository_secret_audit_events",
"repository_secret_operations",
"repository_ssh_credentials",
"repository_ssh_credential_revisions",
"repository_ssh_host_trusts",
"repository_ssh_host_trust_revisions",
"server_secret_versions",
"workdir_create_operations",
"workdir_create_credential_candidates",
"workdir_create_credential_revision_retentions",
] {
tx.execute_batch(&format!("ALTER TABLE {table} RENAME TO {table}_v86;"))?;
}
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;")?;
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")?;
statement
.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, u64>(2)?,
r.get::<_, String>(3)?,
r.get::<_, String>(4)?,
r.get::<_, Vec<u8>>(5)?,
r.get::<_, Vec<u8>>(6)?,
r.get::<_, String>(7)?,
r.get::<_, String>(8)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?
};
for (workspace, id, revision, purpose, algorithm, nonce, ciphertext, created, operation) in
secrets
{
if algorithm != "aes-256-gcm-v1" {
return Err(Error::Store(
"unsupported Repository secret envelope".into(),
));
}
let database = secret_source.ok_or_else(|| {
Error::Store("Repository secret migration requires its database master key".into())
})?;
let (nonce, ciphertext) =
crate::repository_access::migrate_legacy_repository_secret_envelope(
database,
&workspace,
&id,
revision,
&operation,
&purpose,
&nonce,
&ciphertext,
)?;
tx.execute(
"INSERT INTO server_secret_objects VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",
params![
workspace, id, operation, purpose, algorithm, nonce, ciphertext, created
],
)?;
}
let missing_secret: i64 = tx.query_row("SELECT count(*) FROM repository_ssh_credential_keys k WHERE NOT EXISTS (SELECT 1 FROM server_secret_objects s WHERE s.workspace_id=k.workspace_id AND s.secret_id=k.credential_id AND s.operation_id=k.operation_id AND s.purpose='private_key')", [], |r| r.get(0))?;
if missing_secret != 0 {
return Err(Error::Store(
"Repository key migration found a missing private key envelope".into(),
));
}
// Audit events also require an actual receipt, including deletions whose key
// material no longer exists. Keep their timestamps and actor attribution.
let audits = {
let mut statement=tx.prepare("SELECT workspace_id,event_id,kind,resource_id,revision,actor_account_id,created_at FROM repository_secret_audit_events_v86")?;
statement
.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
r.get::<_, String>(3)?,
r.get::<_, i64>(4)?,
r.get::<_, String>(5)?,
r.get::<_, String>(6)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?
};
for (workspace, event, kind, resource, revision, actor, created) in audits {
let resource_kind = if kind.starts_with("credential_") {
"credential"
} else if kind.starts_with("host_trust_") {
"host_trust"
} else {
return Err(Error::Store("unknown Repository audit kind".into()));
};
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],
|r| r.get::<_, String>(0),
)?
.collect::<rusqlite::Result<Vec<_>>>()?;
if matches.len() != 1 {
return Err(Error::Store(
"Repository audit cutover requires one unambiguous mutation receipt".into(),
));
}
tx.execute(
"INSERT INTO repository_secret_audit_events VALUES(?1,?2,?3,?4,?5,?6,?7)",
params![workspace, event, kind, resource, matches[0], actor, created],
)?;
}
// Successful unretained snapshots are history, not future lease input.
// Archive all their SSH evidence, including counters, without interpreting
// a reused credential ID/ordinal as a newer key. Failed creates are retryable.
tx.execute_batch("CREATE TEMP TABLE workdir_terminal_archive_cutover AS
SELECT o.workspace_id,o.operation_id FROM workdir_create_operations_v86 o
WHERE o.state='succeeded'
AND NOT EXISTS(SELECT 1 FROM workdir_create_credential_revision_retentions_v86 r WHERE r.workspace_id=o.workspace_id AND r.operation_id=o.operation_id)
AND (o.credential_id IS NOT NULL OR o.host_trust_id IS NOT NULL OR EXISTS(SELECT 1 FROM workdir_create_credential_candidates_v86 c WHERE c.workspace_id=o.workspace_id AND c.operation_id=o.operation_id));
INSERT INTO repository_secret_legacy_receipts
SELECT e.workspace_id,e.operation_id,e.request_fingerprint,e.resource_kind,e.resource_id,e.result_revision,e.created_at,e.mutation_kind,
CASE WHEN e.mutation_kind IN ('credential_created','host_trust_created') THEN 0
WHEN e.mutation_kind IN ('credential_rotated','host_trust_rotated') AND e.result_revision>1 THEN e.result_revision-1
WHEN e.mutation_kind IN ('credential_deleted','host_trust_deleted') THEN e.result_revision END,
m.operation_id,m.fingerprint
FROM (SELECT o.*, (SELECT min(a.kind) FROM repository_secret_audit_events_v86 a
WHERE a.workspace_id=o.workspace_id AND a.resource_id=o.resource_id AND a.revision=o.result_revision AND a.created_at=o.created_at
AND ((o.resource_kind='credential' AND a.kind LIKE 'credential_%') OR (o.resource_kind='host_trust' AND a.kind LIKE 'host_trust_%'))
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
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,
o.repository_id,o.selector,o.requested_runtime_id,o.resolved_runtime_id,o.config_projection_digest,o.working_directory_id,o.state,o.failure,o.created_at,o.updated_at,
o.source_kind,o.source_uri,o.source_fingerprint,
CASE WHEN a.operation_id IS NULL THEN o.credential_id END,CASE WHEN a.operation_id IS NULL THEN c.fingerprint END,
CASE WHEN a.operation_id IS NULL THEN o.host_trust_id END,CASE WHEN a.operation_id IS NULL THEN h.fingerprint END,
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;
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
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;")?;
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(
"Workdir SSH snapshot is missing key identity evidence".into(),
));
}
let archived = {
let mut statement = tx.prepare(
"SELECT workspace_id,operation_id FROM workdir_terminal_archive_cutover",
)?;
statement
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?
.collect::<rusqlite::Result<Vec<_>>>()?
};
for (workspace, operation) in archived {
let old_operation=frozen_repository_json_rows(&tx,"SELECT * FROM workdir_create_operations_v86 WHERE workspace_id=?1 AND operation_id=?2",params![workspace,operation])?.pop().ok_or_else(|| Error::Store("archived Workdir operation disappeared".into()))?;
let old_candidates = frozen_repository_json_rows(
&tx,
"SELECT * FROM workdir_create_credential_candidates_v86 WHERE workspace_id=?1 AND operation_id=?2 ORDER BY ordinal",
params![workspace, operation],
)?;
tx.execute(
"INSERT INTO workdir_create_legacy_ssh_archives VALUES(?1,?2,?3,?4)",
params![
workspace,
operation,
serde_json::to_string(&old_operation)
.map_err(|e| Error::Store(e.to_string()))?,
serde_json::to_string(&old_candidates)
.map_err(|e| Error::Store(e.to_string()))?
],
)?;
}
tx.execute_batch("DROP TABLE workdir_terminal_archive_cutover;")?;
for table in [
"workdir_create_credential_revision_retentions",
"workdir_create_credential_candidates",
"workdir_create_operations",
"server_secret_versions",
"repository_ssh_credential_revisions",
"repository_ssh_host_trust_revisions",
"repository_ssh_credentials",
"repository_ssh_host_trusts",
"repository_secret_operations",
"repository_secret_audit_events",
] {
tx.execute_batch(&format!("DROP TABLE {table}_v86;"))?;
}
tx.execute_batch("DROP TABLE repository_key_cutover;
CREATE INDEX idx_repository_secret_audit_workspace_created ON repository_secret_audit_events(workspace_id,created_at,event_id);
CREATE INDEX idx_repository_ssh_credentials_workspace_status ON repository_ssh_credentials(workspace_id,status,credential_id);
CREATE INDEX idx_repository_ssh_host_trusts_workspace_host ON repository_ssh_host_trusts(workspace_id,hostname,port);")?;
tx.execute_batch(
r#"CREATE TRIGGER workdir_create_insert_blocked_by_runtime_removal
BEFORE INSERT ON workdir_create_operations FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.resolved_runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER workdir_create_update_blocked_by_runtime_removal
BEFORE UPDATE ON workdir_create_operations FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.resolved_runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
"#,
)?;
if tx.prepare("PRAGMA foreign_key_check")?.exists([])? {
return Err(Error::Store(
"Repository key migration foreign key check failed".into(),
));
}
if table_exists(&tx, "repositories")? {
tx.execute_batch("ALTER TABLE repositories DROP COLUMN source_revision;")?;
}
tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES(87,'Repository SSH key identities and encrypted operation objects')",[])?;
tx.commit()?;
Ok(())
})();
let restore = conn
.execute_batch(&format!(
"PRAGMA legacy_alter_table={}; PRAGMA foreign_keys={};",
i32::from(legacy_alter),
i32::from(foreign_keys)
))
.map_err(Error::from);
result.and(restore)
}
// Preserve every SQLite field and NULL verbatim as typed JSON. Frozen archives
// are never read by credential lookup, lease construction, or a live CAS.
fn frozen_repository_json_rows(
conn: &Connection,
query: &str,
params: &[&dyn rusqlite::ToSql],
) -> Result<Vec<serde_json::Value>> {
let mut statement = conn.prepare(query)?;
let columns = statement
.column_names()
.into_iter()
.map(str::to_string)
.collect::<Vec<_>>();
let mut rows = statement.query(params)?;
let mut result = Vec::new();
while let Some(row) = rows.next()? {
let mut object = serde_json::Map::new();
for (index, name) in columns.iter().enumerate() {
let value = match row.get_ref(index)? {
rusqlite::types::ValueRef::Null => serde_json::Value::Null,
rusqlite::types::ValueRef::Integer(value) => value.into(),
rusqlite::types::ValueRef::Text(value) => serde_json::Value::String(
std::str::from_utf8(value)
.map_err(|e| Error::Store(e.to_string()))?
.to_string(),
),
_ => {
return Err(Error::Store(
"unexpected value in frozen Repository archive".into(),
));
}
};
object.insert(name.clone(), value);
}
result.push(object.into());
}
Ok(result)
}
@@ -0,0 +1,188 @@
CREATE TABLE repository_secret_audit_events (
workspace_id TEXT NOT NULL,
event_id TEXT NOT NULL,
kind TEXT NOT NULL,
resource_id TEXT NOT NULL,
operation_id TEXT NOT NULL CHECK (length(operation_id) > 0),
actor_account_id TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, event_id),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
CREATE TABLE repository_secret_operations (
workspace_id TEXT NOT NULL,
operation_id TEXT NOT NULL,
request_fingerprint TEXT NOT NULL,
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('credential', 'host_trust')),
resource_id TEXT NOT NULL,
result_operation_id TEXT NOT NULL CHECK (length(result_operation_id) > 0),
created_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, operation_id),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
CREATE TABLE repository_ssh_credential_keys (
workspace_id TEXT NOT NULL,
credential_id TEXT NOT NULL,
operation_id TEXT NOT NULL CHECK (length(operation_id) > 0),
public_key_algorithm TEXT NOT NULL,
public_key_fingerprint TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, credential_id, operation_id),
UNIQUE (workspace_id, credential_id, operation_id, public_key_fingerprint),
FOREIGN KEY (workspace_id, credential_id)
REFERENCES repository_ssh_credentials(workspace_id, credential_id)
ON DELETE CASCADE
);
CREATE TABLE repository_ssh_credentials (
workspace_id TEXT NOT NULL,
credential_id TEXT NOT NULL,
name TEXT NOT NULL,
public_key_algorithm TEXT NOT NULL,
public_key_fingerprint TEXT NOT NULL,
current_operation_id TEXT NOT NULL CHECK (length(current_operation_id) > 0),
status TEXT NOT NULL CHECK (status IN ('active', 'revoked')),
created_at TEXT NOT NULL,
rotated_at TEXT,
PRIMARY KEY (workspace_id, credential_id),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
CREATE TABLE repository_ssh_host_trust_keys (
workspace_id TEXT NOT NULL,
host_trust_id TEXT NOT NULL,
operation_id TEXT NOT NULL CHECK (length(operation_id) > 0),
hostname TEXT NOT NULL,
port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535),
key_algorithm TEXT NOT NULL,
host_key TEXT NOT NULL,
fingerprint TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, host_trust_id, operation_id),
FOREIGN KEY (workspace_id, host_trust_id)
REFERENCES repository_ssh_host_trusts(workspace_id, host_trust_id)
ON DELETE CASCADE
);
CREATE TABLE repository_ssh_host_trusts (
workspace_id TEXT NOT NULL,
host_trust_id TEXT NOT NULL,
hostname TEXT NOT NULL,
port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535),
key_algorithm TEXT NOT NULL,
host_key TEXT NOT NULL,
fingerprint TEXT NOT NULL,
current_operation_id TEXT NOT NULL CHECK (length(current_operation_id) > 0),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, host_trust_id),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
CREATE TABLE server_secret_objects (
workspace_id TEXT NOT NULL,
secret_id TEXT NOT NULL,
operation_id TEXT NOT NULL CHECK (length(operation_id) > 0),
purpose TEXT NOT NULL CHECK (purpose IN ('private_key', 'passphrase')),
encryption_algorithm TEXT NOT NULL CHECK (encryption_algorithm = 'aes-256-gcm-v1'),
nonce BLOB NOT NULL CHECK (length(nonce) = 12),
ciphertext BLOB NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, secret_id, operation_id, purpose),
FOREIGN KEY (workspace_id, secret_id, operation_id)
REFERENCES repository_ssh_credential_keys(workspace_id, credential_id, operation_id)
ON DELETE CASCADE
);
CREATE TABLE workdir_create_operations (
workspace_id TEXT NOT NULL,
operation_id TEXT NOT NULL,
request_fingerprint TEXT NOT NULL,
repository_id TEXT NOT NULL,
selector TEXT,
requested_runtime_id TEXT,
resolved_runtime_id TEXT NOT NULL,
config_projection_digest TEXT NOT NULL,
working_directory_id TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('pending', 'succeeded', 'failed')),
failure TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, source_kind TEXT, source_uri TEXT, source_fingerprint TEXT, credential_id TEXT, credential_fingerprint TEXT, host_trust_id TEXT, host_trust_fingerprint TEXT, repository_access_mode TEXT,
PRIMARY KEY (workspace_id, operation_id),
UNIQUE (workspace_id, working_directory_id)
);
CREATE TABLE workdir_create_credential_candidates (
workspace_id TEXT NOT NULL,
operation_id TEXT NOT NULL,
ordinal INTEGER NOT NULL CHECK (ordinal >= 0 AND ordinal < 2),
role TEXT NOT NULL CHECK (role IN ('primary', 'workspace_default_fallback')),
credential_id TEXT NOT NULL CHECK (length(credential_id) BETWEEN 1 AND 128),
credential_fingerprint TEXT NOT NULL CHECK (length(credential_fingerprint) > 0),
PRIMARY KEY (workspace_id, operation_id, ordinal),
UNIQUE (workspace_id, operation_id, ordinal, credential_id, credential_fingerprint),
UNIQUE (workspace_id, operation_id, role),
UNIQUE (workspace_id, operation_id, credential_id),
FOREIGN KEY (workspace_id, operation_id)
REFERENCES workdir_create_operations(workspace_id, operation_id)
ON DELETE CASCADE
);
CREATE INDEX idx_workdir_create_credential_candidates_fingerprint
ON workdir_create_credential_candidates(
workspace_id, credential_id, credential_fingerprint
);
CREATE TABLE workdir_create_credential_retentions (
workspace_id TEXT NOT NULL,
operation_id TEXT NOT NULL,
ordinal INTEGER NOT NULL,
credential_id TEXT NOT NULL,
credential_fingerprint TEXT NOT NULL,
credential_operation_id TEXT NOT NULL,
PRIMARY KEY (workspace_id, operation_id, ordinal),
FOREIGN KEY (workspace_id, operation_id, ordinal, credential_id, credential_fingerprint)
REFERENCES workdir_create_credential_candidates(
workspace_id, operation_id, ordinal, credential_id, credential_fingerprint
)
ON DELETE CASCADE,
FOREIGN KEY (workspace_id, credential_id, credential_operation_id, credential_fingerprint)
REFERENCES repository_ssh_credential_keys(workspace_id, credential_id, operation_id, public_key_fingerprint)
ON DELETE RESTRICT
);
-- Frozen evidence only. Neither table is a key, lease, or mutation authority.
CREATE TABLE workdir_create_legacy_ssh_archives (
workspace_id TEXT NOT NULL,
operation_id TEXT NOT NULL,
operation_json TEXT NOT NULL CHECK (json_valid(operation_json)),
candidates_json TEXT NOT NULL CHECK (json_valid(candidates_json)),
PRIMARY KEY (workspace_id, operation_id),
FOREIGN KEY (workspace_id, operation_id)
REFERENCES workdir_create_operations(workspace_id, operation_id) ON DELETE CASCADE
);
CREATE TRIGGER workdir_create_legacy_archive_cannot_authorize_update
BEFORE UPDATE ON workdir_create_operations FOR EACH ROW
WHEN EXISTS (SELECT 1 FROM workdir_create_legacy_ssh_archives a
WHERE a.workspace_id=OLD.workspace_id AND a.operation_id=OLD.operation_id)
AND (NEW.state <> 'succeeded' OR NEW.credential_id IS NOT NULL
OR NEW.credential_fingerprint IS NOT NULL OR NEW.host_trust_id IS NOT NULL
OR NEW.host_trust_fingerprint IS NOT NULL OR NEW.repository_access_mode IS NOT NULL)
BEGIN SELECT RAISE(ABORT, 'archived_workdir_create_cannot_authorize'); END;
CREATE TRIGGER workdir_create_legacy_archive_blocks_candidates
BEFORE INSERT ON workdir_create_credential_candidates FOR EACH ROW
WHEN EXISTS (SELECT 1 FROM workdir_create_legacy_ssh_archives a
WHERE a.workspace_id=NEW.workspace_id AND a.operation_id=NEW.operation_id)
BEGIN SELECT RAISE(ABORT, 'archived_workdir_create_cannot_authorize'); END;
CREATE TRIGGER workdir_create_legacy_archive_blocks_candidate_updates
BEFORE UPDATE ON workdir_create_credential_candidates FOR EACH ROW
WHEN EXISTS (SELECT 1 FROM workdir_create_legacy_ssh_archives a
WHERE a.workspace_id=NEW.workspace_id AND a.operation_id=NEW.operation_id)
BEGIN SELECT RAISE(ABORT, 'archived_workdir_create_cannot_authorize'); END;
CREATE TABLE repository_secret_legacy_receipts (
workspace_id TEXT NOT NULL,
operation_id TEXT NOT NULL,
request_fingerprint TEXT NOT NULL,
resource_kind TEXT NOT NULL,
resource_id TEXT NOT NULL,
result_revision INTEGER NOT NULL,
created_at TEXT NOT NULL,
mutation_kind TEXT,
expected_revision INTEGER,
expected_operation_id TEXT,
expected_key_fingerprint TEXT,
PRIMARY KEY (workspace_id, operation_id),
FOREIGN KEY (workspace_id, operation_id)
REFERENCES repository_secret_operations(workspace_id, operation_id) ON DELETE CASCADE
);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,764 @@
// Frozen schema-87 -> 88 authority cutover. Old counters are never new IDs.
// Existing private material is unchanged; v1 material has its own frozen decoder.
fn migrate_runtime_bindings_v87_to_v88(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 87 {
return Err(Error::Store(
"Runtime binding cutover requires schema 87".into(),
));
}
let foreign_keys: bool = conn.query_row("PRAGMA foreign_keys", [], |r| r.get(0))?;
let legacy_alter: bool = conn.query_row("PRAGMA legacy_alter_table", [], |r| r.get(0))?;
conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;")?;
let result = (|| -> Result<()> {
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
// An executing/failed removal may already have a durable Runtime receipt,
// with the source gone but Backend metadata not yet committed. Its legacy
// request fingerprint includes ordinal policy/Worker evidence that cannot
// be reconstructed as a new content contract. Never retire that recovery
// authority, regenerate the request, or treat failure as proof of no effect.
let unresolved_removal: Option<(String, String)> = tx
.query_row(
"SELECT operation_id,state FROM worker_removal_operations \
WHERE state IN ('executing','failed') ORDER BY operation_id LIMIT 1",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
if let Some((operation_id, state)) = unresolved_removal {
return Err(Error::Store(format!(
"Runtime binding cutover requires old-version reconciliation of Worker removal `{operation_id}` ({state}); reconcile using schema 87 before retrying the upgrade"
)));
}
// Triggers may refer to a table while it is being replaced. Preserve all of them,
// including cross-domain removal fences, and restore before checking constraints.
let triggers = {
let mut stmt = tx.prepare("SELECT name,sql FROM sqlite_schema WHERE type='trigger'")?;
stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?
.collect::<rusqlite::Result<Vec<_>>>()?
};
let indexes = {
let mut stmt = tx.prepare("SELECT name,tbl_name,sql FROM sqlite_schema WHERE type='index' AND sql IS NOT NULL")?;
stmt.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?
};
for (name, _) in &triggers {
tx.execute_batch(&format!("DROP TRIGGER \"{}\"", name.replace('"', "\"\"")))?;
}
for table in [
"workspace_signing_identities",
"workspace_signing_identity_provisioning_operations",
"workspace_signing_identity_audit",
] {
let unsupported: bool = tx.query_row(
&format!("SELECT EXISTS(SELECT 1 FROM {table} WHERE revision != 1)"),
[],
|r| r.get(0),
)?;
if unsupported {
return Err(Error::Store(
"unsupported legacy Workspace signing identity revision".into(),
));
}
}
tx.execute_batch("CREATE TEMP TABLE runtime_binding_cutover_ids(workspace_id TEXT,runtime_id TEXT,ordinal INTEGER,binding_id TEXT,PRIMARY KEY(workspace_id,runtime_id,ordinal));
CREATE TEMP TABLE retention_cutover_digests(workspace_id TEXT,policy_id TEXT,ordinal INTEGER,policy_digest TEXT,PRIMARY KEY(workspace_id,policy_id,ordinal));")?;
let identities = {
let mut stmt=tx.prepare("SELECT workspace_id,runtime_id,binding_revision FROM workspace_runtime_bindings UNION SELECT workspace_id,runtime_id,binding_revision FROM workspace_runtime_binding_audit")?;
stmt.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, i64>(2)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?
};
for (workspace, runtime, ordinal) in identities {
if ordinal <= 0 {
return Err(Error::Store("invalid legacy binding ordinal".into()));
}
tx.execute(
"INSERT INTO runtime_binding_cutover_ids VALUES(?1,?2,?3,?4)",
params![workspace, runtime, ordinal, new_runtime_binding_id()],
)?;
}
let policies = {
let mut stmt=tx.prepare("SELECT workspace_id,policy_id,revision,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds FROM workspace_worker_retention_policy_revisions")?;
stmt.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, i64>(2)?,
r.get::<_, String>(3)?,
r.get::<_, String>(4)?,
r.get::<_, String>(5)?,
r.get::<_, Option<u64>>(6)?,
r.get::<_, String>(7)?,
r.get::<_, Option<u64>>(8)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?
};
for (
workspace,
id,
ordinal,
session,
metadata,
archive,
seconds,
diagnostics,
diagnostics_seconds,
) in policies
{
let archive = match (archive.as_str(), seconds) {
("forever", None) => serde_json::json!({"kind":"forever"}),
("for_seconds", Some(seconds)) if seconds > 0 => {
serde_json::json!({"kind":"for_seconds","seconds":seconds})
}
_ => return Err(Error::Store("invalid legacy retention duration".into())),
};
let policy: crate::retention::WorkerRetentionPolicyUpdate=serde_json::from_value(serde_json::json!({
"policy_id":id,"session_disposition":session,"metadata_disposition":metadata,
"archive_retention":archive,"diagnostics_disposition":diagnostics,"diagnostics_retention_seconds":diagnostics_seconds
})).map_err(|e|Error::Store(format!("invalid legacy retention policy: {e}")))?;
let digest = crate::retention::worker_retention_policy_digest(&policy);
tx.execute(
"INSERT INTO retention_cutover_digests VALUES(?1,?2,?3,?4)",
params![workspace, policy.policy_id, ordinal, digest],
)?;
}
tx.execute_batch(r#"CREATE TABLE workspace_runtime_bindings_v88 (
workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
display_name TEXT NOT NULL,
base_url TEXT NOT NULL,
public_key TEXT NOT NULL,
public_key_fingerprint TEXT NOT NULL,
binding_id TEXT NOT NULL CHECK (length(binding_id) > 0),
state TEXT NOT NULL CHECK (state IN ('configured', 'verified', 'revoked')),
authentication_mode TEXT NOT NULL CHECK (authentication_mode IN ('legacy_server_issuer', 'workspace_identity')),
workspace_key_id TEXT,
workspace_public_key_fingerprint TEXT,
workspace_trust_id TEXT CHECK (length(workspace_trust_id) > 0),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
revoked_at TEXT,
PRIMARY KEY (workspace_id, runtime_id),
UNIQUE (workspace_id, public_key_fingerprint),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT,
CHECK (
(authentication_mode = 'legacy_server_issuer' AND workspace_key_id IS NULL AND workspace_public_key_fingerprint IS NULL AND workspace_trust_id IS NULL)
OR
(authentication_mode = 'workspace_identity' AND workspace_key_id IS NOT NULL AND (state != 'verified' OR (workspace_trust_id IS NOT NULL AND workspace_public_key_fingerprint IS NOT NULL)))
),
CHECK (
(state = 'revoked' AND revoked_at IS NOT NULL)
OR
(state != 'revoked' AND revoked_at IS NULL)
)
);"#)?;
tx.execute_batch(r#"CREATE TABLE workspace_runtime_verifications_v88 (
workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
binding_id TEXT NOT NULL CHECK(length(binding_id) > 0),
workspace_key_id TEXT NOT NULL,
workspace_public_key_fingerprint TEXT NOT NULL CHECK(length(workspace_public_key_fingerprint) > 0),
workspace_trust_id TEXT NOT NULL CHECK(length(workspace_trust_id) > 0),
runtime_public_key_fingerprint TEXT NOT NULL,
challenge_id TEXT NOT NULL,
state TEXT NOT NULL CHECK(state IN ('pending', 'verified', 'failed')),
last_outcome TEXT NOT NULL,
verified_at TEXT,
checked_at TEXT NOT NULL,
PRIMARY KEY(workspace_id, runtime_id),
FOREIGN KEY(workspace_id, runtime_id)
REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE CASCADE,
CHECK((state = 'verified' AND verified_at IS NOT NULL)
OR (state != 'verified' AND verified_at IS NULL))
);"#)?;
tx.execute_batch(
r#"CREATE TABLE workspace_runtime_binding_audit_v88 (
workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
actor_account_id TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('created', 'replaced', 'reactivated', 'revoked')),
old_fingerprint TEXT,
new_fingerprint TEXT,
binding_id TEXT NOT NULL CHECK (length(binding_id) > 0),
at TEXT NOT NULL,
PRIMARY KEY (workspace_id, runtime_id, binding_id),
FOREIGN KEY(workspace_id, runtime_id)
REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE RESTRICT,
FOREIGN KEY(actor_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT
);"#,
)?;
tx.execute_batch(r#"CREATE TABLE workspace_signing_identities_v88 (
workspace_id TEXT PRIMARY KEY,
key_id TEXT NOT NULL UNIQUE,
algorithm TEXT NOT NULL CHECK (algorithm = 'ed25519'),
public_key TEXT,
public_key_fingerprint TEXT,
private_material_ref TEXT NOT NULL UNIQUE,
state TEXT NOT NULL CHECK (state IN ('pending_provisioning', 'active')),
created_at TEXT NOT NULL,
provisioned_at TEXT,
updated_at TEXT NOT NULL,
CHECK (
(state = 'pending_provisioning' AND public_key IS NULL AND public_key_fingerprint IS NULL AND provisioned_at IS NULL)
OR
(state = 'active' AND public_key IS NOT NULL AND public_key_fingerprint IS NOT NULL AND provisioned_at IS NOT NULL)
),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);"#)?;
tx.execute_batch(r#"CREATE TABLE workspace_signing_identity_provisioning_operations_v88 (
operation_key TEXT PRIMARY KEY,
request_fingerprint TEXT NOT NULL,
operation_kind TEXT NOT NULL CHECK (operation_kind IN ('workspace_create', 'existing_workspace')),
workspace_id TEXT NOT NULL UNIQUE,
key_id TEXT NOT NULL UNIQUE,
private_material_ref TEXT NOT NULL UNIQUE,
actor_account_id TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('pending', 'completed')),
created_at TEXT NOT NULL,
completed_at TEXT
);"#)?;
tx.execute_batch(
r#"CREATE TABLE workspace_signing_identity_audit_v88 (
event_id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
key_id TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('provisioned')),
public_key_fingerprint TEXT NOT NULL,
actor_account_id TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);"#,
)?;
tx.execute_batch(r#"CREATE TABLE runtime_removal_operations_v88 (
operation_id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
request_fingerprint TEXT NOT NULL,
expected_binding_id TEXT NOT NULL CHECK(length(expected_binding_id)>0),
config_digest TEXT NOT NULL CHECK(length(config_digest)>0),
state TEXT NOT NULL CHECK (state IN ('pending', 'cleanup_pending', 'succeeded', 'failed')),
failure_category TEXT,
binding_removed INTEGER NOT NULL CHECK (binding_removed IN (0, 1)),
runtime_registration_removed INTEGER CHECK (runtime_registration_removed IS NULL OR runtime_registration_removed IN (0, 1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
completed_at TEXT,
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);"#)?;
tx.execute_batch(r#"CREATE TABLE workspace_worker_retention_policies_v88 (
workspace_id TEXT PRIMARY KEY, policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL, updated_at TEXT NOT NULL,
FOREIGN KEY(workspace_id,policy_id,policy_digest) REFERENCES workspace_worker_retention_policy_snapshots(workspace_id,policy_id,policy_digest),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);"#)?;
tx.execute_batch(r#"CREATE TABLE workspace_worker_retention_policy_snapshots_v88 (
workspace_id TEXT NOT NULL, policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL CHECK(length(policy_digest)>0),
session_disposition TEXT NOT NULL CHECK(session_disposition IN ('archive','purge')),
metadata_disposition TEXT NOT NULL CHECK(metadata_disposition IN ('tombstone','purge')),
archive_retention_kind TEXT NOT NULL CHECK(archive_retention_kind IN ('forever','for_seconds')),
archive_retention_seconds INTEGER,
diagnostics_disposition TEXT NOT NULL CHECK(diagnostics_disposition IN ('purge','retain')),
diagnostics_retention_seconds INTEGER, created_at TEXT NOT NULL,
PRIMARY KEY(workspace_id,policy_id,policy_digest),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);"#)?;
tx.execute_batch(r#"CREATE TABLE worker_removal_operations_v88 (
operation_id TEXT PRIMARY KEY, plan_id TEXT NOT NULL UNIQUE, input_fingerprint TEXT NOT NULL,
workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
worker_updated_at TEXT NOT NULL,
policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL,
session_disposition TEXT NOT NULL, metadata_disposition TEXT NOT NULL,
archive_retention_kind TEXT NOT NULL, archive_retention_seconds INTEGER,
diagnostics_disposition TEXT NOT NULL,
diagnostics_retention_seconds INTEGER, archive_id TEXT UNIQUE, blockers_json TEXT NOT NULL,
state TEXT NOT NULL CHECK(state IN ('planned','blocked','executing','failed','stale','succeeded')),
reason TEXT NOT NULL, failure_category TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);"#)?;
tx.execute_batch(r#"CREATE TABLE worker_session_archives_v88 (
archive_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
session_id TEXT NOT NULL, checksum_sha256 TEXT NOT NULL, content_bytes INTEGER NOT NULL,
policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL, operation_id TEXT NOT NULL UNIQUE,
committed_at TEXT NOT NULL, expires_at TEXT,
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id));"#)?;
tx.execute_batch(
r#"CREATE TABLE worker_diagnostics_archives_v88 (
operation_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL,
worker_id TEXT NOT NULL, policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL,
committed_at TEXT NOT NULL, expires_at TEXT NOT NULL,
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);"#,
)?;
tx.execute_batch(r#"CREATE TABLE worker_tombstones_v88 (
workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
display_name TEXT NOT NULL, profile TEXT, worker_created_at TEXT NOT NULL, removed_at TEXT NOT NULL,
archive_id TEXT, policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL, operation_id TEXT NOT NULL UNIQUE,
PRIMARY KEY(workspace_id,runtime_id,worker_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
FOREIGN KEY(archive_id) REFERENCES worker_session_archives(archive_id),
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id));"#)?;
tx.execute_batch(
r#"CREATE TABLE workdir_removal_operations_v88 (
workspace_id TEXT NOT NULL,
operation_id TEXT NOT NULL,
request_fingerprint TEXT NOT NULL,
workdir_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
repository_id TEXT NOT NULL,
materialization_fingerprint TEXT NOT NULL,
source_actor TEXT NOT NULL,
reason TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('pending', 'failed', 'completed')),
attempt_id TEXT,
retryable INTEGER NOT NULL CHECK (retryable IN (0, 1)),
disposition TEXT CHECK (disposition IN ('removed', 'retained', 'attention_required')),
failure_category TEXT,
attempt_owner_pid INTEGER CHECK (attempt_owner_pid > 0),
attempt_owner_start_marker INTEGER CHECK (attempt_owner_start_marker >= 0),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
completed_at TEXT,
PRIMARY KEY (workspace_id, operation_id),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);"#,
)?;
tx.execute_batch(
r#"CREATE TABLE workspace_deletion_operations_v88 (
operation_id TEXT PRIMARY KEY,
request_fingerprint TEXT NOT NULL,
workspace_id TEXT NOT NULL,
workspace_display_name TEXT NOT NULL,
workspace_updated_at TEXT NOT NULL,
owner_account_id TEXT NOT NULL,
actor_account_id TEXT NOT NULL,
state TEXT NOT NULL CHECK(state IN ('queued', 'running', 'blocked', 'failed', 'succeeded')),
resource_counts_json TEXT NOT NULL,
child_operation_ids_json TEXT NOT NULL,
blockers_json TEXT NOT NULL,
failure_category TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
completed_at TEXT,
FOREIGN KEY(owner_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT,
FOREIGN KEY(actor_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT
);"#,
)?;
{
let columns = table_columns(&tx, "workspace_runtime_bindings_v88")?;
let mut expressions = Vec::new();
for column in &columns {
let expression=match column.as_str() {
"binding_id" => "(SELECT binding_id FROM runtime_binding_cutover_ids m WHERE m.workspace_id=o.workspace_id AND m.runtime_id=o.runtime_id AND m.ordinal=o.binding_revision)".to_string(),
"workspace_trust_id" => "NULL".to_string(),
"workspace_public_key_fingerprint" => "(SELECT public_key_fingerprint FROM workspace_signing_identities i WHERE i.workspace_id=o.workspace_id AND i.key_id=o.workspace_key_id)".to_string(),
"state" => "CASE WHEN state='verified' AND authentication_mode='workspace_identity' THEN 'configured' ELSE state END".to_string(),
_ => format!("o.{column}"),
};
expressions.push(expression);
}
tx.execute_batch(&format!("INSERT INTO workspace_runtime_bindings_v88 ({}) SELECT {} FROM workspace_runtime_bindings o",columns.join(","),expressions.join(",")))?;
}
// Legacy proof cannot attest to a new binding/trust identity.
{
let columns = table_columns(&tx, "workspace_runtime_binding_audit_v88")?;
let mut expressions = Vec::new();
for column in &columns {
let expression=match column.as_str() {
"binding_id" => "(SELECT binding_id FROM runtime_binding_cutover_ids m WHERE m.workspace_id=o.workspace_id AND m.runtime_id=o.runtime_id AND m.ordinal=o.binding_revision)".to_string(),
_ => format!("o.{column}"),
};
expressions.push(expression);
}
tx.execute_batch(&format!("INSERT INTO workspace_runtime_binding_audit_v88 ({}) SELECT {} FROM workspace_runtime_binding_audit o",columns.join(","),expressions.join(",")))?;
}
{
let columns = table_columns(&tx, "workspace_signing_identities_v88")?;
let mut expressions = Vec::new();
for column in &columns {
let expression = match column.as_str() {
_ => format!("o.{column}"),
};
expressions.push(expression);
}
tx.execute_batch(&format!("INSERT INTO workspace_signing_identities_v88 ({}) SELECT {} FROM workspace_signing_identities o",columns.join(","),expressions.join(",")))?;
}
{
let columns = table_columns(
&tx,
"workspace_signing_identity_provisioning_operations_v88",
)?;
let mut expressions = Vec::new();
for column in &columns {
let expression = match column.as_str() {
_ => format!("o.{column}"),
};
expressions.push(expression);
}
tx.execute_batch(&format!("INSERT INTO workspace_signing_identity_provisioning_operations_v88 ({}) SELECT {} FROM workspace_signing_identity_provisioning_operations o",columns.join(","),expressions.join(",")))?;
migrate_existing_workspace_provisioning_receipts_v87(&tx)?;
}
{
let columns = table_columns(&tx, "workspace_signing_identity_audit_v88")?;
let mut expressions = Vec::new();
for column in &columns {
let expression = match column.as_str() {
_ => format!("o.{column}"),
};
expressions.push(expression);
}
tx.execute_batch(&format!("INSERT INTO workspace_signing_identity_audit_v88 ({}) SELECT {} FROM workspace_signing_identity_audit o",columns.join(","),expressions.join(",")))?;
}
{
let columns = table_columns(&tx, "runtime_removal_operations_v88")?;
let mut expressions = Vec::new();
for column in &columns {
let expression=match column.as_str() {
"expected_binding_id" => "COALESCE((SELECT binding_id FROM runtime_binding_cutover_ids m WHERE m.workspace_id=o.workspace_id AND m.runtime_id=o.runtime_id AND m.ordinal=o.expected_binding_revision),'RB-retired-' || lower(hex(randomblob(16))))".to_string(),
"config_digest" => "(SELECT content_digest FROM workspace_config_trees t WHERE t.workspace_id=o.workspace_id)".to_string(),
"state" => "CASE WHEN state IN ('pending','failed') THEN 'failed' ELSE state END".to_string(),
"failure_category" => "CASE WHEN state IN ('pending','failed') THEN 'binding_identity_cutover' ELSE failure_category END".to_string(),
_ => format!("o.{column}"),
};
expressions.push(expression);
}
tx.execute_batch(&format!("INSERT INTO runtime_removal_operations_v88 ({}) SELECT {} FROM runtime_removal_operations o",columns.join(","),expressions.join(",")))?;
}
{
let columns = table_columns(&tx, "workspace_worker_retention_policies_v88")?;
let mut expressions = Vec::new();
for column in &columns {
let expression=match column.as_str() {
"policy_digest" => "(SELECT policy_digest FROM retention_cutover_digests m WHERE m.workspace_id=o.workspace_id AND m.policy_id=o.policy_id AND m.ordinal=o.revision)".to_string(),
_ => format!("o.{column}"),
};
expressions.push(expression);
}
tx.execute_batch(&format!("INSERT INTO workspace_worker_retention_policies_v88 ({}) SELECT {} FROM workspace_worker_retention_policies o",columns.join(","),expressions.join(",")))?;
}
{
let columns = table_columns(&tx, "workspace_worker_retention_policy_snapshots_v88")?;
let mut expressions = Vec::new();
for column in &columns {
let expression=match column.as_str() {
"policy_digest" => "(SELECT policy_digest FROM retention_cutover_digests m WHERE m.workspace_id=o.workspace_id AND m.policy_id=o.policy_id AND m.ordinal=o.revision)".to_string(),
_ => format!("o.{column}"),
};
expressions.push(expression);
}
tx.execute_batch(&format!("INSERT INTO workspace_worker_retention_policy_snapshots_v88 ({}) SELECT {} FROM workspace_worker_retention_policy_revisions o WHERE 1 ON CONFLICT(workspace_id,policy_id,policy_digest) DO NOTHING",columns.join(","),expressions.join(",")))?;
}
{
let columns = table_columns(&tx, "worker_removal_operations_v88")?;
let mut expressions = Vec::new();
for column in &columns {
let expression=match column.as_str() {
"policy_digest" => "(SELECT policy_digest FROM retention_cutover_digests m WHERE m.workspace_id=o.workspace_id AND m.policy_id=o.policy_id AND m.ordinal=o.policy_revision)".to_string(),
"worker_updated_at" => "worker_revision".to_string(),
// Only unexecuted plans lose admission authority. Executing/failed
// rows were rejected above; succeeded and already-stale evidence stays intact.
"state" => "CASE WHEN state IN ('planned','blocked') THEN 'stale' ELSE state END".to_string(),
"failure_category" => "CASE WHEN state IN ('planned','blocked') THEN 'policy_content_cutover' ELSE failure_category END".to_string(),
_ => format!("o.{column}"),
};
expressions.push(expression);
}
tx.execute_batch(&format!("INSERT INTO worker_removal_operations_v88 ({}) SELECT {} FROM worker_removal_operations o",columns.join(","),expressions.join(",")))?;
}
{
let columns = table_columns(&tx, "worker_session_archives_v88")?;
let mut expressions = Vec::new();
for column in &columns {
let expression=match column.as_str() {
"policy_digest" => "(SELECT policy_digest FROM retention_cutover_digests m WHERE m.workspace_id=o.workspace_id AND m.policy_id=o.policy_id AND m.ordinal=o.policy_revision)".to_string(),
_ => format!("o.{column}"),
};
expressions.push(expression);
}
tx.execute_batch(&format!("INSERT INTO worker_session_archives_v88 ({}) SELECT {} FROM worker_session_archives o",columns.join(","),expressions.join(",")))?;
}
{
let columns = table_columns(&tx, "worker_diagnostics_archives_v88")?;
let mut expressions = Vec::new();
for column in &columns {
let expression=match column.as_str() {
"policy_digest" => "(SELECT policy_digest FROM retention_cutover_digests m WHERE m.workspace_id=o.workspace_id AND m.policy_id=o.policy_id AND m.ordinal=o.policy_revision)".to_string(),
_ => format!("o.{column}"),
};
expressions.push(expression);
}
tx.execute_batch(&format!("INSERT INTO worker_diagnostics_archives_v88 ({}) SELECT {} FROM worker_diagnostics_archives o",columns.join(","),expressions.join(",")))?;
}
{
let columns = table_columns(&tx, "worker_tombstones_v88")?;
let mut expressions = Vec::new();
for column in &columns {
let expression=match column.as_str() {
"policy_digest" => "(SELECT policy_digest FROM retention_cutover_digests m WHERE m.workspace_id=o.workspace_id AND m.policy_id=o.policy_id AND m.ordinal=o.policy_revision)".to_string(),
_ => format!("o.{column}"),
};
expressions.push(expression);
}
tx.execute_batch(&format!(
"INSERT INTO worker_tombstones_v88 ({}) SELECT {} FROM worker_tombstones o",
columns.join(","),
expressions.join(",")
))?;
}
{
let columns = table_columns(&tx, "workdir_removal_operations_v88")?;
let mut expressions = Vec::new();
for column in &columns {
let expression=match column.as_str() {
// This is a Backend-local callback fence, not Runtime receipt
// identity. Recovery still proves the prior owner is orphaned,
// observes the exact materialization (or authoritative absence),
// and uses the unchanged request/materialization fingerprints.
"attempt_id" => "CASE WHEN attempt_count>0 THEN 'WDRA-' || lower(hex(randomblob(16))) ELSE NULL END".to_string(),
_ => format!("o.{column}"),
};
expressions.push(expression);
}
tx.execute_batch(&format!("INSERT INTO workdir_removal_operations_v88 ({}) SELECT {} FROM workdir_removal_operations o",columns.join(","),expressions.join(",")))?;
}
{
let columns = table_columns(&tx, "workspace_deletion_operations_v88")?;
let mut expressions = Vec::new();
for column in &columns {
let expression = match column.as_str() {
"workspace_updated_at" => "workspace_revision".to_string(),
_ => format!("o.{column}"),
};
expressions.push(expression);
}
tx.execute_batch(&format!("INSERT INTO workspace_deletion_operations_v88 ({}) SELECT {} FROM workspace_deletion_operations o",columns.join(","),expressions.join(",")))?;
}
tx.execute_batch("DROP TABLE workspace_runtime_bindings; ALTER TABLE workspace_runtime_bindings_v88 RENAME TO workspace_runtime_bindings;")?;
tx.execute_batch("DROP TABLE workspace_runtime_verifications; ALTER TABLE workspace_runtime_verifications_v88 RENAME TO workspace_runtime_verifications;")?;
tx.execute_batch("DROP TABLE workspace_runtime_binding_audit; ALTER TABLE workspace_runtime_binding_audit_v88 RENAME TO workspace_runtime_binding_audit;")?;
tx.execute_batch("DROP TABLE workspace_signing_identities; ALTER TABLE workspace_signing_identities_v88 RENAME TO workspace_signing_identities;")?;
tx.execute_batch("DROP TABLE workspace_signing_identity_provisioning_operations; ALTER TABLE workspace_signing_identity_provisioning_operations_v88 RENAME TO workspace_signing_identity_provisioning_operations;")?;
tx.execute_batch("DROP TABLE workspace_signing_identity_audit; ALTER TABLE workspace_signing_identity_audit_v88 RENAME TO workspace_signing_identity_audit;")?;
tx.execute_batch("DROP TABLE runtime_removal_operations; ALTER TABLE runtime_removal_operations_v88 RENAME TO runtime_removal_operations;")?;
tx.execute_batch("DROP TABLE workspace_worker_retention_policies; ALTER TABLE workspace_worker_retention_policies_v88 RENAME TO workspace_worker_retention_policies;")?;
tx.execute_batch("DROP TABLE workspace_worker_retention_policy_revisions; ALTER TABLE workspace_worker_retention_policy_snapshots_v88 RENAME TO workspace_worker_retention_policy_snapshots;")?;
tx.execute_batch("DROP TABLE worker_removal_operations; ALTER TABLE worker_removal_operations_v88 RENAME TO worker_removal_operations;")?;
tx.execute_batch("DROP TABLE worker_session_archives; ALTER TABLE worker_session_archives_v88 RENAME TO worker_session_archives;")?;
tx.execute_batch("DROP TABLE worker_diagnostics_archives; ALTER TABLE worker_diagnostics_archives_v88 RENAME TO worker_diagnostics_archives;")?;
tx.execute_batch("DROP TABLE worker_tombstones; ALTER TABLE worker_tombstones_v88 RENAME TO worker_tombstones;")?;
tx.execute_batch("DROP TABLE workdir_removal_operations; ALTER TABLE workdir_removal_operations_v88 RENAME TO workdir_removal_operations;")?;
tx.execute_batch("DROP TABLE workspace_deletion_operations; ALTER TABLE workspace_deletion_operations_v88 RENAME TO workspace_deletion_operations;")?;
tx.execute_batch(
r#"CREATE INDEX workspace_runtime_verifications_state_idx
ON workspace_runtime_verifications(workspace_id, state, checked_at DESC);"#,
)?;
tx.execute_batch(
r#"CREATE INDEX idx_workspace_runtime_binding_audit_recent
ON workspace_runtime_binding_audit(workspace_id, runtime_id, at DESC, binding_id DESC);"#,
)?;
tx.execute_batch(
r#"CREATE INDEX idx_workspace_runtime_bindings_workspace
ON workspace_runtime_bindings(workspace_id, revoked_at, runtime_id);"#,
)?;
tx.execute_batch(
r#"CREATE INDEX workspace_deletion_operations_workspace_recent
ON workspace_deletion_operations(workspace_id, created_at DESC);"#,
)?;
tx.execute_batch(
r#"CREATE UNIQUE INDEX idx_workdir_removal_operations_one_pending
ON workdir_removal_operations(workspace_id, workdir_id)
WHERE state = 'pending';"#,
)?;
// Config content can be evaluated under multiple toolchains/schema bundles.
// Preserve their distinct projection evidence rather than overwriting same-content rows.
let has_history: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name='workspace_config_tree_history')", [], |r|r.get(0))?;
if has_history {
tx.execute_batch(r#"CREATE TABLE workspace_config_tree_history_v88 (
workspace_id TEXT NOT NULL, content_digest TEXT NOT NULL,
toolchain_fingerprint TEXT NOT NULL, projection_digest TEXT NOT NULL,
manifest_json TEXT NOT NULL, created_at TEXT NOT NULL, schema_bundle_json TEXT NOT NULL DEFAULT '{"contributions":[],"source":"{}","fingerprint":""}',
PRIMARY KEY(workspace_id,content_digest,toolchain_fingerprint,projection_digest),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);
INSERT INTO workspace_config_tree_history_v88 SELECT workspace_id,content_digest,toolchain_fingerprint,projection_digest,manifest_json,created_at,schema_bundle_json FROM workspace_config_tree_history;
DROP TABLE workspace_config_tree_history;
ALTER TABLE workspace_config_tree_history_v88 RENAME TO workspace_config_tree_history;"#)?;
}
for (name, table, sql) in indexes {
let exists: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='index' AND name=?1)",
[&name],
|r| r.get(0),
)?;
if !exists {
let sql = if table == "workspace_runtime_binding_audit" {
sql.replace("binding_revision DESC", "at DESC, binding_id DESC")
.replace("binding_revision", "binding_id")
} else {
sql
};
tx.execute_batch(&sql)?;
}
}
for (name, sql) in triggers {
if name == "seed_worker_retention_policy_after_workspace_insert" {
continue;
}
tx.execute_batch(&sql)?;
}
tx.execute_batch(r#"CREATE TRIGGER seed_worker_retention_policy_after_workspace_insert AFTER INSERT ON workspaces BEGIN
INSERT INTO workspace_worker_retention_policy_snapshots
(workspace_id,policy_id,policy_digest,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,created_at)
VALUES(NEW.workspace_id,'workspace-default-conservative','sha256:30d45d5d58a10dadf95c3bf831e589ea10c81dc76da9575bfd9fb13942b3d198','archive','tombstone','forever',NULL,'purge',NULL,NEW.created_at);
INSERT INTO workspace_worker_retention_policies(workspace_id,policy_id,policy_digest,updated_at)
VALUES(NEW.workspace_id,'workspace-default-conservative','sha256:30d45d5d58a10dadf95c3bf831e589ea10c81dc76da9575bfd9fb13942b3d198',NEW.created_at);
END;"#)?;
tx.execute_batch(
"DROP TABLE runtime_binding_cutover_ids; DROP TABLE retention_cutover_digests;",
)?;
let violation: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM pragma_foreign_key_check)",
[],
|r| r.get(0),
)?;
if violation {
return Err(Error::Store(
"Runtime binding cutover foreign-key violation".into(),
));
}
verify_runtime_binding_content_schema(&tx)?;
tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES(88,'Runtime binding identities and retention content contracts')",[])?;
tx.commit()?;
Ok(())
})();
let restore = conn
.execute_batch(&format!(
"PRAGMA legacy_alter_table={}; PRAGMA foreign_keys={};",
i32::from(legacy_alter),
i32::from(foreign_keys)
))
.map_err(Error::from);
result.and(restore)
}
// Only the reservation lookup contract changes. Keep the same reserved key,
// material reference, actor, state and timestamps, including completed receipts.
// workspace_create receipts have an independent request contract and stay opaque.
fn migrate_existing_workspace_provisioning_receipts_v87(conn: &Connection) -> Result<()> {
let receipts = {
let mut stmt = conn.prepare(
"SELECT operation_key,request_fingerprint,workspace_id,key_id,private_material_ref,state,completed_at \
FROM workspace_signing_identity_provisioning_operations WHERE operation_kind='existing_workspace'",
)?;
stmt.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
r.get::<_, String>(3)?,
r.get::<_, String>(4)?,
r.get::<_, String>(5)?,
r.get::<_, Option<String>>(6)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?
};
for (old_operation_key, old_fingerprint, workspace, key, material_ref, state, completed_at) in
receipts
{
let identity: Option<(String, String, String)> = conn
.query_row(
"SELECT key_id,private_material_ref,state FROM workspace_signing_identities WHERE workspace_id=?1",
[&workspace],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.optional()?;
let expected_state = if state == "pending" {
"pending_provisioning"
} else {
"active"
};
if old_operation_key != format!("existing-workspace:{workspace}:revision-1")
|| old_fingerprint
!= frozen_existing_workspace_provisioning_fingerprint_v1(&workspace, &key)
|| identity != Some((key.clone(), material_ref.clone(), expected_state.into()))
|| (state == "completed") != completed_at.is_some()
{
return Err(Error::Store(format!(
"inconsistent legacy Workspace signing identity provisioning receipt `{old_operation_key}`"
)));
}
conn.execute(
"UPDATE workspace_signing_identity_provisioning_operations_v88 SET operation_key=?2,request_fingerprint=?3 WHERE operation_key=?1",
params![
old_operation_key,
format!("existing-workspace:{workspace}:key-{key}"),
crate::workspace_signing_identity::provisioning_fingerprint(&workspace, &key, &material_ref),
],
)?;
}
Ok(())
}
fn frozen_existing_workspace_provisioning_fingerprint_v1(workspace: &str, key: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(workspace.as_bytes());
hasher.update([0]);
hasher.update(key.as_bytes());
hasher.update([0]);
hasher.update(1_u64.to_be_bytes());
let digest: String = hasher
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
format!("sha256:{digest}")
}
fn verify_runtime_binding_content_schema(conn: &Connection) -> Result<()> {
for (table, required, forbidden) in [
(
"workspace_runtime_bindings",
"binding_id",
"binding_revision",
),
(
"workspace_runtime_verifications",
"workspace_public_key_fingerprint",
"runtime_identity_revision",
),
("workspace_signing_identities", "key_id", "revision"),
(
"runtime_removal_operations",
"config_digest",
"config_revision",
),
(
"workspace_worker_retention_policy_snapshots",
"policy_digest",
"revision",
),
("workdir_removal_operations", "attempt_id", "attempt_count"),
(
"workspace_deletion_operations",
"workspace_updated_at",
"workspace_revision",
),
] {
let columns = table_columns(conn, table)?;
if !columns.iter().any(|v| v == required) || columns.iter().any(|v| v == forbidden) {
return Err(Error::Store(format!(
"{table} does not match content/identity authority schema"
)));
}
}
Ok(())
}
@@ -0,0 +1,999 @@
use super::*;
// Frozen schema-87 tables: use only the legacy authorities at this cutover.
fn legacy_binding_database() -> Connection {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch("PRAGMA foreign_keys=ON;
CREATE TABLE __yoi_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL);
INSERT INTO __yoi_schema_migrations VALUES(87,'workspace schema baseline');
CREATE TABLE accounts(account_id TEXT PRIMARY KEY);
INSERT INTO accounts VALUES('owner');
CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);
INSERT INTO workspaces VALUES('space');
CREATE TABLE workspace_config_trees(workspace_id TEXT PRIMARY KEY,content_digest TEXT NOT NULL);
INSERT INTO workspace_config_trees VALUES('space','sha256:config-content');
CREATE TABLE workspace_config_tree_history(workspace_id TEXT,content_digest TEXT,toolchain_fingerprint TEXT,projection_digest TEXT,manifest_json TEXT,created_at TEXT,schema_bundle_json TEXT,PRIMARY KEY(workspace_id,content_digest));
INSERT INTO workspace_config_tree_history VALUES('space','sha256:config-content','toolchain-original','projection-original','{}','created','{}');").unwrap();
let frozen = include_str!("frozen_schema_v85.sql");
for table in [
"workspace_runtime_bindings",
"workspace_runtime_verifications",
"workspace_runtime_binding_audit",
"workspace_signing_identities",
"workspace_signing_identity_provisioning_operations",
"workspace_signing_identity_audit",
"runtime_removal_operations",
"workspace_worker_retention_policies",
"workspace_worker_retention_policy_revisions",
"worker_removal_operations",
"worker_session_archives",
"worker_diagnostics_archives",
"worker_tombstones",
"workdir_removal_operations",
"workspace_deletion_operations",
] {
let marker = format!("CREATE TABLE {table} (");
let start = frozen.find(&marker).unwrap();
let end = start + frozen[start..].find(");").unwrap() + 2;
conn.execute_batch(&frozen[start..end]).unwrap();
}
for index in [
"runtime_removal_operations_one_active_runtime",
"runtime_removal_operations_workspace_state",
"idx_workdir_removal_operations_recovery",
"idx_workdir_removal_operations_workdir",
"workspace_signing_identity_audit_workspace_idx",
] {
let start = frozen
.find(&format!("CREATE INDEX {index}"))
.or_else(|| frozen.find(&format!("CREATE UNIQUE INDEX {index}")))
.unwrap();
let end = start + frozen[start..].find(';').unwrap() + 1;
conn.execute_batch(&frozen[start..end]).unwrap();
}
conn.execute_batch("INSERT INTO workspace_signing_identities(workspace_id,key_id,algorithm,public_key,public_key_fingerprint,private_material_ref,revision,state,created_at,provisioned_at,updated_at)
VALUES('space','workspace-key','ed25519','public','sha256:workspace','material',1,'active','created','provisioned','updated');
INSERT INTO workspace_runtime_bindings(workspace_id,runtime_id,display_name,base_url,public_key,public_key_fingerprint,binding_revision,state,authentication_mode,workspace_key_id,workspace_key_generation,created_at,updated_at)
VALUES('space','remote','Remote','https://remote.test','public','sha256:runtime',8,'verified','workspace_identity','workspace-key',6,'created','updated');
INSERT INTO workspace_runtime_binding_audit VALUES('space','remote','owner','created',NULL,'sha256:runtime',8,'created');
INSERT INTO workspace_runtime_verifications VALUES('space','remote',8,'workspace-key',1,6,'sha256:runtime',2,'challenge','verified','verified','verified-at','checked-at');
INSERT INTO workspace_worker_retention_policy_revisions VALUES('space','policy',1,'archive','tombstone','forever',NULL,'purge',NULL,'first');
INSERT INTO workspace_worker_retention_policy_revisions VALUES('space','policy',9,'archive','tombstone','forever',NULL,'purge',NULL,'ninth');
INSERT INTO workspace_worker_retention_policies VALUES('space','policy',9,'updated');
INSERT INTO runtime_removal_operations VALUES('removal','space','remote','request',8,4,'pending',NULL,0,NULL,'created','updated',NULL);
CREATE TRIGGER runtime_binding_cutover_guard BEFORE UPDATE ON workspace_runtime_bindings
WHEN EXISTS(SELECT 1 FROM runtime_removal_operations WHERE runtime_id=NEW.runtime_id AND state IN ('pending','cleanup_pending'))
BEGIN SELECT RAISE(ABORT,'runtime_removal_in_progress'); END;").unwrap();
conn
}
#[test]
fn binding_cutover_preserves_key_facts_and_indices_but_requires_runtime_issued_trust() {
let conn = legacy_binding_database();
migrate_runtime_bindings_v87_to_v88(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 88);
let (id,state,trust,key,fingerprint): (String,String,Option<String>,String,String) = conn.query_row(
"SELECT binding_id,state,workspace_trust_id,workspace_key_id,workspace_public_key_fingerprint FROM workspace_runtime_bindings", [], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?))).unwrap();
assert!(id.starts_with("RB-"));
assert_eq!(state, "configured");
assert_eq!(trust, None);
assert_eq!(key, "workspace-key");
assert_eq!(fingerprint, "sha256:workspace");
assert_eq!(
conn.query_row(
"SELECT COUNT(*) FROM workspace_runtime_verifications",
[],
|r| r.get::<_, i64>(0)
)
.unwrap(),
0
);
let (expected, state, digest): (String, String, String) = conn
.query_row(
"SELECT expected_binding_id,state,config_digest FROM runtime_removal_operations",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
assert_eq!(expected, id);
assert_eq!(state, "failed");
assert_eq!(digest, "sha256:config-content");
let audit: String = conn
.query_row(
"SELECT binding_id FROM workspace_runtime_binding_audit",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(audit, id);
assert_eq!(
conn.query_row(
"SELECT COUNT(*) FROM workspace_worker_retention_policy_snapshots",
[],
|r| r.get::<_, i64>(0)
)
.unwrap(),
1
);
let update = crate::retention::WorkerRetentionPolicyUpdate {
policy_id: "policy".into(),
session_disposition: worker_runtime::retention::SessionDisposition::Archive,
metadata_disposition: crate::retention::MetadataDisposition::Tombstone,
archive_retention: crate::retention::ArchiveRetention::Forever,
diagnostics_disposition: worker_runtime::retention::DiagnosticsDisposition::Purge,
diagnostics_retention_seconds: None,
};
let digest: String = conn
.query_row(
"SELECT policy_digest FROM workspace_worker_retention_policies",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
digest,
crate::retention::worker_retention_policy_digest(&update)
);
for index in [
"runtime_removal_operations_one_active_runtime",
"runtime_removal_operations_workspace_state",
"idx_workdir_removal_operations_recovery",
"idx_workdir_removal_operations_workdir",
"workspace_signing_identity_audit_workspace_idx",
] {
assert!(
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='index' AND name=?1)",
[index],
|r| r.get::<_, bool>(0)
)
.unwrap(),
"missing index {index}"
);
}
conn.execute(
"UPDATE runtime_removal_operations SET state='cleanup_pending'",
[],
)
.unwrap();
assert!(conn.execute("INSERT INTO runtime_removal_operations SELECT 'other',workspace_id,runtime_id,request_fingerprint,expected_binding_id,config_digest,'pending',NULL,0,NULL,created_at,updated_at,NULL FROM runtime_removal_operations", []).is_err());
assert!(
conn.execute(
"UPDATE workspace_runtime_bindings SET display_name='Blocked'",
[]
)
.is_err()
);
assert_eq!(
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, i64>(0))
.unwrap(),
1
);
assert_eq!(
conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| r
.get::<_, i64>(
0
))
.unwrap(),
0
);
}
#[test]
fn same_config_content_keeps_distinct_toolchain_and_projection_history_after_cutover() {
let conn = legacy_binding_database();
migrate_runtime_bindings_v87_to_v88(&conn).unwrap();
conn.execute("INSERT INTO workspace_config_tree_history VALUES('space','sha256:config-content','toolchain-new','projection-new','{}','later','{}')", []).unwrap();
assert_eq!(conn.query_row("SELECT COUNT(*) FROM workspace_config_tree_history WHERE content_digest='sha256:config-content'", [], |r| r.get::<_, i64>(0)).unwrap(), 2);
let original: String = conn.query_row("SELECT projection_digest FROM workspace_config_tree_history WHERE toolchain_fingerprint='toolchain-original'", [], |r|r.get(0)).unwrap();
assert_eq!(original, "projection-original");
}
#[test]
fn invalid_legacy_signing_or_policy_reference_rolls_back_all_cutover_changes() {
for invalid in [
"UPDATE workspace_signing_identities SET revision=2",
"PRAGMA foreign_keys=OFF; DELETE FROM workspace_worker_retention_policy_revisions WHERE revision=9; PRAGMA foreign_keys=ON;",
"DELETE FROM workspace_config_trees",
] {
let conn = legacy_binding_database();
conn.execute_batch(invalid).unwrap();
assert!(
migrate_runtime_bindings_v87_to_v88(&conn).is_err(),
"accepted {invalid}"
);
assert_eq!(current_schema_version(&conn).unwrap(), 87);
assert!(column_exists(&conn, "workspace_runtime_bindings", "binding_revision").unwrap());
assert!(!column_exists(&conn, "workspace_runtime_bindings", "binding_id").unwrap());
assert_eq!(
conn.query_row(
"SELECT COUNT(*) FROM workspace_runtime_verifications",
[],
|r| r.get::<_, i64>(0)
)
.unwrap(),
1
);
assert_eq!(
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, i64>(0))
.unwrap(),
1
);
}
}
fn seed_legacy_worker_removal(conn: &Connection, state: &str) {
conn.execute(
"INSERT INTO worker_removal_operations \
(operation_id,plan_id,input_fingerprint,workspace_id,runtime_id,worker_id,worker_revision,\
policy_id,policy_revision,session_disposition,metadata_disposition,archive_retention_kind,\
archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,archive_id,\
blockers_json,state,reason,failure_category,created_at,updated_at) \
VALUES('worker-removal','worker-plan','legacy-receipt-fingerprint','space','remote','worker',\
'worker-updated','policy',9,'archive','tombstone','forever',NULL,'retain',3600,\
'archive','[]',?1,'cleanup','legacy-outcome','created','updated')",
[state],
).unwrap();
}
#[test]
fn unresolved_worker_removal_requires_old_version_reconciliation_without_losing_receipt_authority()
{
for state in ["executing", "failed"] {
let conn = legacy_binding_database();
seed_legacy_worker_removal(&conn, state);
let schema = || {
conn.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL ORDER BY name")
.unwrap()
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap()
};
let schema_before = schema();
let message = migrate_runtime_bindings_v87_to_v88(&conn)
.unwrap_err()
.to_string();
assert!(message.contains("old-version reconciliation"), "{message}");
assert!(message.contains("worker-removal"), "{message}");
assert!(message.contains("schema 87"), "{message}");
assert_eq!(current_schema_version(&conn).unwrap(), 87);
let evidence: (String, String, String, i64, String, String, String, String) = conn.query_row(
"SELECT state,input_fingerprint,worker_revision,policy_revision,archive_id,failure_category,created_at,updated_at FROM worker_removal_operations", [],
|r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?,r.get(6)?,r.get(7)?))).unwrap();
assert_eq!(
evidence,
(
state.into(),
"legacy-receipt-fingerprint".into(),
"worker-updated".into(),
9,
"archive".into(),
"legacy-outcome".into(),
"created".into(),
"updated".into()
)
);
assert_eq!(schema(), schema_before);
assert_eq!(
conn.query_row(
"SELECT COUNT(*) FROM workspace_runtime_verifications",
[],
|r| r.get::<_, i64>(0)
)
.unwrap(),
1
);
assert_eq!(
conn.query_row(
"SELECT binding_revision FROM workspace_runtime_bindings",
[],
|r| r.get::<_, i64>(0)
)
.unwrap(),
8
);
assert!(
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, bool>(0))
.unwrap()
);
assert!(
!conn
.query_row("PRAGMA legacy_alter_table", [], |r| r.get::<_, bool>(0))
.unwrap()
);
assert_eq!(
conn.query_row("SELECT COUNT(*) FROM sqlite_temp_schema", [], |r| r
.get::<_, i64>(0))
.unwrap(),
0
);
// Simulate the old Server committing its recovered receipt. The same
// connection can upgrade without dropping or reissuing the operation.
conn.execute(
"UPDATE worker_removal_operations SET state='succeeded',failure_category=NULL",
[],
)
.unwrap();
migrate_runtime_bindings_v87_to_v88(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 88);
assert_eq!(
conn.query_row(
"SELECT input_fingerprint FROM worker_removal_operations",
[],
|r| r.get::<_, String>(0)
)
.unwrap(),
"legacy-receipt-fingerprint"
);
}
}
#[test]
fn unexecuted_and_terminal_worker_removal_history_migrates_without_new_execution_authority() {
for state in ["planned", "blocked", "stale", "succeeded"] {
let conn = legacy_binding_database();
seed_legacy_worker_removal(&conn, state);
if state == "succeeded" {
conn.execute_batch("INSERT INTO worker_session_archives VALUES('archive','space','remote','worker','session','checksum',42,'policy',9,'worker-removal','committed',NULL);
INSERT INTO worker_diagnostics_archives VALUES('worker-removal','space','remote','worker','policy',9,'committed','expires');
INSERT INTO worker_tombstones VALUES('space','remote','worker','Worker','profile','worker-created','removed','archive','policy',9,'worker-removal');").unwrap();
}
migrate_runtime_bindings_v87_to_v88(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 88);
let (actual_state, category, fingerprint, digest): (String, Option<String>, String, String) = conn.query_row(
"SELECT state,failure_category,input_fingerprint,policy_digest FROM worker_removal_operations", [],
|r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?))).unwrap();
let unexecuted = matches!(state, "planned" | "blocked");
assert_eq!(actual_state, if unexecuted { "stale" } else { state });
assert_eq!(
category.as_deref(),
Some(if unexecuted {
"policy_content_cutover"
} else {
"legacy-outcome"
})
);
assert_eq!(fingerprint, "legacy-receipt-fingerprint");
assert_eq!(
conn.query_row(
"SELECT policy_digest FROM workspace_worker_retention_policies",
[],
|r| r.get::<_, String>(0)
)
.unwrap(),
digest
);
if state == "succeeded" {
for table in [
"worker_session_archives",
"worker_diagnostics_archives",
"worker_tombstones",
] {
let (operation, policy): (String, String) = conn
.query_row(
&format!("SELECT operation_id,policy_digest FROM {table}"),
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(operation, "worker-removal");
assert_eq!(policy, digest);
}
assert_eq!(
conn.query_row(
"SELECT checksum_sha256,content_bytes FROM worker_session_archives",
[],
|r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))
)
.unwrap(),
("checksum".into(), 42)
);
}
assert_eq!(
conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| r
.get::<_, i64>(
0
))
.unwrap(),
0
);
}
}
#[test]
fn workdir_removal_cutover_preserves_recovery_state_owner_and_materialization_evidence() {
for (state, attempts, retryable, owner) in [
("pending", 0, true, None),
("pending", 2, true, Some((1234_i64, 5678_i64))),
("failed", 2, true, None),
("failed", 2, false, None),
("completed", 2, false, None),
] {
let conn = legacy_binding_database();
conn.execute("INSERT INTO workdir_removal_operations VALUES('space','workdir-removal','workdir-request','workdir','remote','repository','materialization','owner','cleanup',?1,?2,?3,?4,?5,?6,?7,'created','updated',?8)",
params![state,attempts,retryable,if state == "completed" { Some("removed") } else { None },
if state == "failed" { Some("provider_cleanup_outcome_unknown") } else { None },
owner.map(|o|o.0),owner.map(|o|o.1),if state == "completed" { Some("completed") } else { None }]).unwrap();
migrate_runtime_bindings_v87_to_v88(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 88);
let (actual_state,attempt,actual_retryable,pid,marker,request,materialization):
(String,Option<String>,bool,Option<i64>,Option<i64>,String,String) = conn.query_row(
"SELECT state,attempt_id,retryable,attempt_owner_pid,attempt_owner_start_marker,request_fingerprint,materialization_fingerprint FROM workdir_removal_operations", [],
|r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?,r.get(6)?))).unwrap();
assert_eq!(actual_state, state);
assert_eq!(attempt.is_some(), attempts > 0);
assert_eq!(actual_retryable, retryable);
assert_eq!(pid.zip(marker), owner);
assert_eq!(request, "workdir-request");
assert_eq!(materialization, "materialization");
let (category, disposition): (Option<String>, Option<String>) = conn
.query_row(
"SELECT failure_category,disposition FROM workdir_removal_operations",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(
category.as_deref(),
if state == "failed" {
Some("provider_cleanup_outcome_unknown")
} else {
None
}
);
assert_eq!(
disposition.as_deref(),
if state == "completed" {
Some("removed")
} else {
None
}
);
assert_eq!(conn.query_row("SELECT COUNT(*) FROM workdir_removal_operations WHERE state='pending' OR (state='failed' AND retryable=1)", [], |r| r.get::<_, i64>(0)).unwrap(), i64::from(state == "pending" || (state == "failed" && retryable)));
}
}
#[test]
fn runtime_removal_cutover_keeps_post_binding_commit_cleanup_resumable() {
for state in ["cleanup_pending", "succeeded"] {
let conn = legacy_binding_database();
conn.execute("UPDATE runtime_removal_operations SET state=?1,binding_removed=1,runtime_registration_removed=?2,completed_at=?3",
params![state,if state == "succeeded" { Some(1) } else { None },if state == "succeeded" { Some("completed") } else { None }]).unwrap();
conn.execute_batch(
"DELETE FROM workspace_runtime_binding_audit; DELETE FROM workspace_runtime_bindings;",
)
.unwrap();
migrate_runtime_bindings_v87_to_v88(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 88);
let (actual_state,binding_removed,registration_removed,completed,fingerprint): (String,bool,Option<bool>,Option<String>,String) = conn.query_row(
"SELECT state,binding_removed,runtime_registration_removed,completed_at,request_fingerprint FROM runtime_removal_operations", [],
|r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?))).unwrap();
assert_eq!(actual_state, state);
assert!(binding_removed);
assert_eq!(
registration_removed,
if state == "succeeded" {
Some(true)
} else {
None
}
);
assert_eq!(
completed.as_deref(),
if state == "succeeded" {
Some("completed")
} else {
None
}
);
assert_eq!(fingerprint, "request");
assert_eq!(conn.query_row("SELECT COUNT(*) FROM runtime_removal_operations WHERE state IN ('pending','cleanup_pending')", [], |r| r.get::<_, i64>(0)).unwrap(), i64::from(state == "cleanup_pending"));
assert_eq!(
conn.query_row("SELECT COUNT(*) FROM workspace_runtime_bindings", [], |r| r
.get::<_, i64>(0))
.unwrap(),
0
);
}
}
mod existing_workspace_provisioning {
use super::*;
use crate::workspace_signing_identity::{
FsWorkspaceSigningMaterialStore, WorkspaceSigningIdentityService,
WorkspaceSigningMaterialStore, WorkspaceSigningPrivateMaterial,
};
const OLD_OPERATION_KEY: &str = "existing-workspace:space:revision-1";
// Frozen v1 fingerprint: SHA256(workspace || NUL || key || NUL || 1u64_be).
const OLD_FINGERPRINT: &str =
"sha256:913cd2ceeefdd81fcaf0363b0f5db66d39a0b9040440ca564892f3e0c93a9edd";
const NEW_OPERATION_KEY: &str = "existing-workspace:space:key-workspace-key";
fn legacy_reserved_database() -> Connection {
let conn = legacy_binding_database();
conn.execute_batch("UPDATE workspace_signing_identities SET state='pending_provisioning',public_key=NULL,public_key_fingerprint=NULL,provisioned_at=NULL;").unwrap();
conn.execute("INSERT INTO workspace_signing_identity_provisioning_operations VALUES(?1,?2,'existing_workspace','space','workspace-key','material',1,'owner','pending','reserved',NULL)",
params![OLD_OPERATION_KEY, OLD_FINGERPRINT]).unwrap();
conn
}
fn reopen_store(conn: Connection, path: &Path) -> Arc<SqliteWorkspaceStore> {
let mut persisted = Connection::open(path).unwrap();
Backup::new(&conn, &mut persisted)
.unwrap()
.run_to_completion(128, Duration::from_millis(1), None)
.unwrap();
drop(persisted);
drop(conn);
let conn = Connection::open(path).unwrap();
conn.execute_batch("PRAGMA foreign_keys=ON").unwrap();
// This fixture contains only the schema-87 cutover authorities, not the
// unrelated full Server schema. Exercise the real service/store methods.
Arc::new(SqliteWorkspaceStore {
conn: Arc::new(Mutex::new(conn)),
})
}
fn install_frozen_material(root: &Path) -> String {
let material = WorkspaceSigningPrivateMaterial::generate("space", "workspace-key").unwrap();
let public_key = material
.validate_and_public_key("space", "workspace-key")
.unwrap();
let mut old = serde_json::to_value(material).unwrap();
old["version"] = 1.into();
old["revision"] = 1.into();
std::fs::create_dir_all(root).unwrap();
std::fs::write(
root.join("material.json"),
serde_json::to_vec(&old).unwrap(),
)
.unwrap();
public_key
}
fn assert_receipt(store: &SqliteWorkspaceStore, state: &str) {
store.with_conn(|conn| {
let receipt: (String,String,String,String,String,String,String,String) = conn.query_row(
"SELECT operation_key,operation_kind,workspace_id,key_id,private_material_ref,actor_account_id,state,created_at FROM workspace_signing_identity_provisioning_operations", [],
|r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?,r.get(6)?,r.get(7)?)))?;
assert_eq!(receipt, (NEW_OPERATION_KEY.into(),"existing_workspace".into(),"space".into(),"workspace-key".into(),"material".into(),"owner".into(),state.into(),"reserved".into()));
assert_eq!(conn.query_row("SELECT COUNT(*) FROM workspace_signing_identity_provisioning_operations", [], |r| r.get::<_, i64>(0))?, 1);
Ok(())
}).unwrap();
}
#[test]
fn legacy_reserved_workspace_resumes_same_key_and_receipt_after_cutover_and_restart() {
for material_was_published in [false, true] {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("identities");
let old_public_key = material_was_published.then(|| install_frozen_material(&root));
let old_bytes =
material_was_published.then(|| std::fs::read(root.join("material.json")).unwrap());
let conn = legacy_reserved_database();
migrate_runtime_bindings_v87_to_v88(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 88);
let store = reopen_store(conn, &temp.path().join("server.db"));
let materials = Arc::new(FsWorkspaceSigningMaterialStore::new(root.clone()));
let service = WorkspaceSigningIdentityService::new(store.clone(), materials.clone());
// A recovery caller need not be the original actor: the reserved
// actor remains the authority attributed to the activation audit.
let recovered = service
.provision_existing("space", "recovery-caller")
.unwrap();
assert_eq!(recovered.key_id, "workspace-key");
assert_eq!(recovered.private_material_ref, "material");
assert_eq!(recovered.state, "active");
if let Some(public_key) = old_public_key {
assert_eq!(recovered.public_key.as_deref(), Some(public_key.as_str()));
assert_eq!(
std::fs::read(root.join("material.json")).unwrap(),
old_bytes.unwrap()
);
}
assert_receipt(&store, "completed");
assert_eq!(
service
.provision_existing("space", "recovery-caller")
.unwrap(),
recovered
);
store.with_conn(|conn| {
assert_eq!(conn.query_row("SELECT COUNT(*) FROM workspace_signing_identity_audit WHERE actor_account_id='owner' AND key_id='workspace-key'", [], |r|r.get::<_, i64>(0))?, 1);
assert!(conn.query_row("SELECT completed_at FROM workspace_signing_identity_provisioning_operations", [], |r|r.get::<_, Option<String>>(0))?.is_some());
Ok(())
}).unwrap();
assert!(materials.load("material").unwrap().is_some());
}
}
#[test]
fn legacy_reserved_workspace_activation_failure_rolls_back_and_retries_same_material() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("identities");
let old_public_key = install_frozen_material(&root);
let old_bytes = std::fs::read(root.join("material.json")).unwrap();
let conn = legacy_reserved_database();
migrate_runtime_bindings_v87_to_v88(&conn).unwrap();
conn.execute_batch("CREATE TRIGGER fail_provisioning_audit BEFORE INSERT ON workspace_signing_identity_audit BEGIN SELECT RAISE(ABORT,'injected audit failure'); END;").unwrap();
let path = temp.path().join("server.db");
let store = reopen_store(conn, &path);
let service = WorkspaceSigningIdentityService::new(
store.clone(),
Arc::new(FsWorkspaceSigningMaterialStore::new(root.clone())),
);
assert!(service.provision_existing("space", "owner").is_err());
assert_receipt(&store, "pending");
assert_eq!(
store
.get_workspace_signing_identity("space")
.unwrap()
.unwrap()
.state,
"pending_provisioning"
);
store.with_conn(|conn| {
assert_eq!(conn.query_row("SELECT COUNT(*) FROM workspace_signing_identity_audit", [], |r|r.get::<_, i64>(0))?, 0);
assert_eq!(conn.query_row("SELECT completed_at FROM workspace_signing_identity_provisioning_operations", [], |r|r.get::<_, Option<String>>(0))?, None);
conn.execute_batch("DROP TRIGGER fail_provisioning_audit")?;
Ok(())
}).unwrap();
drop(service);
drop(store);
let store = Arc::new(SqliteWorkspaceStore {
conn: Arc::new(Mutex::new(Connection::open(&path).unwrap())),
});
let service = WorkspaceSigningIdentityService::new(
store.clone(),
Arc::new(FsWorkspaceSigningMaterialStore::new(root.clone())),
);
let recovered = service.provision_existing("space", "owner").unwrap();
assert_eq!(
recovered.public_key.as_deref(),
Some(old_public_key.as_str())
);
assert_eq!(recovered.key_id, "workspace-key");
assert_receipt(&store, "completed");
assert_eq!(
std::fs::read(root.join("material.json")).unwrap(),
old_bytes
);
}
#[test]
fn completed_existing_receipt_keeps_completion_evidence_and_workspace_create_contract_stays_opaque()
{
let conn = legacy_reserved_database();
conn.execute_batch("UPDATE workspace_signing_identities SET state='active',public_key='public',public_key_fingerprint='sha256:workspace',provisioned_at='provisioned';
UPDATE workspace_signing_identity_provisioning_operations SET state='completed',completed_at='completed';
INSERT INTO workspace_signing_identity_provisioning_operations VALUES('workspace-create:request','create-request-fingerprint','workspace_create','new-space','new-key','new-material',1,'owner','pending','create-reserved',NULL);").unwrap();
migrate_runtime_bindings_v87_to_v88(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 88);
let completed: (String,String,String,String,String,String,String) = conn.query_row(
"SELECT operation_key,key_id,private_material_ref,actor_account_id,state,created_at,completed_at FROM workspace_signing_identity_provisioning_operations WHERE operation_kind='existing_workspace'", [],
|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?,r.get(6)?))).unwrap();
assert_eq!(
completed,
(
NEW_OPERATION_KEY.into(),
"workspace-key".into(),
"material".into(),
"owner".into(),
"completed".into(),
"reserved".into(),
"completed".into()
)
);
let create: (String,String,String,String,String,String,String,Option<String>) = conn.query_row(
"SELECT operation_key,request_fingerprint,workspace_id,key_id,private_material_ref,state,created_at,completed_at FROM workspace_signing_identity_provisioning_operations WHERE operation_kind='workspace_create'", [],
|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?,r.get(6)?,r.get(7)?))).unwrap();
assert_eq!(
create,
(
"workspace-create:request".into(),
"create-request-fingerprint".into(),
"new-space".into(),
"new-key".into(),
"new-material".into(),
"pending".into(),
"create-reserved".into(),
None
)
);
}
#[test]
fn late_cutover_failure_keeps_old_reservation_contract_for_upgrade_retry() {
let conn = legacy_reserved_database();
// The missing config fails the runtime-removal copy after the signing
// reservation has been converted inside the cutover transaction.
conn.execute("DELETE FROM workspace_config_trees", [])
.unwrap();
assert!(migrate_runtime_bindings_v87_to_v88(&conn).is_err());
assert_eq!(current_schema_version(&conn).unwrap(), 87);
let receipt: (String,String,String,String) = conn.query_row(
"SELECT operation_key,request_fingerprint,key_id,private_material_ref FROM workspace_signing_identity_provisioning_operations", [],
|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?))).unwrap();
assert_eq!(
receipt,
(
OLD_OPERATION_KEY.into(),
OLD_FINGERPRINT.into(),
"workspace-key".into(),
"material".into()
)
);
assert!(
column_exists(
&conn,
"workspace_signing_identity_provisioning_operations",
"revision"
)
.unwrap()
);
conn.execute(
"INSERT INTO workspace_config_trees VALUES('space','sha256:config-content')",
[],
)
.unwrap();
migrate_runtime_bindings_v87_to_v88(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 88);
assert_eq!(
conn.query_row(
"SELECT operation_key FROM workspace_signing_identity_provisioning_operations",
[],
|r| r.get::<_, String>(0)
)
.unwrap(),
NEW_OPERATION_KEY
);
}
#[test]
fn mismatched_legacy_provisioning_receipt_rolls_back_cutover_without_replacing_reservation() {
for invalid in [
"UPDATE workspace_signing_identity_provisioning_operations SET key_id='different-key'",
"UPDATE workspace_signing_identity_provisioning_operations SET private_material_ref='different-material'",
"UPDATE workspace_signing_identity_provisioning_operations SET workspace_id='different-workspace'",
"UPDATE workspace_signing_identity_provisioning_operations SET operation_key='different-operation'",
"UPDATE workspace_signing_identity_provisioning_operations SET request_fingerprint='different-input'",
"UPDATE workspace_signing_identity_provisioning_operations SET state='completed',completed_at='completed'",
] {
let conn = legacy_reserved_database();
conn.execute_batch(invalid).unwrap();
let before = || {
conn.prepare("SELECT operation_key,request_fingerprint,workspace_id,key_id,private_material_ref,state,completed_at FROM workspace_signing_identity_provisioning_operations").unwrap()
.query_row([], |r| Ok((r.get::<_, String>(0)?,r.get::<_, String>(1)?,r.get::<_, String>(2)?,r.get::<_, String>(3)?,r.get::<_, String>(4)?,r.get::<_, String>(5)?,r.get::<_, Option<String>>(6)?))).unwrap()
};
let receipt = before();
let error = migrate_runtime_bindings_v87_to_v88(&conn)
.unwrap_err()
.to_string();
assert!(
error.contains("legacy Workspace signing identity provisioning"),
"{invalid}: {error}"
);
assert_eq!(current_schema_version(&conn).unwrap(), 87);
assert_eq!(before(), receipt);
assert!(
column_exists(
&conn,
"workspace_signing_identity_provisioning_operations",
"revision"
)
.unwrap()
);
assert!(
column_exists(&conn, "workspace_runtime_bindings", "binding_revision").unwrap()
);
assert_eq!(
conn.query_row(
"SELECT COUNT(*) FROM workspace_runtime_verifications",
[],
|r| r.get::<_, i64>(0)
)
.unwrap(),
1
);
assert_eq!(
conn.query_row("SELECT COUNT(*) FROM sqlite_temp_schema", [], |r| r
.get::<_, i64>(0))
.unwrap(),
0
);
assert!(
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, bool>(0))
.unwrap()
);
}
}
}
mod current_authority {
use super::*;
fn binding_store() -> (SqliteWorkspaceStore, WorkspaceRuntimeBinding) {
let store = SqliteWorkspaceStore::in_memory().unwrap();
let workspace_key =
worker_runtime::auth::RuntimeIdentityMaterial::generate("workspace-key").unwrap();
let runtime_key =
worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-key").unwrap();
let workspace_fingerprint = normalize_runtime_public_key(&workspace_key.public_key)
.unwrap()
.1;
store.with_conn(|conn| {
conn.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('owner','user','owner','Owner','created','updated');
INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('space','owner','Space','active','created','updated');")?;
conn.execute("INSERT INTO workspace_signing_identities(workspace_id,key_id,algorithm,public_key,public_key_fingerprint,private_material_ref,state,created_at,provisioned_at,updated_at) VALUES('space','workspace-key','ed25519',?1,?2,'material','active','created','provisioned','updated')", params![workspace_key.public_key,workspace_fingerprint])?;
Ok(())
}).unwrap();
let request = WorkspaceRuntimeBinding {
workspace_id: "space".into(),
runtime_id: "remote".into(),
display_name: "Remote".into(),
base_url: "https://remote.test".into(),
public_key: runtime_key.public_key,
public_key_fingerprint: String::new(),
binding_id: String::new(),
state: WorkspaceRuntimeBindingState::Configured,
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
workspace_key_id: Some("workspace-key".into()),
workspace_public_key_fingerprint: Some(workspace_fingerprint),
workspace_trust_id: Some("runtime-issued-trust".into()),
created_at: "created".into(),
updated_at: "updated".into(),
revoked_at: None,
};
let (_, binding) = store
.put_workspace_runtime_binding_key(request, None, "owner")
.unwrap();
(store, binding)
}
fn evidence(binding: &WorkspaceRuntimeBinding) -> WorkspaceRuntimeVerificationEvidence {
WorkspaceRuntimeVerificationEvidence {
workspace_id: binding.workspace_id.clone(),
runtime_id: binding.runtime_id.clone(),
binding_id: binding.binding_id.clone(),
workspace_key_id: binding.workspace_key_id.clone().unwrap(),
workspace_public_key_fingerprint: binding
.workspace_public_key_fingerprint
.clone()
.unwrap(),
workspace_trust_id: binding.workspace_trust_id.clone().unwrap(),
runtime_public_key_fingerprint: binding.public_key_fingerprint.clone(),
challenge_id: "challenge".into(),
state: "verified".into(),
last_outcome: "verified".into(),
verified_at: Some("verified-at".into()),
checked_at: "checked-at".into(),
}
}
#[test]
fn same_key_reactivation_fences_old_cas_and_verification_evidence() {
let (store, binding) = binding_store();
let old_proof = evidence(&binding);
store
.record_workspace_runtime_verification_attempt(&old_proof)
.unwrap();
store
.complete_workspace_runtime_verification(&old_proof)
.unwrap();
let (_, revoked) = store
.revoke_workspace_runtime_binding_key(
"space",
"remote",
&binding.binding_id,
"owner",
"revoked-at",
)
.unwrap();
assert_ne!(binding.binding_id, revoked.binding_id);
let mut replacement = binding.clone();
replacement.state = WorkspaceRuntimeBindingState::Configured;
let (_, reactivated) = store
.put_workspace_runtime_binding_key(replacement, Some(&revoked.binding_id), "owner")
.unwrap();
assert_eq!(
binding.public_key_fingerprint,
reactivated.public_key_fingerprint
);
assert_ne!(binding.binding_id, reactivated.binding_id);
assert!(matches!(
store.put_workspace_runtime_binding_key(
reactivated.clone(),
Some(&binding.binding_id),
"owner"
),
Err(Error::RuntimeBindingIdConflict { .. })
));
assert!(matches!(
store.revoke_workspace_runtime_binding_key(
"space",
"remote",
&binding.binding_id,
"owner",
"later"
),
Err(Error::RuntimeBindingIdConflict { .. })
));
assert!(
store
.record_workspace_runtime_verification_attempt(&old_proof)
.is_err()
);
assert!(
store
.complete_workspace_runtime_verification(&old_proof)
.is_err()
);
assert!(
!store
.workspace_runtime_verification_matches(
&reactivated,
&old_proof.workspace_public_key_fingerprint,
&old_proof.workspace_trust_id
)
.unwrap()
);
}
#[test]
fn endpoint_aba_and_workspace_key_change_cannot_reuse_verification_authority() {
let (store, binding) = binding_store();
let proof = evidence(&binding);
store
.record_workspace_runtime_verification_attempt(&proof)
.unwrap();
store
.complete_workspace_runtime_verification(&proof)
.unwrap();
let moved = store
.update_workspace_runtime_binding_metadata(
"space",
"remote",
"Remote",
"https://other.test",
"changed",
)
.unwrap();
let returned = store
.update_workspace_runtime_binding_metadata(
"space",
"remote",
"Remote",
"https://remote.test",
"changed-back",
)
.unwrap();
assert_ne!(moved.binding_id, binding.binding_id);
assert_ne!(returned.binding_id, binding.binding_id);
assert!(
store
.record_workspace_runtime_verification_attempt(&proof)
.is_err()
);
let current = evidence(&returned);
store
.record_workspace_runtime_verification_attempt(&current)
.unwrap();
store.with_conn(|conn| { conn.execute("UPDATE workspace_signing_identities SET public_key_fingerprint='sha256:replacement' WHERE workspace_id='space'",[])?; Ok(()) }).unwrap();
assert!(
store
.complete_workspace_runtime_verification(&current)
.is_err()
);
assert_eq!(
store
.get_workspace_runtime_binding("space", "remote")
.unwrap()
.unwrap()
.state,
WorkspaceRuntimeBindingState::Configured
);
}
}
File diff suppressed because it is too large Load Diff
+39 -33
View File
@@ -564,7 +564,7 @@ pub enum WorkerSpawnIntent {
job_id: String,
attempt_id: String,
purpose: String,
input_revision: String,
input_digest: String,
subjektiv_consolidation: bool,
},
TicketRole {
@@ -1723,8 +1723,8 @@ impl RuntimeRegistry {
"workspace_prompt_projection_notification_failed",
HostDiagnosticSeverity::Warning,
format!(
"runtime '{}' rejected Workspace Prompt projection revision {}: {message}",
runtime.runtime_id(), projection.config_revision
"runtime '{}' rejected Workspace Prompt projection digest {}: {message}",
runtime.runtime_id(), projection.projection_digest
),
)
})
@@ -3614,22 +3614,33 @@ impl WorkspaceRuntimeAuthorization {
"Workspace Runtime binding is missing its Workspace key".to_string(),
)
})?;
let trust_generation = binding.workspace_key_generation.ok_or_else(|| {
let workspace_public_key_fingerprint = binding
.workspace_public_key_fingerprint
.as_deref()
.ok_or_else(|| {
diagnostic(
"workspace_runtime_authorization_invalid",
HostDiagnosticSeverity::Error,
"Workspace Runtime binding is missing its Workspace public key fingerprint"
.to_string(),
)
})?;
let workspace_trust_id = binding.workspace_trust_id.as_deref().ok_or_else(|| {
diagnostic(
"workspace_runtime_authorization_invalid",
HostDiagnosticSeverity::Error,
"Workspace Runtime binding is missing its trust generation".to_string(),
"Workspace Runtime binding is missing its Workspace trust ID".to_string(),
)
})?;
if identity.state != "active"
|| identity.key_id != workspace_key_id
|| identity.revision != trust_generation
|| identity.public_key_fingerprint.as_deref() != Some(workspace_public_key_fingerprint)
|| !self
.store
.workspace_runtime_verification_matches(
&binding,
identity.revision,
trust_generation,
workspace_public_key_fingerprint,
workspace_trust_id,
)
.map_err(|error| {
diagnostic(
@@ -3650,9 +3661,9 @@ impl WorkspaceRuntimeAuthorization {
issuer: self.backend_url.clone(),
issuer_workspace_id: binding.workspace_id.clone(),
issuer_key_id: identity.key_id,
issuer_identity_revision: identity.revision,
trust_generation,
binding_revision: binding.binding_revision,
issuer_public_key_fingerprint: workspace_public_key_fingerprint.to_string(),
trust_id: workspace_trust_id.to_string(),
binding_id: binding.binding_id.clone(),
runtime_id: binding.runtime_id.clone(),
worker_id: worker_id.map(str::to_string),
operation: operation.to_string(),
@@ -5726,13 +5737,13 @@ fn runtime_create_worker_request(
WorkerSpawnIntent::BackendJob {
job_id,
attempt_id,
input_revision,
input_digest,
subjektiv_consolidation,
..
} => Some(worker_runtime::catalog::BackendJobExecutionBinding {
job_id: job_id.clone(),
attempt_id: attempt_id.clone(),
input_revision: Some(input_revision.clone()),
input_digest: Some(input_digest.clone()),
subjektiv_consolidation: *subjektiv_consolidation,
}),
_ => None,
@@ -5824,7 +5835,6 @@ fn builtin_profile_config_bundle(
metadata: ConfigBundleMetadata {
id,
digest: String::new(),
revision: "workspace-runtime-v0".to_string(),
workspace_id: workspace_id.to_string(),
created_at: "runtime-generated".to_string(),
provenance: ConfigBundleProvenance {
@@ -6771,7 +6781,6 @@ mod tests {
fn test_memory_settings() -> manifest::WorkspaceMemorySettingsSnapshot {
manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-test".to_string(),
settings_revision: 1,
language: "English".to_string(),
}
}
@@ -7040,7 +7049,6 @@ mod tests {
metadata: worker_runtime::config_bundle::ConfigBundleMetadata {
id: "bundle-1".to_string(),
digest: String::new(),
revision: "rev-1".to_string(),
workspace_id: "local:test".to_string(),
created_at: "2026-06-26T00:00:00Z".to_string(),
provenance: worker_runtime::config_bundle::ConfigBundleProvenance {
@@ -7137,10 +7145,9 @@ mod tests {
let observed = Arc::new(Mutex::new(Vec::new()));
let expected = RepositoryRefObservation {
repository_id: "repository-1".to_string(),
source_revision: 7,
source_fingerprint: "sha256:source".to_string(),
selector: "refs/heads/published".to_string(),
revision_ref: "0123456789012345678901234567890123456789".to_string(),
resolved_ref: "0123456789012345678901234567890123456789".to_string(),
observed_at_epoch_seconds: 42,
};
let runtime = EmbeddedWorkerRuntime::new_memory_with_execution_backend(
@@ -7159,7 +7166,6 @@ mod tests {
kind: server_api::RepositorySourceKind::LocalPath,
uri: "/provider/repository.git".to_string(),
},
source_revision: 7,
source_fingerprint: "sha256:source".to_string(),
selector: None,
},
@@ -7259,7 +7265,7 @@ mod tests {
runtime_id: String,
host_id: String,
workers: Vec<InternalWorkerSummary>,
observed_prompt_revisions: Arc<Mutex<Vec<u64>>>,
observed_prompt_digests: Arc<Mutex<Vec<String>>>,
}
impl FixtureRuntime {
@@ -7295,7 +7301,7 @@ mod tests {
workdir_attachments: Vec::new(),
diagnostics: Vec::new(),
}],
observed_prompt_revisions: Arc::new(Mutex::new(Vec::new())),
observed_prompt_digests: Arc::new(Mutex::new(Vec::new())),
}
}
}
@@ -7309,10 +7315,10 @@ mod tests {
&self,
projection: worker::WorkspacePromptProjection,
) -> Result<(), String> {
self.observed_prompt_revisions
self.observed_prompt_digests
.lock()
.map_err(|_| "prompt projection observations poisoned".to_string())?
.push(projection.config_revision);
.push(projection.projection_digest);
Ok(())
}
@@ -7404,14 +7410,14 @@ mod tests {
fn registry_gate_rejects_cached_runtime_immediately_after_binding_revocation() {
let remote =
FixtureRuntime::with_worker("runtime-a", "host-a", "worker-a", "worker from runtime a");
let remote_observed = remote.observed_prompt_revisions.clone();
let remote_observed = remote.observed_prompt_digests.clone();
let embedded = FixtureRuntime::with_worker(
EMBEDDED_RUNTIME_ID,
"embedded-host",
"embedded-worker",
"embedded worker",
);
let embedded_observed = embedded.observed_prompt_revisions.clone();
let embedded_observed = embedded.observed_prompt_digests.clone();
let registry = RuntimeRegistry::new(vec![Arc::new(remote), Arc::new(embedded)]);
let active = Arc::new(Mutex::new(true));
let gate_state = active.clone();
@@ -7437,14 +7443,14 @@ mod tests {
"default".to_string(),
"workspace prompt".to_string(),
)]),
12,
"schema",
"toolchain",
)
.unwrap();
let expected_digest = catalog.catalog_digest.clone();
let projection = worker::WorkspacePromptProjection::new(
"workspace-a",
"source-12",
"source-content",
catalog.catalog_digest.clone(),
catalog,
)
@@ -7455,28 +7461,28 @@ mod tests {
.is_empty()
);
assert!(remote_observed.lock().unwrap().is_empty());
assert_eq!(*embedded_observed.lock().unwrap(), vec![12]);
assert_eq!(*embedded_observed.lock().unwrap(), vec![expected_digest]);
}
#[test]
fn registry_broadcasts_workspace_prompt_projection_revisions() {
fn registry_broadcasts_workspace_prompt_projection_digests() {
let runtime =
FixtureRuntime::with_worker("runtime-a", "host-a", "worker-a", "worker from runtime a");
let observed = runtime.observed_prompt_revisions.clone();
let observed = runtime.observed_prompt_digests.clone();
let registry = RuntimeRegistry::new(vec![Arc::new(runtime)]);
let catalog = worker::EffectivePromptCatalog::new(
std::collections::BTreeMap::from([(
"default".to_string(),
"workspace prompt".to_string(),
)]),
12,
"schema",
"toolchain",
)
.unwrap();
let expected_digest = catalog.catalog_digest.clone();
let projection = worker::WorkspacePromptProjection::new(
"workspace-a",
"source-12",
"source-content",
catalog.catalog_digest.clone(),
catalog,
)
@@ -7485,7 +7491,7 @@ mod tests {
let diagnostics = registry.observe_workspace_prompt_projection(projection);
assert!(diagnostics.is_empty());
assert_eq!(*observed.lock().unwrap(), vec![12]);
assert_eq!(*observed.lock().unwrap(), vec![expected_digest]);
}
#[test]
@@ -22,17 +22,20 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
let runtime_identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
let signer = RuntimeVerificationSigner::from_identity(&runtime_identity).unwrap();
let now = Utc::now().timestamp();
let workspace_public_key_fingerprint =
crate::workspace_signing_identity::public_key_fingerprint(&workspace_identity.public_key)
.unwrap();
let binding_id = "binding-restore-test".to_string();
let verifications = Arc::new(InMemoryWorkspaceRuntimeVerificationAuthority::default());
verifications
.record(WorkspaceRuntimeVerificationRecord {
workspace_id: "workspace-test".into(),
runtime_id: "runtime-test".into(),
binding_revision: 1,
binding_id: binding_id.clone(),
workspace_trust_id: "trust-restore-test".to_string(),
workspace_key_id: "workspace-key".into(),
workspace_identity_revision: 1,
workspace_trust_generation: 1,
workspace_public_key_fingerprint: workspace_public_key_fingerprint.clone(),
runtime_public_key_fingerprint: signer.public_key_fingerprint().to_owned(),
runtime_identity_revision: 1,
verified_at: now,
})
.unwrap();
@@ -52,8 +55,7 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
),
identity_revision: 1,
trust_generation: 1,
trust_id: "trust-restore-test".to_string(),
state: WorkspaceIssuerTrustState::Active,
registered_at_unix: now,
updated_at_unix: now,
@@ -73,7 +75,7 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
);
let worker_id = EmbeddedWorkerId::from_legacy_u64(1).to_string();
let body = serde_json::to_vec(&runtime_api::WorkerRestoreCoordinationRequest {
expected_observation_token: "observed-generation".into(),
expected_observation_token: "observed-runtime-state".into(),
request_id: "restore-request".into(),
preparation: None,
})
@@ -90,14 +92,35 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
"wrong-operation",
"wrong-worker",
"wrong-body",
"wrong-key-fingerprint",
"wrong-binding-id",
"wrong-trust-id",
] {
let claims = WorkspaceCapabilityClaims {
issuer: "https://backend.test".into(),
issuer_workspace_id: "workspace-test".into(),
issuer_key_id: "workspace-key".into(),
issuer_identity_revision: 1,
trust_generation: 1,
binding_revision: 1,
issuer_public_key_fingerprint: if case == "wrong-key-fingerprint" {
format!(
"sha256:{}",
Sha256::digest(b"another-workspace-key")
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
)
} else {
workspace_public_key_fingerprint.clone()
},
binding_id: if case == "wrong-binding-id" {
"binding-retired".to_string()
} else {
binding_id.clone()
},
trust_id: if case == "wrong-trust-id" {
"trust-retired".to_string()
} else {
"trust-restore-test".to_string()
},
runtime_id: "runtime-test".into(),
worker_id: if case == "wrong-worker" {
Some(EmbeddedWorkerId::from_legacy_u64(2).to_string())
@@ -153,22 +176,36 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
} else {
assert_eq!(
status,
StatusCode::UNAUTHORIZED,
if case == "wrong-binding-id" {
StatusCode::FORBIDDEN
} else {
StatusCode::UNAUTHORIZED
},
"{path} {case}: {}",
error.error.message
);
assert_eq!(error.error.code, "unauthorized");
assert_eq!(
error.error.code,
if case == "wrong-binding-id" {
"workspace_runtime_verification_stale"
} else {
"unauthorized"
}
);
let expected = match case {
"wrong-operation" => "does not authorize this operation",
"wrong-worker" => "targets another Worker",
"wrong-body" => "does not bind this request body",
"wrong-operation" => Some("does not authorize this operation"),
"wrong-worker" => Some("targets another Worker"),
"wrong-body" => Some("does not bind this request body"),
"wrong-key-fingerprint" | "wrong-binding-id" | "wrong-trust-id" => None,
_ => unreachable!(),
};
assert!(
error.error.message.contains(expected),
"{path} {case}: {}",
error.error.message
);
if let Some(expected) = expected {
assert!(
error.error.message.contains(expected),
"{path} {case}: {}",
error.error.message
);
}
}
}
}
+111 -76
View File
@@ -114,15 +114,14 @@ CREATE TABLE device_login_flows (
approved_at TEXT,
consumed_at TEXT
);
CREATE TABLE flow_source_revisions (
CREATE TABLE flow_source_contents (
workspace_id TEXT NOT NULL,
flow_id TEXT NOT NULL,
revision INTEGER NOT NULL CHECK (revision > 0),
content TEXT NOT NULL,
content_digest TEXT NOT NULL,
definition_json TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, flow_id, revision),
PRIMARY KEY (workspace_id, flow_id, content_digest),
FOREIGN KEY (workspace_id, flow_id)
REFERENCES flow_sources(workspace_id, flow_id) ON DELETE CASCADE
);
@@ -134,7 +133,6 @@ CREATE TABLE flow_sources (
path TEXT NOT NULL,
content TEXT NOT NULL,
content_digest TEXT NOT NULL,
revision INTEGER NOT NULL CHECK (revision > 0),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, flow_id),
@@ -232,7 +230,6 @@ CREATE TABLE "repositories" (
updated_at TEXT NOT NULL,
source_kind TEXT NOT NULL,
source_uri TEXT NOT NULL,
source_revision INTEGER NOT NULL DEFAULT 1,
source_fingerprint TEXT NOT NULL,
observed_status TEXT NOT NULL DEFAULT 'unverified',
observed_at TEXT,
@@ -245,7 +242,7 @@ CREATE TABLE repository_secret_audit_events (
event_id TEXT NOT NULL,
kind TEXT NOT NULL,
resource_id TEXT NOT NULL,
revision INTEGER NOT NULL CHECK (revision >= 1),
operation_id TEXT NOT NULL CHECK (length(operation_id) > 0),
actor_account_id TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, event_id),
@@ -257,19 +254,20 @@ CREATE TABLE repository_secret_operations (
request_fingerprint TEXT NOT NULL,
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('credential', 'host_trust')),
resource_id TEXT NOT NULL,
result_revision INTEGER NOT NULL CHECK (result_revision >= 1),
result_operation_id TEXT NOT NULL CHECK (length(result_operation_id) > 0),
created_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, operation_id),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
CREATE TABLE repository_ssh_credential_revisions (
CREATE TABLE repository_ssh_credential_keys (
workspace_id TEXT NOT NULL,
credential_id TEXT NOT NULL,
revision INTEGER NOT NULL CHECK (revision >= 1),
operation_id TEXT NOT NULL CHECK (length(operation_id) > 0),
public_key_algorithm TEXT NOT NULL,
public_key_fingerprint TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, credential_id, revision),
PRIMARY KEY (workspace_id, credential_id, operation_id),
UNIQUE (workspace_id, credential_id, operation_id, public_key_fingerprint),
FOREIGN KEY (workspace_id, credential_id)
REFERENCES repository_ssh_credentials(workspace_id, credential_id)
ON DELETE CASCADE
@@ -280,24 +278,24 @@ CREATE TABLE repository_ssh_credentials (
name TEXT NOT NULL,
public_key_algorithm TEXT NOT NULL,
public_key_fingerprint TEXT NOT NULL,
current_revision INTEGER NOT NULL CHECK (current_revision >= 1),
current_operation_id TEXT NOT NULL CHECK (length(current_operation_id) > 0),
status TEXT NOT NULL CHECK (status IN ('active', 'revoked')),
created_at TEXT NOT NULL,
rotated_at TEXT,
PRIMARY KEY (workspace_id, credential_id),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
CREATE TABLE repository_ssh_host_trust_revisions (
CREATE TABLE repository_ssh_host_trust_keys (
workspace_id TEXT NOT NULL,
host_trust_id TEXT NOT NULL,
revision INTEGER NOT NULL CHECK (revision >= 1),
operation_id TEXT NOT NULL CHECK (length(operation_id) > 0),
hostname TEXT NOT NULL,
port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535),
key_algorithm TEXT NOT NULL,
host_key TEXT NOT NULL,
fingerprint TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, host_trust_id, revision),
PRIMARY KEY (workspace_id, host_trust_id, operation_id),
FOREIGN KEY (workspace_id, host_trust_id)
REFERENCES repository_ssh_host_trusts(workspace_id, host_trust_id)
ON DELETE CASCADE
@@ -310,24 +308,24 @@ CREATE TABLE repository_ssh_host_trusts (
key_algorithm TEXT NOT NULL,
host_key TEXT NOT NULL,
fingerprint TEXT NOT NULL,
current_revision INTEGER NOT NULL CHECK (current_revision >= 1),
current_operation_id TEXT NOT NULL CHECK (length(current_operation_id) > 0),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, host_trust_id),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
CREATE TABLE server_secret_versions (
CREATE TABLE server_secret_objects (
workspace_id TEXT NOT NULL,
secret_id TEXT NOT NULL,
revision INTEGER NOT NULL CHECK (revision >= 1),
operation_id TEXT NOT NULL CHECK (length(operation_id) > 0),
purpose TEXT NOT NULL CHECK (purpose IN ('private_key', 'passphrase')),
encryption_algorithm TEXT NOT NULL CHECK (encryption_algorithm = 'aes-256-gcm-v1'),
nonce BLOB NOT NULL CHECK (length(nonce) = 12),
ciphertext BLOB NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, secret_id, revision, purpose),
FOREIGN KEY (workspace_id, secret_id, revision)
REFERENCES repository_ssh_credential_revisions(workspace_id, credential_id, revision)
PRIMARY KEY (workspace_id, secret_id, operation_id, purpose),
FOREIGN KEY (workspace_id, secret_id, operation_id)
REFERENCES repository_ssh_credential_keys(workspace_id, credential_id, operation_id)
ON DELETE CASCADE
);
CREATE TABLE ticket_assignment_operations (
@@ -443,11 +441,12 @@ CREATE TABLE workspace_runtime_bindings (
base_url TEXT NOT NULL,
public_key TEXT NOT NULL,
public_key_fingerprint TEXT NOT NULL,
binding_revision INTEGER NOT NULL DEFAULT 1 CHECK (binding_revision > 0),
binding_id TEXT NOT NULL CHECK (length(binding_id) > 0),
state TEXT NOT NULL CHECK (state IN ('configured', 'verified', 'revoked')),
authentication_mode TEXT NOT NULL CHECK (authentication_mode IN ('legacy_server_issuer', 'workspace_identity')),
workspace_key_id TEXT,
workspace_key_generation INTEGER CHECK (workspace_key_generation > 0),
workspace_public_key_fingerprint TEXT,
workspace_trust_id TEXT CHECK (length(workspace_trust_id) > 0),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
revoked_at TEXT,
@@ -455,9 +454,9 @@ CREATE TABLE workspace_runtime_bindings (
UNIQUE (workspace_id, public_key_fingerprint),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT,
CHECK (
(authentication_mode = 'legacy_server_issuer' AND workspace_key_id IS NULL AND workspace_key_generation IS NULL)
(authentication_mode = 'legacy_server_issuer' AND workspace_key_id IS NULL AND workspace_public_key_fingerprint IS NULL AND workspace_trust_id IS NULL)
OR
(authentication_mode = 'workspace_identity' AND workspace_key_id IS NOT NULL AND workspace_key_generation IS NOT NULL)
(authentication_mode = 'workspace_identity' AND workspace_key_id IS NOT NULL AND (state != 'verified' OR (workspace_trust_id IS NOT NULL AND workspace_public_key_fingerprint IS NOT NULL)))
),
CHECK (
(state = 'revoked' AND revoked_at IS NOT NULL)
@@ -468,12 +467,11 @@ CREATE TABLE workspace_runtime_bindings (
CREATE TABLE workspace_runtime_verifications (
workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
binding_revision INTEGER NOT NULL CHECK(binding_revision > 0),
binding_id TEXT NOT NULL CHECK(length(binding_id) > 0),
workspace_key_id TEXT NOT NULL,
workspace_identity_revision INTEGER NOT NULL CHECK(workspace_identity_revision > 0),
workspace_trust_generation INTEGER NOT NULL CHECK(workspace_trust_generation > 0),
workspace_public_key_fingerprint TEXT NOT NULL CHECK(length(workspace_public_key_fingerprint) > 0),
workspace_trust_id TEXT NOT NULL CHECK(length(workspace_trust_id) > 0),
runtime_public_key_fingerprint TEXT NOT NULL,
runtime_identity_revision INTEGER NOT NULL CHECK(runtime_identity_revision > 0),
challenge_id TEXT NOT NULL,
state TEXT NOT NULL CHECK(state IN ('pending', 'verified', 'failed')),
last_outcome TEXT NOT NULL,
@@ -494,15 +492,15 @@ CREATE TABLE workspace_runtime_binding_audit (
action TEXT NOT NULL CHECK (action IN ('created', 'replaced', 'reactivated', 'revoked')),
old_fingerprint TEXT,
new_fingerprint TEXT,
binding_revision INTEGER NOT NULL CHECK (binding_revision > 0),
binding_id TEXT NOT NULL CHECK (length(binding_id) > 0),
at TEXT NOT NULL,
PRIMARY KEY (workspace_id, runtime_id, binding_revision),
PRIMARY KEY (workspace_id, runtime_id, binding_id),
FOREIGN KEY(workspace_id, runtime_id)
REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE RESTRICT,
FOREIGN KEY(actor_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT
);
CREATE INDEX idx_workspace_runtime_binding_audit_recent
ON workspace_runtime_binding_audit(workspace_id, runtime_id, binding_revision DESC);
ON workspace_runtime_binding_audit(workspace_id, runtime_id, at DESC, binding_id DESC);
CREATE TABLE typed_ticket_artifacts (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, relative_path),
@@ -621,13 +619,12 @@ CREATE TABLE workdir_create_operations (
selector TEXT,
requested_runtime_id TEXT,
resolved_runtime_id TEXT NOT NULL,
config_revision INTEGER NOT NULL,
config_projection_digest TEXT NOT NULL,
working_directory_id TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('pending', 'succeeded', 'failed')),
failure TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, source_kind TEXT, source_uri TEXT, source_revision INTEGER, source_fingerprint TEXT, credential_id TEXT, credential_revision INTEGER, host_trust_id TEXT, host_trust_revision INTEGER, repository_access_mode TEXT,
updated_at TEXT NOT NULL, source_kind TEXT, source_uri TEXT, source_fingerprint TEXT, credential_id TEXT, credential_fingerprint TEXT, host_trust_id TEXT, host_trust_fingerprint TEXT, repository_access_mode TEXT,
PRIMARY KEY (workspace_id, operation_id),
UNIQUE (workspace_id, working_directory_id)
);
@@ -637,34 +634,34 @@ CREATE TABLE workdir_create_credential_candidates (
ordinal INTEGER NOT NULL CHECK (ordinal >= 0 AND ordinal < 2),
role TEXT NOT NULL CHECK (role IN ('primary', 'workspace_default_fallback')),
credential_id TEXT NOT NULL CHECK (length(credential_id) BETWEEN 1 AND 128),
credential_revision INTEGER NOT NULL CHECK (credential_revision > 0),
credential_fingerprint TEXT NOT NULL CHECK (length(credential_fingerprint) > 0),
PRIMARY KEY (workspace_id, operation_id, ordinal),
UNIQUE (workspace_id, operation_id, ordinal, credential_id, credential_fingerprint),
UNIQUE (workspace_id, operation_id, role),
UNIQUE (workspace_id, operation_id, credential_id),
FOREIGN KEY (workspace_id, operation_id)
REFERENCES workdir_create_operations(workspace_id, operation_id)
ON DELETE CASCADE
);
CREATE INDEX idx_workdir_create_credential_candidates_revision
CREATE INDEX idx_workdir_create_credential_candidates_fingerprint
ON workdir_create_credential_candidates(
workspace_id, credential_id, credential_revision
workspace_id, credential_id, credential_fingerprint
);
CREATE TABLE workdir_create_credential_revision_retentions (
CREATE TABLE workdir_create_credential_retentions (
workspace_id TEXT NOT NULL,
operation_id TEXT NOT NULL,
ordinal INTEGER NOT NULL,
credential_id TEXT NOT NULL,
credential_revision INTEGER NOT NULL,
credential_fingerprint TEXT NOT NULL,
credential_operation_id TEXT NOT NULL,
PRIMARY KEY (workspace_id, operation_id, ordinal),
FOREIGN KEY (workspace_id, operation_id, ordinal)
FOREIGN KEY (workspace_id, operation_id, ordinal, credential_id, credential_fingerprint)
REFERENCES workdir_create_credential_candidates(
workspace_id, operation_id, ordinal
workspace_id, operation_id, ordinal, credential_id, credential_fingerprint
)
ON DELETE CASCADE,
FOREIGN KEY (workspace_id, credential_id, credential_revision)
REFERENCES repository_ssh_credential_revisions(
workspace_id, credential_id, revision
)
FOREIGN KEY (workspace_id, credential_id, credential_operation_id, credential_fingerprint)
REFERENCES repository_ssh_credential_keys(workspace_id, credential_id, operation_id, public_key_fingerprint)
ON DELETE RESTRICT
);
CREATE TABLE "workdir_registry" (
@@ -734,7 +731,7 @@ CREATE TABLE workdir_removal_operations (
source_actor TEXT NOT NULL,
reason TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('pending', 'failed', 'completed')),
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
attempt_id TEXT,
retryable INTEGER NOT NULL CHECK (retryable IN (0, 1)),
disposition TEXT CHECK (disposition IN ('removed', 'retained', 'attention_required')),
failure_category TEXT,
@@ -774,7 +771,7 @@ CREATE TABLE worker_create_reservations (
create_fingerprint TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('reserved', 'created', 'removed')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, request_fingerprint TEXT, memory_settings_revision INTEGER, memory_language TEXT,
updated_at TEXT NOT NULL, request_fingerprint TEXT, memory_language TEXT,
singleton_key TEXT,
singleton_generation INTEGER CHECK (singleton_generation IS NULL OR singleton_generation > 0),
PRIMARY KEY (workspace_id, allocation_key),
@@ -810,7 +807,7 @@ CREATE TABLE worker_singleton_owners (
);
CREATE TABLE worker_diagnostics_archives (
operation_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL,
worker_id TEXT NOT NULL, policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL,
worker_id TEXT NOT NULL, policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL,
committed_at TEXT NOT NULL, expires_at TEXT NOT NULL,
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);
@@ -846,7 +843,7 @@ CREATE TABLE backend_jobs (
workspace_id TEXT NOT NULL,
job_id TEXT NOT NULL,
purpose TEXT NOT NULL,
input_revision TEXT NOT NULL,
input_digest TEXT NOT NULL,
input_ref TEXT NOT NULL,
resource_key TEXT,
request_json TEXT NOT NULL,
@@ -874,7 +871,7 @@ CREATE TABLE backend_job_attempts (
job_id TEXT NOT NULL,
attempt_id TEXT NOT NULL,
attempt INTEGER NOT NULL CHECK (attempt > 0 AND attempt <= 3),
input_revision TEXT NOT NULL,
input_digest TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('reserved', 'dispatching', 'dispatched', 'completed', 'failed', 'unknown')),
runtime_id TEXT,
worker_id TEXT,
@@ -955,8 +952,8 @@ CREATE TABLE worker_registry_projection_diagnostics (
CREATE TABLE worker_removal_operations (
operation_id TEXT PRIMARY KEY, plan_id TEXT NOT NULL UNIQUE, input_fingerprint TEXT NOT NULL,
workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
worker_revision TEXT NOT NULL,
policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL,
worker_updated_at TEXT NOT NULL,
policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL,
session_disposition TEXT NOT NULL, metadata_disposition TEXT NOT NULL,
archive_retention_kind TEXT NOT NULL, archive_retention_seconds INTEGER,
diagnostics_disposition TEXT NOT NULL,
@@ -972,7 +969,7 @@ CREATE TABLE worker_retention_audit_events (
CREATE TABLE worker_session_archives (
archive_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
session_id TEXT NOT NULL, checksum_sha256 TEXT NOT NULL, content_bytes INTEGER NOT NULL,
policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL, operation_id TEXT NOT NULL UNIQUE,
policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL, operation_id TEXT NOT NULL UNIQUE,
committed_at TEXT NOT NULL, expires_at TEXT,
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id));
@@ -990,7 +987,7 @@ CREATE INDEX worker_session_archive_observe_grants_controller
CREATE TABLE worker_tombstones (
workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
display_name TEXT NOT NULL, profile TEXT, worker_created_at TEXT NOT NULL, removed_at TEXT NOT NULL,
archive_id TEXT, policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL, operation_id TEXT NOT NULL UNIQUE,
archive_id TEXT, policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL, operation_id TEXT NOT NULL UNIQUE,
PRIMARY KEY(workspace_id,runtime_id,worker_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
FOREIGN KEY(archive_id) REFERENCES worker_session_archives(archive_id),
@@ -1027,21 +1024,19 @@ CREATE TABLE workspace_config_entries (
PRIMARY KEY (workspace_id, path),
FOREIGN KEY (workspace_id) REFERENCES workspace_config_trees(workspace_id) ON DELETE CASCADE
);
CREATE TABLE workspace_config_tree_revisions (
CREATE TABLE workspace_config_tree_history (
workspace_id TEXT NOT NULL,
revision INTEGER NOT NULL,
tree_digest TEXT NOT NULL,
content_digest TEXT NOT NULL,
toolchain_fingerprint TEXT NOT NULL,
projection_digest TEXT NOT NULL,
manifest_json TEXT NOT NULL,
created_at TEXT NOT NULL, schema_bundle_json TEXT NOT NULL DEFAULT '{"contributions":[],"source":"{}","fingerprint":""}',
PRIMARY KEY (workspace_id, revision),
PRIMARY KEY (workspace_id, content_digest, toolchain_fingerprint, projection_digest),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
CREATE TABLE workspace_config_trees (
workspace_id TEXT PRIMARY KEY,
revision INTEGER NOT NULL CHECK (revision >= 0),
tree_digest TEXT NOT NULL,
content_digest TEXT NOT NULL,
schema_version INTEGER NOT NULL,
entrypoints_json TEXT NOT NULL,
decodal_version TEXT NOT NULL,
@@ -1065,7 +1060,6 @@ CREATE TABLE workspace_signing_identities (
public_key TEXT,
public_key_fingerprint TEXT,
private_material_ref TEXT NOT NULL UNIQUE,
revision INTEGER NOT NULL CHECK (revision >= 1),
state TEXT NOT NULL CHECK (state IN ('pending_provisioning', 'active')),
created_at TEXT NOT NULL,
provisioned_at TEXT,
@@ -1084,7 +1078,6 @@ CREATE TABLE workspace_signing_identity_provisioning_operations (
workspace_id TEXT NOT NULL UNIQUE,
key_id TEXT NOT NULL UNIQUE,
private_material_ref TEXT NOT NULL UNIQUE,
revision INTEGER NOT NULL CHECK (revision >= 1),
actor_account_id TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('pending', 'completed')),
created_at TEXT NOT NULL,
@@ -1095,7 +1088,6 @@ CREATE TABLE workspace_signing_identity_audit (
workspace_id TEXT NOT NULL,
key_id TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('provisioned')),
revision INTEGER NOT NULL CHECK (revision >= 1),
public_key_fingerprint TEXT NOT NULL,
actor_account_id TEXT NOT NULL,
created_at TEXT NOT NULL,
@@ -1111,7 +1103,6 @@ CREATE TABLE workspace_memory_documents (
);
CREATE TABLE workspace_memory_settings (
workspace_id TEXT PRIMARY KEY NOT NULL,
settings_revision INTEGER NOT NULL CHECK(settings_revision >= 1),
language TEXT NOT NULL CHECK(length(trim(language)) > 0),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
@@ -1137,18 +1128,18 @@ CREATE TABLE "workspace_resource_keys" (
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
CREATE TABLE workspace_worker_retention_policies (
workspace_id TEXT PRIMARY KEY, policy_id TEXT NOT NULL, revision INTEGER NOT NULL, updated_at TEXT NOT NULL,
FOREIGN KEY(workspace_id,policy_id,revision) REFERENCES workspace_worker_retention_policy_revisions(workspace_id,policy_id,revision),
workspace_id TEXT PRIMARY KEY, policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL, updated_at TEXT NOT NULL,
FOREIGN KEY(workspace_id,policy_id,policy_digest) REFERENCES workspace_worker_retention_policy_snapshots(workspace_id,policy_id,policy_digest),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);
CREATE TABLE workspace_worker_retention_policy_revisions (
workspace_id TEXT NOT NULL, policy_id TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>0),
CREATE TABLE workspace_worker_retention_policy_snapshots (
workspace_id TEXT NOT NULL, policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL CHECK(length(policy_digest)>0),
session_disposition TEXT NOT NULL CHECK(session_disposition IN ('archive','purge')),
metadata_disposition TEXT NOT NULL CHECK(metadata_disposition IN ('tombstone','purge')),
archive_retention_kind TEXT NOT NULL CHECK(archive_retention_kind IN ('forever','for_seconds')),
archive_retention_seconds INTEGER,
diagnostics_disposition TEXT NOT NULL CHECK(diagnostics_disposition IN ('purge','retain')),
diagnostics_retention_seconds INTEGER, created_at TEXT NOT NULL,
PRIMARY KEY(workspace_id,policy_id,revision),
PRIMARY KEY(workspace_id,policy_id,policy_digest),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);
CREATE TABLE "workspaces" (
workspace_id TEXT PRIMARY KEY,
@@ -1252,8 +1243,8 @@ CREATE TABLE runtime_removal_operations (
workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
request_fingerprint TEXT NOT NULL,
expected_binding_revision INTEGER NOT NULL,
config_revision INTEGER NOT NULL,
expected_binding_id TEXT NOT NULL CHECK(length(expected_binding_id)>0),
config_digest TEXT NOT NULL CHECK(length(config_digest)>0),
state TEXT NOT NULL CHECK (state IN ('pending', 'cleanup_pending', 'succeeded', 'failed')),
failure_category TEXT,
binding_removed INTEGER NOT NULL CHECK (binding_removed IN (0, 1)),
@@ -1446,7 +1437,7 @@ CREATE TABLE workspace_deletion_operations (
request_fingerprint TEXT NOT NULL,
workspace_id TEXT NOT NULL,
workspace_display_name TEXT NOT NULL,
workspace_revision TEXT NOT NULL,
workspace_updated_at TEXT NOT NULL,
owner_account_id TEXT NOT NULL,
actor_account_id TEXT NOT NULL,
state TEXT NOT NULL CHECK(state IN ('queued', 'running', 'blocked', 'failed', 'succeeded')),
@@ -1464,11 +1455,11 @@ CREATE INDEX workspace_deletion_operations_workspace_recent
ON workspace_deletion_operations(workspace_id, created_at DESC);
CREATE TRIGGER seed_worker_retention_policy_after_workspace_insert AFTER INSERT ON workspaces BEGIN
INSERT INTO workspace_worker_retention_policy_revisions
(workspace_id,policy_id,revision,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,created_at)
VALUES(NEW.workspace_id,'workspace-default-conservative',1,'archive','tombstone','forever',NULL,'purge',NULL,NEW.created_at);
INSERT INTO workspace_worker_retention_policies(workspace_id,policy_id,revision,updated_at)
VALUES(NEW.workspace_id,'workspace-default-conservative',1,NEW.created_at);
INSERT INTO workspace_worker_retention_policy_snapshots
(workspace_id,policy_id,policy_digest,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,created_at)
VALUES(NEW.workspace_id,'workspace-default-conservative','sha256:30d45d5d58a10dadf95c3bf831e589ea10c81dc76da9575bfd9fb13942b3d198','archive','tombstone','forever',NULL,'purge',NULL,NEW.created_at);
INSERT INTO workspace_worker_retention_policies(workspace_id,policy_id,policy_digest,updated_at)
VALUES(NEW.workspace_id,'workspace-default-conservative','sha256:30d45d5d58a10dadf95c3bf831e589ea10c81dc76da9575bfd9fb13942b3d198',NEW.created_at);
END;
CREATE TRIGGER ticket_assignment_operations_validate_insert
BEFORE INSERT ON ticket_assignment_operations
@@ -1654,3 +1645,47 @@ CREATE TRIGGER workspace_drive_grants_worker_deleted BEFORE DELETE ON worker_reg
WHERE workspace_id=OLD.workspace_id AND runtime_id=OLD.runtime_id
AND worker_id=OLD.worker_id AND revoked=0;
END;
-- Frozen evidence only. Neither table is a key, lease, or mutation authority.
CREATE TABLE workdir_create_legacy_ssh_archives (
workspace_id TEXT NOT NULL,
operation_id TEXT NOT NULL,
operation_json TEXT NOT NULL CHECK (json_valid(operation_json)),
candidates_json TEXT NOT NULL CHECK (json_valid(candidates_json)),
PRIMARY KEY (workspace_id, operation_id),
FOREIGN KEY (workspace_id, operation_id)
REFERENCES workdir_create_operations(workspace_id, operation_id) ON DELETE CASCADE
);
CREATE TRIGGER workdir_create_legacy_archive_cannot_authorize_update
BEFORE UPDATE ON workdir_create_operations FOR EACH ROW
WHEN EXISTS (SELECT 1 FROM workdir_create_legacy_ssh_archives a
WHERE a.workspace_id=OLD.workspace_id AND a.operation_id=OLD.operation_id)
AND (NEW.state <> 'succeeded' OR NEW.credential_id IS NOT NULL
OR NEW.credential_fingerprint IS NOT NULL OR NEW.host_trust_id IS NOT NULL
OR NEW.host_trust_fingerprint IS NOT NULL OR NEW.repository_access_mode IS NOT NULL)
BEGIN SELECT RAISE(ABORT, 'archived_workdir_create_cannot_authorize'); END;
CREATE TRIGGER workdir_create_legacy_archive_blocks_candidates
BEFORE INSERT ON workdir_create_credential_candidates FOR EACH ROW
WHEN EXISTS (SELECT 1 FROM workdir_create_legacy_ssh_archives a
WHERE a.workspace_id=NEW.workspace_id AND a.operation_id=NEW.operation_id)
BEGIN SELECT RAISE(ABORT, 'archived_workdir_create_cannot_authorize'); END;
CREATE TRIGGER workdir_create_legacy_archive_blocks_candidate_updates
BEFORE UPDATE ON workdir_create_credential_candidates FOR EACH ROW
WHEN EXISTS (SELECT 1 FROM workdir_create_legacy_ssh_archives a
WHERE a.workspace_id=NEW.workspace_id AND a.operation_id=NEW.operation_id)
BEGIN SELECT RAISE(ABORT, 'archived_workdir_create_cannot_authorize'); END;
CREATE TABLE repository_secret_legacy_receipts (
workspace_id TEXT NOT NULL,
operation_id TEXT NOT NULL,
request_fingerprint TEXT NOT NULL,
resource_kind TEXT NOT NULL,
resource_id TEXT NOT NULL,
result_revision INTEGER NOT NULL,
created_at TEXT NOT NULL,
mutation_kind TEXT,
expected_revision INTEGER,
expected_operation_id TEXT,
expected_key_fingerprint TEXT,
PRIMARY KEY (workspace_id, operation_id),
FOREIGN KEY (workspace_id, operation_id)
REFERENCES repository_secret_operations(workspace_id, operation_id) ON DELETE CASCADE
);
+4 -4
View File
@@ -136,10 +136,10 @@ pub enum Error {
WorkspaceConfigConflict(String),
#[error("Runtime binding conflict: {0}")]
RuntimeBindingConflict(String),
#[error("Runtime binding revision conflict: expected {expected:?}, current {actual:?}")]
RuntimeBindingRevisionConflict {
expected: Option<u64>,
actual: Option<u64>,
#[error("Runtime binding identity conflict: expected {expected:?}, current {actual:?}")]
RuntimeBindingIdConflict {
expected: Option<String>,
actual: Option<String>,
},
#[error("Runtime public key fingerprint is already bound in this Workspace: {fingerprint}")]
RuntimeBindingFingerprintConflict { fingerprint: String },
+6 -4
View File
@@ -786,11 +786,12 @@ mod tests {
base_url: "https://runtime.example.test".to_owned(),
public_key: "unused".to_owned(),
public_key_fingerprint: "unused".to_owned(),
binding_revision: 1,
binding_id: "binding-test".to_owned(),
state: WorkspaceRuntimeBindingState::Verified,
authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
workspace_public_key_fingerprint: None,
workspace_trust_id: None,
created_at: "2026-09-01T00:00:00Z".to_owned(),
updated_at: "2026-09-01T00:00:00Z".to_owned(),
revoked_at: None,
@@ -813,11 +814,12 @@ mod tests {
base_url: "http://127.0.0.1:8788".to_owned(),
public_key: "unused".to_owned(),
public_key_fingerprint: "unused".to_owned(),
binding_revision: 1,
binding_id: "binding-test".to_owned(),
state: WorkspaceRuntimeBindingState::Verified,
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
workspace_key_id: Some("WK-test".to_owned()),
workspace_key_generation: Some(1),
workspace_public_key_fingerprint: Some("sha256:workspace-key".to_owned()),
workspace_trust_id: Some("trust-test".to_owned()),
created_at: "2026-09-01T00:00:00Z".to_owned(),
updated_at: "2026-09-01T00:00:00Z".to_owned(),
revoked_at: None,
@@ -176,7 +176,7 @@ fn memory_evidence_origin_projection(origin: EvidenceOrigin) -> MemoryEvidenceOr
worker_id: origin.worker_id,
flow_selector: origin.flow_selector,
flow_definition_id: origin.flow_definition_id,
flow_definition_revision: origin.flow_definition_revision,
flow_definition_fingerprint: origin.flow_definition_fingerprint,
}
}
@@ -313,7 +313,7 @@ mod tests {
worker_id: Some("worker-1".to_string()),
flow_selector: Some("builtin:coder-review".to_string()),
flow_definition_id: Some("flow-1".to_string()),
flow_definition_revision: Some(7),
flow_definition_fingerprint: Some("sha256:flow-content".into()),
}),
..SourceEvidenceRef::default()
});
@@ -328,7 +328,10 @@ mod tests {
Some("builtin:coder-review")
);
assert_eq!(origin.flow_definition_id.as_deref(), Some("flow-1"));
assert_eq!(origin.flow_definition_revision, Some(7));
assert_eq!(
origin.flow_definition_fingerprint,
Some("sha256:flow-content".into())
);
}
}
@@ -0,0 +1,124 @@
// Public migration entrypoints must preserve the old authority on refusal and
// validate encrypted data using the original database's secret context.
fn seed_migration_workspace(conn: &Connection) {
conn.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at)
VALUES('owner','user','owner','Owner','1','1');
INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at)
VALUES('space','owner','Space','active','1','1');").unwrap();
}
#[test]
fn opening_schema_84_with_uncertain_removal_preserves_old_server_recovery() {
for state in ["executing", "failed"] {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("server.db");
prepare_retained_schema(&path, 84);
let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap();
seed_migration_workspace(&conn);
conn.execute_batch("INSERT INTO workspace_worker_retention_policy_revisions VALUES('space','policy',1,'archive','tombstone','forever',NULL,'retain',3600,'created');
UPDATE workspace_worker_retention_policies SET policy_id='policy',revision=1,updated_at='updated' WHERE workspace_id='space';").unwrap();
conn.execute("INSERT INTO worker_removal_operations
(operation_id,plan_id,input_fingerprint,workspace_id,runtime_id,worker_id,worker_revision,
policy_id,policy_revision,session_disposition,metadata_disposition,archive_retention_kind,
archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,archive_id,
blockers_json,state,reason,failure_category,created_at,updated_at)
VALUES('removal','plan','legacy-receipt','space','runtime','worker','updated',
'policy',1,'archive','tombstone','forever',NULL,'retain',3600,'archive',
'[]',?1,'cleanup','uncertain','created','updated')",[state]).unwrap();
let schema_before: String = conn.query_row(
"SELECT group_concat(sql, char(10)) FROM (SELECT sql FROM sqlite_schema ORDER BY type,name)",
[], |row| row.get(0)).unwrap();
drop(conn);
for attempt in 0..3 {
let error = match attempt {
0 => SqliteWorkspaceStore::open(&path).err().unwrap(),
1 => SqliteWorkspaceStore::migration_plan(&path).unwrap_err(),
_ => SqliteWorkspaceStore::migrate_database(&path).unwrap_err(),
};
assert!(
error.to_string().contains("receipt reconciliation"),
"{error}"
);
let conn = Connection::open(&path).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 84);
let schema_after: String = conn.query_row(
"SELECT group_concat(sql, char(10)) FROM (SELECT sql FROM sqlite_schema ORDER BY type,name)",
[], |row| row.get(0)).unwrap();
assert_eq!(schema_after, schema_before);
let receipt: (String,String,String) = conn.query_row(
"SELECT input_fingerprint,archive_id,state FROM worker_removal_operations WHERE operation_id='removal'",
[], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?))).unwrap();
assert_eq!(
receipt,
("legacy-receipt".into(), "archive".into(), state.into())
);
}
// Simulate the old Server settling its receipt, then upgrade normally.
let conn = Connection::open(&path).unwrap();
conn.execute(
"UPDATE worker_removal_operations SET state='succeeded' WHERE operation_id='removal'",
[],
)
.unwrap();
drop(conn);
let store = SqliteWorkspaceStore::open(&path).unwrap();
store.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
assert_eq!(conn.query_row("SELECT input_fingerprint FROM worker_removal_operations WHERE operation_id='removal'",[],|r|r.get::<_,String>(0))?,"legacy-receipt");
Ok(())
}).unwrap();
}
}
#[test]
fn migration_dry_run_and_apply_share_the_original_repository_secret_authority() {
use ring::aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey};
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("server.db");
prepare_retained_schema(&path, 84);
let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap();
seed_migration_workspace(&conn);
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','create-key','intent','credential','key',7,'created');
INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519','fingerprint',7,'active','created',NULL);
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',7,'ssh-ed25519','fingerprint','created');
INSERT INTO repository_secret_audit_events VALUES('space','audit','credential_created','key',7,'owner','created');").unwrap();
let key = LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &[42; 32]).unwrap());
let mut ciphertext = b"private bytes".to_vec();
key.seal_in_place_append_tag(
Nonce::assume_unique_for_key([1; 12]),
Aad::from(b"yoi/repository-secret/v1/space/key/7/private_key".as_slice()),
&mut ciphertext,
)
.unwrap();
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',7,'private_key','aes-256-gcm-v1',?1,?2,'created')",params![[1u8;12].as_slice(),ciphertext]).unwrap();
drop(conn);
let master_key = dir.path().join("repository-secrets.master-key");
std::fs::write(&master_key, [42; 32]).unwrap();
let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap();
assert_eq!(plan.current_schema_version, 84);
let conn = Connection::open(&path).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 84);
let unchanged: Vec<u8> = conn
.query_row("SELECT ciphertext FROM server_secret_versions", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(unchanged, ciphertext);
assert!(!table_exists(&conn, "server_secret_objects").unwrap());
drop(conn);
assert_eq!(SqliteWorkspaceStore::migrate_database(&path).unwrap(), plan);
let store = SqliteWorkspaceStore::open(&path).unwrap();
store.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?,LATEST_SCHEMA_VERSION);
let (nonce, mut sealed): (Vec<u8>,Vec<u8>) = conn.query_row(
"SELECT nonce,ciphertext FROM server_secret_objects WHERE workspace_id='space' AND secret_id='key' AND operation_id='create-key'",
[],|r|Ok((r.get(0)?,r.get(1)?)))?;
let aad = serde_json::to_string(&("yoi/repository-secret/operation","space","key","create-key","private_key")).unwrap();
let plaintext = key.open_in_place(Nonce::assume_unique_for_key(nonce.try_into().unwrap()),Aad::from(aad.as_bytes()),&mut sealed).unwrap();
assert_eq!(plaintext,b"private bytes");
Ok(())
}).unwrap();
assert_eq!(std::fs::read(master_key).unwrap(), [42; 32]);
}
+27 -36
View File
@@ -145,7 +145,7 @@ pub(crate) fn project_profiles_from_evaluation(
resolve_profile_artifact_value(
entry.profile.clone(),
ProfileSource::Archive {
archive_id: format!("workspace-config-r{}", state.snapshot.revision),
archive_id: format!("workspace-config:{}", state.snapshot.digest),
source: entry.selector.clone(),
},
Path::new("/"),
@@ -172,7 +172,6 @@ pub(crate) fn project_profiles_from_evaluation(
content_digest: config_source::digest_bytes(&value_bytes),
provenance: WorkspaceProfileSourceProvenance::ProjectProfileSourceTree,
editable: false,
revision: state.snapshot.revision.to_string(),
size_bytes: value_bytes.len() as u64,
diagnostics: Vec::new(),
});
@@ -190,8 +189,6 @@ pub(crate) fn project_profiles_from_evaluation(
Ok(ProfileConfigProjection {
settings: ProfileSettingsResponse {
workspace_id: workspace_id.to_string(),
registry_revision: format!("config:{}", state.snapshot.revision),
config_revision: Some(state.snapshot.revision),
tree_digest: Some(state.snapshot.digest.clone()),
projection_digest: Some(evaluation.projection_digest.clone()),
default_profile: Some(config.profile.default_profile),
@@ -259,10 +256,6 @@ fn validate_prompt_projection_matches_state(
projection.projection_digest != prompt_catalog.catalog_digest,
"projection digest",
),
(
prompt_catalog.config_revision != state.snapshot.revision,
"config revision",
),
(
prompt_catalog.schema_fingerprint != state.contract.schema_bundle.fingerprint,
"schema fingerprint",
@@ -296,8 +289,6 @@ fn virtual_profile_bundle_id(
hasher.update(b"workspace-profile-launch-v1\0");
hasher.update(workspace_id.as_bytes());
hasher.update(b"\0");
hasher.update(state.snapshot.revision.to_le_bytes());
hasher.update(b"\0");
hasher.update(state.snapshot.digest.as_bytes());
hasher.update(b"\0");
hasher.update(state.projection_digest.as_bytes());
@@ -320,10 +311,7 @@ fn virtual_profile_bundle_id(
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
Ok(format!(
"workspace-config-profile-r{}-{identity}",
state.snapshot.revision
))
Ok(format!("workspace-config-profile-{identity}"))
}
pub fn build_virtual_profile_config_bundle(
@@ -386,7 +374,6 @@ pub fn build_virtual_profile_config_bundle_with_prompt_projection(
prompt_projection: &worker::WorkspacePromptProjection,
) -> Result<Option<ConfigBundle>> {
if projection.settings.workspace_id != workspace_id
|| projection.settings.config_revision != Some(state.snapshot.revision)
|| projection.settings.tree_digest.as_deref() != Some(state.snapshot.digest.as_str())
|| projection.settings.projection_digest.as_deref()
!= Some(state.projection_digest.as_str())
@@ -418,14 +405,13 @@ pub fn build_virtual_profile_config_bundle_with_prompt_projection(
metadata: ConfigBundleMetadata {
id: bundle_id,
digest: String::new(),
revision: state.snapshot.revision.to_string(),
workspace_id: workspace_id.to_string(),
created_at: workspace_created_at.to_string(),
provenance: ConfigBundleProvenance {
source: "workspace_config".to_string(),
detail: Some(format!(
"revision={} tree={} projection={}",
state.snapshot.revision, state.snapshot.digest, state.projection_digest
"content={} projection={}",
state.snapshot.digest, state.projection_digest
)),
},
},
@@ -474,7 +460,7 @@ fn build_virtual_profile_archive(
)
})?;
ProfileSourceArchive::build_evaluated_profile_with_builtin_sources(
format!("workspace-config-profile-r{}", state.snapshot.revision),
format!("workspace-config-profile:{}", state.snapshot.digest),
selector.to_string(),
entry.profile.clone(),
builtin_sources,
@@ -489,7 +475,7 @@ pub fn workspace_metadata_settings(
workspace_id: workspace.workspace_id.clone(),
display_name: workspace.display_name.clone(),
created_at: workspace.created_at.clone(),
revision: workspace.updated_at.clone(),
updated_at: workspace.updated_at.clone(),
source: "server_db".to_string(),
diagnostics: Vec::new(),
}
@@ -586,7 +572,7 @@ mod tests {
assert_eq!(settings.workspace_id, workspace.workspace_id);
assert_eq!(settings.display_name, workspace.display_name);
assert_eq!(settings.created_at, workspace.created_at);
assert_eq!(settings.revision, workspace.updated_at);
assert_eq!(settings.updated_at, workspace.updated_at);
assert_eq!(settings.source, "server_db");
assert!(settings.diagnostics.is_empty());
}
@@ -606,7 +592,7 @@ mod tests {
}
fn virtual_state(entries: Vec<config_source::ConfigEntry>) -> WorkspaceConfigState {
let snapshot = config_source::ConfigTreeSnapshot::from_entries(7, entries).unwrap();
let snapshot = config_source::ConfigTreeSnapshot::from_entries(entries).unwrap();
let schema_bundle = config_source::WorkspaceConfigSchemaBundle::compose([
ProfileConfigSchemaProvider.contribution().unwrap(),
crate::prompt_settings::PromptConfigSchemaProvider
@@ -632,7 +618,7 @@ mod tests {
}
#[test]
fn virtual_config_projection_builds_archive_from_active_revision() {
fn virtual_config_projection_builds_archive_from_active_content() {
let state = virtual_state(vec![
config_source::ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
@@ -657,9 +643,12 @@ mod tests {
)
.unwrap()
.unwrap();
assert_eq!(projection.settings.config_revision, Some(7));
assert_eq!(
projection.settings.tree_digest.as_deref(),
Some(state.snapshot.digest.as_str())
);
let prompt_catalog = bundle.prompt_catalog.as_ref().unwrap();
assert_eq!(prompt_catalog.config_revision, 7);
assert_eq!(prompt_catalog.source_digest, state.snapshot.digest);
assert!(!prompt_catalog.templates.is_empty());
assert!(
bundle
@@ -667,7 +656,7 @@ mod tests {
.provenance
.detail
.unwrap()
.contains("revision=7")
.contains(&format!("content={}", state.snapshot.digest))
);
let archive = bundle.profile_source_archive.unwrap();
assert_eq!(
@@ -767,18 +756,13 @@ mod tests {
companion
.metadata
.id
.starts_with("workspace-config-profile-r7-")
);
assert!(
coder
.metadata
.id
.starts_with("workspace-config-profile-r7-")
.starts_with("workspace-config-profile-")
);
assert!(coder.metadata.id.starts_with("workspace-config-profile-"));
}
#[test]
fn virtual_config_launch_bundle_rejects_prompt_projection_from_other_revision() {
fn virtual_config_launch_bundle_rejects_prompt_projection_from_other_content() {
let state = virtual_state(vec![
config_source::ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
@@ -792,7 +776,14 @@ mod tests {
crate::prompt_settings::project_workspace_prompt_projection("workspace-test", &state)
.unwrap();
let mut mismatched_state = state.clone();
mismatched_state.snapshot.revision += 1;
mismatched_state.snapshot = state
.snapshot
.apply(&[config_source::ConfigTreeChange::Create {
path: VirtualPath::parse("changed.txt").unwrap(),
content_type: ConfigContentType::Text,
content: "different source tree".into(),
}])
.unwrap();
let error = build_virtual_profile_config_bundle_with_prompt_projection(
&projection,
@@ -866,7 +857,7 @@ mod tests {
.contribution()
.unwrap()])
.unwrap();
let snapshot = config_source::ConfigTreeSnapshot::from_entries(1, [config_source::ConfigEntry::new(
let snapshot = config_source::ConfigTreeSnapshot::from_entries([config_source::ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(), ConfigContentType::Decodal,
r#"{ profile = { entries = [{ selector = "project:alpha"; profile = {}; }]; }; } as WorkspaceConfigSchema"#,
).unwrap()]).unwrap();
+102 -211
View File
@@ -12,7 +12,6 @@ use crate::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct PromptProjectionCacheKey {
workspace_id: String,
config_revision: u64,
source_digest: String,
projection_digest: String,
schema_fingerprint: String,
@@ -23,7 +22,6 @@ impl PromptProjectionCacheKey {
fn new(workspace_id: &str, state: &WorkspaceConfigState) -> Self {
Self {
workspace_id: workspace_id.to_string(),
config_revision: state.snapshot.revision,
source_digest: state.snapshot.digest.clone(),
projection_digest: state.projection_digest.clone(),
schema_fingerprint: state.contract.schema_bundle.fingerprint.clone(),
@@ -37,15 +35,15 @@ type PromptProjectionCell = OnceLock<std::result::Result<Arc<WorkspacePromptProj
#[derive(Debug, Default)]
struct PromptProjectionCacheState {
entries: BTreeMap<PromptProjectionCacheKey, Arc<PromptProjectionCell>>,
active: BTreeMap<String, PromptProjectionCacheKey>,
}
/// WorkspaceApi-shared immutable Prompt projections keyed by authoritative Workspace config
/// identity.
///
/// This cache is an evaluation optimization only. Callers must load the active
/// [`WorkspaceConfigState`] from Server DB authority before resolving an entry. Advancing a
/// Workspace replaces only its active cache entry; in-flight users retain their immutable `Arc`.
/// [`WorkspaceConfigState`] from Server DB authority before resolving an entry.
/// Content and evaluation contracts are distinct keys; in-flight users retain their
/// immutable `Arc`. No ordering or freshness is inferred from a digest.
#[derive(Debug, Clone, Default)]
pub struct WorkspacePromptProjectionCache {
inner: Arc<Mutex<PromptProjectionCacheState>>,
@@ -58,33 +56,25 @@ impl WorkspacePromptProjectionCache {
state: &WorkspaceConfigState,
) -> Result<Arc<WorkspacePromptProjection>> {
let key = PromptProjectionCacheKey::new(workspace_id, state);
let (cell, cached) = {
let cell = {
let mut cache = self.lock()?;
if let Some(active) = cache.active.get(workspace_id) {
if key.config_revision == active.config_revision && key != *active {
return Err(Error::RegistryInconsistency(format!(
"Workspace Prompt projection identity changed without a config revision transition: workspace={workspace_id} revision={}",
key.config_revision
)));
}
if key.config_revision < active.config_revision {
(Arc::new(PromptProjectionCell::new()), false)
} else {
let cell = cache
.entries
.entry(key.clone())
.or_insert_with(|| Arc::new(PromptProjectionCell::new()))
.clone();
(cell, true)
}
} else {
let cell = cache
// Bound retained cells without treating any content identity as newer.
// In-flight evaluation cells are never evicted, preserving single flight.
if !cache.entries.contains_key(&key) && cache.entries.len() >= 64 {
let evict = cache
.entries
.entry(key.clone())
.or_insert_with(|| Arc::new(PromptProjectionCell::new()))
.clone();
(cell, true)
.iter()
.find(|(_, cell)| Arc::strong_count(cell) == 1)
.map(|(key, _)| key.clone());
if let Some(evict) = evict {
cache.entries.remove(&evict);
}
}
cache
.entries
.entry(key.clone())
.or_insert_with(|| Arc::new(PromptProjectionCell::new()))
.clone()
};
let resolved = cell
@@ -97,54 +87,29 @@ impl WorkspacePromptProjectionCache {
let catalog = match resolved {
Ok(catalog) => catalog,
Err(error) => {
if cached {
self.lock()?.entries.remove(&key);
}
self.lock()?.entries.remove(&key);
return Err(Error::Config(error));
}
};
if cached {
self.record_resolved(workspace_id, &key, &cell)?;
// Concurrent distinct evaluations may temporarily exceed the retention
// limit. Trim completed cells after resolution without evicting in-flight
// work or assigning a temporal order to content identities.
let mut cache = self.lock()?;
while cache.entries.len() > 64 {
let evict = cache
.entries
.iter()
.find(|(_, cell)| Arc::strong_count(cell) == 1)
.map(|(key, _)| key.clone());
let Some(evict) = evict else {
break;
};
cache.entries.remove(&evict);
}
Ok(catalog)
}
fn record_resolved(
&self,
workspace_id: &str,
key: &PromptProjectionCacheKey,
cell: &Arc<PromptProjectionCell>,
) -> Result<()> {
let mut cache = self.lock()?;
let active = cache.active.get(workspace_id).cloned();
match active {
Some(active) if active.config_revision > key.config_revision => {
cache.entries.remove(key);
}
Some(active) if active.config_revision == key.config_revision => {
if active != *key {
cache.entries.remove(key);
return Err(Error::RegistryInconsistency(format!(
"Workspace Prompt projection identity changed without a config revision transition: workspace={workspace_id} revision={}",
key.config_revision
)));
}
cache.entries.entry(key.clone()).or_insert(cell.clone());
}
_ => {
cache.entries.insert(key.clone(), cell.clone());
cache.active.insert(workspace_id.to_string(), key.clone());
cache.entries.retain(|existing, _| {
existing.workspace_id != workspace_id
|| existing == key
|| existing.config_revision > key.config_revision
});
}
}
Ok(())
}
fn lock(&self) -> Result<std::sync::MutexGuard<'_, PromptProjectionCacheState>> {
self.inner.lock().map_err(|_| {
Error::RegistryInconsistency("Prompt projection cache lock was poisoned".to_string())
@@ -192,7 +157,7 @@ pub fn validate_evaluated_prompt_catalog(
let prompts = projection.data_json.get("prompts").ok_or_else(|| {
Error::InvalidInput("Workspace config projection has no prompts namespace".to_string())
})?;
EffectivePromptCatalog::from_projection(prompts, 0, "preview", "preview")
EffectivePromptCatalog::from_projection(prompts, "preview", "preview")
.map(|_| ())
.map_err(|error| Error::InvalidInput(format!("invalid Prompt catalog: {error}")))
}
@@ -203,7 +168,7 @@ pub fn project_prompts_from_workspace_config(
let evaluation = evaluate_workspace_config_state(state, state.contract.schema_bundle.clone())?;
if evaluation.projection_digest != state.projection_digest {
return Err(Error::RegistryInconsistency(
"Prompt projection digest does not match the active Workspace config revision"
"Prompt projection digest does not match the active Workspace config content"
.to_string(),
));
}
@@ -217,7 +182,6 @@ pub fn project_prompts_from_workspace_config(
})?;
let mut catalog = EffectivePromptCatalog::from_projection(
prompts,
state.snapshot.revision,
state.contract.schema_bundle.fingerprint.clone(),
state.contract.fingerprint.clone(),
)
@@ -249,23 +213,16 @@ mod tests {
};
fn state(source: &str) -> WorkspaceConfigState {
state_at(7, source)
}
fn state_at(revision: u64, source: &str) -> WorkspaceConfigState {
let schema = WorkspaceConfigSchemaBundle::compose([PromptConfigSchemaProvider
.contribution()
.unwrap()])
.unwrap();
let snapshot = ConfigTreeSnapshot::from_entries(
revision,
[ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
source,
)
.unwrap()],
let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
source,
)
.unwrap()])
.unwrap();
let contract = ToolchainContract::with_schema_bundle(
config_source::DEFAULT_SCHEMA_VERSION,
@@ -285,134 +242,74 @@ mod tests {
}
#[test]
fn prompt_projection_cache_shares_immutable_entry_and_replaces_workspace_revision() {
fn prompt_projection_cache_reuses_content_and_separates_workspaces() {
let cache = WorkspacePromptProjectionCache::default();
let initial = state("{}");
let first = cache.resolve("workspace-a", &initial).unwrap();
let retry = cache.resolve("workspace-a", &initial).unwrap();
let retry = cache.resolve("workspace-a", &state("{}")).unwrap();
assert!(Arc::ptr_eq(&first, &retry));
assert_eq!(cache.len(), 1);
let updated = state_at(
8,
r#"{ prompts = { common = { language = "UPDATED"; }; }; }"#,
);
let updated = state(r#"{ prompts = { common = { language = "UPDATED"; }; }; }"#);
let replacement = cache.resolve("workspace-a", &updated).unwrap();
assert!(!Arc::ptr_eq(&first, &replacement));
assert_eq!(
replacement.catalog().templates["common.language"],
"UPDATED"
);
assert_eq!(cache.len(), 1);
assert!(Arc::ptr_eq(
&first,
&cache.resolve("workspace-a", &initial).unwrap()
));
assert_ne!(first.catalog().templates["common.language"], "UPDATED");
let other = cache.resolve("workspace-b", &updated).unwrap();
assert!(!Arc::ptr_eq(&replacement, &other));
assert_eq!(cache.len(), 3);
}
let other_workspace = cache.resolve("workspace-b", &updated).unwrap();
assert!(!Arc::ptr_eq(&replacement, &other_workspace));
#[test]
fn prompt_projection_cache_separates_contracts_for_same_content() {
let cache = WorkspacePromptProjectionCache::default();
let initial = state("{}");
let mut changed = initial.clone();
let bundle = WorkspaceConfigSchemaBundle::compose([
PromptConfigSchemaProvider.contribution().unwrap(),
ConfigSchemaContribution::new(
"builtin:extra",
"extra",
"1",
"{ extra = Bool default false; }",
)
.unwrap(),
])
.unwrap();
changed.contract = ToolchainContract::with_schema_bundle(
config_source::DEFAULT_SCHEMA_VERSION,
changed.contract.entrypoints.clone(),
config_source::DEFAULT_IMPORT_POLICY_VERSION,
bundle,
);
changed.projection_digest = SnapshotEnvironment::new(changed.snapshot.clone())
.evaluate_contract(&changed.contract)
.unwrap()
.projection_digest;
assert_eq!(initial.snapshot.digest, changed.snapshot.digest);
let first = cache.resolve("workspace-a", &initial).unwrap();
let second = cache.resolve("workspace-a", &changed).unwrap();
assert!(!Arc::ptr_eq(&first, &second));
assert!(Arc::ptr_eq(
&first,
&cache.resolve("workspace-a", &initial).unwrap()
));
assert_eq!(cache.len(), 2);
}
#[test]
fn prompt_projection_cache_does_not_let_stale_revision_evict_active_entry() {
fn prompt_projection_cache_bounds_retained_content_cells() {
let cache = WorkspacePromptProjectionCache::default();
let current = state_at(
8,
r#"{ prompts = { common = { language = "CURRENT"; }; }; }"#,
);
let stale = state_at(7, "{}");
let current_catalog = cache.resolve("workspace-a", &current).unwrap();
let stale_catalog = cache.resolve("workspace-a", &stale).unwrap();
let current_retry = cache.resolve("workspace-a", &current).unwrap();
assert_eq!(stale_catalog.catalog().config_revision, 7);
assert!(Arc::ptr_eq(&current_catalog, &current_retry));
assert_eq!(cache.len(), 1);
}
#[test]
fn prompt_projection_cache_rejects_same_revision_reinterpretation() {
let cache = WorkspacePromptProjectionCache::default();
let first = state_at(7, "{}");
let changed = state_at(
7,
r#"{ prompts = { common = { language = "CHANGED"; }; }; }"#,
);
cache.resolve("workspace-a", &first).unwrap();
let error = cache.resolve("workspace-a", &changed).unwrap_err();
assert!(
error
.to_string()
.contains("without a config revision transition")
);
assert_eq!(cache.len(), 1);
}
#[test]
fn prompt_projection_cache_post_init_rejects_concurrent_same_revision_identity() {
let cache = WorkspacePromptProjectionCache::default();
let first = PromptProjectionCacheKey::new("workspace-a", &state_at(7, "{}"));
let conflicting = PromptProjectionCacheKey::new(
"workspace-a",
&state_at(
7,
r#"{ prompts = { common = { language = "CONFLICT"; }; }; }"#,
),
);
let first_cell = Arc::new(PromptProjectionCell::new());
let conflicting_cell = Arc::new(PromptProjectionCell::new());
{
let mut state = cache.lock().unwrap();
state.entries.insert(first.clone(), first_cell.clone());
state
.entries
.insert(conflicting.clone(), conflicting_cell.clone());
for index in 0..80 {
let source = format!("{{ prompts = {{ common = {{ language = \"{index}\"; }}; }}; }}");
cache.resolve("workspace-a", &state(&source)).unwrap();
}
cache
.record_resolved("workspace-a", &first, &first_cell)
.unwrap();
let error = cache
.record_resolved("workspace-a", &conflicting, &conflicting_cell)
.unwrap_err();
assert!(
error
.to_string()
.contains("without a config revision transition")
);
let state = cache.lock().unwrap();
assert_eq!(state.active["workspace-a"], first);
assert!(!state.entries.contains_key(&conflicting));
}
#[test]
fn prompt_projection_cache_keeps_newer_inflight_entry_when_older_finishes_first() {
let cache = WorkspacePromptProjectionCache::default();
let older = PromptProjectionCacheKey::new("workspace-a", &state_at(7, "{}"));
let newer = PromptProjectionCacheKey::new(
"workspace-a",
&state_at(8, r#"{ prompts = { common = { language = "NEW"; }; }; }"#),
);
let older_cell = Arc::new(PromptProjectionCell::new());
let newer_cell = Arc::new(PromptProjectionCell::new());
{
let mut state = cache.lock().unwrap();
state.entries.insert(older.clone(), older_cell.clone());
state.entries.insert(newer.clone(), newer_cell.clone());
}
cache
.record_resolved("workspace-a", &older, &older_cell)
.unwrap();
assert!(cache.lock().unwrap().entries.contains_key(&newer));
cache
.record_resolved("workspace-a", &newer, &newer_cell)
.unwrap();
let state = cache.lock().unwrap();
assert_eq!(state.active["workspace-a"], newer);
assert_eq!(state.entries.len(), 1);
assert!(state.entries.contains_key(&newer));
assert_eq!(cache.len(), 64);
}
#[test]
@@ -442,7 +339,7 @@ mod tests {
let baseline = project_prompts_from_workspace_config(&state("{}")).unwrap();
let state = state(r#"{ prompts = { common = { language = "OVERRIDE"; }; }; }"#);
let catalog = project_prompts_from_workspace_config(&state).unwrap();
assert_eq!(catalog.config_revision, 7);
assert_eq!(catalog.source_digest, state.snapshot.digest);
assert_eq!(catalog.templates["common.language"], "OVERRIDE");
for (key, value) in baseline.templates {
if key != "common.language" {
@@ -462,15 +359,12 @@ mod tests {
r#"{ prompts = { common = { language = "{%- include \"missing\" -%}"; }; }; }"#,
r#"{ prompts = { common = { language = "{% include \"common.workspace\" %}"; workspace = "{% include \"common.language\" %}"; }; }; }"#,
] {
let snapshot = ConfigTreeSnapshot::from_entries(
0,
[ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
source,
)
.unwrap()],
let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
source,
)
.unwrap()])
.unwrap();
let contract = ToolchainContract::with_schema_bundle(
config_source::DEFAULT_SCHEMA_VERSION,
@@ -496,15 +390,12 @@ mod tests {
"{ prompts = { common = { unknown = \"bad\"; }; }; }",
"{ prompts = { common = { language = 42; }; }; }",
] {
let snapshot = ConfigTreeSnapshot::from_entries(
0,
[ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
source,
)
.unwrap()],
let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
source,
)
.unwrap()])
.unwrap();
let contract = ToolchainContract::with_schema_bundle(
config_source::DEFAULT_SCHEMA_VERSION,
+4 -5
View File
@@ -18,7 +18,6 @@ pub struct ConfiguredRepository {
pub repository_key: String,
pub provider: String,
pub source: RepositorySource,
pub source_revision: u64,
pub source_fingerprint: String,
pub observed_status: RepositoryObservedStatus,
pub observed_at: Option<String>,
@@ -321,7 +320,6 @@ impl RepositoryRegistryReader {
kind: repository.provider.clone(),
provider: repository.provider.clone(),
source: repository.source.clone(),
source_revision: repository.source_revision,
source_fingerprint: repository.source_fingerprint.clone(),
observed_status: repository.observed_status,
observed_at: repository.observed_at.clone(),
@@ -606,7 +604,6 @@ mod tests {
provider: "git".into(),
source_fingerprint: crate::repository_source::repository_source_fingerprint(&source),
source,
source_revision: 1,
observed_status: RepositoryObservedStatus::Unverified,
observed_at: None,
path: None,
@@ -638,7 +635,6 @@ mod tests {
provider: "git".into(),
source_fingerprint: crate::repository_source::repository_source_fingerprint(&source),
source,
source_revision: 1,
observed_status: RepositoryObservedStatus::Unverified,
observed_at: None,
path: None,
@@ -648,6 +644,10 @@ mod tests {
let projection = reader.list();
let summary = &projection.items[0];
assert_eq!(summary.source.kind, server_api::RepositorySourceKind::Ssh);
assert_eq!(
summary.source_fingerprint,
crate::repository_source::repository_source_fingerprint(&summary.source)
);
assert_eq!(
summary.observed_status,
RepositoryObservedStatus::Unverified
@@ -761,7 +761,6 @@ mod tests {
&source_descriptor,
),
source: source_descriptor,
source_revision: 1,
observed_status: RepositoryObservedStatus::Unverified,
observed_at: None,
path: Some(path.to_path_buf()),
File diff suppressed because it is too large Load Diff
@@ -99,8 +99,6 @@ impl BackendResourceBroker {
operation: BackendResourceOperation::FetchArchive,
expires_at_unix_seconds: expires_at.timestamp(),
nonce: nonce.clone(),
revision: archive.reference.digest.clone(),
generation: None,
max_bytes: DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES,
content_type: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(),
redaction: ResourceRedactionPolicy::RuntimeInternalOnly,
@@ -126,7 +124,6 @@ impl BackendResourceBroker {
workspace_id: impl Into<String>,
runtime_id: &str,
resource_id: impl Into<String>,
revision: impl Into<String>,
expires_at_unix_seconds: i64,
secret: RepositorySshAccessSecret,
) -> Result<BackendResourceHandle, BackendResourceError> {
@@ -142,7 +139,6 @@ impl BackendResourceBroker {
}
let workspace_id = workspace_id.into();
let resource_id = resource_id.into();
let revision = revision.into();
let nonce = Uuid::now_v7().to_string();
let handle = BackendResourceHandle {
kind: BackendResourceKind::RepositorySshAccess,
@@ -155,8 +151,6 @@ impl BackendResourceBroker {
operation: BackendResourceOperation::FetchOnce,
expires_at_unix_seconds,
nonce: nonce.clone(),
revision,
generation: None,
max_bytes: DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES,
content_type: REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
redaction: ResourceRedactionPolicy::RuntimeInternalOnly,
@@ -404,13 +398,12 @@ mod tests {
"workspace-test",
"runtime-test",
"repository-access-test",
"1",
i64::MAX,
RepositorySshAccessSecret {
credential_candidates: vec![
worker_runtime::resource::RepositorySshAccessSecretCandidate {
credential_id: "credential-test".to_string(),
credential_revision: 1,
public_key_fingerprint: "sha256:test-credential-key".to_string(),
private_key: "private-key-bytes".to_string(),
},
],
+169 -88
View File
@@ -38,7 +38,7 @@ pub enum ArchiveRetention {
pub struct WorkerRetentionPolicy {
pub workspace_id: String,
pub policy_id: String,
pub revision: u64,
pub policy_digest: String,
pub session_disposition: SessionDisposition,
pub metadata_disposition: MetadataDisposition,
pub archive_retention: ArchiveRetention,
@@ -97,9 +97,9 @@ pub struct WorkerRemovalPlan {
pub input_fingerprint: String,
pub workspace_id: String,
pub worker: RuntimeWorkerRef,
pub worker_revision: String,
pub worker_updated_at: String,
pub policy_id: String,
pub policy_revision: u64,
pub policy_digest: String,
pub session_disposition: SessionDisposition,
pub metadata_disposition: MetadataDisposition,
pub archive_retention: ArchiveRetention,
@@ -132,7 +132,7 @@ pub struct WorkerTombstone {
pub removed_at: String,
pub archive_id: Option<String>,
pub policy_id: String,
pub policy_revision: u64,
pub policy_digest: String,
pub operation_id: String,
}
@@ -145,7 +145,7 @@ pub struct WorkerSessionArchiveRecord {
pub checksum_sha256: String,
pub content_bytes: u64,
pub policy_id: String,
pub policy_revision: u64,
pub policy_digest: String,
pub operation_id: String,
pub committed_at: String,
pub expires_at: Option<String>,
@@ -168,8 +168,8 @@ pub enum WorkerRetentionError {
Store(#[from] StoreError),
#[error("Worker retention policy is not configured for Workspace {workspace_id}")]
PolicyMissing { workspace_id: String },
#[error("Worker retention policy revision conflict: expected {expected}, current {actual}")]
PolicyRevisionConflict { expected: u64, actual: u64 },
#[error("Worker retention policy digest conflict: expected {expected}, current {actual}")]
PolicyDigestConflict { expected: String, actual: String },
#[error("Worker was not found in the requested Workspace")]
WorkerNotFound,
#[error("Worker belongs to a different Workspace")]
@@ -195,21 +195,24 @@ impl SqliteWorkspaceStore {
pub fn update_worker_retention_policy(
&self,
workspace_id: &str,
expected: u64,
expected: &str,
update: &WorkerRetentionPolicyUpdate,
) -> Result<WorkerRetentionPolicy, WorkerRetentionError> {
validate_policy(update)?;
self.with_conn_mut(|conn| {
let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let current=load_policy(&tx,workspace_id)?.ok_or_else(|| StoreError::InvalidInput(format!("policy-missing:{workspace_id}")))?;
if current.revision!=expected { return Err(StoreError::InvalidInput(format!("policy-conflict:{expected}:{}",current.revision))); }
let revision=current.revision+1; let now=Utc::now().to_rfc3339();
tx.execute("INSERT INTO workspace_worker_retention_policy_revisions
(workspace_id,policy_id,revision,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,created_at)
VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",params![workspace_id,update.policy_id,revision,sess(update.session_disposition),meta(update.metadata_disposition),archive_kind(update.archive_retention),archive_seconds(update.archive_retention),diag(update.diagnostics_disposition),update.diagnostics_retention_seconds,now])?;
let changed=tx.execute("UPDATE workspace_worker_retention_policies SET policy_id=?1,revision=?2,updated_at=?3 WHERE workspace_id=?4 AND revision=?5",
params![update.policy_id,revision,now,workspace_id,expected])?;
if changed!=1 { return Err(StoreError::InvalidInput(format!("policy-conflict:{expected}:{revision}"))); }
if current.policy_digest != expected {
return Err(StoreError::InvalidInput(format!("policy-conflict:{}", serde_json::to_string(&(expected, &current.policy_digest)).unwrap())));
}
let digest = worker_retention_policy_digest(update);
let now = Utc::now().to_rfc3339();
tx.execute("INSERT OR IGNORE INTO workspace_worker_retention_policy_snapshots
(workspace_id,policy_id,policy_digest,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,created_at)
VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",params![workspace_id,update.policy_id,digest,sess(update.session_disposition),meta(update.metadata_disposition),archive_kind(update.archive_retention),archive_seconds(update.archive_retention),diag(update.diagnostics_disposition),update.diagnostics_retention_seconds,now])?;
let changed=tx.execute("UPDATE workspace_worker_retention_policies SET policy_id=?1,policy_digest=?2,updated_at=?3 WHERE workspace_id=?4 AND policy_digest=?5",
params![update.policy_id,digest,now,workspace_id,expected])?;
if changed!=1 { return Err(StoreError::InvalidInput(format!("policy-conflict:{}", serde_json::to_string(&(expected, &current.policy_digest)).unwrap()))); }
tx.commit()?; load_policy(conn,workspace_id)?.ok_or_else(|| StoreError::InvalidInput("updated policy missing".into()))
}).map_err(map_error)
}
@@ -243,7 +246,7 @@ impl SqliteWorkspaceStore {
let plan_id=stable("wrp",&fp); let operation_id=stable("wro",&fp);
let archive_id=(policy.session_disposition==SessionDisposition::Archive).then(||stable("wra",&fp));
let state=if blockers.is_empty(){WorkerRemovalPlanState::Planned}else{WorkerRemovalPlanState::Blocked};
tx.execute("INSERT OR IGNORE INTO worker_removal_operations(operation_id,plan_id,input_fingerprint,workspace_id,runtime_id,worker_id,worker_revision,policy_id,policy_revision,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,archive_id,blockers_json,state,reason,created_at,updated_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?20)",params![operation_id,plan_id,fp,req.workspace_id,req.worker.runtime_id,req.worker.worker_id,worker.updated_at,policy.policy_id,policy.revision,sess(policy.session_disposition),meta(policy.metadata_disposition),archive_kind(policy.archive_retention),archive_seconds(policy.archive_retention),diag(policy.diagnostics_disposition),policy.diagnostics_retention_seconds,archive_id,serde_json::to_string(&blockers).map_err(|e|StoreError::InvalidInput(e.to_string()))?,state_s(state),req.reason,now])?;
tx.execute("INSERT OR IGNORE INTO worker_removal_operations(operation_id,plan_id,input_fingerprint,workspace_id,runtime_id,worker_id,worker_updated_at,policy_id,policy_digest,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,archive_id,blockers_json,state,reason,created_at,updated_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?20)",params![operation_id,plan_id,fp,req.workspace_id,req.worker.runtime_id,req.worker.worker_id,worker.updated_at,policy.policy_id,policy.policy_digest,sess(policy.session_disposition),meta(policy.metadata_disposition),archive_kind(policy.archive_retention),archive_seconds(policy.archive_retention),diag(policy.diagnostics_disposition),policy.diagnostics_retention_seconds,archive_id,serde_json::to_string(&blockers).map_err(|e|StoreError::InvalidInput(e.to_string()))?,state_s(state),req.reason,now])?;
let plan=load_plan(&tx,&plan_id)?.ok_or_else(||StoreError::InvalidInput("plan missing".into()))?;
if plan.input_fingerprint!=fp{return Err(StoreError::InvalidInput(format!("fingerprint:{}",plan.operation_id)));}
tx.commit()?; Ok(plan)
@@ -270,14 +273,14 @@ impl SqliteWorkspaceStore {
}
if !plan.blockers.is_empty(){return Err(StoreError::InvalidInput(format!("blocked:{}",serde_json::to_string(&plan.blockers).unwrap())));}
let policy=load_policy(&tx,workspace_id)?.ok_or_else(||StoreError::InvalidInput(format!("policy-missing:{workspace_id}")))?;
if policy.policy_id!=plan.policy_id||policy.revision!=plan.policy_revision{
mark_stale(&tx,&plan,"policy revision changed")?;tx.commit()?;
return Err(stale_error(&plan,"policy revision changed"));
if policy.policy_id!=plan.policy_id||policy.policy_digest!=plan.policy_digest{
mark_stale(&tx,&plan,"policy content changed")?;tx.commit()?;
return Err(stale_error(&plan,"policy content changed"));
}
let worker=load_worker(&tx,workspace_id,&plan.worker)?.ok_or_else(||StoreError::InvalidInput(format!("stale:{plan_id}:Worker missing")))?;
if worker.updated_at!=plan.worker_revision{
mark_stale(&tx,&plan,"Worker revision changed")?;tx.commit()?;
return Err(stale_error(&plan,"Worker revision changed"));
if worker.updated_at!=plan.worker_updated_at{
mark_stale(&tx,&plan,"Worker updated_at changed")?;tx.commit()?;
return Err(stale_error(&plan,"Worker updated_at changed"));
}
if worker.retention_state=="pinned"{
mark_stale(&tx,&plan,"hold added")?;tx.commit()?;
@@ -295,7 +298,7 @@ impl SqliteWorkspaceStore {
}
/// Revalidates Backend authority and derives the complete Runtime request
/// from the immutable plan. Callers cannot substitute generation or
/// from the immutable plan. Callers cannot substitute Worker timestamps or
/// dispositions without causing a fingerprint/manifest mismatch.
pub fn prepare_worker_removal_execution(
&self,
@@ -327,13 +330,11 @@ impl SqliteWorkspaceStore {
workspace_id: plan.workspace_id.clone(),
source_runtime_id: plan.worker.runtime_id.clone(),
worker_id: worker_id,
expected_worker_revision: plan.worker_revision.clone(),
source_created_at: worker.created_at,
removed_at,
effective_profile: worker.profile,
retention_class: None,
policy_id: plan.policy_id.clone(),
policy_revision: plan.policy_revision,
session_disposition: plan.session_disposition,
diagnostics_disposition: plan.diagnostics_disposition,
},
@@ -354,7 +355,7 @@ impl SqliteWorkspaceStore {
WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3
AND state IN ('planned','executing','failed','succeeded')
AND (
state='succeeded' OR worker_revision=(
state='succeeded' OR worker_updated_at=(
SELECT updated_at FROM worker_registry
WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3
)
@@ -398,7 +399,6 @@ impl SqliteWorkspaceStore {
workspace_id: plan.workspace_id.clone(),
source_runtime_id: plan.worker.runtime_id.clone(),
worker_id: worker_id,
expected_worker_revision: plan.worker_revision.clone(),
source_created_at: worker
.as_ref()
.map(|worker| worker.created_at.clone())
@@ -410,7 +410,6 @@ impl SqliteWorkspaceStore {
.unwrap_or_else(|| Some("removed".to_string())),
retention_class: None,
policy_id: plan.policy_id.clone(),
policy_revision: plan.policy_revision,
session_disposition: plan.session_disposition,
diagnostics_disposition: plan.diagnostics_disposition,
},
@@ -458,8 +457,7 @@ impl SqliteWorkspaceStore {
if plan.state != WorkerRemovalPlanState::Executing {
return Err(StoreError::InvalidInput(format!("stale:{}:plan state {} is not committable", plan.plan_id, state_s(plan.state))));
}
if result.expected_worker_revision != plan.worker_revision
|| result.worker_id.to_string() != plan.worker.worker_id
if result.worker_id.to_string() != plan.worker.worker_id
|| result.session_disposition != plan.session_disposition
|| result.diagnostics_disposition != plan.diagnostics_disposition
{
@@ -467,15 +465,15 @@ impl SqliteWorkspaceStore {
}
if !result.source_removed{return Err(StoreError::InvalidInput("Runtime source was not removed".into()));}
let worker=load_worker(&tx,workspace_id,&plan.worker)?.ok_or_else(||StoreError::InvalidInput("Worker missing before commit".into()))?;
if worker.updated_at!=plan.worker_revision{return Err(StoreError::InvalidInput(format!("stale:{}:Worker revision changed",plan.plan_id)));}
if worker.updated_at!=plan.worker_updated_at{return Err(StoreError::InvalidInput(format!("stale:{}:Worker updated_at changed",plan.plan_id)));}
if worker.retention_state=="pinned" { return Err(StoreError::InvalidInput(format!("stale:{}:hold added",plan.plan_id))); }
let assigned:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3 UNION ALL SELECT 1 FROM ticket_assignment_operations WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND claim_state='pending' AND action IN ('assign','reassign'))",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id],|row|row.get(0))?;
if assigned { return Err(StoreError::InvalidInput(format!("stale:{}:unfinished work added",plan.plan_id))); }
let now=Utc::now().to_rfc3339();
if let Some(a)=&result.archive{
if plan.archive_id.as_deref()!=Some(&a.archive_id)||a.workspace_id!=workspace_id||a.source_runtime_id!=plan.worker.runtime_id||a.source_worker_id.to_string()!=plan.worker.worker_id||a.policy_id!=plan.policy_id||a.policy_revision!=plan.policy_revision{return Err(StoreError::InvalidInput("archive manifest mismatch".into()));}
if plan.archive_id.as_deref()!=Some(&a.archive_id)||a.workspace_id!=workspace_id||a.source_runtime_id!=plan.worker.runtime_id||a.source_worker_id.to_string()!=plan.worker.worker_id||a.policy_id!=plan.policy_id||a.operation_id!=operation_id||a.input_fingerprint!=fp{return Err(StoreError::InvalidInput("archive manifest mismatch".into()));}
let expires_at=match plan.archive_retention { ArchiveRetention::Forever=>None, ArchiveRetention::ForSeconds{seconds}=>{let seconds=i64::try_from(seconds).map_err(|_|StoreError::InvalidInput("archive retention deadline overflow".into()))?;Some((Utc::now()+chrono::Duration::seconds(seconds)).to_rfc3339())} };
tx.execute("INSERT OR IGNORE INTO worker_session_archives(archive_id,workspace_id,runtime_id,worker_id,session_id,checksum_sha256,content_bytes,policy_id,policy_revision,operation_id,committed_at,expires_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)",params![a.archive_id,workspace_id,plan.worker.runtime_id,plan.worker.worker_id,a.source_session_id,a.content_checksum_sha256,a.content_bytes,plan.policy_id,plan.policy_revision,operation_id,now,expires_at])?;
tx.execute("INSERT OR IGNORE INTO worker_session_archives(archive_id,workspace_id,runtime_id,worker_id,session_id,checksum_sha256,content_bytes,policy_id,policy_digest,operation_id,committed_at,expires_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)",params![a.archive_id,workspace_id,plan.worker.runtime_id,plan.worker.worker_id,a.source_session_id,a.content_checksum_sha256,a.content_bytes,plan.policy_id,plan.policy_digest,operation_id,now,expires_at])?;
let observe_grants = {
let mut statement = tx.prepare(
"SELECT grant_id, controller_runtime_id, controller_worker_id,
@@ -529,15 +527,15 @@ impl SqliteWorkspaceStore {
let seconds=plan.diagnostics_retention_seconds.ok_or_else(||StoreError::InvalidInput("diagnostics retention deadline missing".into()))?;
let seconds=i64::try_from(seconds).map_err(|_|StoreError::InvalidInput("diagnostics retention deadline overflow".into()))?;
let expires_at=(Utc::now()+chrono::Duration::seconds(seconds)).to_rfc3339();
tx.execute("INSERT OR IGNORE INTO worker_diagnostics_archives(operation_id,workspace_id,runtime_id,worker_id,policy_id,policy_revision,committed_at,expires_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",params![operation_id,workspace_id,plan.worker.runtime_id,plan.worker.worker_id,plan.policy_id,plan.policy_revision,now,expires_at])?;
tx.execute("INSERT OR IGNORE INTO worker_diagnostics_archives(operation_id,workspace_id,runtime_id,worker_id,policy_id,policy_digest,committed_at,expires_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",params![operation_id,workspace_id,plan.worker.runtime_id,plan.worker.worker_id,plan.policy_id,plan.policy_digest,now,expires_at])?;
}
DiagnosticsDisposition::Purge => {}
}
if plan.metadata_disposition==MetadataDisposition::Tombstone{
tx.execute("INSERT OR IGNORE INTO worker_tombstones(workspace_id,runtime_id,worker_id,display_name,profile,worker_created_at,removed_at,archive_id,policy_id,policy_revision,operation_id) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id,worker.display_name,worker.profile,worker.created_at,now,plan.archive_id,plan.policy_id,plan.policy_revision,operation_id])?;
tx.execute("INSERT OR IGNORE INTO worker_tombstones(workspace_id,runtime_id,worker_id,display_name,profile,worker_created_at,removed_at,archive_id,policy_id,policy_digest,operation_id) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id,worker.display_name,worker.profile,worker.created_at,now,plan.archive_id,plan.policy_id,plan.policy_digest,operation_id])?;
}
tx.execute("UPDATE worker_workdir_links SET unlinked_at=?4 WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND unlinked_at IS NULL",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id,now])?;
let deleted=tx.execute("DELETE FROM worker_registry WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND updated_at=?4",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id,plan.worker_revision])?;
let deleted=tx.execute("DELETE FROM worker_registry WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND updated_at=?4",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id,plan.worker_updated_at])?;
tx.execute("UPDATE worker_create_reservations SET state='removed',updated_at=?4 WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND state IN ('reserved','created')",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id,now])?;
if deleted!=1{return Err(StoreError::InvalidInput(format!("stale:{}:removal fence changed",plan.plan_id)));}
let catalog=commit_worker_catalog_removal(&tx,workspace_id,&plan.worker)?;
@@ -657,7 +655,7 @@ impl SqliteWorkspaceStore {
workspace_id: &str,
worker: &RuntimeWorkerRef,
) -> crate::Result<Option<WorkerTombstone>> {
self.with_conn(|conn|conn.query_row("SELECT display_name,profile,worker_created_at,removed_at,archive_id,policy_id,policy_revision,operation_id FROM worker_tombstones WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3",params![workspace_id,worker.runtime_id,worker.worker_id],|r|Ok(WorkerTombstone{workspace_id:workspace_id.into(),worker:worker.clone(),display_name:r.get(0)?,profile:r.get(1)?,created_at:r.get(2)?,removed_at:r.get(3)?,archive_id:r.get(4)?,policy_id:r.get(5)?,policy_revision:r.get::<_,i64>(6)? as u64,operation_id:r.get(7)?})).optional().map_err(StoreError::from))
self.with_conn(|conn|conn.query_row("SELECT display_name,profile,worker_created_at,removed_at,archive_id,policy_id,policy_digest,operation_id FROM worker_tombstones WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3",params![workspace_id,worker.runtime_id,worker.worker_id],|r|Ok(WorkerTombstone{workspace_id:workspace_id.into(),worker:worker.clone(),display_name:r.get(0)?,profile:r.get(1)?,created_at:r.get(2)?,removed_at:r.get(3)?,archive_id:r.get(4)?,policy_id:r.get(5)?,policy_digest:r.get(6)?,operation_id:r.get(7)?})).optional().map_err(StoreError::from))
}
pub fn worker_session_archive(
@@ -670,7 +668,7 @@ impl SqliteWorkspaceStore {
connection
.query_row(
"SELECT archive_id, checksum_sha256, content_bytes, policy_id,
policy_revision, operation_id, committed_at, expires_at
policy_digest, operation_id, committed_at, expires_at
FROM worker_session_archives
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
AND session_id = ?4",
@@ -689,7 +687,7 @@ impl SqliteWorkspaceStore {
checksum_sha256: row.get(1)?,
content_bytes: row.get::<_, i64>(2)? as u64,
policy_id: row.get(3)?,
policy_revision: row.get::<_, i64>(4)? as u64,
policy_digest: row.get(4)?,
operation_id: row.get(5)?,
committed_at: row.get(6)?,
expires_at: row.get(7)?,
@@ -789,12 +787,12 @@ fn load_worker(c: &Connection, w: &str, r: &RuntimeWorkerRef) -> crate::Result<O
}
fn load_policy(c: &Connection, w: &str) -> crate::Result<Option<WorkerRetentionPolicy>> {
c.query_row(
"SELECT p.policy_id,p.revision,r.session_disposition,r.metadata_disposition,
"SELECT p.policy_id,p.policy_digest,r.session_disposition,r.metadata_disposition,
r.archive_retention_kind,r.archive_retention_seconds,
r.diagnostics_disposition,r.diagnostics_retention_seconds,r.created_at,p.updated_at
FROM workspace_worker_retention_policies p
JOIN workspace_worker_retention_policy_revisions r
ON r.workspace_id=p.workspace_id AND r.policy_id=p.policy_id AND r.revision=p.revision
JOIN workspace_worker_retention_policy_snapshots r
ON r.workspace_id=p.workspace_id AND r.policy_id=p.policy_id AND r.policy_digest=p.policy_digest
WHERE p.workspace_id=?1",
params![w],
|row| {
@@ -806,7 +804,7 @@ fn load_policy(c: &Connection, w: &str) -> crate::Result<Option<WorkerRetentionP
Ok(WorkerRetentionPolicy {
workspace_id: w.into(),
policy_id: row.get(0)?,
revision: row.get::<_, i64>(1)? as u64,
policy_digest: row.get(1)?,
session_disposition: parse_s(&session)?,
metadata_disposition: parse_m(&metadata)?,
archive_retention: parse_archive(&archive_kind, archive_seconds)?,
@@ -829,7 +827,7 @@ fn load_plan_op(c: &Connection, id: &str) -> crate::Result<Option<WorkerRemovalP
fn load_plan_q(c: &Connection, key: &str, id: &str) -> crate::Result<Option<WorkerRemovalPlan>> {
let query = format!(
"SELECT plan_id,operation_id,input_fingerprint,workspace_id,runtime_id,worker_id,
worker_revision,policy_id,policy_revision,session_disposition,
worker_updated_at,policy_id,policy_digest,session_disposition,
metadata_disposition,archive_retention_kind,archive_retention_seconds,
diagnostics_disposition,diagnostics_retention_seconds,archive_id,blockers_json,
state,reason,created_at,updated_at,failure_category
@@ -852,9 +850,9 @@ fn load_plan_q(c: &Connection, key: &str, id: &str) -> crate::Result<Option<Work
runtime_id: row.get(4)?,
worker_id: row.get(5)?,
},
worker_revision: row.get(6)?,
worker_updated_at: row.get(6)?,
policy_id: row.get(7)?,
policy_revision: row.get::<_, i64>(8)? as u64,
policy_digest: row.get(8)?,
session_disposition: parse_s(&session)?,
metadata_disposition: parse_m(&metadata)?,
archive_retention: parse_archive(&archive_kind, archive_seconds)?,
@@ -891,7 +889,7 @@ fn stale_error(plan: &WorkerRemovalPlan, reason: &str) -> StoreError {
}
fn fingerprint(
r: &WorkerRemovalPlanRequest,
worker_revision: &str,
worker_updated_at: &str,
i: &WorkerRetentionInventory,
p: &WorkerRetentionPolicy,
b: &[WorkerRemovalBlocker],
@@ -900,11 +898,11 @@ fn fingerprint(
r.workspace_id,
r.worker.runtime_id,
r.worker.worker_id,
worker_revision,
worker_updated_at,
i.session_id,
i.segment_ids,
p.policy_id,
p.revision,
p.policy_digest,
p.session_disposition,
p.metadata_disposition,
p.archive_retention,
@@ -916,6 +914,21 @@ fn fingerprint(
.map(|v| hash(&v))
.map_err(|e| StoreError::InvalidInput(e.to_string()))
}
/// Content identity of the complete resolved policy, excluding observation timestamps.
/// The ordered tuple is shared with schema seeding and migration; no update counter is used.
pub fn worker_retention_policy_digest(policy: &WorkerRetentionPolicyUpdate) -> String {
let bytes = serde_json::to_vec(&serde_json::json!([
policy.policy_id,
policy.session_disposition,
policy.metadata_disposition,
policy.archive_retention,
policy.diagnostics_disposition,
policy.diagnostics_retention_seconds,
]))
.expect("retention policy content is serializable");
format!("sha256:{}", hash(&bytes))
}
fn hash(b: &[u8]) -> String {
Sha256::digest(b)
.iter()
@@ -977,11 +990,8 @@ fn map_error(e: StoreError) -> WorkerRetentionError {
};
}
if let Some(x) = m.strip_prefix("policy-conflict:") {
let mut s = x.split(':');
return WorkerRetentionError::PolicyRevisionConflict {
expected: s.next().and_then(|v| v.parse().ok()).unwrap_or(0),
actual: s.next().and_then(|v| v.parse().ok()).unwrap_or(0),
};
let (expected, actual) = serde_json::from_str::<(String, String)>(x).unwrap_or_default();
return WorkerRetentionError::PolicyDigestConflict { expected, actual };
}
if m == "cross-workspace" {
return WorkerRetentionError::CrossWorkspace;
@@ -1121,7 +1131,7 @@ mod tests {
c.execute(
"INSERT INTO worker_registry(\
workspace_id,worker_id,runtime_id,display_name,profile,retention_state,created_at,updated_at\
) VALUES('w',?1,'r','one','builtin:coder','normal','created','rev1')",
) VALUES('w',?1,'r','one','builtin:coder','normal','created','2026-08-24T00:00:00Z')",
[worker_id().to_string()],
)?;
c.execute(
@@ -1169,24 +1179,65 @@ mod tests {
diagnostics_disposition: DiagnosticsDisposition::Purge,
diagnostics_retention_seconds: None,
};
let updated = s.update_worker_retention_policy("w", 1, &u).unwrap();
assert_eq!(updated.revision, 2);
let updated = s
.update_worker_retention_policy("w", &p.policy_digest, &u)
.unwrap();
assert_eq!(updated.policy_digest, worker_retention_policy_digest(&u));
assert_eq!(
updated.archive_retention,
ArchiveRetention::ForSeconds { seconds: 3_600 }
);
assert!(matches!(
s.update_worker_retention_policy("w", 1, &u),
Err(WorkerRetentionError::PolicyRevisionConflict { .. })
s.update_worker_retention_policy("w", &p.policy_digest, &u),
Err(WorkerRetentionError::PolicyDigestConflict { .. })
));
}
#[test]
fn identical_policy_content_keeps_digest_and_removal_plan() {
let store = setup();
let policy = store.worker_retention_policy("w").unwrap().unwrap();
let update = WorkerRetentionPolicyUpdate {
policy_id: policy.policy_id.clone(),
session_disposition: policy.session_disposition,
metadata_disposition: policy.metadata_disposition,
archive_retention: policy.archive_retention,
diagnostics_disposition: policy.diagnostics_disposition,
diagnostics_retention_seconds: policy.diagnostics_retention_seconds,
};
assert_eq!(
policy.policy_digest,
worker_retention_policy_digest(&update)
);
let before = store.plan_worker_removal(&req(), &inv()).unwrap();
let after = store
.update_worker_retention_policy("w", &policy.policy_digest, &update)
.unwrap();
assert_eq!(policy.policy_digest, after.policy_digest);
assert_eq!(before, store.plan_worker_removal(&req(), &inv()).unwrap());
store
.begin_worker_removal("w", &before.plan_id, &before.input_fingerprint)
.unwrap();
let mut changed = update.clone();
changed.metadata_disposition = MetadataDisposition::Purge;
assert_ne!(
worker_retention_policy_digest(&update),
worker_retention_policy_digest(&changed)
);
changed = update.clone();
changed.archive_retention = ArchiveRetention::ForSeconds { seconds: 3600 };
assert_ne!(
worker_retention_policy_digest(&update),
worker_retention_policy_digest(&changed)
);
}
#[test]
fn deterministic_plan_hold_and_cross_workspace() {
let s = setup();
let a = s.plan_worker_removal(&req(), &inv()).unwrap();
let b = s.plan_worker_removal(&req(), &inv()).unwrap();
assert_eq!(a.plan_id, b.plan_id);
assert_eq!(a.worker_revision, "rev1");
assert_eq!(a.worker_updated_at, "2026-08-24T00:00:00Z");
s.with_conn(|c| {
c.execute(
"UPDATE worker_registry SET retention_state='pinned' WHERE workspace_id='w'",
@@ -1226,7 +1277,15 @@ mod tests {
diagnostics_disposition: DiagnosticsDisposition::Purge,
diagnostics_retention_seconds: None,
};
s.update_worker_retention_policy("w", 1, &u).unwrap();
s.update_worker_retention_policy(
"w",
&s.worker_retention_policy("w")
.unwrap()
.unwrap()
.policy_digest,
&u,
)
.unwrap();
assert!(matches!(
s.begin_worker_removal("w", &p.plan_id, &p.input_fingerprint),
Err(WorkerRetentionError::StalePlan { .. })
@@ -1295,7 +1354,10 @@ mod tests {
prepared.runtime_request.session_disposition,
SessionDisposition::Archive
);
assert_eq!(prepared.runtime_request.policy_revision, 1);
assert_eq!(
prepared.runtime_request.input_fingerprint,
prepared.plan.input_fingerprint
);
assert_eq!(
prepared.runtime_request.worker_id,
WorkerId::from_legacy_u64(1)
@@ -1337,7 +1399,7 @@ mod tests {
.unwrap();
s.with_conn(|conn| {
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES('w','ticket-old','ticket-old','Old Ticket','open','task','normal','','planning',1)", [])?;
conn.execute("INSERT INTO worker_registry(workspace_id,worker_id,runtime_id,display_name,profile,retention_state,created_at,updated_at) VALUES('w','1','r','old worker','builtin:coder','normal','created','rev1')", [])?;
conn.execute("INSERT INTO worker_registry(workspace_id,worker_id,runtime_id,display_name,profile,retention_state,created_at,updated_at) VALUES('w','1','r','old worker','builtin:coder','normal','created','2026-08-24T00:00:00Z')", [])?;
conn.execute("INSERT INTO worker_create_reservations(workspace_id,allocation_key,worker_id,runtime_id,create_fingerprint,state,created_at,updated_at) VALUES('w','allocation-old',?1,'r','fingerprint','created','created','created')", [worker_id().to_string()])?;
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,runtime_id,worker_id,assigned_by,assigned_at) VALUES('w','ticket-old','assignment-old','r','1','test','t')", [])?;
conn.execute("DELETE FROM worker_registry WHERE workspace_id='w' AND runtime_id='r' AND worker_id='1'", [])?;
@@ -1350,7 +1412,7 @@ mod tests {
let result = WorkerRetentionExecutionResult {
operation_id: p.operation_id.clone(),
input_fingerprint: p.input_fingerprint.clone(),
expected_worker_revision: p.worker_revision.clone(),
worker_id: worker_id(),
session_disposition: p.session_disposition,
diagnostics_disposition: p.diagnostics_disposition,
@@ -1371,7 +1433,7 @@ mod tests {
content_bytes: 1,
content_file_count: 1,
policy_id: p.policy_id.clone(),
policy_revision: p.policy_revision,
operation_id: p.operation_id.clone(),
input_fingerprint: p.input_fingerprint.clone(),
}),
@@ -1608,7 +1670,11 @@ mod tests {
store
.update_worker_retention_policy(
"w",
1,
&store
.worker_retention_policy("w")
.unwrap()
.unwrap()
.policy_digest,
&WorkerRetentionPolicyUpdate {
policy_id: "purge".into(),
session_disposition: SessionDisposition::Purge,
@@ -1630,7 +1696,6 @@ mod tests {
let result = WorkerRetentionExecutionResult {
operation_id: plan.operation_id.clone(),
input_fingerprint: plan.input_fingerprint.clone(),
expected_worker_revision: plan.worker_revision.clone(),
worker_id: worker_id(),
session_disposition: plan.session_disposition,
diagnostics_disposition: plan.diagnostics_disposition,
@@ -1817,7 +1882,7 @@ mod tests {
let result = WorkerRetentionExecutionResult {
operation_id: plan.operation_id.clone(),
input_fingerprint: plan.input_fingerprint.clone(),
expected_worker_revision: plan.worker_revision.clone(),
worker_id: worker_id(),
session_disposition: plan.session_disposition,
diagnostics_disposition: plan.diagnostics_disposition,
@@ -1897,7 +1962,7 @@ mod tests {
let result = WorkerRetentionExecutionResult {
operation_id: plan.operation_id.clone(),
input_fingerprint: plan.input_fingerprint.clone(),
expected_worker_revision: plan.worker_revision.clone(),
worker_id: worker_id(),
session_disposition: plan.session_disposition,
diagnostics_disposition: plan.diagnostics_disposition,
@@ -1949,14 +2014,22 @@ mod tests {
diagnostics_disposition: DiagnosticsDisposition::Purge,
diagnostics_retention_seconds: None,
};
s.update_worker_retention_policy("w", 1, &u).unwrap();
s.update_worker_retention_policy(
"w",
&s.worker_retention_policy("w")
.unwrap()
.unwrap()
.policy_digest,
&u,
)
.unwrap();
let p = s.plan_worker_removal(&req(), &inv()).unwrap();
s.begin_worker_removal("w", &p.plan_id, &p.input_fingerprint)
.unwrap();
let r = WorkerRetentionExecutionResult {
operation_id: p.operation_id.clone(),
input_fingerprint: p.input_fingerprint.clone(),
expected_worker_revision: p.worker_revision.clone(),
worker_id: worker_id(),
session_disposition: SessionDisposition::Purge,
diagnostics_disposition: DiagnosticsDisposition::Purge,
@@ -1980,7 +2053,15 @@ mod tests {
diagnostics_retention_seconds: None,
};
store
.update_worker_retention_policy("w", 1, &purge)
.update_worker_retention_policy(
"w",
&store
.worker_retention_policy("w")
.unwrap()
.unwrap()
.policy_digest,
&purge,
)
.unwrap();
let plan = store.plan_worker_removal(&req(), &inv()).unwrap();
store
@@ -1989,7 +2070,7 @@ mod tests {
let result = WorkerRetentionExecutionResult {
operation_id: plan.operation_id.clone(),
input_fingerprint: plan.input_fingerprint.clone(),
expected_worker_revision: plan.worker_revision.clone(),
worker_id: worker_id(),
session_disposition: SessionDisposition::Purge,
diagnostics_disposition: DiagnosticsDisposition::Purge,
@@ -2067,7 +2148,7 @@ mod tests {
let mut result = WorkerRetentionExecutionResult {
operation_id: plan.operation_id.clone(),
input_fingerprint: plan.input_fingerprint.clone(),
expected_worker_revision: plan.worker_revision.clone(),
worker_id: worker_id(),
session_disposition: plan.session_disposition,
diagnostics_disposition: plan.diagnostics_disposition,
@@ -2117,15 +2198,15 @@ mod tests {
summary_ref: None,
diagnostics_ref: None,
created_at: "created".into(),
updated_at: "rev2".into(),
updated_at: "2026-08-24T00:00:01Z".into(),
};
store.upsert_worker_registry(&stale).unwrap();
let revision: String = store.with_conn(|conn| conn.query_row(
let updated_at: String = store.with_conn(|conn| conn.query_row(
"SELECT updated_at FROM worker_registry WHERE workspace_id='w' AND runtime_id='r' AND worker_id=?1",
[worker_id().to_string()],
|row| row.get(0),
).map_err(StoreError::from)).unwrap();
assert_eq!(revision, "rev1");
assert_eq!(updated_at, "2026-08-24T00:00:00Z");
let assignment = TicketWorkerAssignmentRecord {
workspace_id: "w".into(),
@@ -2152,7 +2233,7 @@ mod tests {
}
#[test]
fn runtime_result_must_match_prepared_worker_revision() {
fn runtime_result_must_match_prepared_input_fingerprint() {
let s = setup();
let plan = s.plan_worker_removal(&req(), &inv()).unwrap();
let prepared = s
@@ -2161,7 +2242,7 @@ mod tests {
let mut runtime_result = WorkerRetentionExecutionResult {
operation_id: prepared.plan.operation_id.clone(),
input_fingerprint: prepared.plan.input_fingerprint.clone(),
expected_worker_revision: prepared.plan.worker_revision.clone(),
worker_id: worker_id(),
session_disposition: prepared.plan.session_disposition,
diagnostics_disposition: prepared.plan.diagnostics_disposition,
@@ -2169,7 +2250,7 @@ mod tests {
source_removed: true,
diagnostics_retained: false,
};
runtime_result.expected_worker_revision = "stale-revision".to_string();
runtime_result.input_fingerprint = "stale-input-fingerprint".to_string();
let error = s
.commit_worker_removal(
"w",
@@ -2180,7 +2261,7 @@ mod tests {
.unwrap_err();
assert!(
!error.to_string().is_empty(),
"mismatched Runtime revision must be rejected"
"mismatched Runtime input fingerprint must be rejected"
);
}
@@ -2237,7 +2318,7 @@ mod tests {
store
.with_conn(|conn| {
conn.execute(
"UPDATE worker_registry SET updated_at='rev2' WHERE workspace_id='w' AND runtime_id='r' AND worker_id=?1",
"UPDATE worker_registry SET updated_at='2026-08-24T00:00:01Z' WHERE workspace_id='w' AND runtime_id='r' AND worker_id=?1",
[worker_id().to_string()],
)?;
Ok(())
@@ -2251,7 +2332,7 @@ mod tests {
.is_none()
);
let replacement = store.plan_worker_removal(&request, &inv()).unwrap();
assert_eq!(replacement.worker_revision, "rev2");
assert_eq!(replacement.worker_updated_at, "2026-08-24T00:00:01Z");
assert_ne!(replacement.plan_id, plan.plan_id);
}
@@ -2266,7 +2347,7 @@ mod tests {
let runtime_result = WorkerRetentionExecutionResult {
operation_id: prepared.plan.operation_id.clone(),
input_fingerprint: prepared.plan.input_fingerprint.clone(),
expected_worker_revision: prepared.plan.worker_revision.clone(),
worker_id: worker_id(),
session_disposition: prepared.plan.session_disposition,
diagnostics_disposition: prepared.plan.diagnostics_disposition,
@@ -2287,7 +2368,7 @@ mod tests {
content_bytes: 1,
content_file_count: 1,
policy_id: prepared.plan.policy_id.clone(),
policy_revision: prepared.plan.policy_revision,
operation_id: prepared.plan.operation_id.clone(),
input_fingerprint: prepared.plan.input_fingerprint.clone(),
}),
@@ -2332,8 +2413,8 @@ mod tests {
.unwrap();
assert_eq!(recovered.plan.state, WorkerRemovalPlanState::Succeeded);
assert_eq!(
recovered.runtime_request.expected_worker_revision,
plan.worker_revision
recovered.runtime_request.input_fingerprint,
plan.input_fingerprint
);
}
+55 -30
View File
@@ -24,7 +24,6 @@ impl WorkspaceConfigSchemaProvider for RuntimeConfigSchemaProvider {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeConfigProjection {
pub config_revision: u64,
pub projection_digest: String,
pub default_runtime_id: Option<String>,
}
@@ -52,7 +51,6 @@ pub fn project_runtime_from_workspace_config(
.any(|entry| entry.provider_id == "builtin:runtime");
if !has_runtime_schema {
return Ok(RuntimeConfigProjection {
config_revision: state.snapshot.revision,
projection_digest: state.projection_digest.clone(),
default_runtime_id: None,
});
@@ -71,7 +69,6 @@ pub fn project_runtime_from_workspace_config(
.map_err(|error| Error::RegistryInconsistency(error.to_string()))?;
let default_runtime_id = normalize_runtime_id(&config.runtime.default_runtime_id)?;
Ok(RuntimeConfigProjection {
config_revision: state.snapshot.revision,
projection_digest: evaluation.projection_digest,
default_runtime_id,
})
@@ -103,15 +100,12 @@ mod tests {
.contribution()
.unwrap()])
.unwrap();
let snapshot = ConfigTreeSnapshot::from_entries(
7,
[ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
source,
)
.unwrap()],
let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
source,
)
.unwrap()])
.unwrap();
let contract = config_source::ToolchainContract::with_schema_bundle(
config_source::DEFAULT_SCHEMA_VERSION,
@@ -131,7 +125,7 @@ mod tests {
}
#[test]
fn runtime_projection_reads_default_and_preserves_revision_evidence() {
fn runtime_projection_reads_default_and_preserves_content_evidence() {
let projection = project_runtime_from_workspace_config(
"workspace",
&state(
@@ -140,10 +134,48 @@ mod tests {
)
.unwrap();
assert_eq!(projection.default_runtime_id.as_deref(), Some("arcadia"));
assert_eq!(projection.config_revision, 7);
assert!(!projection.projection_digest.is_empty());
}
#[test]
fn runtime_projection_digest_tracks_content_not_observation_order() {
let first =
state(r#"{ runtime = { default_runtime_id = "arcadia"; }; } as WorkspaceConfigSchema"#);
let same =
state(r#"{ runtime = { default_runtime_id = "arcadia"; }; } as WorkspaceConfigSchema"#);
let changed =
state(r#"{ runtime = { default_runtime_id = "other"; }; } as WorkspaceConfigSchema"#);
let first_projection = project_runtime_from_workspace_config("workspace", &first).unwrap();
assert_eq!(
first_projection,
project_runtime_from_workspace_config("workspace", &same).unwrap()
);
let changed_projection =
project_runtime_from_workspace_config("workspace", &changed).unwrap();
assert_ne!(
first_projection.projection_digest,
changed_projection.projection_digest
);
assert_eq!(
changed_projection.default_runtime_id.as_deref(),
Some("other")
);
assert_eq!(
first_projection,
project_runtime_from_workspace_config("workspace", &first).unwrap()
);
}
#[test]
fn runtime_projection_rejects_mismatched_content_evidence() {
let mut active = state("{} as WorkspaceConfigSchema");
active.projection_digest = "sha256:tampered".to_string();
assert!(matches!(
project_runtime_from_workspace_config("workspace", &active),
Err(Error::RegistryInconsistency(_))
));
}
#[test]
fn runtime_projection_treats_missing_default_as_unconfigured() {
let projection = project_runtime_from_workspace_config(
@@ -165,15 +197,12 @@ mod tests {
)
.unwrap()])
.unwrap();
let snapshot = ConfigTreeSnapshot::from_entries(
6,
[ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
"{ legacy = { enabled = true; }; } as WorkspaceConfigSchema",
)
.unwrap()],
let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
"{ legacy = { enabled = true; }; } as WorkspaceConfigSchema",
)
.unwrap()])
.unwrap();
let contract = config_source::ToolchainContract::with_schema_bundle(
config_source::DEFAULT_SCHEMA_VERSION,
@@ -193,7 +222,6 @@ mod tests {
let projection = project_runtime_from_workspace_config("workspace", &state).unwrap();
assert_eq!(projection.default_runtime_id, None);
assert_eq!(projection.config_revision, 6);
assert_eq!(projection.projection_digest, projection_digest);
}
@@ -204,15 +232,12 @@ mod tests {
.contribution()
.unwrap()])
.unwrap();
let snapshot = ConfigTreeSnapshot::from_entries(
1,
[ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
"{ runtime = { default_runtime_id = 42; }; } as WorkspaceConfigSchema",
)
.unwrap()],
let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
"{ runtime = { default_runtime_id = 42; }; } as WorkspaceConfigSchema",
)
.unwrap()])
.unwrap();
let contract = config_source::ToolchainContract::with_schema_bundle(
config_source::DEFAULT_SCHEMA_VERSION,
@@ -70,7 +70,6 @@ fn create_request(name: &str) -> CreateWorkerRequest {
workspace_api: None,
memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "local".to_string(),
settings_revision: 1,
language: "English".to_string(),
}),
subjektiv_attached: false,
@@ -0,0 +1,102 @@
// Included in store::tests so both paths use the real retained migration chain.
#[test]
fn fresh_and_upgraded_state_contract_schemas_enforce_the_same_constraints() {
fn signature(conn: &Connection, table: &str) -> Vec<Vec<String>> {
let queries = [
"SELECT name, type, CAST(\"notnull\" AS TEXT), COALESCE(dflt_value, '<NULL>'), CAST(pk AS TEXT) FROM pragma_table_info(?1) ORDER BY name",
"SELECT \"table\", \"from\", \"to\", on_update, on_delete, match, CAST(seq AS TEXT) FROM pragma_foreign_key_list(?1) ORDER BY \"table\", \"from\", \"to\", seq",
"SELECT CAST(i.\"unique\" AS TEXT), CAST(i.partial AS TEXT), (SELECT group_concat(name, ',') FROM (SELECT name FROM pragma_index_info(i.name) ORDER BY seqno)) FROM pragma_index_list(?1) AS i ORDER BY 1, 2, 3",
"SELECT name, sql FROM sqlite_schema WHERE type='trigger' AND tbl_name=?1 ORDER BY name",
];
queries
.into_iter()
.flat_map(|sql| {
let mut statement = conn.prepare(sql).unwrap();
let count = statement.column_count();
statement
.query_map([table], |row| {
(0..count)
.map(|i| row.get::<_, String>(i))
.collect::<rusqlite::Result<Vec<_>>>()
})
.unwrap()
.map(|row| {
row.unwrap()
.into_iter()
.map(|value| {
value
.chars()
.filter(|c| !c.is_whitespace() && *c != '"')
.collect()
})
.collect()
})
.collect::<Vec<_>>()
})
.collect()
}
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("upgraded.db");
prepare_retained_schema(&path, 85);
let upgraded = SqliteWorkspaceStore::open(&path).unwrap();
let fresh = SqliteWorkspaceStore::in_memory().unwrap();
let mut mismatches = Vec::new();
for table in [
"workspace_config_trees",
"workspace_config_tree_history",
"workspace_memory_settings",
"worker_create_reservations",
"backend_jobs",
"backend_job_attempts",
"flow_sources",
"flow_source_contents",
"repositories",
"workspace_runtime_bindings",
"workspace_runtime_verifications",
"workspace_runtime_binding_audit",
"workspace_signing_identities",
"workspace_signing_identity_provisioning_operations",
"workspace_signing_identity_audit",
"runtime_removal_operations",
"workdir_removal_operations",
"workspace_deletion_operations",
"workspace_worker_retention_policies",
"workspace_worker_retention_policy_snapshots",
"worker_removal_operations",
"worker_session_archives",
"worker_diagnostics_archives",
"worker_tombstones",
"repository_secret_audit_events",
"repository_secret_operations",
"repository_ssh_credential_keys",
"repository_ssh_credentials",
"repository_ssh_host_trust_keys",
"repository_ssh_host_trusts",
"server_secret_objects",
"workdir_create_operations",
"workdir_create_credential_candidates",
"workdir_create_credential_retentions",
"workdir_create_legacy_ssh_archives",
"repository_secret_legacy_receipts",
] {
let expected = fresh.with_conn(|conn| Ok(signature(conn, table))).unwrap();
let actual = upgraded
.with_conn(|conn| Ok(signature(conn, table)))
.unwrap();
assert!(!expected.is_empty(), "missing canonical table {table}");
if actual != expected {
mismatches.push(format!(
"{table}:\n upgraded-only: {:?}\n fresh-only: {:?}",
actual
.iter()
.filter(|row| !expected.contains(row))
.collect::<Vec<_>>(),
expected
.iter()
.filter(|row| !actual.contains(row))
.collect::<Vec<_>>()
));
}
}
assert!(mismatches.is_empty(), "{}", mismatches.join("\n"));
}
File diff suppressed because it is too large Load Diff
@@ -31,7 +31,7 @@ pub(super) fn active_grant(
|| attempt.state != BackendJobAttemptState::Dispatched
|| attempt.worker.as_ref() != Some(worker)
|| job.current_attempt != attempt.attempt
|| attempt.input_revision != job.request.input_revision
|| attempt.input_digest != job.request.input_digest()?
|| chrono::DateTime::parse_from_rfc3339(&attempt.deadline_at)
.map_err(|e| Error::Store(format!("invalid Job deadline: {e}")))?
<= Utc::now()
@@ -161,7 +161,7 @@ pub(super) fn dispatch(
total_bytes: u64,
) -> ApiResult<MemoryConsolidationOutput> {
let store = open_subjektiv_store(api)?;
let subject = store
store
.subject(subject_id)
.map_err(subjektiv_store_error)?
.ok_or_else(|| Error::SubjektivSubjectNotFound(subject_id.into()))?;
@@ -177,7 +177,6 @@ pub(super) fn dispatch(
let request = BackendJobRequest {
job_id: format!("subjektiv-consolidation:{}", Uuid::new_v4()),
purpose: PURPOSE.into(),
input_revision: subject.store_revision.to_string(),
input_ref: format!("subjektiv://{subject_id}/consolidation"),
input: serde_json::json!({"subject_id": subject_id, "candidate_ids": candidate_ids}),
instruction: "Resolve ONLY the immutable candidate_ids batch using MemoryStagingList/Read, confirmed Memory reads and MemoryApplyCandidate. Already resolved candidates are retained receipts, not work to repeat. Leave newly arriving candidates to a subsequent Job. When every batch candidate has a durable disposition, call SubmitBackendJobResult with {subject_id, candidate_ids}; its Host capability awaits bounded clean-context surface generation and adds the actual surface outcome before submission. Final prose is not completion.".into(),
@@ -106,7 +106,7 @@ async fn drive_http_receipts_cas_stable_url_and_workspace_refs_follow_storage_au
let create = json!({"operation":"create_text","parent":reference(&root),"name":"日本語.md","text":"# Shared\n","content_type":"text/markdown"});
let first = mutate(&api, &token, "create", create.clone(), StatusCode::OK).await;
let id = first["entry"]["entry"]["node_id"].as_str().unwrap();
let rev = first["entry"]["revision"].as_str().unwrap();
let rev = first["entry"]["last_mutation_id"].as_str().unwrap();
assert_eq!(
first,
mutate(&api, &token, "create", create.clone(), StatusCode::OK).await,
@@ -137,7 +137,7 @@ async fn drive_http_receipts_cas_stable_url_and_workspace_refs_follow_storage_au
)
.await;
assert_eq!(text["text"], "# Shared\n");
let wrong=mutate(&api,&token,"foreign",json!({"operation":"delete","id":{"workspace_id":"foreign","node_id":id},"expected_revision":rev}),StatusCode::FORBIDDEN).await;
let wrong=mutate(&api,&token,"foreign",json!({"operation":"delete","id":{"workspace_id":"foreign","node_id":id},"expected_mutation_id":rev}),StatusCode::FORBIDDEN).await;
assert_eq!(wrong["code"], "denied");
user_call(
&api,
@@ -156,19 +156,19 @@ async fn drive_http_receipts_cas_stable_url_and_workspace_refs_follow_storage_au
StatusCode::BAD_REQUEST,
)
.await;
let updated=mutate(&api,&token,"update",json!({"operation":"update_text","id":reference(id),"expected_revision":rev,"text":"new","content_type":"text/plain"}),StatusCode::OK).await;
let updated=mutate(&api,&token,"update",json!({"operation":"update_text","id":reference(id),"expected_mutation_id":rev,"text":"new","content_type":"text/plain"}),StatusCode::OK).await;
for operation in [
json!({"operation":"update_text","id":reference(id),"expected_revision":rev,"text":"lost","content_type":"text/plain"}),
json!({"operation":"delete","id":reference(id),"expected_revision":rev}),
json!({"operation":"relocate","id":reference(id),"expected_revision":rev,"parent":reference(&root),"name":"lost"}),
json!({"operation":"update_text","id":reference(id),"expected_mutation_id":rev,"text":"lost","content_type":"text/plain"}),
json!({"operation":"delete","id":reference(id),"expected_mutation_id":rev}),
json!({"operation":"relocate","id":reference(id),"expected_mutation_id":rev,"parent":reference(&root),"name":"lost"}),
] {
mutate(&api, &token, "stale", operation, StatusCode::CONFLICT).await;
}
user_call(&api,&token,"GET",&format!("{}/read-chunk?entry_workspace_id={TEST_WORKSPACE_ID}&id={id}&expected_revision={rev}&offset=0&length=1",base()),None,StatusCode::CONFLICT).await;
let renamed=mutate(&api,&token,"rename",json!({"operation":"relocate","id":reference(id),"expected_revision":updated["entry"]["revision"],"parent":reference(&root),"name":"renamed.md"}),StatusCode::OK).await;
user_call(&api,&token,"GET",&format!("{}/read-chunk?entry_workspace_id={TEST_WORKSPACE_ID}&id={id}&expected_mutation_id={rev}&offset=0&length=1",base()),None,StatusCode::CONFLICT).await;
let renamed=mutate(&api,&token,"rename",json!({"operation":"relocate","id":reference(id),"expected_mutation_id":updated["entry"]["last_mutation_id"],"parent":reference(&root),"name":"renamed.md"}),StatusCode::OK).await;
// URL is independent of name/parent; metadata lookup still resolves the same ID.
assert_eq!(renamed["entry"]["entry"], first["entry"]["entry"]);
let deleted=mutate(&api,&token,"delete",json!({"operation":"delete","id":reference(id),"expected_revision":renamed["entry"]["revision"]}),StatusCode::OK).await;
let deleted=mutate(&api,&token,"delete",json!({"operation":"delete","id":reference(id),"expected_mutation_id":renamed["entry"]["last_mutation_id"]}),StatusCode::OK).await;
assert!(deleted["entry"].is_null());
user_call(
&api,
@@ -536,7 +536,7 @@ async fn drive_generated_client_roundtrips_binary_and_declared_headers_over_real
parent_id: Some(root.entry.node_id.clone()),
name: Some(request.into()),
id: None,
expected_revision: None,
expected_mutation_id: None,
content_type: media.into(),
size: bytes.len() as u32,
sha256: digest(bytes),
@@ -552,7 +552,7 @@ async fn drive_generated_client_roundtrips_binary_and_declared_headers_over_real
DriveDownloadQuery {
entry_workspace_id: workspace.clone(),
id: saved.entry.node_id.clone(),
expected_revision: Some(saved.revision.clone()),
expected_mutation_id: Some(saved.last_mutation_id.clone()),
},
)
.await
@@ -574,7 +574,7 @@ async fn drive_generated_client_roundtrips_binary_and_declared_headers_over_real
DriveReadChunkQuery {
entry_workspace_id: workspace.clone(),
id: saved.entry.node_id,
expected_revision: saved.revision,
expected_mutation_id: saved.last_mutation_id,
offset: 0,
length: 65536,
},
@@ -770,7 +770,7 @@ async fn drive_read_entrypoints_use_current_grants_and_cannot_fallback_to_browse
let root = root_id(&api, &owner).await;
let saved=mutate(&api,&owner,"matrix-file",json!({"operation":"create_text","parent":reference(&root),"name":"matrix","text":"bytes","content_type":"text/plain"}),StatusCode::OK).await;
let id = saved["entry"]["entry"]["node_id"].as_str().unwrap();
let rev = saved["entry"]["revision"].as_str().unwrap();
let rev = saved["entry"]["last_mutation_id"].as_str().unwrap();
let (_runtime, workers) = workers(&api).await;
let identity = RuntimeIdentityMaterial::generate(&workers[0].runtime_id).unwrap();
configure_runtime_request_auth(&mut api, &identity, &workers[0].runtime_id);
@@ -790,7 +790,7 @@ async fn drive_read_entrypoints_use_current_grants_and_cannot_fallback_to_browse
base()
),
format!(
"{}/read-chunk?entry_workspace_id={TEST_WORKSPACE_ID}&id={id}&expected_revision={rev}&offset=0&length=4",
"{}/read-chunk?entry_workspace_id={TEST_WORKSPACE_ID}&id={id}&expected_mutation_id={rev}&offset=0&length=4",
base()
),
format!(
@@ -885,9 +885,9 @@ async fn drive_concurrent_http_cas_has_one_winner_and_name_search_pages_are_scop
let root = root_id(&api, &token).await;
let saved=mutate(&api,&token,"concurrent-file",json!({"operation":"create_text","parent":reference(&root),"name":"candidate","text":"find me","content_type":"text/plain"}),StatusCode::OK).await;
let id = &saved["entry"]["entry"]["node_id"];
let rev = &saved["entry"]["revision"];
let rev = &saved["entry"]["last_mutation_id"];
let request = |identity: &str, text: &str| {
Request::builder().method("POST").uri(format!("{}/mutate",base())).header("authorization",format!("Bearer {token}")).header(CONTENT_TYPE,"application/json").body(Body::from(json!({"request_id":identity,"mutation":{"operation":"update_text","id":reference(id.as_str().unwrap()),"expected_revision":rev,"text":text,"content_type":"text/plain"}}).to_string())).unwrap()
Request::builder().method("POST").uri(format!("{}/mutate",base())).header("authorization",format!("Bearer {token}")).header(CONTENT_TYPE,"application/json").body(Body::from(json!({"request_id":identity,"mutation":{"operation":"update_text","id":reference(id.as_str().unwrap()),"expected_mutation_id":rev,"text":text,"content_type":"text/plain"}}).to_string())).unwrap()
};
let app = build_router(api.clone());
let (a, b) = tokio::join!(
@@ -955,7 +955,7 @@ async fn drive_concurrent_http_cas_has_one_winner_and_name_search_pages_are_scop
}
#[tokio::test]
async fn drive_download_stream_expires_instead_of_mixing_revisions() {
async fn drive_download_stream_expires_instead_of_mixing_last_mutation_ids() {
let temp = tempfile::tempdir().unwrap();
let api = test_api(temp.path()).await;
let token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID);
@@ -981,7 +981,7 @@ async fn drive_download_stream_expires_instead_of_mixing_revisions() {
.unwrap();
let saved = response_json(response, StatusCode::OK).await;
let id = saved["entry"]["entry"]["node_id"].as_str().unwrap();
let rev = saved["entry"]["revision"].as_str().unwrap();
let rev = saved["entry"]["last_mutation_id"].as_str().unwrap();
let response = build_router(api.clone())
.oneshot(
Request::builder()
@@ -996,7 +996,7 @@ async fn drive_download_stream_expires_instead_of_mixing_revisions() {
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
mutate(&api,&token,"stream-update",json!({"operation":"update_text","id":reference(id),"expected_revision":rev,"text":"generation b","content_type":"text/plain"}),StatusCode::OK).await;
mutate(&api,&token,"stream-update",json!({"operation":"update_text","id":reference(id),"expected_mutation_id":rev,"text":"generation b","content_type":"text/plain"}),StatusCode::OK).await;
let mut stream = response.into_body().into_data_stream();
let first = stream.next().await.unwrap().unwrap();
assert_eq!(first.as_ref(), vec![b'a'; 64 * 1024]);
@@ -57,7 +57,7 @@ async fn cached_drive_handle_rejects_mutation_after_deletion_reservation() {
&config.workspace_id,
&WorkspaceDeletionRequest {
operation_id: "delete-cached-drive".into(),
expected_revision: preflight.expected_revision,
expected_workspace_updated_at: preflight.expected_workspace_updated_at,
confirmation: preflight.display_name,
},
)
@@ -137,7 +137,7 @@ async fn deleting_workspace_reopens_fenced_drive_and_purges_blobs_before_feature
&config.workspace_id,
&WorkspaceDeletionRequest {
operation_id: "delete-drive-workspace".into(),
expected_revision: preflight.expected_revision,
expected_workspace_updated_at: preflight.expected_workspace_updated_at,
confirmation: preflight.display_name,
},
)
@@ -110,11 +110,12 @@ impl RealDriveFixture {
base_url: "https://runtime.example.invalid".into(),
public_key: identity.public_key.clone(),
public_key_fingerprint: String::new(),
binding_revision: 1,
binding_id: "binding-drive-test".to_string(),
state: StoredRuntimeBindingState::Verified,
authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
workspace_public_key_fingerprint: None,
workspace_trust_id: None,
created_at: "1".into(),
updated_at: "1".into(),
revoked_at: None,
@@ -1,6 +1,6 @@
use super::*;
use crate::subjektiv::{
CandidateDecision, CandidateDecisionRequest, MemoryDraft, MemoryRevisionTarget, SubjectRole,
CandidateDecision, CandidateDecisionRequest, MemoryChangeTarget, MemoryDraft, SubjectRole,
SubjectStagingRecord, SurfaceAvailability,
};
use memory::extract::{CandidateKind, ExtractedCandidate, StagingEvidence, StagingRecord};
@@ -16,7 +16,7 @@ fn stage(store: &crate::subjektiv::SubjektivStore, subject: &str, id: &str) {
worker_id: None,
flow_selector: None,
flow_definition_id: None,
flow_definition_revision: None,
flow_definition_fingerprint: None,
};
let record = StagingRecord::from_candidate(
id,
@@ -173,7 +173,7 @@ async fn subjektiv_job_bounds_batch_auth_partial_receipts_surface_and_cleanup()
candidate_id: "candidate-first".into(),
reason: "retain the learned constraint".into(),
decision: CandidateDecision::Apply {
target: MemoryRevisionTarget::Create,
target: MemoryChangeTarget::Create,
draft: MemoryDraft::active(
CandidateKind::Constraint,
"Keep committed memory",
@@ -191,7 +191,7 @@ async fn subjektiv_job_bounds_batch_auth_partial_receipts_surface_and_cleanup()
receipt.memory.as_ref().unwrap().id,
replay.memory.as_ref().unwrap().id
);
assert_eq!(receipt.store_revision, replay.store_revision);
assert_eq!(receipt.memory_fingerprint, replay.memory_fingerprint);
let generation = store
.prepare_job_surface_generation(
&subject.id,
@@ -211,13 +211,13 @@ async fn subjektiv_job_bounds_batch_auth_partial_receipts_surface_and_cleanup()
.await
.unwrap();
assert!(
matches!(failed, server_api::SubjektivMemoryBackendResponse::SurfaceFailed(output) if output.status == "failed_confirmed" && output.store_revision == generation.store_revision)
matches!(failed, server_api::SubjektivMemoryBackendResponse::SurfaceFailed(output) if output.status == "failed_confirmed" && output.memory_fingerprint == generation.memory_fingerprint)
);
let result = serde_json::json!({"subject_id": subject.id, "candidate_ids": ["candidate-first"], "surface": {"availability":"failed", "generation_id":generation.id, "store_revision":generation.store_revision,"reason_code":"editor_failed"}});
let result = serde_json::json!({"subject_id": subject.id, "candidate_ids": ["candidate-first"], "surface": {"availability":"failed", "generation_id":generation.id, "memory_fingerprint":generation.memory_fingerprint,"reason_code":"editor_failed"}});
let submission = BackendJobResultSubmission {
job_id: job.request.job_id.clone(),
attempt_id: attempt.attempt_id.clone(),
input_revision: job.request.input_revision.clone(),
input_digest: job.request.input_digest().unwrap(),
result,
};
assert!(
@@ -359,8 +359,8 @@ async fn subjektiv_job_surface_only_requires_attempt_owned_generation_and_real_o
let submission = BackendJobResultSubmission {
job_id: job.request.job_id.clone(),
attempt_id: attempt.attempt_id.clone(),
input_revision: job.request.input_revision.clone(),
result: serde_json::json!({"subject_id":subject.id,"candidate_ids":[],"surface":{"availability":"ready","generation_id":generation.id,"store_revision":generation.store_revision,"snapshot_id":snapshot.id}}),
input_digest: job.request.input_digest().unwrap(),
result: serde_json::json!({"subject_id":subject.id,"candidate_ids":[],"surface":{"availability":"ready","generation_id":generation.id,"memory_fingerprint":generation.memory_fingerprint,"snapshot_id":snapshot.id}}),
};
api.accept_backend_job_result(worker, &submission).unwrap();
wait_for_backend_job_worker_cleanup(
@@ -395,7 +395,6 @@ async fn backend_job_profile_resolution_rejects_ungranted_requirements_without_f
let mut request = BackendJobRequest {
job_id: "profile-resolution".into(),
purpose: "any-purpose".into(),
input_revision: "1".into(),
input_ref: "test://profiles".into(),
input: serde_json::json!({}),
instruction: "return structured result".into(),
@@ -450,7 +449,7 @@ async fn backend_job_project_recipe_is_resolved_and_delivered_without_system_ove
let entrypoint = state.contract.entrypoints.first().unwrap();
let root = state.snapshot.entries.get(entrypoint).unwrap();
let config = config_commit_request_from_api(server_api::ConfigCommitRequest {
base_revision: state.snapshot.revision, base_digest: state.snapshot.digest.clone(),
base_digest: state.snapshot.digest.clone(),
entrypoints: state.contract.entrypoints.iter().map(|p| p.as_str().into()).collect(),
changes: vec![server_api::ConfigTreeChange::Update {
path: entrypoint.as_str().into(), expected_digest: root.content_digest.clone(),
@@ -464,7 +463,6 @@ async fn backend_job_project_recipe_is_resolved_and_delivered_without_system_ove
let request = BackendJobRequest {
job_id: "project-recipe-job".into(),
purpose: "no-profile-switch".into(),
input_revision: "r1".into(),
input_ref: "test://recipe".into(),
input: serde_json::json!({}),
instruction: "Submit the result".into(),
@@ -671,8 +669,8 @@ async fn subjektiv_job_long_receipts_and_escaped_ids_fit_result_budget_without_l
let submission = BackendJobResultSubmission {
job_id: job.request.job_id.clone(),
attempt_id: attempt.attempt_id.clone(),
input_revision: job.request.input_revision.clone(),
result: serde_json::json!({"subject_id":subject.id,"candidate_ids":ids,"surface":{"availability":"ready","generation_id":generation.id,"store_revision":generation.store_revision,"snapshot_id":snapshot.id}}),
input_digest: job.request.input_digest().unwrap(),
result: serde_json::json!({"subject_id":subject.id,"candidate_ids":ids,"surface":{"availability":"ready","generation_id":generation.id,"memory_fingerprint":generation.memory_fingerprint,"snapshot_id":snapshot.id}}),
};
seed_worker_session_for_cleanup(dir.path(), worker);
let accepted = api.accept_backend_job_result(worker, &submission).unwrap();
@@ -122,7 +122,7 @@ impl EvidenceApiFixture {
}
fn revision(&self) -> String {
ticket_item_checker::item_revision(
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_item_revision: self.revision(),
ticket_content_digest: self.revision(),
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_item_revision: self.revision(),
expected_content_digest: self.revision(),
expected_state: TicketWorkflowState::InProgress,
reason: "Implementation judged complete independently of MR attestation".into(),
references: Vec::new(),
@@ -247,7 +247,7 @@ impl EvidenceApiFixture {
assert_eq!(ids, expected, "{filter}");
if matches {
let shown = self.show().await;
assert_eq!(result.items[0].item_revision, shown.item_revision);
assert_eq!(result.items[0].content_digest, shown.content_digest);
assert_eq!(result.items[0].evidence, shown.evidence);
assert_eq!(result.items[0].merge_requests, shown.merge_requests);
}
@@ -306,7 +306,7 @@ async fn done_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_
.backend
.complete(&fixture.ticket_id, fixture.completion(&approval))
.unwrap();
assert_eq!(ticket::ticket_item_revision(&completion), revision);
assert_eq!(ticket::ticket_content_digest(&completion), revision);
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.item_revision, revision);
assert_eq!(shown.content_digest, revision);
assert!(
shown.evidence.complete_for_integration,
"{:?}",
@@ -366,9 +366,9 @@ async fn done_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_
assert!(empty.merge_request.thread.is_empty());
for detail in [first, empty] {
assert_eq!(detail.source.status, "known");
assert_eq!(detail.source.revision_ref.as_deref(), Some(EVIDENCE_SOURCE));
assert_eq!(detail.source.resolved_ref.as_deref(), Some(EVIDENCE_SOURCE));
assert_eq!(detail.target.status, "known");
assert_eq!(detail.target.revision_ref.as_deref(), Some(EVIDENCE_TARGET));
assert_eq!(detail.target.resolved_ref.as_deref(), Some(EVIDENCE_TARGET));
assert_eq!(detail.source.observed_at, merge.created_at.to_rfc3339());
assert_eq!(detail.source.diagnostic, None);
assert_eq!(detail.target.diagnostic, None);
@@ -382,7 +382,7 @@ async fn latest_postmerge_approval_requires_exact_revision_and_stored_source_res
let merge = fixture.integrate(&integration);
fixture.rescope();
let revision = fixture.revision();
assert_ne!(integration.ticket_item_revision, revision);
assert_ne!(integration.ticket_content_digest, revision);
let stale = fixture.show().await;
assert!(stale.evidence.approved_current_subject); // integration proof survives
@@ -396,7 +396,7 @@ async fn latest_postmerge_approval_requires_exact_revision_and_stored_source_res
);
fixture.assert_filters(false, true, true).await;
let mut outdated_completion = fixture.completion(&integration);
outdated_completion.expected_item_revision = integration.ticket_item_revision.clone();
outdated_completion.expected_content_digest = integration.ticket_content_digest.clone();
assert!(
fixture
.backend
@@ -407,7 +407,7 @@ async fn latest_postmerge_approval_requires_exact_revision_and_stored_source_res
let latest = fixture.approve("latest-revision");
assert_eq!(latest.created_at, integration.created_at);
assert_eq!(latest.ticket_item_revision, revision);
assert_eq!(latest.ticket_content_digest, revision);
assert_eq!(latest.ticket_merge_request_subjects, fixture.subjects());
let fresh = fixture.show().await;
assert!(
@@ -484,13 +484,13 @@ async fn latest_postmerge_approval_requires_exact_revision_and_stored_source_res
assert_eq!(stored.merged_result().unwrap(), &merge);
let detail = fixture.detail(repaired.sequence - 1, 1).await;
assert_eq!(detail.merge_request.thread.len(), 1);
assert_eq!(detail.source.revision_ref.as_deref(), Some(EVIDENCE_SOURCE));
assert_eq!(detail.target.revision_ref.as_deref(), Some(EVIDENCE_TARGET));
assert_eq!(detail.source.resolved_ref.as_deref(), Some(EVIDENCE_SOURCE));
assert_eq!(detail.target.resolved_ref.as_deref(), Some(EVIDENCE_TARGET));
let completion = fixture
.backend
.complete(&fixture.ticket_id, fixture.completion(&repaired))
.unwrap();
assert_eq!(ticket::ticket_item_revision(&completion), revision);
assert_eq!(ticket::ticket_content_digest(&completion), revision);
assert_eq!(fixture.show().await.state, "done");
fixture.assert_filters(true, false, false).await;
}
@@ -560,7 +560,7 @@ async fn open_source_without_runtime_is_typed_unavailable_not_stale_after_rescop
let detail = fixture.detail(approval.sequence, 1).await;
assert!(detail.merge_request.thread.is_empty());
assert_eq!(detail.source.status, "unknown");
assert_eq!(detail.source.revision_ref, None);
assert_eq!(detail.source.resolved_ref, None);
assert_eq!(
detail.source.diagnostic.unwrap().code,
"source_ref_runtime_unavailable"
@@ -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_item_revision: fixture.revision(),
expected_content_digest: fixture.revision(),
expected_state: TicketWorkflowState::InProgress,
state: TicketWorkflowState::Closed,
reason: "The request was withdrawn; no integration or approval is asserted".into(),
@@ -686,7 +686,7 @@ async fn mrless_completion_is_a_ticket_judgment_not_approved_or_missing_merge_ev
&reference.id,
ticket::TicketCompletion {
operation_key: "research-result".into(),
expected_item_revision: ticket::ticket_item_revision(&ticket),
expected_content_digest: ticket::ticket_content_digest(&ticket),
expected_state: TicketWorkflowState::Planning,
reason: "Answer recorded in the Ticket thread".into(),
references: Vec::new(),
@@ -760,7 +760,7 @@ async fn all_public_state_and_close_routes_require_explicit_cas_and_operation_re
)
.await;
let close = json!({
"operation_key":"guarded-close","expected_item_revision":ticket::ticket_item_revision(&before),
"operation_key":"guarded-close","expected_content_digest":ticket::ticket_content_digest(&before),
"expected_state":"planning","reason":"No further work required","author":"spoofed-client-author"
});
let response = app
@@ -786,7 +786,7 @@ async fn all_public_state_and_close_routes_require_explicit_cas_and_operation_re
Some(&"guarded-close".to_string())
);
let reopen = json!({
"operation_key":"reopen","expected_item_revision":ticket::ticket_item_revision(&closed),
"operation_key":"reopen","expected_content_digest":ticket::ticket_content_digest(&closed),
"expected_state":"closed","state":"planning","reason":"A new question arrived"
});
let response = app
@@ -201,13 +201,13 @@ async fn ambiguous_existing_worker_restore_keeps_claim_pending_until_accepted_re
.unwrap()
.unwrap();
assert_eq!(
recovery.item_revision,
recovery.content_digest,
fixture
.api
.authority
.ticket(&fixture.ticket_id)
.unwrap()
.item_revision
.content_digest
);
assert_eq!(recovery.selected.len(), request.workdir_bindings.len());
for selected in &request.workdir_bindings {
@@ -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().item_revision;
let admitted_revision = 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.item_revision, admitted_revision);
assert_eq!(recovery.content_digest, admitted_revision);
assert!(recovery.selected.is_empty());
assert!(recovery.original_links.is_empty());
assert!(recovery.effective_links.is_empty());
@@ -571,7 +571,7 @@ async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits
pending.request_fingerprint
);
assert_eq!(
api.authority.ticket(&ticket_id).unwrap().item_revision,
api.authority.ticket(&ticket_id).unwrap().content_digest,
admitted_revision
);
assert_eq!(
@@ -183,8 +183,7 @@ impl LaunchFixture {
.public_key_fingerprint
.clone()
.unwrap(),
identity_revision: workspace_identity.revision,
trust_generation: workspace_identity.revision,
trust_id: "trust-launch-test".to_string(),
state: WorkspaceIssuerTrustState::Active,
registered_at_unix: 1,
updated_at_unix: 1,
@@ -206,11 +205,12 @@ impl LaunchFixture {
base_url: endpoint.clone(),
public_key: runtime_identity.public_key.clone(),
public_key_fingerprint: signer.public_key_fingerprint().to_string(),
binding_revision: 1,
binding_id: "binding-launch-test".to_string(),
state: StoredRuntimeBindingState::Configured,
authentication_mode: StoredRuntimeAuthenticationMode::WorkspaceIdentity,
workspace_key_id: Some(workspace_identity.key_id),
workspace_key_generation: Some(workspace_identity.revision),
workspace_public_key_fingerprint: workspace_identity.public_key_fingerprint.clone(),
workspace_trust_id: Some("trust-launch-test".to_string()),
created_at: TEST_CREATED_AT.into(),
updated_at: TEST_CREATED_AT.into(),
revoked_at: None,
@@ -221,6 +221,13 @@ impl LaunchFixture {
.upsert_workspace_runtime_binding_record(binding.clone(), false)
.await
.unwrap();
let binding = fixture
.api
.store
.get_workspace_runtime_binding(TEST_WORKSPACE_ID, &fixture.runtime_id)
.await
.unwrap()
.unwrap();
fixture.runtime_server = Some(tokio::spawn(async move {
worker_runtime::http_server::serve_runtime_http_with_workspace_auth(
runtime,
@@ -471,7 +478,6 @@ async fn worker_launch_uses_published_project_profile_and_workspace_default_on_r
.unwrap();
let main = config_source::VirtualPath::parse("main.dcdl").unwrap();
let request = crate::config_source::ConfigCommitRequest {
base_revision: state.snapshot.revision,
base_digest: state.snapshot.digest.clone(),
entrypoints: state.contract.entrypoints.clone(),
changes: vec![config_source::ConfigTreeChange::Update {
@@ -1025,7 +1031,7 @@ async fn choosing_reviewer_profile_does_not_register_a_reviewer_child_or_grant_m
.request_review(merge_request::RequestMergeRequestReview {
merge_request_id: mr_id.into(),
ticket_id: ticket_id.clone(),
ticket_item_revision: ticket_item_checker::item_revision(
ticket_content_digest: ticket_item_checker::content_digest(
&browser_ticket_backend(&fixture.api)
.unwrap()
.show(ticket_id.clone().into())
@@ -31,7 +31,6 @@ fn request(
}];
changes.extend(extra);
ConfigCommitRequest {
base_revision: state.snapshot.revision,
base_digest: state.snapshot.digest,
entrypoints: state.contract.entrypoints,
changes,
@@ -75,10 +74,7 @@ async fn value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_ba
StatusCode::CREATED,
)
.await;
assert_eq!(
saved["snapshot"]["revision"].as_u64().unwrap(),
initial["snapshot"]["revision"].as_u64().unwrap() + 1
);
assert_ne!(saved["snapshot"]["digest"], initial["snapshot"]["digest"]);
let listed = get_json_authenticated(app.clone(), &tree, &token).await;
assert_eq!(listed, saved);
let entries = listed["snapshot"]["entries"].as_object().unwrap();
@@ -108,7 +104,6 @@ async fn value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_ba
.unwrap();
let main_digest = entries["main.dcdl"]["content_digest"].as_str().unwrap();
let base = json!({
"base_revision": listed["snapshot"]["revision"],
"base_digest": listed["snapshot"]["digest"],
"entrypoints": listed["contract"]["entrypoints"],
});
@@ -326,7 +321,7 @@ async fn value_profiles_builtin_companion_composition_seals_observed_sources() {
}
#[tokio::test]
async fn value_profiles_save_project_and_runtime_consume_the_same_revision() {
async fn value_profiles_save_project_and_runtime_consume_the_same_content() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let forms = [
@@ -390,8 +385,8 @@ async fn value_profiles_save_project_and_runtime_consume_the_same_revision() {
Some("project:alpha")
);
assert_eq!(
projection.settings.config_revision,
Some(state.snapshot.revision)
projection.settings.tree_digest.as_deref(),
Some(state.snapshot.digest.as_str())
);
let selected = projection
.settings
@@ -413,10 +408,7 @@ async fn value_profiles_save_project_and_runtime_consume_the_same_revision() {
.unwrap()
.unwrap();
assert_eq!(bundle.metadata.digest, bundle.computed_digest());
assert_eq!(
bundle.metadata.revision,
state.snapshot.revision.to_string()
);
let archive = bundle.profile_source_archive.as_ref().unwrap();
let verified = archive.verify().unwrap();
// This is the Runtime's archive consumer. It needs neither the Workspace tree nor imports.
@@ -454,7 +446,7 @@ async fn value_profiles_save_project_and_runtime_consume_the_same_revision() {
}
if index == 2 {
let mut other_state = state.clone();
other_state.snapshot.revision += 1;
other_state.snapshot.digest = "sha256:different-tree".to_string();
assert!(
build_virtual_profile_config_bundle(
&projection,
@@ -393,8 +393,8 @@ async fn workdirless_workers_share_markdown_through_current_read_and_write_grant
.unwrap(),
);
assert_ne!(
updated["entry"]["metadata"]["revision"],
created["entry"]["metadata"]["revision"]
updated["entry"]["metadata"]["last_mutation_id"],
created["entry"]["metadata"]["last_mutation_id"]
);
assert_eq!(
value(
@@ -416,7 +416,8 @@ async fn workdirless_workers_share_markdown_through_current_read_and_write_grant
}
#[tokio::test]
async fn simultaneous_worker_edits_publish_exactly_one_revision_and_preserve_the_loser_conflict() {
async fn simultaneous_worker_edits_publish_exactly_one_last_mutation_id_and_preserve_the_loser_conflict()
{
let f = Fixture::new(["read_write", "read_write"]).await;
let id = entry(&f.create("simultaneous.md", "original").await);
for feature in &f.features {
@@ -456,7 +457,7 @@ async fn simultaneous_worker_edits_publish_exactly_one_revision_and_preserve_the
}
#[tokio::test]
async fn stale_worker_observations_cannot_write_edit_relocate_or_delete_newer_revision() {
async fn stale_worker_observations_cannot_write_edit_relocate_or_delete_newer_last_mutation_id() {
let f = Fixture::new(["read_write", "read_write"]).await;
let id = entry(&f.create("cas.md", "old").await);
let root = f.root(0).await;
@@ -914,8 +915,8 @@ async fn lost_mutation_response_is_resolved_by_receipt_without_resending_effect(
let listed = value(tool(&f.features[1], "DriveList", json!({})).await.unwrap());
assert_eq!(listed["entries"].as_array().unwrap().len(), 1);
assert_eq!(
listed["entries"][0]["metadata"]["revision"],
created["entry"]["metadata"]["revision"]
listed["entries"][0]["metadata"]["last_mutation_id"],
created["entry"]["metadata"]["last_mutation_id"]
);
}
@@ -954,8 +955,8 @@ async fn scoped_workdir_import_publishes_document_and_image_bytes_with_bounded_r
.append_pair("entry_workspace_id", TEST_WORKSPACE_ID)
.append_pair("id", id["node_id"].as_str().unwrap())
.append_pair(
"expected_revision",
metadata["metadata"]["revision"].as_str().unwrap(),
"expected_mutation_id",
metadata["metadata"]["last_mutation_id"].as_str().unwrap(),
)
.append_pair("offset", "0")
.append_pair("length", "17")
@@ -1271,7 +1272,7 @@ async fn native_tree_does_not_index_entries_but_direct_inspect_and_invoke_use_cu
}
#[tokio::test]
async fn native_create_and_write_use_client_managed_revisions_and_reject_stale_worker() {
async fn native_create_and_write_use_client_managed_committed_requests_and_reject_stale_worker() {
let f = Fixture::new(["read_write", "read_write"]).await;
let first = native_tools(&f.features[0]);
let second = native_tools(&f.features[1]);
@@ -1294,8 +1295,8 @@ async fn native_create_and_write_use_client_managed_revisions_and_reject_stale_w
);
let updated = value(native_tool(&first, "Invoke", json!({"path":path,"interface":observed_first["interfaces"][0]["reference"],"operation":"write","arguments":{"content":"committed"}})).await.unwrap());
assert_ne!(
updated["entry"]["metadata"]["revision"],
created["entry"]["metadata"]["revision"]
updated["entry"]["metadata"]["last_mutation_id"],
created["entry"]["metadata"]["last_mutation_id"]
);
let error = native_tool(&second, "Invoke", json!({"path":path,"interface":observed_second["interfaces"][0]["reference"],"operation":"write","arguments":{"content":"lost"}})).await.unwrap_err();
assert!(
@@ -1333,7 +1334,7 @@ async fn image_attachment_retains_original_bytes_after_drive_update_and_revocati
request: &str,
bytes: &[u8],
id: Option<&Value>,
revision: Option<&str>| {
last_mutation_id: Option<&str>| {
let mut query = url::form_urlencoded::Serializer::new(String::new());
query
.append_pair("operation", operation)
@@ -1345,7 +1346,7 @@ async fn image_attachment_retains_original_bytes_after_drive_update_and_revocati
if let Some(id) = id {
query
.append_pair("id", id["node_id"].as_str().unwrap())
.append_pair("expected_revision", revision.unwrap());
.append_pair("expected_mutation_id", last_mutation_id.unwrap());
} else {
query
.append_pair("parent_id", root["node_id"].as_str().unwrap())
@@ -1400,7 +1401,7 @@ async fn image_attachment_retains_original_bytes_after_drive_update_and_revocati
"image-update",
replacement,
Some(&id),
saved["entry"]["revision"].as_str(),
saved["entry"]["last_mutation_id"].as_str(),
),
replacement,
)
@@ -184,7 +184,6 @@ async fn handle_resolution_rejects_unknown_foreign_and_internal_only_workers() {
&source.worker_ref,
protocol::Event::InternalWorker {
worker: internal.clone(),
revision: 1,
event: Box::new(protocol::Event::WorkerState {
snapshot: protocol::WorkerStateSnapshot::initial(),
}),
@@ -522,7 +521,6 @@ async fn browser_protocol_sender_rejects_job_input_and_all_live_methods() {
let request = BackendJobRequest {
job_id: "worker-operation-read-only-job".into(),
purpose: "operation_boundary_test".into(),
input_revision: "revision-1".into(),
input_ref: "test://worker-operation/input".into(),
input: serde_json::json!({"immutable": true}),
instruction: "Return a structured result.".into(),
@@ -755,7 +753,6 @@ async fn generic_management_aliases_cannot_bypass_worker_grants_or_target_bound_
TEST_WORKSPACE_ID.into(),
"offline-runtime".into(),
ExecuteRuntimeCleanupRequest {
expected_plan_revision: "irrelevant".into(),
expected_plan_digest: "irrelevant".into(),
worker_target_ids: Vec::new(),
workdir_target_ids: Vec::new(),
@@ -875,7 +872,6 @@ async fn browser_protocol_connection_and_cleanup_reject_cookie_origin_mismatch()
&api,
EMBEDDED_WORKER_RUNTIME_ID,
ExecuteRuntimeCleanupRequest {
expected_plan_revision: "irrelevant".into(),
expected_plan_digest: "irrelevant".into(),
worker_target_ids: Vec::new(),
workdir_target_ids: Vec::new(),
@@ -1112,7 +1108,7 @@ async fn restore_guard_stale_ssh_intent_does_not_issue_workspace_resource() {
hostname: "example.invalid".into(),
port: 22,
host_key: public_key,
expected_revision: None,
expected_fingerprint: None,
},
"owner-account",
)
@@ -1642,7 +1638,7 @@ async fn restore_guard_cross_runtime_ssh_completion_retries_pinned_snapshot_at_w
hostname: "example.invalid".into(),
port: 22,
host_key: public_key,
expected_revision: None,
expected_fingerprint: None,
},
"owner-account",
)
@@ -1678,7 +1674,7 @@ async fn restore_guard_cross_runtime_ssh_completion_retries_pinned_snapshot_at_w
);
assert_ne!(first.materialization.runtime_id, identity.runtime_id);
// Rotate current host trust while the accepted operation is uncertain. Its
// original credential/trust revisions, source and binding must remain pinned.
// original credential/trust fingerprints, source and binding must remain pinned.
let alternate = api
.repository_secrets
.generate_credential(
@@ -1697,12 +1693,13 @@ async fn restore_guard_cross_runtime_ssh_completion_retries_pinned_snapshot_at_w
.unwrap()
.unwrap()
.public_key;
let original_trust_revision = first
let original_trust_fingerprint = first
.materialization
.ssh
.as_ref()
.unwrap()
.host_trust_revision;
.host_key_fingerprint
.clone();
api.repository_secrets
.put_host_trust(
api.workspace_id(),
@@ -1712,7 +1709,7 @@ async fn restore_guard_cross_runtime_ssh_completion_retries_pinned_snapshot_at_w
hostname: "example.invalid".into(),
port: 22,
host_key: alternate_public,
expected_revision: Some(original_trust_revision),
expected_fingerprint: Some(original_trust_fingerprint),
},
"owner-account",
)
@@ -598,7 +598,6 @@ async fn runtime_callbacks_during_deletion_drain_still_require_bound_proofs() {
TEST_WORKSPACE_ID,
WorkdirlessFixtureRuntime::RUNTIME_ID,
"fixture-resource",
"1",
Utc::now().timestamp() + 60,
worker_runtime::resource::RepositorySshAccessSecret {
credential_candidates: Vec::new(),
@@ -846,7 +845,7 @@ async fn create_and_restore_session_callbacks_finish_while_deletion_start_waits(
&format!("/api/workspaces/{TEST_WORKSPACE_ID}/deletion"),
json!({
"operation_id": if restore {"delete-during-restore"} else {"delete-during-create"},
"expected_revision": "fixture-revision", "confirmation":"fixture",
"expected_workspace_updated_at": "fixture-updated-at", "confirmation":"fixture",
}),
);
let mut deletion = Box::pin(f.app.clone().oneshot(request));
+77 -31
View File
@@ -4,7 +4,7 @@ use super::*;
use futures::StreamExt;
use server_api::*;
use sha2::{Digest, Sha256};
use workspace_drive::{Drive, Mutation, NodeId, Revision};
use workspace_drive::{Drive, Mutation, NodeId};
fn mutation_actor(context: &ServerRequestContext) -> DriveResult<String> {
if let Some(source) = &context.runtime_source {
@@ -65,8 +65,11 @@ async fn blocking<T: Send + 'static>(
fn node_id(value: &str) -> DriveResult<NodeId> {
value.to_owned().try_into().map_err(|_| failure(400))
}
fn revision(value: &str) -> DriveResult<Revision> {
value.to_owned().try_into().map_err(|_| failure(400))
fn last_mutation_id(value: &str) -> DriveResult<String> {
if value.is_empty() || value.len() > 128 || value.chars().any(char::is_control) {
return Err(failure(400));
}
Ok(value.to_owned())
}
fn entry(workspace: &str, reference: &DriveEntryRef) -> DriveResult<NodeId> {
if reference.workspace_id != workspace {
@@ -188,7 +191,7 @@ fn public_node(node: workspace_drive::Node) -> DriveEntry {
workspace_drive::Kind::File => DriveEntryKind::File,
workspace_drive::Kind::Directory => DriveEntryKind::Folder,
},
revision: node.revision.get().to_string(),
last_mutation_id: node.last_mutation_id.clone(),
size: (node.kind == workspace_drive::Kind::File).then_some(node.size as u32),
content_type: node.content_type,
updated_by: node.updated_by,
@@ -292,7 +295,7 @@ pub(super) async fn read_text(
}
let (node, chunk) = blocking(false, move || {
let node = drive.metadata(id)?;
let chunk = drive.read(id, node.revision, 0, max)?;
let chunk = drive.read(id, &node.last_mutation_id, 0, max)?;
Ok((node, chunk))
})
.await?;
@@ -317,12 +320,17 @@ pub(super) async fn read_chunk(
) -> DriveResult<BinaryBody> {
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
let id = scoped_id(workspace, &query.entry_workspace_id, &query.id)?;
let revision = revision(&query.expected_revision)?;
let last_mutation_id = last_mutation_id(&query.expected_mutation_id)?;
if query.length == 0 || query.length > DRIVE_CHUNK_MAX_BYTES {
return Err(failure(413));
}
let chunk = blocking(false, move || {
drive.read(id, revision, query.offset.into(), query.length as usize)
drive.read(
id,
&last_mutation_id,
query.offset.into(),
query.length as usize,
)
})
.await?;
Ok(chunk.bytes.into())
@@ -352,32 +360,32 @@ fn mutation(workspace: &str, mutation: DriveMutation) -> DriveResult<Mutation> {
},
DriveMutation::UpdateText {
id,
expected_revision,
expected_mutation_id,
text,
content_type,
} => Mutation::Update {
id: entry(workspace, &id)?,
expected_revision: revision(&expected_revision)?,
expected_mutation_id: last_mutation_id(&expected_mutation_id)?,
content_type: safe_content_type(&content_type)?,
bytes: text_bytes(text)?,
},
DriveMutation::Relocate {
id,
expected_revision,
expected_mutation_id,
parent,
name,
} => Mutation::Relocate {
id: entry(workspace, &id)?,
expected_revision: revision(&expected_revision)?,
expected_mutation_id: last_mutation_id(&expected_mutation_id)?,
parent: entry(workspace, &parent)?,
name,
},
DriveMutation::Delete {
id,
expected_revision,
expected_mutation_id,
} => Mutation::Delete {
id: entry(workspace, &id)?,
expected_revision: revision(&expected_revision)?,
expected_mutation_id: last_mutation_id(&expected_mutation_id)?,
},
})
}
@@ -413,7 +421,9 @@ fn upload_mutation(
}
let content_type = safe_content_type(&query.content_type)?;
match query.operation {
DriveUploadOperation::Create if query.id.is_none() && query.expected_revision.is_none() => {
DriveUploadOperation::Create
if query.id.is_none() && query.expected_mutation_id.is_none() =>
{
Ok(Mutation::CreateFile {
parent: node_id(query.parent_id.as_deref().ok_or_else(|| failure(400))?)?,
name: query.name.clone().ok_or_else(|| failure(400))?,
@@ -424,9 +434,9 @@ fn upload_mutation(
DriveUploadOperation::Update if query.parent_id.is_none() && query.name.is_none() => {
Ok(Mutation::Update {
id: node_id(query.id.as_deref().ok_or_else(|| failure(400))?)?,
expected_revision: revision(
expected_mutation_id: last_mutation_id(
query
.expected_revision
.expected_mutation_id
.as_deref()
.ok_or_else(|| failure(400))?,
)?,
@@ -508,9 +518,9 @@ async fn select_download(
return Err(failure(400));
}
if query
.expected_revision
.expected_mutation_id
.as_deref()
.is_some_and(|v| v != node.revision.get().to_string())
.is_some_and(|v| v != node.last_mutation_id.clone())
{
return Err(failure(409));
}
@@ -528,8 +538,9 @@ pub(super) async fn download(
let mut offset = 0;
loop {
let copy = drive.clone();
let node = node.clone();
let chunk = blocking(false, move || {
copy.read(node.id, node.revision, offset, TRANSFER_CHUNK)
copy.read(node.id, &node.last_mutation_id, offset, TRANSFER_CHUNK)
})
.await?;
offset += chunk.bytes.len() as u64;
@@ -548,12 +559,18 @@ pub(super) async fn download(
})
}
fn validator(node: &workspace_drive::Node) -> String {
format!(
"\"drive-{}-{}-{}\"",
node.workspace_id,
node.id.get(),
node.revision.get()
)
// Request IDs are caller-chosen UTF-8, never interpolate them into HTTP headers.
let mut digest = Sha256::new();
digest.update(node.workspace_id.as_bytes());
digest.update([0]);
digest.update(node.id.get().to_be_bytes());
digest.update(node.last_mutation_id.as_bytes());
let hex: String = digest
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect();
format!("\"drive-{hex}\"")
}
pub(super) fn router(service: Arc<ServerApiContractService>) -> Router {
@@ -664,16 +681,17 @@ async fn streaming_download(
let (drive, node) = select_download(api, &context, &workspace, query).await?;
// Validate the referenced blob before HTTP success, including a zero-byte file.
let first_drive = drive.clone();
let selected = node.clone();
let first = blocking(false, move || {
first_drive.read(node.id, node.revision, 0, TRANSFER_CHUNK)
first_drive.read(selected.id, &selected.last_mutation_id, 0, TRANSFER_CHUNK)
})
.await?;
let id = node.id;
let revision = node.revision;
let state = (drive, id, revision, Some(first), 0_u64, false);
let last_mutation_id = node.last_mutation_id.clone();
let state = (drive, id, last_mutation_id, Some(first), 0_u64, false);
let stream = futures::stream::try_unfold(
state,
|(drive, id, revision, first, offset, done)| async move {
|(drive, id, last_mutation_id, first, offset, done)| async move {
if done {
return Ok(None);
}
@@ -681,8 +699,9 @@ async fn streaming_download(
Some(chunk) => chunk,
None => {
let copy = drive.clone();
let expected = last_mutation_id.clone();
blocking(false, move || {
copy.read(id, revision, offset, TRANSFER_CHUNK)
copy.read(id, &expected, offset, TRANSFER_CHUNK)
})
.await
.map_err(|_| stream_error())?
@@ -692,7 +711,7 @@ async fn streaming_download(
let eof = chunk.eof;
Ok::<_, std::io::Error>(Some((
axum::body::Bytes::from(chunk.bytes),
(drive, id, revision, None, next, eof),
(drive, id, last_mutation_id, None, next, eof),
)))
},
);
@@ -734,3 +753,30 @@ async fn streaming_download(
Err(error) => error_response(error),
}
}
#[cfg(test)]
mod validator_tests {
use super::*;
#[test]
fn request_identity_is_hashed_before_becoming_an_http_validator() {
let mut node = workspace_drive::Node {
id: "2".to_string().try_into().unwrap(),
workspace_id: "workspace".into(),
parent_id: Some("1".to_string().try_into().unwrap()),
name: "document".into(),
kind: workspace_drive::Kind::File,
last_mutation_id: "write/資料 ?\"#&=+".into(),
size: 0,
content_type: Some("text/plain".into()),
updated_by: "actor".into(),
updated_at_ms: 0,
};
let first = validator(&node);
assert!(first.parse::<axum::http::HeaderValue>().is_ok());
assert!(!first.contains(&node.last_mutation_id));
assert_eq!(first, validator(&node));
node.last_mutation_id = "another-request".into();
assert_ne!(first, validator(&node));
}
}
@@ -268,7 +268,8 @@ fn tree(api: &WorkspaceApi) -> ConfigResult<crate::config_source::WorkspaceConfi
fn validator(
grant: &WorkspaceConfigGrantResponse,
link: &WorkerWorkdirLinkRecord,
snapshot: &ConfigTreeSnapshot,
state: &crate::config_source::WorkspaceConfigState,
applied_schema_fingerprint: &str,
path: &str,
) -> String {
let bound = serde_json::to_vec(&(
@@ -280,8 +281,10 @@ fn validator(
&link.connection_id,
effective_access(grant, link),
path,
snapshot.revision,
&snapshot.digest,
&state.snapshot.digest,
&state.contract.fingerprint,
&state.projection_digest,
applied_schema_fingerprint,
))
.expect("bounded configuration validator tuple");
format!(
@@ -313,6 +316,11 @@ pub(super) async fn observe(
let _guard = lock(api, worker).await?;
let (grant, link) = resolve(api, worker, &request.connection_id)?;
let state = tree(api)?;
let schema_fingerprint = api
.config_schema_registry
.compose()
.map_err(pre_error)?
.fingerprint;
let snapshot = &state.snapshot;
let writable = effective_access(&grant, &link) == Access::ReadWrite;
// Build only bounded metadata (the canonical tree itself is bounded by config-source).
@@ -386,7 +394,7 @@ pub(super) async fn observe(
}
}
WorkspaceConfigNode {
validator: validator(&grant, &link, snapshot, &path),
validator: validator(&grant, &link, &state, &schema_fingerprint, &path),
path,
kind,
digest: entry.map(|entry| entry.content_digest.clone()),
@@ -402,8 +410,7 @@ pub(super) async fn observe(
.collect();
Ok(WorkspaceConfigObserveResponse {
connection_id: link.connection_id.clone(),
validator: validator(&grant, &link, snapshot, ""),
revision: snapshot.revision,
validator: validator(&grant, &link, &state, &schema_fingerprint, ""),
digest: snapshot.digest.clone(),
entrypoints: state
.contract
@@ -423,7 +430,12 @@ pub(super) async fn read(
let _guard = lock(api, worker).await?;
let (grant, link) = resolve(api, worker, &request.connection_id)?;
let state = tree(api)?;
let expected = validator(&grant, &link, &state.snapshot, &request.path);
let schema_fingerprint = api
.config_schema_registry
.compose()
.map_err(pre_error)?
.fingerprint;
let expected = validator(&grant, &link, &state, &schema_fingerprint, &request.path);
check_validator(&request.validator, &expected)?;
let path =
config_source::VirtualPath::parse(&request.path).map_err(|_| fail(400, "invalid_path"))?;
@@ -476,9 +488,14 @@ pub(super) async fn commit(
return Err(fail(403, "access_denied"));
}
let state = tree(api)?;
let schema_fingerprint = api
.config_schema_registry
.compose()
.map_err(pre_error)?
.fingerprint;
check_validator(
&request.validator,
&validator(&grant, &link, &state.snapshot, ""),
&validator(&grant, &link, &state, &schema_fingerprint, ""),
)?;
let canonical = config_commit_request_from_api(request.request).map_err(pre_error)?;
// Evaluation/semantic validation are pure preparation. Bound this stage
@@ -496,6 +513,13 @@ pub(super) async fn commit(
.map_err(|_| fail(503, "evaluation_timeout"))?
.map_err(|_| fail(500, "evaluation_unavailable"))?
.map_err(pre_error)?;
if candidate.base_digest != state.snapshot.digest
|| candidate.base_toolchain_fingerprint != state.contract.fingerprint
|| candidate.base_projection_digest != state.projection_digest
|| candidate.contract.schema_bundle.fingerprint != schema_fingerprint
{
return Err(fail(409, "stale_validator"));
}
// Recheck durable authority before the first auxiliary/canonical effects.
let (current_grant, current_link) = resolve(api, worker, &request.connection_id)?;
if current_grant != grant || current_link.capabilities != link.capabilities {
@@ -506,8 +530,7 @@ pub(super) async fn commit(
let state = persist_workspace_config_candidate(api, &api.config.workspace_id, &candidate)
.map_err(|_| unknown())?;
Ok(WorkspaceConfigCommitResponse {
validator: validator(&grant, &link, &state.snapshot, ""),
revision: state.snapshot.revision,
validator: validator(&grant, &link, &state, &schema_fingerprint, ""),
digest: state.snapshot.digest,
})
}
@@ -68,7 +68,6 @@ mod workspace_config_integration {
connection_id: connection.into(),
validator: snapshot.validator.clone(),
request: server_api::ConfigCommitRequest {
base_revision: snapshot.revision,
base_digest: snapshot.digest.clone(),
changes,
entrypoints: snapshot.entrypoints.clone(),
@@ -127,97 +126,335 @@ mod workspace_config_integration {
)
}
#[tokio::test]
async fn workspace_config_schema_refresh_invalidates_read_and_commit_validators_without_source_change()
{
struct AddedSchema;
impl crate::config_source::WorkspaceConfigSchemaProvider for AddedSchema {
fn contribution(&self) -> Result<config_source::ConfigSchemaContribution> {
Ok(config_source::ConfigSchemaContribution::new(
"builtin:validator-test",
"validator_test",
"1",
"{ validator_test = Bool default true; }",
)
.unwrap())
}
}
let mut fixture = manual_worker_assignment_fixture().await;
grant(&fixture.api, &fixture.worker, Access::ReadWrite).await;
let attached = attach(&fixture.api, &fixture.worker).await;
let prior = observe(
&fixture.api,
&fixture.worker,
&attached.connection_id,
&[""],
1,
)
.await;
let file = prior
.nodes
.iter()
.find(|node| node.path == "main.dcdl")
.unwrap();
// Also bind the schema that will be applied, even before canonical refresh.
fixture.api.config_schema_registry = fixture
.api
.config_schema_registry
.clone()
.with_provider(Arc::new(AddedSchema));
let applied = observe(
&fixture.api,
&fixture.worker,
&attached.connection_id,
&[""],
1,
)
.await;
assert_eq!(applied.digest, prior.digest);
assert_ne!(applied.validator, prior.validator);
fixture
.api
.config_store
.ensure_workspace_config_materialized_with_schema(
TEST_WORKSPACE_ID,
"2026-08-14T00:00:00Z",
fixture.api.config_schema_registry.compose().unwrap(),
)
.unwrap();
let refreshed = observe(
&fixture.api,
&fixture.worker,
&attached.connection_id,
&[""],
1,
)
.await;
assert_eq!(refreshed.digest, prior.digest);
assert_ne!(refreshed.validator, applied.validator);
let rejected = workspace_config::read(
&fixture.api,
&fixture.worker,
Read {
connection_id: attached.connection_id.clone(),
path: file.path.clone(),
validator: file.validator.clone(),
},
)
.await
.unwrap_err();
assert_eq!(
(rejected.status, rejected.code.as_str()),
(409, "stale_validator")
);
let rejected = workspace_config::commit(
&fixture.api,
&fixture.worker,
commit_request(
&attached.connection_id,
&prior,
vec![create("stale.txt", "must not commit")],
),
)
.await
.unwrap_err();
assert_eq!(
(rejected.status, rejected.code.as_str()),
(409, "stale_validator")
);
assert_eq!(rejected.classification, Classification::NotCommitted);
assert!(
!fixture
.api
.config_store
.load_workspace_config(TEST_WORKSPACE_ID)
.unwrap()
.unwrap()
.snapshot
.entries
.contains_key(&config_source::VirtualPath::parse("stale.txt").unwrap())
);
}
#[tokio::test]
async fn workspace_config_signed_editor_routes_cannot_bypass_wip_authority() {
let mut fixture = manual_worker_assignment_fixture().await;
let identity = RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id).unwrap();
configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id);
let api = &fixture.api;
let initial = api.config_store.load_workspace_config(TEST_WORKSPACE_ID).unwrap().unwrap();
let initial = api
.config_store
.load_workspace_config(TEST_WORKSPACE_ID)
.unwrap()
.unwrap();
let tree = format!("/api/w/{TEST_WORKSPACE_ID}/config/source-tree");
let change = server_api::ConfigCommitRequest {
base_revision: initial.snapshot.revision,
base_digest: initial.snapshot.digest.clone(),
changes: vec![create("notes/editor-only.txt", "must not persist from Worker")],
changes: vec![create(
"notes/editor-only.txt",
"must not persist from Worker",
)],
entrypoints: vec!["main.dcdl".into()],
};
let routes = [
("GET", tree.clone()),
("GET", format!("{tree}/entries/main.dcdl")),
("GET", format!("{tree}/revisions/{}", initial.snapshot.revision)),
("GET", format!("{tree}/history/{}", initial.snapshot.digest)),
("POST", format!("{tree}/commit")),
("GET", format!("/api/w/{TEST_WORKSPACE_ID}/settings/profiles")),
(
"GET",
format!("/api/w/{TEST_WORKSPACE_ID}/settings/profiles"),
),
];
for state in ["ungranted", "read_only", "revoked", "detached", "read_write"] {
for state in [
"ungranted",
"read_only",
"revoked",
"detached",
"read_write",
] {
match state {
"read_only" => {
grant(api, &fixture.worker, Access::ReadOnly).await;
attach(api, &fixture.worker).await;
}
"revoked" => {
let current = api.store.current_workspace_config_grant(TEST_WORKSPACE_ID, &fixture.worker).unwrap().unwrap();
workspace_config::revoke_grant(api, &test_owner_actor(), TEST_WORKSPACE_ID,
&current.grant_id).await.unwrap();
let current = api
.store
.current_workspace_config_grant(TEST_WORKSPACE_ID, &fixture.worker)
.unwrap()
.unwrap();
workspace_config::revoke_grant(
api,
&test_owner_actor(),
TEST_WORKSPACE_ID,
&current.grant_id,
)
.await
.unwrap();
}
"detached" => {
grant(api, &fixture.worker, Access::ReadWrite).await;
let attached = attach(api, &fixture.worker).await;
detach_current_worker_workdir(api, &fixture.worker, "workspace-config",
Some(&attached.connection_id)).await.unwrap();
detach_current_worker_workdir(
api,
&fixture.worker,
"workspace-config",
Some(&attached.connection_id),
)
.await
.unwrap();
}
"read_write" => {
attach(api, &fixture.worker).await;
}
"read_write" => { attach(api, &fixture.worker).await; }
_ => {}
}
for (method, path) in &routes {
let body = if *method == "POST" { serde_json::to_vec(&change).unwrap() } else { vec![] };
let body = if *method == "POST" {
serde_json::to_vec(&change).unwrap()
} else {
vec![]
};
for scoped in [false, true] {
let app = if scoped {
workspace_server_router(WorkspaceServerApi::new(api.config.clone(), api.store.clone()))
workspace_server_router(WorkspaceServerApi::new(
api.config.clone(),
api.store.clone(),
))
} else {
build_router(api.clone())
};
let response = app.oneshot(runtime_source_request(&identity,
Some(&fixture.worker.worker_id), method, path, body.clone())).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN,
"{state}: {method} {path}, scoped={scoped}");
let response = app
.oneshot(runtime_source_request(
&identity,
Some(&fixture.worker.worker_id),
method,
path,
body.clone(),
))
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::FORBIDDEN,
"{state}: {method} {path}, scoped={scoped}"
);
let bytes = to_bytes(response.into_body(), 4096).await.unwrap();
assert!(!String::from_utf8_lossy(&bytes).contains("must not persist"));
}
}
let after = api.config_store.load_workspace_config(TEST_WORKSPACE_ID).unwrap().unwrap();
assert_eq!(after.snapshot, initial.snapshot, "{state}: editor rejection must have no effect");
let after = api
.config_store
.load_workspace_config(TEST_WORKSPACE_ID)
.unwrap()
.unwrap();
assert_eq!(
after.snapshot, initial.snapshot,
"{state}: editor rejection must have no effect"
);
}
// A Runtime-only proof is not a user editor identity either.
assert_eq!(signed_call(api, &identity, None, "GET", &tree, Value::Null).await.0,
StatusCode::FORBIDDEN);
assert_eq!(
signed_call(api, &identity, None, "GET", &tree, Value::Null)
.await
.0,
StatusCode::FORBIDDEN
);
let attached = attach(api, &fixture.worker).await;
let observed = observe(api, &fixture.worker, &attached.connection_id, &["main.dcdl"], 0).await;
let node = observed.nodes.iter().find(|n| n.path == "main.dcdl").unwrap();
assert!(workspace_config::read(api, &fixture.worker, Read {
connection_id: attached.connection_id,
path: "main.dcdl".into(), validator: node.validator.clone(),
}).await.is_ok(), "the granted WIP path remains available");
let observed = observe(
api,
&fixture.worker,
&attached.connection_id,
&["main.dcdl"],
0,
)
.await;
let node = observed
.nodes
.iter()
.find(|n| n.path == "main.dcdl")
.unwrap();
assert!(
workspace_config::read(
api,
&fixture.worker,
Read {
connection_id: attached.connection_id,
path: "main.dcdl".into(),
validator: node.validator.clone(),
}
)
.await
.is_ok(),
"the granted WIP path remains available"
);
// Real API-token authentication still permits the normal UI editor.
let token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID);
for (method, path) in &routes {
if *method == "GET" {
request_json_authenticated(build_router(api.clone()), method, path, None,
&token, StatusCode::OK).await;
request_json_authenticated(
build_router(api.clone()),
method,
path,
None,
&token,
StatusCode::OK,
)
.await;
}
}
request_json_authenticated(build_router(api.clone()), "POST", &format!("{tree}/commit"),
Some(serde_json::to_value(change).unwrap()), &token, StatusCode::CREATED).await;
let saved = api.config_store.load_workspace_config(TEST_WORKSPACE_ID).unwrap().unwrap();
assert_eq!(saved.snapshot.revision, initial.snapshot.revision + 1);
assert!(saved.snapshot.entries.keys().any(|p| p.as_str() == "notes/editor-only.txt"));
request_json_authenticated(
build_router(api.clone()),
"POST",
&format!("{tree}/commit"),
Some(serde_json::to_value(change).unwrap()),
&token,
StatusCode::CREATED,
)
.await;
let saved = api
.config_store
.load_workspace_config(TEST_WORKSPACE_ID)
.unwrap()
.unwrap();
assert_ne!(saved.snapshot.digest, initial.snapshot.digest);
assert!(
saved
.snapshot
.entries
.keys()
.any(|p| p.as_str() == "notes/editor-only.txt")
);
// Prompt and Skill runtime consumption are separate read-only projections,
// not authored-tree editor APIs; bootstrap must remain usable.
assert_eq!(signed_call(api, &identity, Some(&fixture.worker.worker_id), "GET",
&format!("/api/w/{TEST_WORKSPACE_ID}/config/projections/prompts"), Value::Null).await.0,
StatusCode::OK);
assert_eq!(signed_call(api, &identity, Some(&fixture.worker.worker_id), "GET",
&format!("/api/w/{TEST_WORKSPACE_ID}/skills"), Value::Null).await.0, StatusCode::OK);
assert_eq!(
signed_call(
api,
&identity,
Some(&fixture.worker.worker_id),
"GET",
&format!("/api/w/{TEST_WORKSPACE_ID}/config/projections/prompts"),
Value::Null
)
.await
.0,
StatusCode::OK
);
assert_eq!(
signed_call(
api,
&identity,
Some(&fixture.worker.worker_id),
"GET",
&format!("/api/w/{TEST_WORKSPACE_ID}/skills"),
Value::Null
)
.await
.0,
StatusCode::OK
);
}
#[tokio::test]
@@ -836,7 +1073,6 @@ mod workspace_config_integration {
let b = attach(api, &second).await;
let first = observe(api, &fixture.worker, &a.connection_id, &[""], 0).await;
let other = observe(api, &second, &b.connection_id, &[""], 0).await;
assert_eq!(first.revision, other.revision);
assert_eq!(first.digest, other.digest);
assert_ne!(first.validator, other.validator);
workspace_config::commit(
@@ -871,7 +1107,6 @@ mod workspace_config_integration {
validator: file.validator.clone(),
};
let canonical = config_commit_request_from_api(server_api::ConfigCommitRequest {
base_revision: fresh.revision,
base_digest: fresh.digest.clone(),
entrypoints: fresh.entrypoints.clone(),
changes: vec![server_api::ConfigTreeChange::Update {
@@ -881,34 +1116,12 @@ mod workspace_config_integration {
}],
})
.unwrap();
// Same bytes, new virtual identity/revision via the ORIGINAL UI commit boundary.
// Identical bytes retain content identity through the original UI commit boundary.
commit_workspace_config_tree(api, TEST_WORKSPACE_ID, &canonical).unwrap();
let after = observe(api, &second, &b.connection_id, &["notes/concurrent.md"], 0).await;
assert_eq!(after.digest, fresh.digest);
assert!(after.revision > fresh.revision);
assert_ne!(after.validator, fresh.validator);
assert_eq!(
workspace_config::read(api, &second, old_read)
.await
.unwrap_err()
.code,
"stale_validator"
);
assert_eq!(
workspace_config::commit(
api,
&second,
commit_request(
&b.connection_id,
&fresh,
vec![create("notes/stale.md", "stale")]
)
)
.await
.unwrap_err()
.code,
"stale_validator"
);
assert_eq!(after.validator, fresh.validator);
assert!(workspace_config::read(api, &second, old_read).await.is_ok());
let delete = vec![server_api::ConfigTreeChange::Delete {
path: file.path.clone(),
expected_digest: file.digest.clone().unwrap(),
@@ -475,7 +475,7 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im
.load_workspace_config(TEST_WORKSPACE_ID)
.unwrap()
.unwrap();
assert_eq!(after.snapshot.revision, before.snapshot.revision + 1);
assert_ne!(after.snapshot.digest, before.snapshot.digest);
assert!(
after
.snapshot
+28 -30
View File
@@ -71,7 +71,7 @@ pub enum SkillError {
NotFound(String),
#[error("Skill `{0}` has blocking diagnostics")]
InvalidSkill(String),
#[error("failed to evaluate the active Workspace config revision: {0}")]
#[error("failed to evaluate the active Workspace config content: {0}")]
Evaluation(String),
#[error("the active Workspace config projection is missing its root value")]
MissingProjection,
@@ -120,7 +120,6 @@ pub fn lint(state: &WorkspaceConfigState) -> Result<SkillCatalogResponse, SkillE
fn projection_identity(state: &WorkspaceConfigState) -> SkillProjectionIdentity {
SkillProjectionIdentity {
config_revision: state.snapshot.revision,
tree_digest: state.snapshot.digest.clone(),
}
}
@@ -183,7 +182,7 @@ fn merged_skills(
kind: SkillSourceKind::Builtin,
id: format!("builtin:{BUILTIN_SKILL_ID}"),
virtual_path: Some(BUILTIN_SKILL_VIRTUAL_PATH.to_string()),
revision: None,
source_digest: Some(config_source::digest_bytes(BUILTIN_SKILL_SOURCE.as_bytes())),
tree_digest: None,
},
@@ -249,7 +248,7 @@ fn merged_skills(
kind: SkillSourceKind::Workspace,
id: format!("workspace:{name}"),
virtual_path: Some(canonical_path),
revision: Some(state.snapshot.revision),
source_digest: Some(source_digest),
tree_digest: Some(state.snapshot.digest.clone()),
},
@@ -479,29 +478,26 @@ mod tests {
fn state(main: &str, markdown: &str, name: &str) -> WorkspaceConfigState {
let skill_path = format!("skills/{name}/SKILL.md");
let reference_path = format!("skills/{name}/references/checklist.md");
let tree = ConfigTreeSnapshot::from_entries(
9,
[
ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
main,
)
.unwrap(),
ConfigEntry::new(
VirtualPath::parse(&skill_path).unwrap(),
ConfigContentType::Text,
markdown,
)
.unwrap(),
ConfigEntry::new(
VirtualPath::parse(&reference_path).unwrap(),
ConfigContentType::Text,
"checklist",
)
.unwrap(),
],
)
let tree = ConfigTreeSnapshot::from_entries([
ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
main,
)
.unwrap(),
ConfigEntry::new(
VirtualPath::parse(&skill_path).unwrap(),
ConfigContentType::Text,
markdown,
)
.unwrap(),
ConfigEntry::new(
VirtualPath::parse(&reference_path).unwrap(),
ConfigContentType::Text,
"checklist",
)
.unwrap(),
])
.unwrap();
let bundle = WorkspaceConfigSchemaBundle::compose([SkillConfigSchemaProvider
.contribution()
@@ -553,8 +549,10 @@ mod tests {
.find(|item| item.name == "debug-rust")
.unwrap();
assert_eq!(item.provenance.kind, SkillSourceKind::Workspace);
assert_eq!(item.provenance.revision, Some(9));
assert_eq!(catalog.projection.config_revision, 9);
assert_eq!(
item.provenance.tree_digest.as_deref(),
Some(state.snapshot.digest.as_str())
);
assert_eq!(catalog.projection.tree_digest, state.snapshot.digest);
assert_eq!(item.activation_status, SkillActivationStatus::Active);
assert_eq!(item.projection_status, SkillProjectionStatus::Valid);
@@ -565,7 +563,7 @@ mod tests {
);
let detail = detail(&state, "debug-rust").unwrap();
assert_eq!(detail.authority, SKILL_CATALOG_AUTHORITY);
assert_eq!(detail.projection.config_revision, 9);
assert_eq!(detail.projection.tree_digest, state.snapshot.digest);
assert_eq!(detail.activation_status, SkillActivationStatus::Active);
assert_eq!(detail.projection_status, SkillProjectionStatus::Valid);
assert_eq!(detail.body, "# Debug Rust\n");
File diff suppressed because it is too large Load Diff
@@ -21,11 +21,11 @@ const MAX_SUGGESTION_BYTES: usize = 512;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct TicketItemCheckInput {
/// Canonical internal identity used for an authoritative revision reread.
/// Canonical internal identity used for an authoritative content_digest reread.
pub ticket_id: String,
/// User-facing identity used in checker output and advisory notifications.
pub ticket_resource_key: String,
pub revision: String,
pub content_digest: String,
pub title: String,
pub body: String,
pub edit: TicketItemCheckEdit,
@@ -88,8 +88,8 @@ impl TicketItemCheckFindingCategory {
}
}
pub fn item_revision(ticket: &ticket::Ticket) -> String {
ticket::ticket_item_revision(ticket)
pub fn content_digest(ticket: &ticket::Ticket) -> String {
ticket::ticket_content_digest(ticket)
}
pub fn request(
@@ -98,7 +98,7 @@ pub fn request(
source_worker: RuntimeWorkerRef,
instruction: String,
) -> Result<BackendJobRequest> {
let revision = item_revision(ticket);
let content_digest = content_digest(ticket);
let resource_key = ticket
.meta
.resource_key
@@ -107,16 +107,15 @@ pub fn request(
let input = TicketItemCheckInput {
ticket_id: ticket.meta.id.clone(),
ticket_resource_key: resource_key.clone(),
revision: revision.clone(),
content_digest: content_digest.clone(),
title: ticket.meta.title.clone(),
body: ticket.document.body.as_str().to_string(),
edit,
};
let request = BackendJobRequest {
job_id: format!("ticket-item-check:{}:{revision}", ticket.meta.id),
let mut request = BackendJobRequest {
job_id: format!("ticket-item-check:{}", ticket.meta.id),
purpose: PURPOSE.to_string(),
input_revision: revision.clone(),
input_ref: format!("ticket://{resource_key}/revisions/{revision}"),
input_ref: format!("ticket://{resource_key}/contents/{content_digest}"),
input: serde_json::to_value(input).map_err(|error| {
Error::InvalidInput(format!("serialize Ticket item checker input: {error}"))
})?,
@@ -133,6 +132,11 @@ pub fn request(
max_attempts: 1,
},
};
// The same item content can result from distinct edits or different source
// Workers. Content identity fences advisory freshness, but does not identify
// the complete Job intent. Hash the request with its stable Ticket prefix
// before adding the digest so exact retries still reuse the same Job.
request.job_id = format!("{}:{}", request.job_id, request.fingerprint()?);
request.validate()?;
Ok(request)
}
@@ -143,7 +147,7 @@ pub fn parse_input(value: &Value) -> Result<TicketItemCheckInput> {
})?;
if input.ticket_id.trim().is_empty()
|| input.ticket_resource_key.trim().is_empty()
|| input.revision.trim().is_empty()
|| input.content_digest.trim().is_empty()
{
return Err(Error::InvalidInput(
"Ticket item checker input identity is incomplete".to_string(),
@@ -217,8 +221,7 @@ pub fn notification(
Error::InvalidInput("completed Ticket item checker Job has no result".to_string())
})?,
)?;
if input.revision != acceptance.job.request.input_revision
|| input.revision != item_revision(current_ticket)
if input.content_digest != content_digest(current_ticket)
|| input.ticket_id != current_ticket.meta.id
{
return Ok(TicketItemCheckNotification::Stale);
@@ -228,8 +231,8 @@ pub fn notification(
}
let mut content = format!(
"[Ticket item checker advisory]\nTicket: {}\nRevision: {}\n\nThis is a post-save advisory based only on the inspected Ticket text. It is not a new user request, approval gate, workflow blocker, or implementation instruction.\n",
input.ticket_resource_key, input.revision
"[Ticket item checker advisory]\nTicket: {}\nContent digest: {}\n\nThis is a post-save advisory based only on the inspected Ticket text. It is not a new user request, approval gate, workflow blocker, or implementation instruction.\n",
input.ticket_resource_key, input.content_digest
);
for (index, finding) in result.findings.iter().enumerate() {
let quoted = serde_json::to_string(&finding.quote).map_err(|error| {
@@ -255,13 +258,79 @@ mod tests {
serde_json::json!({
"ticket_id": "ticket-internal",
"ticket_resource_key": "T-42",
"revision": "revision-7",
"content_digest": "content_digest-7",
"title": "Deploy safely",
"body": "This turn only creates the Ticket; I will not implement it. Production deployment requires approval.",
"edit": { "kind": "create" }
})
}
#[test]
fn checker_job_identity_reuses_exact_intent_but_separates_edits_and_sources() {
use ticket::TicketBackend;
let dir = tempfile::tempdir().unwrap();
let backend =
ticket::SqliteTicketBackend::open(dir.path().join("tickets.db"), "space").unwrap();
let (_, ticket) = backend
.create_with_snapshot(ticket::NewTicket::new("Check this content"))
.unwrap();
let source = RuntimeWorkerRef {
runtime_id: "runtime".into(),
worker_id: "source".into(),
};
let build = |edit, source, instruction: &str| {
request(&ticket, edit, source, instruction.to_string()).unwrap()
};
let created = build(
TicketItemCheckEdit::Create,
source.clone(),
"Check the item.",
);
let replay = build(
TicketItemCheckEdit::Create,
source.clone(),
"Check the item.",
);
assert_eq!(created.job_id, replay.job_id);
assert_eq!(
created.fingerprint().unwrap(),
replay.fingerprint().unwrap()
);
let edited = build(
TicketItemCheckEdit::Edit {
title_changed: true,
body_changed: false,
previous_title: Some("Different prior title".into()),
previous_body: None,
body_replacement: None,
},
source.clone(),
"Check the item.",
);
let other_source = build(
TicketItemCheckEdit::Create,
RuntimeWorkerRef {
worker_id: "other-source".into(),
..source.clone()
},
"Check the item.",
);
let other_instruction = build(
TicketItemCheckEdit::Create,
source,
"Check with new policy.",
);
for different_intent in [edited, other_source, other_instruction] {
assert_eq!(
created.input["content_digest"],
different_intent.input["content_digest"]
);
assert_ne!(created.job_id, different_intent.job_id);
}
}
#[test]
fn result_requires_bounded_findings_grounded_in_the_saved_item() {
let valid = serde_json::json!({"findings": [{
@@ -27,8 +27,23 @@ pub fn request_fingerprint(
requested_runtime_id: Option<&str>,
display_name: Option<&str>,
repository_source_fingerprint: &str,
repository_source_revision: u64,
) -> String {
encode_request_digest(request_hasher(
repository_id,
selector,
requested_runtime_id,
display_name,
repository_source_fingerprint,
))
}
fn request_hasher(
repository_id: &str,
selector: Option<&str>,
requested_runtime_id: Option<&str>,
display_name: Option<&str>,
repository_source_fingerprint: &str,
) -> Sha256 {
let mut hasher = Sha256::new();
for value in [
Some(repository_id),
@@ -46,7 +61,10 @@ pub fn request_fingerprint(
None => hasher.update([0]),
}
}
hasher.update(repository_source_revision.to_be_bytes());
hasher
}
fn encode_request_digest(hasher: Sha256) -> String {
let digest = hasher.finalize();
let mut encoded = String::with_capacity(digest.len() * 2);
for byte in digest {
@@ -56,6 +74,52 @@ pub fn request_fingerprint(
format!("sha256:{encoded}")
}
/// Resolve replay identity without rewriting the original durable request. Schema 86
/// did not persist display_name, so its digest cannot be converted at migration time.
/// The frozen ordinal is hash evidence only, never a live Repository precondition.
pub fn request_fingerprint_for_replay(
repository_id: &str,
selector: Option<&str>,
requested_runtime_id: Option<&str>,
display_name: Option<&str>,
repository_source_fingerprint: &str,
persisted_fingerprint: Option<&str>,
) -> Result<String> {
let current = request_fingerprint(
repository_id,
selector,
requested_runtime_id,
display_name,
repository_source_fingerprint,
);
let Some(persisted) = persisted_fingerprint else {
return Ok(current);
};
if persisted == current {
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>() {
let mut hasher = request_hasher(
repository_id,
selector,
requested_runtime_id,
display_name,
repository_source_fingerprint,
);
hasher.update(revision.to_be_bytes());
if encode_request_digest(hasher) == digest {
return Ok(persisted.to_string());
}
}
}
}
Err(Error::InvalidInput(
"Workdir create operation was reused with different input or lacks frozen request identity evidence".into(),
))
}
impl SqliteWorkspaceStore {
pub fn reserve_workdir_create_operation(
&self,
@@ -66,11 +130,11 @@ impl SqliteWorkspaceStore {
tx.execute(
r#"INSERT OR IGNORE INTO workdir_create_operations (
workspace_id, operation_id, request_fingerprint, repository_id, selector,
requested_runtime_id, resolved_runtime_id, config_revision,
config_projection_digest, source_kind, source_uri, source_revision,
requested_runtime_id, resolved_runtime_id,
config_projection_digest, source_kind, source_uri,
source_fingerprint, working_directory_id, state, failure,
created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)"#,
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)"#,
params![
record.workspace_id,
record.operation_id,
@@ -79,11 +143,9 @@ impl SqliteWorkspaceStore {
record.selector,
record.requested_runtime_id,
record.resolved_runtime_id,
record.config_revision as i64,
record.config_projection_digest,
record.source_kind,
record.source_uri,
record.source_revision.map(|revision| revision as i64),
record.source_fingerprint,
record.working_directory_id,
record.state,
@@ -131,7 +193,7 @@ impl SqliteWorkspaceStore {
"Workdir create operation `{operation_id}` was reused with different input"
)));
}
if operation.state != "failed" {
if operation.state != "failed" || workdir_create_has_archived_ssh(&tx, workspace_id, operation_id)? {
return Err(Error::WorkdirAttachmentConflict(format!(
"Workdir create operation `{operation_id}` is not a failed retry"
)));
@@ -176,16 +238,17 @@ impl SqliteWorkspaceStore {
operation_id: &str,
request_fingerprint: &str,
credential_id: &str,
credential_revision: u64,
credential_fingerprint: &str,
host_trust_id: &str,
host_trust_revision: u64,
host_trust_fingerprint: &str,
repository_access_mode: &str,
credential_candidates: &[WorkdirCreateCredentialCandidate],
now: &str,
) -> Result<WorkdirCreateOperationRecord> {
validate_content_fingerprint(host_trust_fingerprint)?;
validate_workdir_create_credential_candidates(
credential_id,
credential_revision,
credential_fingerprint,
credential_candidates,
)?;
self.with_conn_mut(|conn| {
@@ -201,11 +264,14 @@ impl SqliteWorkspaceStore {
"Workdir create operation `{operation_id}` was reused with different input"
)));
}
if operation.state != "pending" || workdir_create_has_archived_ssh(&tx, workspace_id, operation_id)? {
return Err(Error::WorkdirAttachmentConflict("Only a pending, nonarchived Workdir create can bind Repository access".into()));
}
if let Some(existing) = operation.credential_id.as_deref() {
if existing != credential_id
|| operation.credential_revision != Some(credential_revision)
|| operation.credential_fingerprint.as_deref() != Some(credential_fingerprint)
|| operation.host_trust_id.as_deref() != Some(host_trust_id)
|| operation.host_trust_revision != Some(host_trust_revision)
|| operation.host_trust_fingerprint.as_deref() != Some(host_trust_fingerprint)
|| operation.repository_access_mode.as_deref()
!= Some(repository_access_mode)
|| operation.credential_candidates != credential_candidates
@@ -218,8 +284,8 @@ impl SqliteWorkspaceStore {
}
let updated = tx.execute(
r#"UPDATE workdir_create_operations
SET credential_id = ?4, credential_revision = ?5,
host_trust_id = ?6, host_trust_revision = ?7,
SET credential_id = ?4, credential_fingerprint = ?5,
host_trust_id = ?6, host_trust_fingerprint = ?7,
repository_access_mode = ?8, updated_at = ?9
WHERE workspace_id = ?1 AND operation_id = ?2
AND request_fingerprint = ?3 AND credential_id IS NULL"#,
@@ -228,13 +294,9 @@ impl SqliteWorkspaceStore {
operation_id,
request_fingerprint,
credential_id,
i64::try_from(credential_revision).map_err(|_| Error::InvalidInput(
"credential revision is out of range".to_string()
))?,
credential_fingerprint,
host_trust_id,
i64::try_from(host_trust_revision).map_err(|_| Error::InvalidInput(
"host-trust revision is out of range".to_string()
))?,
host_trust_fingerprint,
repository_access_mode,
now,
],
@@ -248,7 +310,7 @@ impl SqliteWorkspaceStore {
tx.execute(
r#"INSERT INTO workdir_create_credential_candidates (
workspace_id, operation_id, ordinal, role,
credential_id, credential_revision
credential_id, credential_fingerprint
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#,
params![
workspace_id,
@@ -258,18 +320,21 @@ impl SqliteWorkspaceStore {
))?,
candidate.role.as_str(),
candidate.credential_id,
i64::try_from(candidate.credential_revision).map_err(|_| {
Error::InvalidInput(
"credential candidate revision is out of range".to_string(),
)
})?,
candidate.credential_fingerprint,
],
)?;
tx.execute(
r#"INSERT INTO workdir_create_credential_revision_retentions (
let retained = tx.execute(
r#"INSERT INTO workdir_create_credential_retentions (
workspace_id, operation_id, ordinal,
credential_id, credential_revision
) VALUES (?1, ?2, ?3, ?4, ?5)"#,
credential_id, credential_fingerprint, credential_operation_id
) SELECT ?1, ?2, ?3, ?4, ?5, k.operation_id
FROM repository_ssh_credential_keys k
JOIN repository_ssh_credentials c
ON c.workspace_id=k.workspace_id AND c.credential_id=k.credential_id
WHERE k.workspace_id=?1 AND k.credential_id=?4 AND k.public_key_fingerprint=?5
AND c.status='active'
ORDER BY (k.operation_id=c.current_operation_id) DESC,
k.created_at DESC, k.operation_id DESC LIMIT 1"#,
params![
workspace_id,
operation_id,
@@ -277,14 +342,11 @@ impl SqliteWorkspaceStore {
"credential candidate ordinal is out of range".to_string()
))?,
candidate.credential_id,
i64::try_from(candidate.credential_revision).map_err(|_| {
Error::InvalidInput(
"credential candidate revision is out of range".to_string(),
)
})?,
candidate.credential_fingerprint,
],
)?;
}
if retained != 1 { return Err(Error::RegistryInconsistency("Workdir create credential fingerprint has no stored key".into())); } }
let bound = read_workdir_create_operation(&tx, workspace_id, operation_id)?
.ok_or_else(|| {
Error::RegistryInconsistency(format!(
@@ -307,11 +369,31 @@ impl SqliteWorkspaceStore {
) -> Result<WorkdirCreateOperationRecord> {
self.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let operation = read_workdir_create_operation(&tx, workspace_id, operation_id)?
.ok_or_else(|| {
Error::RegistryInconsistency(
"Workdir create disappeared before finalization".into(),
)
})?;
let target_state = if succeeded { "succeeded" } else { "failed" };
if operation.request_fingerprint != request_fingerprint {
return Err(Error::InvalidInput(
"Workdir create operation was reused with different input".into(),
));
}
if operation.state != "pending" {
if operation.state == target_state && operation.failure.as_deref() == failure {
return Ok(operation);
}
return Err(Error::WorkdirAttachmentConflict(
"A completed Workdir create cannot be finalized into another state".into(),
));
}
let changed = tx.execute(
r#"UPDATE workdir_create_operations
SET state = ?1, failure = ?2, updated_at = ?3
WHERE workspace_id = ?4 AND operation_id = ?5
AND request_fingerprint = ?6"#,
AND request_fingerprint = ?6 AND state='pending'"#,
params![
if succeeded { "succeeded" } else { "failed" },
failure,
@@ -328,7 +410,7 @@ impl SqliteWorkspaceStore {
}
if succeeded {
tx.execute(
r#"DELETE FROM workdir_create_credential_revision_retentions
r#"DELETE FROM workdir_create_credential_retentions
WHERE workspace_id = ?1 AND operation_id = ?2"#,
params![workspace_id, operation_id],
)?;
@@ -344,6 +426,24 @@ impl SqliteWorkspaceStore {
})
}
/// Frozen, nonauthorizing historical data. Never use this payload for a retry or lease.
pub fn load_workdir_create_legacy_ssh_archive(
&self,
workspace_id: &str,
operation_id: &str,
) -> Result<Option<(serde_json::Value, serde_json::Value)>> {
self.with_conn(|conn| {
let archive: Option<(String, String)> = conn.query_row(
"SELECT operation_json,candidates_json FROM workdir_create_legacy_ssh_archives WHERE workspace_id=?1 AND operation_id=?2",
params![workspace_id, operation_id], |r| Ok((r.get(0)?, r.get(1)?)),
).optional()?;
archive.map(|(operation, candidates)| Ok((
serde_json::from_str(&operation).map_err(|e| Error::Store(e.to_string()))?,
serde_json::from_str(&candidates).map_err(|e| Error::Store(e.to_string()))?,
))).transpose()
})
}
pub fn load_workdir_create_operation(
&self,
workspace_id: &str,
@@ -353,6 +453,14 @@ impl SqliteWorkspaceStore {
}
}
fn workdir_create_has_archived_ssh(
conn: &rusqlite::Connection,
workspace_id: &str,
operation_id: &str,
) -> Result<bool> {
Ok(conn.query_row("SELECT EXISTS(SELECT 1 FROM workdir_create_legacy_ssh_archives WHERE workspace_id=?1 AND operation_id=?2)", params![workspace_id,operation_id], |r| r.get(0))?)
}
fn read_workdir_create_operation(
conn: &rusqlite::Connection,
workspace_id: &str,
@@ -361,10 +469,10 @@ fn read_workdir_create_operation(
let mut operation = conn
.query_row(
r#"SELECT workspace_id, operation_id, request_fingerprint, repository_id, selector,
requested_runtime_id, resolved_runtime_id, config_revision,
config_projection_digest, source_kind, source_uri, source_revision,
source_fingerprint, credential_id, credential_revision,
host_trust_id, host_trust_revision, repository_access_mode,
requested_runtime_id, resolved_runtime_id,
config_projection_digest, source_kind, source_uri,
source_fingerprint, credential_id, credential_fingerprint,
host_trust_id, host_trust_fingerprint, repository_access_mode,
working_directory_id, state, failure,
created_at, updated_at
FROM workdir_create_operations
@@ -379,23 +487,21 @@ fn read_workdir_create_operation(
selector: row.get(4)?,
requested_runtime_id: row.get(5)?,
resolved_runtime_id: row.get(6)?,
config_revision: row.get::<_, i64>(7)? as u64,
config_projection_digest: row.get(8)?,
source_kind: row.get(9)?,
source_uri: row.get(10)?,
source_revision: row.get::<_, Option<i64>>(11)?.map(|value| value as u64),
source_fingerprint: row.get(12)?,
credential_id: row.get(13)?,
credential_revision: row.get::<_, Option<i64>>(14)?.map(|value| value as u64),
host_trust_id: row.get(15)?,
host_trust_revision: row.get::<_, Option<i64>>(16)?.map(|value| value as u64),
repository_access_mode: row.get(17)?,
config_projection_digest: row.get(7)?,
source_kind: row.get(8)?,
source_uri: row.get(9)?,
source_fingerprint: row.get(10)?,
credential_id: row.get(11)?,
credential_fingerprint: row.get(12)?,
host_trust_id: row.get(13)?,
host_trust_fingerprint: row.get(14)?,
repository_access_mode: row.get(15)?,
credential_candidates: Vec::new(),
working_directory_id: row.get(18)?,
state: row.get(19)?,
failure: row.get(20)?,
created_at: row.get(21)?,
updated_at: row.get(22)?,
working_directory_id: row.get(16)?,
state: row.get(17)?,
failure: row.get(18)?,
created_at: row.get(19)?,
updated_at: row.get(20)?,
})
},
)
@@ -403,7 +509,7 @@ fn read_workdir_create_operation(
if let Some(operation) = operation.as_mut() {
let mut statement = conn.prepare(
r#"SELECT ordinal, role, credential_id, credential_revision
r#"SELECT ordinal, role, credential_id, credential_fingerprint
FROM workdir_create_credential_candidates
WHERE workspace_id = ?1 AND operation_id = ?2
ORDER BY ordinal ASC"#,
@@ -413,11 +519,11 @@ fn read_workdir_create_operation(
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, i64>(3)?,
row.get::<_, String>(3)?,
))
})?;
for (expected_ordinal, row) in rows.enumerate() {
let (ordinal, role, credential_id, credential_revision) = row?;
let (ordinal, role, credential_id, credential_fingerprint) = row?;
if ordinal
!= i64::try_from(expected_ordinal).map_err(|_| {
Error::Store(
@@ -434,17 +540,16 @@ fn read_workdir_create_operation(
.push(WorkdirCreateCredentialCandidate {
role: WorkdirCreateCredentialCandidateRole::parse(&role)?,
credential_id,
credential_revision: u64::try_from(credential_revision).map_err(|_| {
Error::Store(format!(
"invalid Workdir create credential candidate revision `{credential_revision}`"
))
})?,
credential_fingerprint,
});
}
if !operation.credential_candidates.is_empty() {
validate_workdir_create_credential_candidates(
operation.credential_id.as_deref().unwrap_or_default(),
operation.credential_revision.unwrap_or_default(),
operation
.credential_fingerprint
.as_deref()
.unwrap_or_default(),
&operation.credential_candidates,
)
.map_err(|error| {
@@ -457,9 +562,21 @@ fn read_workdir_create_operation(
Ok(operation)
}
fn validate_content_fingerprint(fingerprint: &str) -> Result<()> {
if fingerprint.trim().is_empty()
|| fingerprint.len() > 256
|| fingerprint.chars().any(char::is_control)
{
return Err(Error::InvalidInput(
"Workdir create content fingerprint is invalid".to_string(),
));
}
Ok(())
}
pub(crate) fn validate_workdir_create_credential_candidates(
credential_id: &str,
credential_revision: u64,
credential_fingerprint: &str,
candidates: &[WorkdirCreateCredentialCandidate],
) -> Result<()> {
if candidates.is_empty() || candidates.len() > MAX_WORKDIR_CREATE_CREDENTIAL_CANDIDATES {
@@ -472,15 +589,11 @@ pub(crate) fn validate_workdir_create_credential_candidates(
"Workdir create credential id is invalid".to_string(),
));
}
if credential_revision == 0 {
return Err(Error::InvalidInput(
"Workdir create credential revision must be greater than zero".to_string(),
));
}
validate_content_fingerprint(credential_fingerprint)?;
let primary = &candidates[0];
if primary.role != WorkdirCreateCredentialCandidateRole::Primary
|| primary.credential_id != credential_id
|| primary.credential_revision != credential_revision
|| primary.credential_fingerprint != credential_fingerprint
{
return Err(Error::InvalidInput(
"Workdir create primary credential evidence does not match the ordered candidate snapshot"
@@ -494,10 +607,13 @@ pub(crate) fn validate_workdir_create_credential_candidates(
"Workdir create fallback credential role is invalid".to_string(),
));
}
for candidate in candidates {
validate_content_fingerprint(&candidate.credential_fingerprint)?;
}
if candidates.iter().any(|candidate| {
candidate.credential_id.is_empty()
|| candidate.credential_id.len() > MAX_CREDENTIAL_ID_BYTES
|| candidate.credential_revision == 0
|| candidate.credential_fingerprint.is_empty()
}) {
return Err(Error::InvalidInput(
"Workdir create credential candidate identity is invalid".to_string(),
@@ -516,6 +632,106 @@ mod tests {
use super::*;
use crate::store::{ControlPlaneStore, RepositoryRecord, WorkspaceRecord};
#[test]
fn request_identity_depends_on_content_and_distinguishes_optional_values() {
let original = request_fingerprint("main", None, None, None, "sha256:source-a");
assert_eq!(
original,
request_fingerprint("main", None, None, None, "sha256:source-a")
);
assert_ne!(
original,
request_fingerprint("main", None, None, None, "sha256:source-b")
);
assert_ne!(
original,
request_fingerprint("main", Some(""), None, None, "sha256:source-a")
);
assert_ne!(
request_fingerprint("a", Some("bc"), None, None, "source"),
request_fingerprint("ab", Some("c"), None, None, "source")
);
}
#[test]
fn frozen_request_replay_checks_every_input_and_keeps_original_identity() {
// Digest emitted by the schema-86 algorithm, including source ordinal 9.
let legacy = "workdir-create-v86:9:sha256:db15419814117c33c089a36c5348bda85d01a53fccd8d4d427685daf5983353b";
assert_eq!(
request_fingerprint_for_replay(
"repo",
Some("main"),
None,
Some("Name"),
"source",
Some(legacy)
)
.unwrap(),
legacy,
);
for (repo, selector, runtime, name, source) in [
("other", Some("main"), None, Some("Name"), "source"),
("repo", Some("develop"), None, Some("Name"), "source"),
(
"repo",
Some("main"),
Some("runtime"),
Some("Name"),
"source",
),
("repo", Some("main"), None, None, "source"),
("repo", Some("main"), None, Some("Other name"), "source"),
("repo", Some("main"), None, Some("Name"), "changed-source"),
] {
assert!(
request_fingerprint_for_replay(repo, selector, runtime, name, source, Some(legacy))
.is_err()
);
}
for invalid in [
"workdir-create-v86:unknown:sha256:db15419814117c33c089a36c5348bda85d01a53fccd8d4d427685daf5983353b",
"workdir-create-v86:8:sha256:db15419814117c33c089a36c5348bda85d01a53fccd8d4d427685daf5983353b",
"workdir-create-v86:9:sha256:bad",
] {
assert!(
request_fingerprint_for_replay(
"repo",
Some("main"),
None,
Some("Name"),
"source",
Some(invalid)
)
.is_err()
);
}
let current = request_fingerprint("repo", Some("main"), None, Some("Name"), "source");
assert_eq!(
request_fingerprint_for_replay(
"repo",
Some("main"),
None,
Some("Name"),
"source",
Some(&current)
)
.unwrap(),
current
);
assert_eq!(
request_fingerprint_for_replay(
"repo",
Some("main"),
None,
Some("Name"),
"source",
None
)
.unwrap(),
current
);
}
#[test]
fn retry_selector_keeps_persisted_default_but_honors_explicit_input() {
assert_eq!(
@@ -556,7 +772,6 @@ mod tests {
uri: "/tmp/main".to_string(),
},
default_ref: Some("develop".to_string()),
source_revision: 1,
source_fingerprint: "sha256:test".to_string(),
observed_status: server_api::RepositoryObservedStatus::Unverified,
observed_at: None,
@@ -573,22 +788,19 @@ mod tests {
None,
None,
"sha256:test",
1,
),
repository_id: "main".to_string(),
selector: Some("develop".to_string()),
requested_runtime_id: None,
resolved_runtime_id: "arcadia".to_string(),
config_revision: 7,
config_projection_digest: "sha256:projection".to_string(),
source_kind: Some("local_path".to_string()),
source_uri: Some("/tmp/repo".to_string()),
source_revision: Some(1),
source_fingerprint: Some("sha256:source".to_string()),
credential_id: None,
credential_revision: None,
credential_fingerprint: None,
host_trust_id: None,
host_trust_revision: None,
host_trust_fingerprint: None,
repository_access_mode: None,
credential_candidates: Vec::new(),
working_directory_id: "wd-1".to_string(),
@@ -603,25 +815,26 @@ mod tests {
);
store
.with_conn_mut(|conn| {
for (credential_id, revision) in
[("credential-1", 3_i64), ("workspace-default-ssh", 7_i64)]
{
for (credential_id, fingerprint) in [
("credential-1", "key-a"),
("workspace-default-ssh", "key-fallback"),
] {
conn.execute(
r#"INSERT INTO repository_ssh_credentials (
workspace_id, credential_id, name,
public_key_algorithm, public_key_fingerprint,
current_revision, status, created_at
) VALUES ('workspace', ?1, ?1, 'ssh-ed25519', ?1, ?2,
'active', '2026-08-24T00:00:00Z')"#,
params![credential_id, revision],
current_operation_id, status, created_at
) VALUES ('workspace', ?1, ?1, 'ssh-ed25519', ?2,
?1, 'active', '2026-08-24T00:00:00Z')"#,
params![credential_id, fingerprint],
)?;
conn.execute(
r#"INSERT INTO repository_ssh_credential_revisions (
workspace_id, credential_id, revision,
r#"INSERT INTO repository_ssh_credential_keys (
workspace_id, credential_id, operation_id,
public_key_algorithm, public_key_fingerprint, created_at
) VALUES ('workspace', ?1, ?2, 'ssh-ed25519', ?1,
) VALUES ('workspace', ?1, ?1, 'ssh-ed25519', ?2,
'2026-08-24T00:00:00Z')"#,
params![credential_id, revision],
params![credential_id, fingerprint],
)?;
}
Ok(())
@@ -631,12 +844,12 @@ mod tests {
WorkdirCreateCredentialCandidate {
role: WorkdirCreateCredentialCandidateRole::Primary,
credential_id: "credential-1".to_string(),
credential_revision: 3,
credential_fingerprint: "key-a".to_string(),
},
WorkdirCreateCredentialCandidate {
role: WorkdirCreateCredentialCandidateRole::WorkspaceDefaultFallback,
credential_id: "workspace-default-ssh".to_string(),
credential_revision: 7,
credential_fingerprint: "key-fallback".to_string(),
},
];
let bound = store
@@ -645,26 +858,26 @@ mod tests {
"call-1",
&record.request_fingerprint,
"credential-1",
3,
"key-a",
"trust-1",
5,
"trust-a",
"read_only",
&candidates,
"2026-08-24T00:00:01Z",
)
.unwrap();
assert_eq!(bound.credential_id.as_deref(), Some("credential-1"));
assert_eq!(bound.credential_revision, Some(3));
assert_eq!(bound.host_trust_revision, Some(5));
assert_eq!(bound.credential_fingerprint.as_deref(), Some("key-a"));
assert_eq!(bound.host_trust_fingerprint.as_deref(), Some("trust-a"));
assert_eq!(bound.credential_candidates, candidates);
let serialized = serde_json::to_string(&bound).unwrap();
assert!(serialized.contains("workspace_default_fallback"));
assert!(!serialized.contains("private_key"));
assert!(!serialized.contains("known_hosts"));
// A concurrent Workspace-default rotation must not replace the fallback
// revision already bound to this operation.
// fingerprint already bound to this operation.
let mut changed_candidates = candidates.clone();
changed_candidates[1].credential_revision = 8;
changed_candidates[1].credential_fingerprint = "key-rotated".to_string();
assert!(
store
.bind_workdir_create_repository_access(
@@ -672,9 +885,9 @@ mod tests {
"call-1",
&record.request_fingerprint,
"credential-1",
3,
"key-a",
"trust-1",
5,
"trust-a",
"read_only",
&changed_candidates,
"2026-08-24T00:00:02Z",
@@ -683,9 +896,9 @@ mod tests {
);
let mut changed_resolution = record.clone();
changed_resolution.resolved_runtime_id = "other".to_string();
changed_resolution.config_revision = 8;
changed_resolution.config_projection_digest = "sha256:changed".to_string();
changed_resolution.source_uri = Some("ssh://git@other.test/repo.git".to_string());
changed_resolution.source_revision = Some(9);
changed_resolution.source_fingerprint = Some("sha256:changed-source".to_string());
let replayed = store
.reserve_workdir_create_operation(&changed_resolution)
.unwrap();
@@ -721,7 +934,7 @@ mod tests {
);
let mut changed_input = record.clone();
changed_input.request_fingerprint =
request_fingerprint("main", Some("main"), None, None, "sha256:test", 1);
request_fingerprint("main", Some("main"), None, None, "sha256:test");
assert!(
store
.reserve_workdir_create_operation(&changed_input)
+89 -23
View File
@@ -75,7 +75,7 @@ pub struct WorkdirRemovalOperation {
pub source_actor: String,
pub reason: String,
pub state: WorkdirRemovalOperationState,
pub attempt_count: u64,
pub attempt_id: Option<String>,
pub retryable: bool,
pub disposition: Option<WorkdirRemovalDisposition>,
pub failure_category: Option<String>,
@@ -202,9 +202,9 @@ impl SqliteWorkspaceStore {
r#"INSERT INTO workdir_removal_operations (
workspace_id, operation_id, request_fingerprint, workdir_id, runtime_id,
repository_id, materialization_fingerprint, source_actor, reason, state,
attempt_count, retryable, disposition, failure_category,
attempt_id, retryable, disposition, failure_category,
created_at, updated_at, completed_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'pending', 0, 1, NULL, NULL, ?10, ?10, NULL)"#,
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'pending', NULL, 1, NULL, NULL, ?10, ?10, NULL)"#,
params![
intent.workspace_id,
intent.operation_id,
@@ -248,14 +248,14 @@ impl SqliteWorkspaceStore {
request_fingerprint: &str,
owner: WorkdirRemovalAttemptOwner,
expected_prior_owner: Option<WorkdirRemovalAttemptOwner>,
expected_attempt_count: u64,
expected_attempt_id: Option<&str>,
) -> Result<WorkdirRemovalOperation> {
self.begin_workdir_removal_attempt_inner(
workspace_id,
operation_id,
request_fingerprint,
owner,
Some((expected_prior_owner, expected_attempt_count)),
Some((expected_prior_owner, expected_attempt_id)),
)
}
@@ -265,7 +265,7 @@ impl SqliteWorkspaceStore {
operation_id: &str,
request_fingerprint: &str,
owner: WorkdirRemovalAttemptOwner,
recovery_expected: Option<(Option<WorkdirRemovalAttemptOwner>, u64)>,
recovery_expected: Option<(Option<WorkdirRemovalAttemptOwner>, Option<&str>)>,
) -> Result<WorkdirRemovalOperation> {
self.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
@@ -282,17 +282,17 @@ impl SqliteWorkspaceStore {
"Workdir removal operation `{operation_id}` is not retryable"
)));
}
if let Some((expected_owner, expected_attempt_count)) = recovery_expected {
if let Some((expected_owner, expected_attempt_id)) = recovery_expected {
if operation.state != WorkdirRemovalOperationState::Pending
|| operation.attempt_owner != expected_owner
|| operation.attempt_count != expected_attempt_count
|| operation.attempt_id.as_deref() != expected_attempt_id
{
return Err(Error::WorkdirAttachmentConflict(format!(
"Workdir removal operation `{operation_id}` changed after orphan proof"
)));
}
} else if operation.state == WorkdirRemovalOperationState::Pending
&& operation.attempt_count > 0
&& operation.attempt_id.is_some()
{
return Err(Error::WorkdirAttachmentConflict(format!(
"Workdir removal operation `{operation_id}` already has an active attempt"
@@ -300,7 +300,7 @@ impl SqliteWorkspaceStore {
}
let now = Utc::now().to_rfc3339();
tx.execute(
"UPDATE workdir_removal_operations SET state='pending', attempt_count=attempt_count+1, retryable=1, failure_category=NULL, disposition=NULL, attempt_owner_pid=?1, attempt_owner_start_marker=?2, updated_at=?3, completed_at=NULL WHERE workspace_id=?4 AND operation_id=?5 AND request_fingerprint=?6",
"UPDATE workdir_removal_operations SET state='pending', attempt_id=?7, retryable=1, failure_category=NULL, disposition=NULL, attempt_owner_pid=?1, attempt_owner_start_marker=?2, updated_at=?3, completed_at=NULL WHERE workspace_id=?4 AND operation_id=?5 AND request_fingerprint=?6",
params![
owner.process_id,
i64::try_from(owner.process_start_marker).map_err(|_| {
@@ -312,6 +312,7 @@ impl SqliteWorkspaceStore {
workspace_id,
operation_id,
request_fingerprint,
uuid::Uuid::now_v7().to_string(),
],
)?;
let operation =
@@ -462,6 +463,7 @@ impl SqliteWorkspaceStore {
tx.commit()?;
return Ok(current);
}
require_matching_attempt(operation, &current)?;
let now = Utc::now().to_rfc3339();
tx.execute(
"UPDATE workdir_removal_operations SET state='failed', retryable=?1, disposition=?2, failure_category=?3, attempt_owner_pid=NULL, attempt_owner_start_marker=NULL, updated_at=?4 WHERE workspace_id=?5 AND operation_id=?6 AND request_fingerprint=?7",
@@ -519,6 +521,7 @@ impl SqliteWorkspaceStore {
tx.commit()?;
return Ok(current);
}
require_matching_attempt(operation, &current)?;
if delete_registry {
let registry = load_workdir_record(
&tx,
@@ -616,6 +619,22 @@ impl SqliteWorkspaceStore {
}
}
fn require_matching_attempt(
expected: &WorkdirRemovalOperation,
current: &WorkdirRemovalOperation,
) -> Result<()> {
if current.state != WorkdirRemovalOperationState::Pending
|| expected.attempt_id != current.attempt_id
|| expected.attempt_owner != current.attempt_owner
{
return Err(Error::WorkdirAttachmentConflict(format!(
"Workdir removal operation `{}` execution attempt changed",
expected.operation_id
)));
}
Ok(())
}
fn require_no_removal_blockers(
conn: &Connection,
workspace_id: &str,
@@ -804,7 +823,7 @@ fn load_operation(
fn operation_select_sql() -> &'static str {
r#"SELECT operation_id, request_fingerprint, workspace_id, workdir_id, runtime_id,
repository_id, materialization_fingerprint, source_actor, reason, state,
attempt_count, retryable, disposition, failure_category,
attempt_id, retryable, disposition, failure_category,
attempt_owner_pid, attempt_owner_start_marker,
created_at, updated_at, completed_at
FROM workdir_removal_operations"#
@@ -816,7 +835,7 @@ fn read_operation(row: &rusqlite::Row<'_>) -> rusqlite::Result<WorkdirRemovalOpe
.get::<_, Option<String>>(12)?
.map(|value| parse_disposition(&value))
.transpose()?;
let attempt_count = row.get::<_, i64>(10)?;
let attempt_id = row.get::<_, Option<String>>(10)?;
let attempt_owner_pid = row.get::<_, Option<i64>>(14)?;
let attempt_owner_start_marker = row.get::<_, Option<i64>>(15)?;
let attempt_owner = match (attempt_owner_pid, attempt_owner_start_marker) {
@@ -848,9 +867,7 @@ fn read_operation(row: &rusqlite::Row<'_>) -> rusqlite::Result<WorkdirRemovalOpe
source_actor: row.get(7)?,
reason: row.get(8)?,
state,
attempt_count: attempt_count
.try_into()
.map_err(|_| rusqlite::Error::IntegralValueOutOfRange(10, attempt_count))?,
attempt_id,
retryable: row.get(11)?,
disposition,
failure_category: row.get(13)?,
@@ -942,7 +959,6 @@ mod tests {
uri: "/repository-a".to_string(),
},
default_ref: Some("develop".to_string()),
source_revision: 1,
source_fingerprint: "source-a".to_string(),
observed_status: RepositoryObservedStatus::Unverified,
observed_at: None,
@@ -1006,7 +1022,7 @@ mod tests {
attempt_owner(),
)
.unwrap();
assert_eq!(first.attempt_count, 1);
assert!(first.attempt_id.is_some());
let failed = store
.fail_workdir_removal_operation(&first, "runtime_unavailable", true)
.unwrap();
@@ -1019,7 +1035,7 @@ mod tests {
attempt_owner(),
)
.unwrap();
assert_eq!(retry.attempt_count, 2);
assert_ne!(retry.attempt_id, first.attempt_id);
let completed = store.commit_workdir_removal_removed(&retry).unwrap();
assert_eq!(
completed.disposition,
@@ -1042,6 +1058,58 @@ mod tests {
assert_eq!(replay, completed);
}
#[tokio::test]
async fn recovery_and_completion_are_fenced_by_attempt_identity() {
let (store, workdir) = seeded_store().await;
let intent = workdir_removal_intent(&workdir, "workspace-api", "remove Workdir").unwrap();
let reserved = store.reserve_workdir_removal_operation(&intent).unwrap();
let first = store
.begin_workdir_removal_attempt(
&reserved.workspace_id,
&reserved.operation_id,
&reserved.request_fingerprint,
attempt_owner(),
)
.unwrap();
let recovered = store
.reclaim_workdir_removal_attempt_for_recovery(
&first.workspace_id,
&first.operation_id,
&first.request_fingerprint,
attempt_owner(),
first.attempt_owner,
first.attempt_id.as_deref(),
)
.unwrap();
assert_ne!(first.attempt_id, recovered.attempt_id);
assert!(matches!(
store.reclaim_workdir_removal_attempt_for_recovery(
&first.workspace_id,
&first.operation_id,
&first.request_fingerprint,
attempt_owner(),
first.attempt_owner,
first.attempt_id.as_deref(),
),
Err(Error::WorkdirAttachmentConflict(_))
));
assert!(matches!(
store.fail_workdir_removal_operation(&first, "late_failure", true),
Err(Error::WorkdirAttachmentConflict(_))
));
assert!(matches!(
store.commit_workdir_removal_removed(&first),
Err(Error::WorkdirAttachmentConflict(_))
));
assert!(
store
.get_workdir_registry("workspace-a", "workdir-a")
.unwrap()
.is_some()
);
store.commit_workdir_removal_removed(&recovered).unwrap();
}
#[tokio::test]
async fn concurrent_claims_invoke_the_simulated_provider_once() {
use std::sync::atomic::{AtomicUsize, Ordering};
@@ -1109,16 +1177,14 @@ mod tests {
selector: Some("develop".to_string()),
requested_runtime_id: Some("runtime-a".to_string()),
resolved_runtime_id: "runtime-a".to_string(),
config_revision: 1,
config_projection_digest: "projection-a".to_string(),
source_kind: Some("local_path".to_string()),
source_uri: Some("/repository-a".to_string()),
source_revision: Some(1),
source_fingerprint: Some("source-a".to_string()),
credential_id: None,
credential_revision: None,
credential_fingerprint: None,
host_trust_id: None,
host_trust_revision: None,
host_trust_fingerprint: None,
repository_access_mode: None,
credential_candidates: Vec::new(),
working_directory_id: "workdir-a".to_string(),
@@ -1382,7 +1448,7 @@ mod tests {
)
.unwrap();
assert_eq!(retry.state, WorkdirRemovalOperationState::Pending);
assert_eq!(retry.attempt_count, 1);
assert!(retry.attempt_id.is_some());
assert_eq!(retry.disposition, None);
assert_eq!(retry.failure_category, None);
assert!(retry.retryable);
@@ -23,7 +23,6 @@ const MAX_OPERATION_KEY_BYTES: usize = 200;
pub struct WorkspaceCreateResult {
pub workspace: WorkspaceRecord,
pub repository: Option<RepositoryRecord>,
pub config_revision: u64,
pub request_fingerprint: String,
pub replayed: bool,
}
@@ -159,7 +158,6 @@ impl WorkspaceCatalogService {
provider: Some("git".to_string()),
source: repository_source.clone(),
default_ref: Some(default_ref),
source_revision: 1,
source_fingerprint: repository_source_fingerprint(&repository_source),
observed_status: RepositoryObservedStatus::Unverified,
observed_at: None,
@@ -174,7 +172,6 @@ impl WorkspaceCatalogService {
Ok(WorkspaceCreateResult {
workspace: result.workspace,
repository: result.repository,
config_revision: result.config_revision,
request_fingerprint: fingerprint,
replayed: result.replayed,
})
@@ -632,11 +629,10 @@ mod tests {
let created = service.create(request.clone(), owner.clone()).unwrap();
let mut repository = created.repository.unwrap();
repository.source = validate_repository_source("https://example.test/changed.git").unwrap();
repository.source_revision += 1;
repository.source_fingerprint = repository_source_fingerprint(&repository.source);
store.with_conn(|conn| {
conn.execute("UPDATE repositories SET source_kind = ?1, source_uri = ?2, source_revision = ?3, source_fingerprint = ?4 WHERE workspace_id = ?5 AND repository_key = 'main'",
rusqlite::params![repository.source.kind.as_str(), repository.source.uri, repository.source_revision, repository.source_fingerprint, repository.workspace_id])?;
conn.execute("UPDATE repositories SET source_kind = ?1, source_uri = ?2, source_fingerprint = ?3 WHERE workspace_id = ?4 AND repository_key = 'main'",
rusqlite::params![repository.source.kind.as_str(), repository.source.uri, repository.source_fingerprint, repository.workspace_id])?;
Ok(())
}).unwrap();
assert_eq!(
@@ -880,7 +876,6 @@ mod tests {
.unwrap()
.unwrap();
assert_eq!(persisted.source.kind, RepositorySourceKind::Ssh);
assert_eq!(persisted.source_revision, 1);
assert!(persisted.source_fingerprint.starts_with("sha256:"));
assert_eq!(
persisted.observed_status,
@@ -3,7 +3,7 @@ use rusqlite::{OptionalExtension, params};
use server_api::{
WORKSPACE_DELETION_MAX_BLOCKER_MESSAGE_BYTES, WORKSPACE_DELETION_MAX_BLOCKERS,
WORKSPACE_DELETION_MAX_CHILD_OPERATION_IDS, WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES,
WORKSPACE_DELETION_MAX_RESOURCE_VALUE_BYTES, WORKSPACE_DELETION_MAX_REVISION_BYTES,
WORKSPACE_DELETION_MAX_RESOURCE_VALUE_BYTES, WORKSPACE_DELETION_MAX_UPDATED_AT_BYTES,
WorkspaceDeletionBlocker, WorkspaceDeletionBlockerKind, WorkspaceDeletionOperationResponse,
WorkspaceDeletionPreflightResponse, WorkspaceDeletionRequest, WorkspaceDeletionResourceCounts,
WorkspaceDeletionState,
@@ -22,7 +22,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"backend_job_attempts",
"backend_jobs",
"external_workdir_grants",
"flow_source_revisions",
"flow_source_contents",
"flow_sources",
"memory_staging_records",
"memory_staging_resolutions",
@@ -39,11 +39,12 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"runtime_removal_operations",
"repository_secret_audit_events",
"repository_secret_operations",
"repository_ssh_credential_revisions",
"repository_secret_legacy_receipts",
"repository_ssh_credential_keys",
"repository_ssh_credentials",
"repository_ssh_host_trust_revisions",
"repository_ssh_host_trust_keys",
"repository_ssh_host_trusts",
"server_secret_versions",
"server_secret_objects",
"ticket_assignment_operations",
"ticket_assignment_work_releases",
"ticket_assignment_ticket_tombstones",
@@ -63,7 +64,8 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"typed_ticket_targets",
"typed_tickets",
"workdir_create_credential_candidates",
"workdir_create_credential_revision_retentions",
"workdir_create_credential_retentions",
"workdir_create_legacy_ssh_archives",
"workdir_create_operations",
"workdir_registry",
"workdir_removal_operations",
@@ -88,7 +90,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"workspace_config_entries",
"workspace_config_grants",
"workspace_drive_grants",
"workspace_config_tree_revisions",
"workspace_config_tree_history",
"workspace_config_trees",
"workspace_create_operations",
"workspace_memory_documents",
@@ -102,7 +104,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"workspace_signing_identity_audit",
"workspace_signing_identity_provisioning_operations",
"workspace_worker_retention_policies",
"workspace_worker_retention_policy_revisions",
"workspace_worker_retention_policy_snapshots",
];
#[derive(Debug, Clone)]
@@ -198,7 +200,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
Ok(WorkspaceDeletionPreflightResponse {
workspace_id: workspace.workspace_id,
display_name: workspace.display_name,
expected_revision: workspace.updated_at,
expected_workspace_updated_at: workspace.updated_at,
can_delete: blockers.is_empty(),
resources,
blockers,
@@ -213,7 +215,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
request: &WorkspaceDeletionRequest,
) -> Result<WorkspaceDeletionReservation> {
validate_operation_id(&request.operation_id)?;
if request.expected_revision.len() > WORKSPACE_DELETION_MAX_REVISION_BYTES
if request.expected_workspace_updated_at.len() > WORKSPACE_DELETION_MAX_UPDATED_AT_BYTES
|| request.confirmation.len() > server_api::WORKSPACE_DELETION_MAX_CONFIRMATION_BYTES
{
return Err(Error::InvalidInput(
@@ -245,7 +247,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
"confirmation must exactly match the displayed Workspace name".to_string(),
));
}
if workspace.updated_at != request.expected_revision {
if workspace.updated_at != request.expected_workspace_updated_at {
return Err(Error::WorkspaceConfigConflict(
"Workspace metadata changed; reload deletion impact before confirming".to_string(),
));
@@ -273,7 +275,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
tx.execute(
"INSERT INTO workspace_deletion_operations (
operation_id, request_fingerprint, workspace_id, workspace_display_name,
workspace_revision, owner_account_id, actor_account_id,
workspace_updated_at, owner_account_id, actor_account_id,
state, resource_counts_json,
child_operation_ids_json, blockers_json, failure_category,
created_at, updated_at, completed_at
@@ -283,7 +285,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
fingerprint,
workspace_id,
workspace.display_name,
request.expected_revision,
request.expected_workspace_updated_at,
workspace.owner_account_id,
actor_account_id,
serde_json::to_string(&resources).map_err(|error| Error::Store(error.to_string()))?,
@@ -293,7 +295,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
let changed = tx.execute(
"UPDATE workspaces SET state = 'deleting', updated_at = ?2
WHERE workspace_id = ?1 AND updated_at = ?3 AND state = 'active'",
params![workspace_id, now, request.expected_revision],
params![workspace_id, now, request.expected_workspace_updated_at],
)?;
if changed != 1 {
return Err(Error::WorkspaceConfigConflict(
@@ -766,7 +768,7 @@ fn resource_counts(
workdirs: table_count(conn, "workdir_registry", workspace_id)?,
repositories: table_count(conn, "repositories", workspace_id)?,
runtime_bindings: table_count(conn, "workspace_runtime_bindings", workspace_id)?,
secrets: table_count(conn, "server_secret_versions", workspace_id)?,
secrets: table_count(conn, "server_secret_objects", workspace_id)?,
artifacts: table_count(conn, "artifacts", workspace_id)?,
})
}
@@ -826,7 +828,7 @@ fn request_fingerprint(
) -> String {
let canonical = format!(
"workspace-delete-v1\0{actor_account_id}\0{workspace_id}\0{}\0{}\0{}",
request.operation_id, request.expected_revision, request.confirmation
request.operation_id, request.expected_workspace_updated_at, request.confirmation
);
encode_hex(&Sha256::digest(canonical.as_bytes()))
}
@@ -959,7 +961,7 @@ mod tests {
.expect("preflight");
let request = WorkspaceDeletionRequest {
operation_id: "delete-workspace-a".to_string(),
expected_revision: preflight.expected_revision,
expected_workspace_updated_at: preflight.expected_workspace_updated_at,
confirmation: "Alpha".to_string(),
};
let first = store
@@ -1073,7 +1075,7 @@ mod tests {
}
#[test]
fn owner_confirmation_and_revision_are_required_before_reservation() {
fn owner_confirmation_and_updated_at_are_required_before_reservation() {
let (store, owner, workspace_id) = setup();
store.with_conn(|conn| {
conn.execute(
@@ -1093,7 +1095,7 @@ mod tests {
.expect("preflight");
let mut request = WorkspaceDeletionRequest {
operation_id: "delete-alpha-guarded".to_string(),
expected_revision: "stale".to_string(),
expected_workspace_updated_at: "stale".to_string(),
confirmation: "Alpha".to_string(),
};
assert!(matches!(
@@ -1106,7 +1108,7 @@ mod tests {
request_fingerprint(&owner, &workspace_id, &request),
request_fingerprint(&owner, &workspace_id, &other_operation)
);
request.expected_revision = preflight.expected_revision;
request.expected_workspace_updated_at = preflight.expected_workspace_updated_at;
request.confirmation = "delete Alpha".to_string();
assert!(matches!(
store.reserve_workspace_deletion(&owner, &workspace_id, &request),
@@ -1130,11 +1132,11 @@ mod tests {
conn.execute(
"INSERT INTO workdir_create_operations (
workspace_id, operation_id, request_fingerprint, repository_id,
selector, requested_runtime_id, resolved_runtime_id, config_revision,
selector, requested_runtime_id, resolved_runtime_id,
config_projection_digest, working_directory_id, state, created_at, updated_at
) VALUES (
?1, 'workdir-create', 'fingerprint', 'repository-pending',
'develop', 'runtime-a', 'runtime-a', 1,
'develop', 'runtime-a', 'runtime-a',
'projection', 'workdir-pending', 'pending', '1', '1'
)",
params![workspace_id],
@@ -1160,7 +1162,7 @@ mod tests {
);
let request = WorkspaceDeletionRequest {
operation_id: "delete-with-pending-creates".to_string(),
expected_revision: preflight.expected_revision,
expected_workspace_updated_at: preflight.expected_workspace_updated_at,
confirmation: "Alpha".to_string(),
};
assert!(matches!(
@@ -1295,7 +1297,7 @@ mod tests {
}));
let request = WorkspaceDeletionRequest {
operation_id: "delete-pinned".to_string(),
expected_revision: preflight.expected_revision,
expected_workspace_updated_at: preflight.expected_workspace_updated_at,
confirmation: "Alpha".to_string(),
};
assert!(matches!(
@@ -1338,7 +1340,7 @@ mod tests {
}
#[test]
fn last_accessible_workspace_and_revision_conflicts_fail_closed() {
fn last_accessible_workspace_and_updated_at_conflicts_fail_closed() {
let (store, owner, workspace_id) = setup();
let other = "workspace-b";
let preflight = store
@@ -1352,7 +1354,7 @@ mod tests {
other,
&WorkspaceDeletionRequest {
operation_id: "delete-beta".to_string(),
expected_revision: preflight.expected_revision,
expected_workspace_updated_at: preflight.expected_workspace_updated_at,
confirmation: "Beta".to_string(),
},
)
@@ -22,8 +22,7 @@ use crate::store::{
use crate::{Error, Result};
pub const WORKSPACE_SIGNING_ALGORITHM: &str = "ed25519";
pub const WORKSPACE_SIGNING_IDENTITY_REVISION: u64 = 1;
const MATERIAL_SCHEMA_VERSION: u32 = 1;
const MATERIAL_SCHEMA_VERSION: u32 = 2;
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -31,7 +30,6 @@ pub struct WorkspaceSigningPrivateMaterial {
version: u32,
workspace_id: String,
key_id: String,
revision: u64,
private_key: String,
}
@@ -42,7 +40,6 @@ impl fmt::Debug for WorkspaceSigningPrivateMaterial {
.field("version", &self.version)
.field("workspace_id", &self.workspace_id)
.field("key_id", &self.key_id)
.field("revision", &self.revision)
.field("private_key", &"[REDACTED]")
.finish()
}
@@ -66,7 +63,6 @@ impl WorkspaceSigningPrivateMaterial {
version: MATERIAL_SCHEMA_VERSION,
workspace_id: workspace_id.to_string(),
key_id: key_id.to_string(),
revision: WORKSPACE_SIGNING_IDENTITY_REVISION,
private_key: material.private_key,
})
}
@@ -75,12 +71,10 @@ impl WorkspaceSigningPrivateMaterial {
&self,
expected_workspace_id: &str,
expected_key_id: &str,
expected_revision: u64,
) -> Result<ring::signature::Ed25519KeyPair> {
if self.version != MATERIAL_SCHEMA_VERSION
|| self.workspace_id != expected_workspace_id
|| self.key_id != expected_key_id
|| self.revision != expected_revision
{
return Err(identity_error(
"workspace_signing_identity_material_mismatch",
@@ -105,10 +99,8 @@ impl WorkspaceSigningPrivateMaterial {
&self,
expected_workspace_id: &str,
expected_key_id: &str,
expected_revision: u64,
) -> Result<String> {
let signing_key =
self.signing_key(expected_workspace_id, expected_key_id, expected_revision)?;
let signing_key = self.signing_key(expected_workspace_id, expected_key_id)?;
Ok(encode_public_key(signing_key.public_key().as_ref()))
}
}
@@ -146,7 +138,8 @@ impl WorkspaceSigningIdentityService {
) -> Result<(WorkspaceSigningIdentityActivation, String)> {
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
let proposed_key_id = format!("WK-{}", uuid::Uuid::now_v7().simple());
let proposed_material_ref = format!("workspace-signing/{proposed_workspace_id}/ed25519-v1");
let proposed_material_ref =
format!("workspace-signing/{proposed_workspace_id}/{proposed_key_id}");
let operation_key = format!("workspace-create:{workspace_create_operation_key}");
let operation = self.store.reserve_workspace_signing_identity_provisioning(
&WorkspaceSigningIdentityProvisioningOperation {
@@ -156,7 +149,6 @@ impl WorkspaceSigningIdentityService {
workspace_id: proposed_workspace_id.to_string(),
key_id: proposed_key_id,
private_material_ref: proposed_material_ref,
revision: WORKSPACE_SIGNING_IDENTITY_REVISION,
actor_account_id: actor_account_id.to_string(),
state: "pending".to_string(),
created_at: now,
@@ -191,12 +183,12 @@ impl WorkspaceSigningIdentityService {
"Workspace signing identity state is invalid",
));
}
let operation_key = format!(
"existing-workspace:{workspace_id}:revision-{}",
identity.revision
let operation_key = format!("existing-workspace:{workspace_id}:key-{}", identity.key_id);
let request_fingerprint = provisioning_fingerprint(
workspace_id,
&identity.key_id,
&identity.private_material_ref,
);
let request_fingerprint =
provisioning_fingerprint(workspace_id, &identity.key_id, identity.revision);
let operation = self.store.reserve_workspace_signing_identity_provisioning(
&WorkspaceSigningIdentityProvisioningOperation {
operation_key: operation_key.clone(),
@@ -205,7 +197,6 @@ impl WorkspaceSigningIdentityService {
workspace_id: workspace_id.to_string(),
key_id: identity.key_id.clone(),
private_material_ref: identity.private_material_ref.clone(),
revision: identity.revision,
actor_account_id: actor_account_id.to_string(),
state: "pending".to_string(),
created_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
@@ -253,8 +244,20 @@ impl WorkspaceSigningIdentityService {
"Workspace signing private material is missing",
)
})?;
let signing_key =
material.signing_key(workspace_id, &identity.key_id, identity.revision)?;
let fingerprint = identity.public_key_fingerprint.as_deref().ok_or_else(|| {
identity_error(
"workspace_signing_identity_material_mismatch",
"Workspace signing public key fingerprint is missing",
)
})?;
let signing_key = material.signing_key(workspace_id, &identity.key_id)?;
let public_key = encode_public_key(signing_key.public_key().as_ref());
if public_key_fingerprint(&public_key)? != fingerprint {
return Err(identity_error(
"workspace_signing_identity_material_mismatch",
"Workspace signing private material does not match public metadata",
));
}
Ok(signing_key.sign(payload).as_ref().to_vec())
}
@@ -290,11 +293,8 @@ impl WorkspaceSigningIdentityService {
.put_if_absent(&operation.private_material_ref, &generated)?
}
};
let public_key = material.validate_and_public_key(
&operation.workspace_id,
&operation.key_id,
operation.revision,
)?;
let public_key =
material.validate_and_public_key(&operation.workspace_id, &operation.key_id)?;
let public_key_fingerprint = public_key_fingerprint(&public_key)?;
Ok(WorkspaceSigningIdentityActivation {
workspace_id: operation.workspace_id.clone(),
@@ -302,7 +302,6 @@ impl WorkspaceSigningIdentityService {
public_key,
public_key_fingerprint,
private_material_ref: operation.private_material_ref.clone(),
revision: operation.revision,
provisioned_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
})
}
@@ -317,11 +316,8 @@ impl WorkspaceSigningIdentityService {
"Workspace signing private material is missing",
)
})?;
let public_key = material.validate_and_public_key(
&identity.workspace_id,
&identity.key_id,
identity.revision,
)?;
let public_key =
material.validate_and_public_key(&identity.workspace_id, &identity.key_id)?;
let fingerprint = public_key_fingerprint(&public_key)?;
if identity.public_key.as_deref() != Some(public_key.as_str())
|| identity.public_key_fingerprint.as_deref() != Some(fingerprint.as_str())
@@ -335,17 +331,116 @@ impl WorkspaceSigningIdentityService {
}
}
fn provisioning_fingerprint(workspace_id: &str, key_id: &str, revision: u64) -> String {
pub(crate) fn provisioning_fingerprint(
workspace_id: &str,
key_id: &str,
material_ref: &str,
) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(b"workspace-signing-provisioning\0");
hasher.update(workspace_id.as_bytes());
hasher.update([0]);
hasher.update(key_id.as_bytes());
hasher.update([0]);
hasher.update(revision.to_be_bytes());
hasher.update(material_ref.as_bytes());
format!("sha256:{}", hex_lower(&hasher.finalize()))
}
// Frozen v1 private-material decoder. New writes never include the old counter.
mod frozen_material_v1 {
use super::*;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Material {
version: u32,
workspace_id: String,
key_id: String,
revision: u64,
private_key: String,
}
impl Drop for Material {
fn drop(&mut self) {
self.private_key.zeroize();
}
}
pub(super) fn decode(bytes: &[u8]) -> Option<WorkspaceSigningPrivateMaterial> {
let old: Material = serde_json::from_slice(bytes).ok()?;
if old.version != 1 || old.revision != 1 {
return None;
}
Some(WorkspaceSigningPrivateMaterial {
version: MATERIAL_SCHEMA_VERSION,
workspace_id: old.workspace_id.clone(),
key_id: old.key_id.clone(),
private_key: old.private_key.clone(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frozen_file_load_preserves_key_and_new_writes_use_current_shape() {
let material = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap();
let public_key = material.validate_and_public_key("ws-1", "WK-1").unwrap();
let mut old = serde_json::to_value(&material).unwrap();
old["version"] = 1.into();
old["revision"] = 1.into();
let temp = tempfile::tempdir().unwrap();
let store = FsWorkspaceSigningMaterialStore::new(temp.path().join("identities"));
store.put_if_absent("ws-1/ed25519-v1", &material).unwrap();
fs::write(
store.material_path("ws-1/ed25519-v1").unwrap(),
serde_json::to_vec(&old).unwrap(),
)
.unwrap();
let migrated = store.load("ws-1/ed25519-v1").unwrap().unwrap();
assert_eq!(
migrated.validate_and_public_key("ws-1", "WK-1").unwrap(),
public_key
);
let current = serde_json::to_value(&migrated).unwrap();
assert_eq!(current["version"], MATERIAL_SCHEMA_VERSION);
assert!(current.get("revision").is_none());
assert_eq!(current, serde_json::to_value(material).unwrap());
}
#[test]
fn frozen_decoder_rejects_unsupported_or_mixed_metadata() {
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();
assert!(decode_private_material(&serde_json::to_vec(&old).unwrap()).is_err());
}
old["revision"] = 1.into();
old["version"] = MATERIAL_SCHEMA_VERSION.into();
assert!(decode_private_material(&serde_json::to_vec(&old).unwrap()).is_err());
old["version"] = 1.into();
old["unexpected"] = true.into();
assert!(decode_private_material(&serde_json::to_vec(&old).unwrap()).is_err());
}
}
}
fn decode_private_material(
bytes: &[u8],
) -> std::result::Result<WorkspaceSigningPrivateMaterial, ()> {
if let Ok(material) = serde_json::from_slice::<WorkspaceSigningPrivateMaterial>(bytes) {
if material.version == MATERIAL_SCHEMA_VERSION {
return Ok(material);
}
return Err(());
}
frozen_material_v1::decode(bytes).ok_or(())
}
#[derive(Default)]
pub struct InMemoryWorkspaceSigningMaterialStore {
materials: std::sync::Mutex<std::collections::HashMap<String, WorkspaceSigningPrivateMaterial>>,
@@ -418,7 +513,7 @@ impl WorkspaceSigningMaterialStore for FsWorkspaceSigningMaterialStore {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(material_io_error("read", error)),
};
serde_json::from_slice(&bytes).map(Some).map_err(|_| {
decode_private_material(&bytes).map(Some).map_err(|_| {
identity_error(
"workspace_signing_identity_material_corrupt",
"Workspace signing private material is corrupt",
@@ -606,6 +701,34 @@ mod tests {
use super::*;
struct ReplaceMaterialAfterValidation {
inner: Arc<InMemoryWorkspaceSigningMaterialStore>,
replacement: WorkspaceSigningPrivateMaterial,
loads: std::sync::atomic::AtomicUsize,
}
impl WorkspaceSigningMaterialStore for ReplaceMaterialAfterValidation {
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>> {
if self.loads.fetch_add(1, Ordering::SeqCst) == 0 {
self.inner.load(material_ref)
} else {
Ok(Some(self.replacement.clone()))
}
}
fn put_if_absent(
&self,
material_ref: &str,
material: &WorkspaceSigningPrivateMaterial,
) -> Result<WorkspaceSigningPrivateMaterial> {
self.inner.put_if_absent(material_ref, material)
}
fn delete(&self, material_ref: &str) -> Result<()> {
self.inner.delete(material_ref)
}
}
struct FailFirstMaterialWrite {
inner: Arc<InMemoryWorkspaceSigningMaterialStore>,
fail: AtomicBool,
@@ -709,6 +832,21 @@ mod tests {
ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, public_key)
.verify(payload, &signature)
.unwrap();
let replacement =
WorkspaceSigningPrivateMaterial::generate("workspace-1", &provisioned.key_id).unwrap();
let swapping_service = WorkspaceSigningIdentityService::new(
store.clone(),
Arc::new(ReplaceMaterialAfterValidation {
inner: materials.clone(),
replacement,
loads: std::sync::atomic::AtomicUsize::new(0),
}),
);
assert!(matches!(
swapping_service.sign("workspace-1", payload),
Err(Error::WorkspaceSigningIdentity { ref code, .. })
if code == "workspace_signing_identity_material_mismatch"
));
assert_eq!(
service
.provision_existing("workspace-1", "account-1")
@@ -747,17 +885,16 @@ mod tests {
let operation = store
.reserve_workspace_signing_identity_provisioning(
&WorkspaceSigningIdentityProvisioningOperation {
operation_key: "existing-workspace:workspace-2:revision-1".to_string(),
operation_key: format!("existing-workspace:workspace-2:key-{}", pending.key_id),
request_fingerprint: provisioning_fingerprint(
"workspace-2",
&pending.key_id,
pending.revision,
&pending.private_material_ref,
),
operation_kind: "existing_workspace".to_string(),
workspace_id: "workspace-2".to_string(),
key_id: pending.key_id.clone(),
private_material_ref: pending.private_material_ref.clone(),
revision: pending.revision,
actor_account_id: "account-1".to_string(),
state: "pending".to_string(),
created_at: "1".to_string(),
@@ -826,7 +963,7 @@ mod tests {
let temp = tempfile::tempdir().unwrap();
let store = FsWorkspaceSigningMaterialStore::new(temp.path().join("identities"));
let first = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap();
let first_public = first.validate_and_public_key("ws-1", "WK-1", 1).unwrap();
let first_public = first.validate_and_public_key("ws-1", "WK-1").unwrap();
let persisted = store.put_if_absent("ws-1/ed25519-v1", &first).unwrap();
#[cfg(unix)]
{
@@ -849,18 +986,14 @@ mod tests {
);
}
assert_eq!(
persisted
.validate_and_public_key("ws-1", "WK-1", 1)
.unwrap(),
persisted.validate_and_public_key("ws-1", "WK-1").unwrap(),
first_public
);
let second = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap();
let persisted = store.put_if_absent("ws-1/ed25519-v1", &second).unwrap();
assert_eq!(
persisted
.validate_and_public_key("ws-1", "WK-1", 1)
.unwrap(),
persisted.validate_and_public_key("ws-1", "WK-1").unwrap(),
first_public
);
}
@@ -872,7 +1005,7 @@ mod tests {
let material = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap();
store.put_if_absent("ws-1/ed25519-v1", &material).unwrap();
let loaded = store.load("ws-1/ed25519-v1").unwrap().unwrap();
assert!(loaded.validate_and_public_key("ws-2", "WK-1", 1).is_err());
assert!(loaded.validate_and_public_key("ws-2", "WK-1").is_err());
fs::write(store.material_path("ws-1/ed25519-v1").unwrap(), b"not json").unwrap();
assert!(store.load("ws-1/ed25519-v1").is_err());