fix: normalize SSH migration timestamp comparisons

This commit is contained in:
2026-10-11 01:11:53 +09:00
parent 4099359560
commit 536c593539
4 changed files with 552 additions and 56 deletions
@@ -32,7 +32,8 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
tx.execute_batch( tx.execute_batch(
"CREATE TEMP TABLE repository_key_cutover ( "CREATE TEMP TABLE repository_key_cutover (
workspace_id TEXT NOT NULL, kind TEXT NOT NULL, resource_id TEXT NOT NULL, workspace_id TEXT NOT NULL, kind TEXT NOT NULL, resource_id TEXT NOT NULL,
legacy_counter INTEGER NOT NULL, operation_id TEXT NOT NULL, fingerprint TEXT NOT NULL, created_at TEXT NOT NULL, legacy_counter INTEGER NOT NULL, operation_id TEXT NOT NULL, fingerprint TEXT NOT NULL,
created_at_seconds INTEGER NOT NULL, created_at_subsec_nanos INTEGER NOT NULL,
PRIMARY KEY(workspace_id,kind,resource_id,legacy_counter));", PRIMARY KEY(workspace_id,kind,resource_id,legacy_counter));",
)?; )?;
for (table, kind, id, fingerprint) in [ for (table, kind, id, fingerprint) in [
@@ -68,15 +69,20 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
for (workspace, resource, legacy_counter, fingerprint, created) in rows { for (workspace, resource, legacy_counter, fingerprint, created) in rows {
let mut statement = tx.prepare("SELECT operation_id FROM repository_secret_operations WHERE workspace_id=?1 AND resource_kind=?2 AND resource_id=?3 AND result_revision=?4 AND created_at=?5")?; let 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 let matches = statement
.query_map(params![workspace, kind, resource, legacy_counter, created], |r| { .query_map(
r.get::<_, String>(0) params![workspace, kind, resource, legacy_counter, created],
})? |r| r.get::<_, String>(0),
)?
.collect::<rusqlite::Result<Vec<_>>>()?; .collect::<rusqlite::Result<Vec<_>>>()?;
if matches.len() != 1 || matches[0].is_empty() { if matches.len() != 1 || matches[0].is_empty() {
return Err(Error::Store("Repository key cutover requires one unambiguous mutation receipt per stored key".into())); return Err(Error::Store("Repository key cutover requires one unambiguous mutation receipt per stored key".into()));
} }
let created_time = frozen_repository_timestamp(
&created,
&format!("{table}.created_at (workspace {workspace}, resource {resource})"),
)?;
tx.execute( tx.execute(
"INSERT INTO repository_key_cutover VALUES(?1,?2,?3,?4,?5,?6,?7)", "INSERT INTO repository_key_cutover VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",
params![ params![
workspace, workspace,
kind, kind,
@@ -84,11 +90,26 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
legacy_counter, legacy_counter,
matches[0], matches[0],
fingerprint, fingerprint,
created created_time.timestamp(),
created_time.timestamp_subsec_nanos()
], ],
)?; )?;
} }
} }
// Candidate copying uses an INNER JOIN: reject corrupt source rows before
// they can disappear and evade the destination foreign-key check.
let orphaned_candidates: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM workdir_create_credential_candidates c
LEFT JOIN workdir_create_operations o ON o.workspace_id=c.workspace_id AND o.operation_id=c.operation_id
WHERE o.operation_id IS NULL)",
[],
|row| row.get(0),
)?;
if orphaned_candidates {
return Err(Error::Store(
"Repository key cutover found credential candidates without a Workdir create operation".into(),
));
}
// Current rows must agree with their pointed-to historical key, not merely // Current rows must agree with their pointed-to historical key, not merely
// resolve to a nonempty receipt. FK checks alone cannot prove this identity. // resolve to a nonempty receipt. FK checks alone cannot prove this identity.
let inconsistent: bool = tx.query_row("SELECT EXISTS ( let inconsistent: bool = tx.query_row("SELECT EXISTS (
@@ -148,8 +169,17 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
})? })?
.collect::<rusqlite::Result<Vec<_>>>()? .collect::<rusqlite::Result<Vec<_>>>()?
}; };
for (workspace, id, legacy_counter, purpose, algorithm, nonce, ciphertext, created, operation) in for (
secrets workspace,
id,
legacy_counter,
purpose,
algorithm,
nonce,
ciphertext,
created,
operation,
) in secrets
{ {
if algorithm != "aes-256-gcm-v1" { if algorithm != "aes-256-gcm-v1" {
return Err(Error::Store( return Err(Error::Store(
@@ -233,8 +263,9 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
SELECT o.workspace_id,o.operation_id FROM workdir_create_operations_v86 o SELECT o.workspace_id,o.operation_id FROM workdir_create_operations_v86 o
WHERE o.state='succeeded' 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 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)); 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 prepare_repository_cutover_times(&tx)?;
tx.execute_batch("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, 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 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_rotated','host_trust_rotated') AND e.result_revision>1 THEN e.result_revision-1
@@ -244,8 +275,9 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
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 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_%')) 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 HAVING count(*)=1) AS mutation_kind FROM repository_secret_operations_v86 o) e
JOIN repository_cutover_times t ON t.kind='receipt' AND t.workspace_id=e.workspace_id AND t.operation_id=e.operation_id
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 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.created_at_seconds,m.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos)
AND m.legacy_counter=CASE WHEN e.mutation_kind IN ('credential_rotated','host_trust_rotated') THEN e.result_revision-1 AND m.legacy_counter=CASE WHEN e.mutation_kind IN ('credential_rotated','host_trust_rotated') THEN e.result_revision-1
WHEN e.mutation_kind IN ('credential_deleted','host_trust_deleted') THEN e.result_revision END; WHEN e.mutation_kind IN ('credential_deleted','host_trust_deleted') THEN e.result_revision END;
INSERT INTO workdir_create_operations INSERT INTO workdir_create_operations
@@ -257,12 +289,14 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
CASE WHEN a.operation_id IS NULL THEN o.repository_access_mode END CASE WHEN a.operation_id IS NULL THEN o.repository_access_mode END
FROM workdir_create_operations_v86 o 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 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_counter=o.credential_revision AND c.created_at<=o.updated_at LEFT JOIN repository_cutover_times t ON t.kind='workdir' AND t.workspace_id=o.workspace_id AND t.operation_id=o.operation_id
LEFT JOIN repository_key_cutover h ON h.workspace_id=o.workspace_id AND h.kind='host_trust' AND h.resource_id=o.host_trust_id AND h.legacy_counter=o.host_trust_revision AND h.created_at<=o.updated_at; LEFT JOIN repository_key_cutover c ON c.workspace_id=o.workspace_id AND c.kind='credential' AND c.resource_id=o.credential_id AND c.legacy_counter=o.credential_revision AND (c.created_at_seconds,c.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos)
LEFT JOIN repository_key_cutover h ON h.workspace_id=o.workspace_id AND h.kind='host_trust' AND h.resource_id=o.host_trust_id AND h.legacy_counter=o.host_trust_revision AND (h.created_at_seconds,h.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos);
INSERT INTO workdir_create_credential_candidates INSERT INTO workdir_create_credential_candidates
SELECT c.workspace_id,c.operation_id,c.ordinal,c.role,c.credential_id,m.fingerprint 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 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
ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_counter=c.credential_revision AND m.created_at<=o.updated_at JOIN repository_cutover_times t ON t.kind='workdir' AND t.workspace_id=o.workspace_id AND t.operation_id=o.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_counter=c.credential_revision AND (m.created_at_seconds,m.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos)
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); 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_counter=c.credential_revision;")?; INSERT INTO workdir_create_credential_retentions SELECT c.workspace_id,c.operation_id,c.ordinal,c.credential_id,m.fingerprint,m.operation_id FROM workdir_create_credential_revision_retentions_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_counter=c.credential_revision;")?;
let missing:i64=tx.query_row("SELECT count(*) FROM workdir_create_operations WHERE (credential_id IS NOT NULL AND credential_fingerprint IS NULL) OR (host_trust_id IS NOT NULL AND host_trust_fingerprint IS NULL)",[],|r|r.get(0))?; 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))?;
@@ -313,7 +347,7 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
] { ] {
tx.execute_batch(&format!("DROP TABLE {table}_v86;"))?; tx.execute_batch(&format!("DROP TABLE {table}_v86;"))?;
} }
tx.execute_batch("DROP TABLE repository_key_cutover; tx.execute_batch("DROP TABLE repository_key_cutover; DROP TABLE repository_cutover_times;
CREATE INDEX idx_repository_secret_audit_workspace_created ON repository_secret_audit_events(workspace_id,created_at,event_id); 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_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);")?; CREATE INDEX idx_repository_ssh_host_trusts_workspace_host ON repository_ssh_host_trusts(workspace_id,hostname,port);")?;
@@ -360,6 +394,109 @@ BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
result.and(restore) result.and(restore)
} }
// Schema 86 used RFC3339 for Repository secrets and epoch-millisecond text for
// registry operations. Compare typed instants, never their persisted spelling.
fn frozen_repository_timestamp(
value: &str,
context: &str,
) -> Result<chrono::DateTime<chrono::Utc>> {
if let Ok(timestamp) = chrono::DateTime::parse_from_rfc3339(value) {
return Ok(timestamp.with_timezone(&chrono::Utc));
}
if let Ok(millis) = value.parse::<i64>()
&& let Some(timestamp) = chrono::DateTime::from_timestamp_millis(millis)
{
return Ok(timestamp);
}
Err(Error::Store(format!(
"Repository key migration found invalid timestamp in {context}"
)))
}
fn prepare_repository_cutover_times(tx: &rusqlite::Transaction<'_>) -> Result<()> {
// These temporary normalized values never rewrite audit history or the
// original timestamps used to identify receipts and encrypted envelopes.
// Seconds + nanoseconds also preserve sub-millisecond ordering and offsets.
tx.execute_batch(
"CREATE TEMP TABLE repository_cutover_times (
kind TEXT NOT NULL, workspace_id TEXT NOT NULL, operation_id TEXT NOT NULL,
seconds INTEGER NOT NULL, subsec_nanos INTEGER NOT NULL,
PRIMARY KEY(kind,workspace_id,operation_id));",
)?;
for (kind, context, query) in [
(
"receipt",
"repository_secret_operations.created_at",
"SELECT workspace_id,operation_id,created_at FROM repository_secret_operations_v86",
),
(
"workdir",
"workdir_create_operations.updated_at",
"SELECT o.workspace_id,o.operation_id,o.updated_at FROM workdir_create_operations_v86 o
WHERE NOT EXISTS(SELECT 1 FROM workdir_terminal_archive_cutover a
WHERE a.workspace_id=o.workspace_id AND a.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))",
),
] {
let rows = tx
.prepare(query)?
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
for (workspace, operation, value) in rows {
let timestamp = frozen_repository_timestamp(
&value,
&format!("{context} (workspace {workspace}, operation {operation})"),
)?;
tx.execute(
"INSERT INTO repository_cutover_times VALUES(?1,?2,?3,?4,?5)",
params![
kind,
workspace,
operation,
timestamp.timestamp(),
timestamp.timestamp_subsec_nanos()
],
)?;
}
}
// Refuse missing or newer/reused keys with a domain error before a NULL
// fingerprint reaches a NOT NULL column. Archived successes are excluded:
// they retain opaque history, not authority to retry with a current key.
let missing: Option<(String, String)> = tx.query_row(
"SELECT r.workspace_id,r.operation_id FROM (
SELECT workspace_id,operation_id,'credential' AS kind,credential_id AS resource_id,credential_revision AS legacy_counter
FROM workdir_create_operations_v86 WHERE credential_id IS NOT NULL
UNION
SELECT workspace_id,operation_id,'host_trust',host_trust_id,host_trust_revision
FROM workdir_create_operations_v86 WHERE host_trust_id IS NOT NULL
UNION
SELECT workspace_id,operation_id,'credential',credential_id,credential_revision
FROM workdir_create_credential_candidates_v86
) r
JOIN repository_cutover_times t ON t.kind='workdir' AND t.workspace_id=r.workspace_id AND t.operation_id=r.operation_id
LEFT JOIN repository_key_cutover k ON k.workspace_id=r.workspace_id AND k.kind=r.kind
AND k.resource_id=r.resource_id AND k.legacy_counter=r.legacy_counter
AND (k.created_at_seconds,k.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos)
WHERE k.operation_id IS NULL LIMIT 1",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
).optional()?;
if let Some((workspace, operation)) = missing {
return Err(Error::Store(format!(
"Workdir SSH snapshot is missing key identity evidence at or before its timestamp (workspace {workspace}, operation {operation})"
)));
}
Ok(())
}
// Preserve every SQLite field and NULL verbatim as typed JSON. Frozen archives // Preserve every SQLite field and NULL verbatim as typed JSON. Frozen archives
// are never read by credential lookup, lease construction, or a live CAS. // are never read by credential lookup, lease construction, or a live CAS.
fn frozen_repository_json_rows( fn frozen_repository_json_rows(
@@ -32,12 +32,12 @@ fn legacy_repository_keys(path: &std::path::Path) -> Connection {
let end = start + frozen[start..].find(end).unwrap(); let end = start + frozen[start..].find(end).unwrap();
conn.execute_batch(&frozen[start..end]).unwrap(); conn.execute_batch(&frozen[start..end]).unwrap();
} }
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','create-key','intent','credential','key',7,'created'); conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','create-key','intent','credential','key',7,'2026-01-01T00:00:00.000Z');
INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519','fingerprint',7,'active','created',NULL); INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519','fingerprint',7,'active','2026-01-01T00:00:00.000Z',NULL);
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',7,'ssh-ed25519','fingerprint','created'); INSERT INTO repository_ssh_credential_revisions VALUES('space','key',7,'ssh-ed25519','fingerprint','2026-01-01T00:00:00.000Z');
INSERT INTO repository_secret_audit_events VALUES('space','audit','credential_created','key',7,'actor','created'); INSERT INTO repository_secret_audit_events VALUES('space','audit','credential_created','key',7,'actor','2026-01-01T00:00:00.000Z');
INSERT INTO workdir_create_operations(workspace_id,operation_id,request_fingerprint,repository_id,resolved_runtime_id,config_revision,config_projection_digest,working_directory_id,state,created_at,updated_at,credential_id,credential_revision) INSERT INTO workdir_create_operations(workspace_id,operation_id,request_fingerprint,repository_id,resolved_runtime_id,config_revision,config_projection_digest,working_directory_id,state,created_at,updated_at,credential_id,credential_revision)
VALUES('space','create-workdir','intent','repo','runtime',3,'projection','workdir','pending','created','created','key',7); VALUES('space','create-workdir','intent','repo','runtime',3,'projection','workdir','pending','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z','key',7);
INSERT INTO workdir_create_credential_candidates VALUES('space','create-workdir',0,'primary','key',7); INSERT INTO workdir_create_credential_candidates VALUES('space','create-workdir',0,'primary','key',7);
INSERT INTO workdir_create_credential_revision_retentions VALUES('space','create-workdir',0,'key',7); INSERT INTO workdir_create_credential_revision_retentions VALUES('space','create-workdir',0,'key',7);
CREATE TRIGGER workdir_create_insert_blocked_by_runtime_removal CREATE TRIGGER workdir_create_insert_blocked_by_runtime_removal
@@ -59,11 +59,300 @@ fn legacy_repository_keys(path: &std::path::Path) -> Connection {
&mut ciphertext, &mut ciphertext,
) )
.unwrap(); .unwrap();
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',7,?1,'aes-256-gcm-v1',?2,?3,'created')",params![purpose,nonce.as_slice(),ciphertext]).unwrap(); conn.execute("INSERT INTO server_secret_versions VALUES('space','key',7,?1,'aes-256-gcm-v1',?2,?3,'2026-01-01T00:00:00.000Z')",params![purpose,nonce.as_slice(),ciphertext]).unwrap();
} }
conn conn
} }
// Exercise the actual persisted formats, including failed creates whose SSH
// snapshot remains retry authority. The encrypted fixture uses the real old AAD.
fn legacy_ssh_snapshot_times(conn: &Connection, key_created: &str, workdir_updated: &str) {
conn.execute(
"UPDATE repository_secret_operations SET created_at=?1",
[key_created],
)
.unwrap();
conn.execute(
"UPDATE repository_secret_audit_events SET created_at=?1",
[key_created],
)
.unwrap();
conn.execute(
"UPDATE repository_ssh_credentials SET created_at=?1",
[key_created],
)
.unwrap();
conn.execute(
"UPDATE repository_ssh_credential_revisions SET created_at=?1",
[key_created],
)
.unwrap();
conn.execute(
"UPDATE server_secret_versions SET created_at=?1",
[key_created],
)
.unwrap();
conn.execute("INSERT INTO repository_secret_operations VALUES('space','create-host','intent','host_trust','host',3,?1)", [key_created]).unwrap();
conn.execute("INSERT INTO repository_secret_audit_events VALUES('space','host-audit','host_trust_created','host',3,'actor',?1)", [key_created]).unwrap();
conn.execute("INSERT INTO repository_ssh_host_trusts VALUES('space','host','example.test',22,'ssh-ed25519','host-key','host-fingerprint',3,?1,?1)", [key_created]).unwrap();
conn.execute("INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',3,'example.test',22,'ssh-ed25519','host-key','host-fingerprint',?1)", [key_created]).unwrap();
conn.execute("UPDATE workdir_create_operations SET state='failed',failure='runtime_workdir_create_failed',host_trust_id='host',host_trust_revision=3,repository_access_mode='read_only',updated_at=?1", [workdir_updated]).unwrap();
}
#[test]
fn repository_key_cutover_compares_mixed_timestamp_formats_as_instants() {
for (key_created, workdir_updated) in [
("2026-01-01T00:00:00.000Z", "1767225600000"),
("2026-01-01T00:00:00.000Z", "1767225600001"),
("2026-01-01T09:00:00.000+09:00", "2026-01-01T00:00:00Z"),
(
"2026-01-01T00:00:00.000000001Z",
"2026-01-01T00:00:00.000000002Z",
),
] {
let dir = tempfile::tempdir().unwrap();
let conn = legacy_repository_keys(&dir.path().join("server.db"));
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
legacy_ssh_snapshot_times(&conn, key_created, workdir_updated);
migrate_repository_keys_v86_to_v87(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 87);
let snapshot: (String, String, String, String) = conn.query_row(
"SELECT state,credential_fingerprint,host_trust_fingerprint,updated_at FROM workdir_create_operations",
[], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?)),
).unwrap();
assert_eq!(
snapshot,
(
"failed".into(),
"fingerprint".into(),
"host-fingerprint".into(),
workdir_updated.into()
)
);
let retained: (String, String, String) = conn.query_row(
"SELECT c.credential_fingerprint,r.credential_fingerprint,r.credential_operation_id
FROM workdir_create_credential_candidates c JOIN workdir_create_credential_retentions r
ON r.workspace_id=c.workspace_id AND r.operation_id=c.operation_id AND r.ordinal=c.ordinal",
[], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?)),
).unwrap();
assert_eq!(
retained,
(
"fingerprint".into(),
"fingerprint".into(),
"create-key".into()
)
);
assert_eq!(
conn.query_row(
"SELECT created_at FROM repository_ssh_credential_keys",
[],
|r| r.get::<_, String>(0)
)
.unwrap(),
key_created
);
assert!(
!conn
.prepare("PRAGMA foreign_key_check")
.unwrap()
.exists([])
.unwrap()
);
}
}
fn assert_timestamp_cutover_rolls_back(conn: &Connection, expected_context: &str) {
let before =
frozen_repository_json_rows(conn, "SELECT * FROM workdir_create_operations", &[]).unwrap();
let ciphertext: Vec<u8> = conn
.query_row(
"SELECT ciphertext FROM server_secret_versions WHERE purpose='private_key'",
[],
|r| r.get(0),
)
.unwrap();
let error = migrate_repository_keys_v86_to_v87(conn)
.unwrap_err()
.to_string();
assert!(error.contains(expected_context), "{error}");
assert!(error.contains("timestamp"), "{error}");
assert_eq!(current_schema_version(conn).unwrap(), 86);
assert_eq!(
frozen_repository_json_rows(conn, "SELECT * FROM workdir_create_operations", &[]).unwrap(),
before
);
assert_eq!(
conn.query_row(
"SELECT ciphertext FROM server_secret_versions WHERE purpose='private_key'",
[],
|r| r.get::<_, Vec<u8>>(0)
)
.unwrap(),
ciphertext
);
assert!(table_exists(conn, "repository_ssh_credential_revisions").unwrap());
assert!(!table_exists(conn, "repository_ssh_credential_keys").unwrap());
assert_eq!(
conn.query_row(
"SELECT count(*) FROM workdir_create_credential_candidates",
[],
|r| r.get::<_, i64>(0)
)
.unwrap(),
1
);
assert_eq!(
conn.query_row(
"SELECT count(*) FROM workdir_create_credential_revision_retentions",
[],
|r| r.get::<_, i64>(0)
)
.unwrap(),
1
);
assert_eq!(conn.query_row("SELECT count(*) FROM sqlite_temp_master WHERE name IN ('repository_key_cutover','repository_cutover_times','workdir_terminal_archive_cutover')", [], |r| r.get::<_,i64>(0)).unwrap(), 0);
assert_eq!(
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, i64>(0))
.unwrap(),
1
);
assert_eq!(
conn.query_row("PRAGMA legacy_alter_table", [], |r| r.get::<_, i64>(0))
.unwrap(),
0
);
}
#[test]
fn repository_key_cutover_rejects_newer_reused_snapshot_keys_without_committing_authority() {
for source in ["credential", "host_trust", "candidate"] {
for later in ["2026-01-01T00:00:00.001Z", "2026-01-01T00:00:00.000000001Z"] {
let dir = tempfile::tempdir().unwrap();
let conn = legacy_repository_keys(&dir.path().join("server.db"));
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
legacy_ssh_snapshot_times(&conn, "2026-01-01T00:00:00.000Z", "1767225600000");
if source == "host_trust" {
conn.execute(
"UPDATE repository_ssh_host_trust_revisions SET created_at=?1",
[later],
)
.unwrap();
conn.execute("UPDATE repository_secret_operations SET created_at=?1 WHERE resource_kind='host_trust'", [later]).unwrap();
conn.execute("UPDATE repository_secret_audit_events SET created_at=?1 WHERE kind='host_trust_created'", [later]).unwrap();
} else {
conn.execute(
"UPDATE repository_ssh_credential_revisions SET created_at=?1",
[later],
)
.unwrap();
conn.execute("UPDATE repository_secret_operations SET created_at=?1 WHERE resource_kind='credential'", [later]).unwrap();
conn.execute("UPDATE repository_secret_audit_events SET created_at=?1 WHERE kind='credential_created'", [later]).unwrap();
if source == "candidate" {
conn.execute("UPDATE workdir_create_operations SET credential_id=NULL,credential_revision=NULL", []).unwrap();
}
}
assert_timestamp_cutover_rolls_back(&conn, "Workdir SSH snapshot");
}
}
}
#[test]
fn repository_key_cutover_invalid_authority_timestamps_roll_back_with_source_context() {
for (source, invalid) in [
("workdir_create_operations.updated_at", "not-a-date"),
(
"workdir_create_operations.updated_at",
"9223372036854775807",
),
(
"repository_ssh_credential_revisions.created_at",
"not-a-date",
),
(
"repository_ssh_host_trust_revisions.created_at",
"not-a-date",
),
("repository_secret_operations.created_at", "not-a-date"),
] {
let dir = tempfile::tempdir().unwrap();
let conn = legacy_repository_keys(&dir.path().join("server.db"));
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
legacy_ssh_snapshot_times(&conn, "2026-01-01T00:00:00.000Z", "1767225600000");
match source {
"workdir_create_operations.updated_at" => {
conn.execute(
"UPDATE workdir_create_operations SET updated_at=?1",
[invalid],
)
.unwrap();
}
"repository_ssh_credential_revisions.created_at" => {
conn.execute(
"UPDATE repository_ssh_credential_revisions SET created_at=?1",
[invalid],
)
.unwrap();
conn.execute("UPDATE repository_secret_operations SET created_at=?1 WHERE resource_kind='credential'", [invalid]).unwrap();
}
"repository_ssh_host_trust_revisions.created_at" => {
conn.execute(
"UPDATE repository_ssh_host_trust_revisions SET created_at=?1",
[invalid],
)
.unwrap();
conn.execute("UPDATE repository_secret_operations SET created_at=?1 WHERE resource_kind='host_trust'", [invalid]).unwrap();
}
"repository_secret_operations.created_at" => {
conn.execute("INSERT INTO repository_secret_operations VALUES('space','old-delete','intent','credential','gone',1,?1)", [invalid]).unwrap();
conn.execute("INSERT INTO repository_secret_audit_events VALUES('space','old-delete-audit','credential_deleted','gone',1,'actor',?1)", [invalid]).unwrap();
}
_ => unreachable!(),
}
assert_timestamp_cutover_rolls_back(&conn, source);
}
}
#[test]
fn repository_key_cutover_rejects_orphan_candidates_without_dropping_source_evidence() {
let dir = tempfile::tempdir().unwrap();
let conn = legacy_repository_keys(&dir.path().join("server.db"));
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
conn.execute_batch("PRAGMA foreign_keys=OFF;
INSERT INTO workdir_create_credential_candidates VALUES('space','missing-parent',0,'primary','key',7);
PRAGMA foreign_keys=ON;").unwrap();
let before = frozen_repository_json_rows(
&conn,
"SELECT * FROM workdir_create_credential_candidates ORDER BY operation_id,ordinal",
&[],
)
.unwrap();
let error = migrate_repository_keys_v86_to_v87(&conn).unwrap_err();
assert!(
error
.to_string()
.contains("credential candidates without a Workdir create operation"),
"{error}"
);
assert_eq!(current_schema_version(&conn).unwrap(), 86);
assert_eq!(
frozen_repository_json_rows(
&conn,
"SELECT * FROM workdir_create_credential_candidates ORDER BY operation_id,ordinal",
&[]
)
.unwrap(),
before
);
assert!(table_exists(&conn, "repository_ssh_credential_revisions").unwrap());
assert!(!table_exists(&conn, "repository_ssh_credential_keys").unwrap());
assert_eq!(
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, i64>(0))
.unwrap(),
1
);
}
#[test] #[test]
fn repository_key_cutover_reseals_secrets_and_preserves_retention_and_audit() { fn repository_key_cutover_reseals_secrets_and_preserves_retention_and_audit() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -150,12 +439,12 @@ fn repository_key_cutover_reseals_secrets_and_preserves_retention_and_audit() {
.unwrap(); .unwrap();
assert!( assert!(
conn.execute( conn.execute(
"UPDATE workdir_create_operations SET updated_at='later'", "UPDATE workdir_create_operations SET updated_at='2026-01-04T00:00:00.000Z'",
[] []
) )
.is_err() .is_err()
); );
assert!(conn.execute("INSERT INTO workdir_create_operations(workspace_id,operation_id,request_fingerprint,repository_id,resolved_runtime_id,config_projection_digest,working_directory_id,state,created_at,updated_at) VALUES('space','blocked','intent','repo','runtime','projection','other-workdir','pending','created','created')", []).is_err()); assert!(conn.execute("INSERT INTO workdir_create_operations(workspace_id,operation_id,request_fingerprint,repository_id,resolved_runtime_id,config_projection_digest,working_directory_id,state,created_at,updated_at) VALUES('space','blocked','intent','repo','runtime','projection','other-workdir','pending','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z')", []).is_err());
assert!( assert!(
!conn !conn
.prepare("PRAGMA foreign_key_check") .prepare("PRAGMA foreign_key_check")
@@ -274,14 +563,14 @@ fn repository_key_cutover_preserves_host_endpoints_deleted_audit_and_nondefault_
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let conn = legacy_repository_keys(&dir.path().join("server.db")); let conn = legacy_repository_keys(&dir.path().join("server.db"));
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap(); std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','host-create','intent','host_trust','host',3,'created'); conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','host-create','intent','host_trust','host',3,'2026-01-01T00:00:00.000Z');
INSERT INTO repository_secret_operations VALUES('space','host-move','intent','host_trust','host',4,'updated'); INSERT INTO repository_secret_operations VALUES('space','host-move','intent','host_trust','host',4,'2026-01-02T00:00:00.000Z');
INSERT INTO repository_ssh_host_trusts VALUES('space','host','new.example.test',2222,'ssh-ed25519','host-key','host-fingerprint',4,'created','updated'); INSERT INTO repository_ssh_host_trusts VALUES('space','host','new.example.test',2222,'ssh-ed25519','host-key','host-fingerprint',4,'2026-01-01T00:00:00.000Z','2026-01-02T00:00:00.000Z');
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',3,'old.example.test',22,'ssh-ed25519','host-key','host-fingerprint','created'); INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',3,'old.example.test',22,'ssh-ed25519','host-key','host-fingerprint','2026-01-01T00:00:00.000Z');
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',4,'new.example.test',2222,'ssh-ed25519','host-key','host-fingerprint','updated'); INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',4,'new.example.test',2222,'ssh-ed25519','host-key','host-fingerprint','2026-01-02T00:00:00.000Z');
INSERT INTO repository_secret_audit_events VALUES('space','host-audit','host_trust_rotated','host',4,'host-actor','updated'); INSERT INTO repository_secret_audit_events VALUES('space','host-audit','host_trust_rotated','host',4,'host-actor','2026-01-02T00:00:00.000Z');
INSERT INTO repository_secret_operations VALUES('space','delete-gone','intent','credential','gone',5,'deleted'); INSERT INTO repository_secret_operations VALUES('space','delete-gone','intent','credential','gone',5,'2026-01-03T00:00:00.000Z');
INSERT INTO repository_secret_audit_events VALUES('space','delete-audit','credential_deleted','gone',5,'delete-actor','deleted'); INSERT INTO repository_secret_audit_events VALUES('space','delete-audit','credential_deleted','gone',5,'delete-actor','2026-01-03T00:00:00.000Z');
UPDATE workdir_create_operations SET host_trust_id='host',host_trust_revision=3;").unwrap(); UPDATE workdir_create_operations SET host_trust_id='host',host_trust_revision=3;").unwrap();
conn.execute_batch(&format!( conn.execute_batch(&format!(
"PRAGMA foreign_keys={foreign_keys}; PRAGMA legacy_alter_table={legacy_alter};" "PRAGMA foreign_keys={foreign_keys}; PRAGMA legacy_alter_table={legacy_alter};"
@@ -342,8 +631,8 @@ fn deleted_successful_workdir_archives_verbatim_history_without_authorizing_retr
let digest = "sha256:db15419814117c33c089a36c5348bda85d01a53fccd8d4d427685daf5983353b"; let digest = "sha256:db15419814117c33c089a36c5348bda85d01a53fccd8d4d427685daf5983353b";
conn.execute("UPDATE workdir_create_operations SET state='succeeded',request_fingerprint=?1,selector='main',source_kind='local_path',source_uri='/tmp/repo',source_revision=9,source_fingerprint='source'",[digest]).unwrap(); conn.execute("UPDATE workdir_create_operations SET state='succeeded',request_fingerprint=?1,selector='main',source_kind='local_path',source_uri='/tmp/repo',source_revision=9,source_fingerprint='source'",[digest]).unwrap();
conn.execute_batch("DELETE FROM workdir_create_credential_revision_retentions; conn.execute_batch("DELETE FROM workdir_create_credential_revision_retentions;
INSERT INTO repository_secret_operations VALUES('space','delete-key','delete-intent','credential','key',7,'deleted'); INSERT INTO repository_secret_operations VALUES('space','delete-key','delete-intent','credential','key',7,'2026-01-03T00:00:00.000Z');
INSERT INTO repository_secret_audit_events VALUES('space','delete-audit','credential_deleted','key',7,'deleting-actor','deleted'); INSERT INTO repository_secret_audit_events VALUES('space','delete-audit','credential_deleted','key',7,'deleting-actor','2026-01-03T00:00:00.000Z');
DELETE FROM repository_ssh_credentials;").unwrap(); DELETE FROM repository_ssh_credentials;").unwrap();
let original = let original =
frozen_repository_json_rows(&conn, "SELECT * FROM workdir_create_operations", &[]) frozen_repository_json_rows(&conn, "SELECT * FROM workdir_create_operations", &[])
@@ -666,7 +955,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
"credential", "credential",
"key", "key",
1, 1,
"2026-01-01", "2026-01-01T00:00:00.000Z",
"credential_created", "credential_created",
), ),
( (
@@ -675,7 +964,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
"credential", "credential",
"key", "key",
2, 2,
"2026-01-02", "2026-01-02T00:00:00.000Z",
"credential_rotated", "credential_rotated",
), ),
( (
@@ -684,7 +973,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
"host_trust", "host_trust",
"host", "host",
1, 1,
"2026-01-01", "2026-01-01T00:00:00.000Z",
"host_trust_created", "host_trust_created",
), ),
( (
@@ -693,7 +982,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
"host_trust", "host_trust",
"host", "host",
2, 2,
"2026-01-02", "2026-01-02T00:00:00.000Z",
"host_trust_rotated", "host_trust_rotated",
), ),
] { ] {
@@ -708,11 +997,11 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
) )
.unwrap(); .unwrap();
} }
conn.execute("INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519',?1,2,'active','2026-01-01','2026-01-02')",[&next_fp]).unwrap(); conn.execute("INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519',?1,2,'active','2026-01-01T00:00:00.000Z','2026-01-02T00:00:00.000Z')",[&next_fp]).unwrap();
let encryption_key = LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &[42; 32]).unwrap()); let encryption_key = LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &[42; 32]).unwrap());
for (legacy_counter, fingerprint, private, timestamp) in [ for (legacy_counter, fingerprint, private, timestamp) in [
(1u64, &old_fp, &old_private, "2026-01-01"), (1u64, &old_fp, &old_private, "2026-01-01T00:00:00.000Z"),
(2, &next_fp, &next_private, "2026-01-02"), (2, &next_fp, &next_private, "2026-01-02T00:00:00.000Z"),
] { ] {
conn.execute("INSERT INTO repository_ssh_credential_revisions VALUES('space','key',?1,'ssh-ed25519',?2,?3)",params![legacy_counter,fingerprint,timestamp]).unwrap(); conn.execute("INSERT INTO repository_ssh_credential_revisions VALUES('space','key',?1,'ssh-ed25519',?2,?3)",params![legacy_counter,fingerprint,timestamp]).unwrap();
let nonce = [legacy_counter as u8; 12]; let nonce = [legacy_counter as u8; 12];
@@ -727,10 +1016,10 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
.unwrap(); .unwrap();
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',?1,'private_key','aes-256-gcm-v1',?2,?3,?4)",params![legacy_counter,nonce.as_slice(),ciphertext,timestamp]).unwrap(); conn.execute("INSERT INTO server_secret_versions VALUES('space','key',?1,'private_key','aes-256-gcm-v1',?2,?3,?4)",params![legacy_counter,nonce.as_slice(),ciphertext,timestamp]).unwrap();
} }
conn.execute("INSERT INTO repository_ssh_host_trusts VALUES('space','host','new.example.test',22,'ssh-ed25519',?1,?2,2,'2026-01-01','2026-01-02')",params![public,old_fp]).unwrap(); conn.execute("INSERT INTO repository_ssh_host_trusts VALUES('space','host','new.example.test',22,'ssh-ed25519',?1,?2,2,'2026-01-01T00:00:00.000Z','2026-01-02T00:00:00.000Z')",params![public,old_fp]).unwrap();
for (legacy_counter, hostname, timestamp) in [ for (legacy_counter, hostname, timestamp) in [
(1, "old.example.test", "2026-01-01"), (1, "old.example.test", "2026-01-01T00:00:00.000Z"),
(2, "new.example.test", "2026-01-02"), (2, "new.example.test", "2026-01-02T00:00:00.000Z"),
] { ] {
conn.execute("INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',?1,?2,22,'ssh-ed25519',?3,?4,?5)",params![legacy_counter,hostname,public,old_fp,timestamp]).unwrap(); conn.execute("INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',?1,?2,22,'ssh-ed25519',?3,?4,?5)",params![legacy_counter,hostname,public,old_fp,timestamp]).unwrap();
} }
@@ -884,8 +1173,8 @@ fn deleted_legacy_receipt_does_not_bind_to_later_reused_key_ordinal() {
// The old deleted ID has been recreated using the same ordinal. Its earlier // The old deleted ID has been recreated using the same ordinal. Its earlier
// delete receipt must remain history, not acquire proof from this new key. // delete receipt must remain history, not acquire proof from this new key.
conn.execute_batch("DELETE FROM workdir_create_operations; conn.execute_batch("DELETE FROM workdir_create_operations;
INSERT INTO repository_secret_operations VALUES('space','old-delete','intent','credential','key',7,'2025-01-01'); INSERT INTO repository_secret_operations VALUES('space','old-delete','intent','credential','key',7,'2025-01-01T00:00:00.000Z');
INSERT INTO repository_secret_audit_events VALUES('space','old-delete-audit','credential_deleted','key',7,'actor','2025-01-01');").unwrap(); INSERT INTO repository_secret_audit_events VALUES('space','old-delete-audit','credential_deleted','key',7,'actor','2025-01-01T00:00:00.000Z');").unwrap();
migrate_repository_keys_v86_to_v87(&conn).unwrap(); migrate_repository_keys_v86_to_v87(&conn).unwrap();
// The service consumes current receipt columns; unrelated schemas are not this fixture. // The service consumes current receipt columns; unrelated schemas are not this fixture.
rename_saved_secret_receipt_counters(&conn).unwrap(); rename_saved_secret_receipt_counters(&conn).unwrap();
@@ -966,8 +1255,8 @@ fn repository_key_cutover_failure_preserves_schema_ciphertext_and_connection_pra
} }
"inconsistent_retention" => { "inconsistent_retention" => {
// A different valid retained key passes the ordinal-only candidate FK. // A different valid retained key passes the ordinal-only candidate FK.
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','another-key','intent','credential','key',8,'later'); conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','another-key','intent','credential','key',8,'2026-01-04T00:00:00.000Z');
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',8,'ssh-ed25519','other','later'); INSERT INTO repository_ssh_credential_revisions VALUES('space','key',8,'ssh-ed25519','other','2026-01-04T00:00:00.000Z');
UPDATE workdir_create_credential_revision_retentions SET credential_revision=8;").unwrap(); UPDATE workdir_create_credential_revision_retentions SET credential_revision=8;").unwrap();
} }
"missing_audit_receipt" => { "missing_audit_receipt" => {
@@ -978,12 +1267,12 @@ fn repository_key_cutover_failure_preserves_schema_ciphertext_and_connection_pra
.unwrap(); .unwrap();
} }
"missing_host_receipt" => { "missing_host_receipt" => {
conn.execute_batch("INSERT INTO repository_ssh_host_trusts VALUES('space','host','example.test',22,'ssh-ed25519','key','host-fingerprint',4,'created','created'); conn.execute_batch("INSERT INTO repository_ssh_host_trusts VALUES('space','host','example.test',22,'ssh-ed25519','key','host-fingerprint',4,'2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z');
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',4,'example.test',22,'ssh-ed25519','key','host-fingerprint','created');").unwrap(); INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',4,'example.test',22,'ssh-ed25519','key','host-fingerprint','2026-01-01T00:00:00.000Z');").unwrap();
} }
"foreign_key_violation" => { "foreign_key_violation" => {
conn.execute_batch("PRAGMA foreign_keys=OFF; conn.execute_batch("PRAGMA foreign_keys=OFF;
INSERT INTO repository_secret_operations VALUES('missing-space','orphan','intent','credential','deleted',1,'created'); INSERT INTO repository_secret_operations VALUES('missing-space','orphan','intent','credential','deleted',1,'2026-01-01T00:00:00.000Z');
PRAGMA foreign_keys=ON;").unwrap(); PRAGMA foreign_keys=ON;").unwrap();
} }
_ => {} _ => {}
@@ -80,10 +80,10 @@ fn migration_dry_run_and_apply_share_the_original_repository_secret_authority()
let conn = Connection::open(&path).unwrap(); let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap(); configure_sqlite(&conn).unwrap();
seed_migration_workspace(&conn); seed_migration_workspace(&conn);
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','create-key','intent','credential','key',7,'created'); conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','create-key','intent','credential','key',7,'2026-01-01T00:00:00.000Z');
INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519','fingerprint',7,'active','created',NULL); INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519','fingerprint',7,'active','2026-01-01T00:00:00.000Z',NULL);
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',7,'ssh-ed25519','fingerprint','created'); INSERT INTO repository_ssh_credential_revisions VALUES('space','key',7,'ssh-ed25519','fingerprint','2026-01-01T00:00:00.000Z');
INSERT INTO repository_secret_audit_events VALUES('space','audit','credential_created','key',7,'owner','created');").unwrap(); INSERT INTO repository_secret_audit_events VALUES('space','audit','credential_created','key',7,'owner','2026-01-01T00:00:00.000Z');").unwrap();
let key = LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &[42; 32]).unwrap()); let key = LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &[42; 32]).unwrap());
let mut ciphertext = b"private bytes".to_vec(); let mut ciphertext = b"private bytes".to_vec();
key.seal_in_place_append_tag( key.seal_in_place_append_tag(
@@ -92,7 +92,7 @@ fn migration_dry_run_and_apply_share_the_original_repository_secret_authority()
&mut ciphertext, &mut ciphertext,
) )
.unwrap(); .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(); conn.execute("INSERT INTO server_secret_versions VALUES('space','key',7,'private_key','aes-256-gcm-v1',?1,?2,'2026-01-01T00:00:00.000Z')",params![[1u8;12].as_slice(),ciphertext]).unwrap();
drop(conn); drop(conn);
let master_key = dir.path().join("repository-secrets.master-key"); let master_key = dir.path().join("repository-secrets.master-key");
std::fs::write(&master_key, [42; 32]).unwrap(); std::fs::write(&master_key, [42; 32]).unwrap();
@@ -0,0 +1,70 @@
# schema86→87のSSH移行: 異なる時刻形式の文字列比較でfingerprintがNULLになる
調査日: 2026-10-10
報告された起動エラー:
`yoi-server: store error: workspace schema migration failed: sqlite error: NOT NULL constraint failed: workdir_create_credential_candidates.credential_fingerprint`
## 原因
`crates/workspace-server/src/frozen_repository_key_migration.rs:262–266`のcredential candidate移行は、旧credential ID/revisionから`repository_key_cutover`へLEFT JOINし、`m.fingerprint`を新しいNOT NULL列に挿入する。
JOIN条件の`m.created_at <= o.updated_at`が、RFC3339の鍵作成時刻とepoch-millis文字列のWorkdir更新時刻を、そのままTEXTとして比較している。日時としての順序を判定していないため、有効な古い鍵のJOINが外れ、fingerprintがNULLになる。
- 鍵作成時刻: `repository_access.rs:2256`の`Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)`。
- Workdir作成・更新時刻: `server.rs:30936–30940`の`now_registry_timestamp()`はepochミリ秒の文字列。reserveでもこれを使用する(`server.rs:24272–24308`)。
同じ異形式比較はoperation本体のcredential/host trust移行にもある(migration L260–261)。candidateだけ直してもoperation snapshotのfingerprintが欠落する問題が残る。
## 実データによる確認
ユーザーから共有済みの`yoi.local/server/server.db`をNodeの`DatabaseSync({readOnly: true})`と`PRAGMA query_only=ON`で照合した。秘密鍵、暗号化envelope、master keyは取得していない。migrationやDB更新も実行していない。
- DBはschema86のまま。
- 新candidateへ移される5件すべてがfailed Workdir createのcredential候補で、旧retentionが各1件あるため成功済み・unretainedのarchive経路には入らない。
- 5件とも参照先の鍵は存在する。対応するmutation receiptも各1件あり、証拠欠落ではない。
- 5件ともSQLiteのTEXT比較はfalse、実際の時刻比較はtrue。
代表例:
| 項目 | 値 |
| --- | --- |
| operation | `call_mgmyCNofdQMZ8uyI5tg0mBhv` |
| credential | `workspace-default`、旧revision 1 |
| 鍵created_at | `2026-09-16T18:44:56.736Z` |
| Workdir updated_at | `1790173821030` |
| Workdir updated_atを日時に変換 | `2026-09-23T14:30:21.030Z` |
| SQLiteの文字列比較 | false |
| 日時としての比較 | true |
他の4件では鍵作成が`2026-09-10T13:09:52.113Z`、Workdir更新は9/23または9/29で、同じ問題になる。
## 修正方針(この調査では未実装)
- frozen migration内で旧データの時刻形式を明示的に読み、同じ時刻型/epoch-millisへ正規化して比較する。文字列比較やSQLの安易なCASTに頼らない。
- `created_at <= updated_at`の安全確認そのものは削除しない。旧ID/revisionが削除後に再利用された鍵を、過去のcandidateに誤って結び付けることを防ぐ条件である。
- candidateだけでなくoperation本体のcredential/host trust等、同じ境界の比較を確認する。
- 正規化不能・不正な時刻や真に必要な証拠が欠落した場合は、NOT NULLエラーになる前に文脈付きで拒否し、transactionをrollbackする。
既存の`frozen_repository_key_tests.rs:35–42`の共通fixtureは、鍵とWorkdirの時刻をすべて`created`に揃えており、実データの異形式を再現していない。RFC3339鍵時刻+epoch-millis Workdir時刻での移行成功と、実時刻が操作より後の再作成鍵を拒否するケースが必要。テスト追加時は`docs/development/rust-testing-strategy.md`に従う。
## 影響・実施範囲
schema86→87はtransaction内で実行され、version87の記録とcommitは最後に行う(migration L29、L349–350)。今回のDBでもschema86と旧テーブルを確認した。ただし、それより前のschema更新まで含めた全起動処理が一括で元に戻るという意味ではない。
NOT NULLを外す、適当なfingerprintを入れる、candidate/retentionを消して通す、といったDB修復は行うべきではない。正しい既存データをmigrationが誤判定しているので、移行コード側を修正する。
調査時点で追加したのは本報告のみ。以下は、その後のユーザー指示による修正記録。
## 修正と検証(2026-10-10)
- RFC3339/epoch-millisをRustで読み、temporary tableのUTC秒・ナノ秒へ正規化して比較するよう修正。credential/host trustのoperation snapshot、candidate、旧receiptの時刻条件に適用した。
- タイムゾーン・sub-millisecondの順序を維持し、元の履歴時刻やreceipt照合用の文字列、暗号化AADは書き換えない。永続schemaの変更・version bumpは不要。
- 不正な時刻、欠けた鍵、操作より新しい再利用鍵は文脈付きで拒否しrollback。NOT NULLを緩めたり候補を削除したりしない。
- 独立コード確認で見つかった、親operationのない旧candidateがINNER JOINから落ちて消える経路も、コピー前の拒否とrollbackテストで保護した。追修正に追加のblocking findingなし。
- 偽時刻`created`や日付だけのfixtureは実際のRFC3339へ修正。新規回帰は混在形式・同時刻・offset・ナノ秒精度、各SSH参照での新しい鍵拒否、不正時刻、孤立候補の保持を検証する。
- 最小の混在形式テスト、migrationモジュール、公開migration dry-run/applyテスト、孤立候補テストを順に実行して成功。最終の`cargo test -p yoi-workspace-server`はlibrary **815 passed / 1 ignored**、binary **11 passed**、doctest失敗なし。
- root `cargo check`、`cargo fmt --all -- --check`、変更したincludeファイルの明示的`rustfmt --check`、`git diff --check HEAD`も成功。
- 稼働DBでのmigration、ビルド済みバイナリの配備、サービスの停止・再起動は行っていない。更新はユーザー操作のまま。
作業中、テスト担当SubWorkerはcwd基準の読取で、scopeに付与した別ディレクトリのテスト規則を参照できなかった(絶対pathもinvalid logical filesystem pathで拒否)。編集なしを確認して停止し、親でテストを実装した。読取専用レビュー担当はroot cwdで規則・ソースを読めた。cwdとscopeにまたがる論理pathの案内・検証がツール上の改善点。