From 536c593539bc8945f81209afaa7663a88b8b85a4 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 11 Oct 2026 01:11:53 +0900 Subject: [PATCH] fix: normalize SSH migration timestamp comparisons --- .../src/frozen_repository_key_migration.rs | 169 ++++++++- .../src/frozen_repository_key_tests.rs | 359 ++++++++++++++++-- .../src/migration_entrypoint_tests.rs | 10 +- ...sitory-key-migration-timestamp-mismatch.md | 70 ++++ 4 files changed, 552 insertions(+), 56 deletions(-) create mode 100644 docs/report/2026-10-10-repository-key-migration-timestamp-mismatch.md diff --git a/crates/workspace-server/src/frozen_repository_key_migration.rs b/crates/workspace-server/src/frozen_repository_key_migration.rs index fe6e2ce8a..708cd358e 100644 --- a/crates/workspace-server/src/frozen_repository_key_migration.rs +++ b/crates/workspace-server/src/frozen_repository_key_migration.rs @@ -32,7 +32,8 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source( tx.execute_batch( "CREATE TEMP TABLE repository_key_cutover ( workspace_id TEXT NOT NULL, kind TEXT NOT NULL, resource_id TEXT NOT NULL, - legacy_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));", )?; 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 { 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, legacy_counter, created], |r| { - r.get::<_, String>(0) - })? + .query_map( + params![workspace, kind, resource, legacy_counter, created], + |r| r.get::<_, String>(0), + )? .collect::>>()?; if matches.len() != 1 || matches[0].is_empty() { 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( - "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![ workspace, kind, @@ -84,11 +90,26 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source( legacy_counter, matches[0], 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 // resolve to a nonempty receipt. FK checks alone cannot prove this identity. let inconsistent: bool = tx.query_row("SELECT EXISTS ( @@ -148,8 +169,17 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source( })? .collect::>>()? }; - for (workspace, id, legacy_counter, purpose, algorithm, nonce, ciphertext, created, operation) in - secrets + for ( + workspace, + id, + legacy_counter, + purpose, + algorithm, + nonce, + ciphertext, + created, + operation, + ) in secrets { if algorithm != "aes-256-gcm-v1" { 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 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 + 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));")?; + 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, 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 @@ -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 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 + 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 - 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 WHEN e.mutation_kind IN ('credential_deleted','host_trust_deleted') THEN e.result_revision END; 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 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_counter=o.credential_revision AND c.created_at<=o.updated_at - LEFT JOIN repository_key_cutover h ON h.workspace_id=o.workspace_id AND h.kind='host_trust' AND h.resource_id=o.host_trust_id AND h.legacy_counter=o.host_trust_revision AND h.created_at<=o.updated_at; + 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 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 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_counter=c.credential_revision AND m.created_at<=o.updated_at + 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 + 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); 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))?; @@ -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("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_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);")?; @@ -360,6 +394,109 @@ BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; 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> { + if let Ok(timestamp) = chrono::DateTime::parse_from_rfc3339(value) { + return Ok(timestamp.with_timezone(&chrono::Utc)); + } + if let Ok(millis) = value.parse::() + && 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::>>()?; + 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 // are never read by credential lookup, lease construction, or a live CAS. fn frozen_repository_json_rows( diff --git a/crates/workspace-server/src/frozen_repository_key_tests.rs b/crates/workspace-server/src/frozen_repository_key_tests.rs index d8599dcdf..adeef98fe 100644 --- a/crates/workspace-server/src/frozen_repository_key_tests.rs +++ b/crates/workspace-server/src/frozen_repository_key_tests.rs @@ -32,12 +32,12 @@ fn legacy_repository_keys(path: &std::path::Path) -> Connection { let end = start + frozen[start..].find(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'); - 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,'actor','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','2026-01-01T00:00:00.000Z',NULL); + 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','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) - 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_revision_retentions VALUES('space','create-workdir',0,'key',7); CREATE TRIGGER workdir_create_insert_blocked_by_runtime_removal @@ -59,11 +59,300 @@ fn legacy_repository_keys(path: &std::path::Path) -> Connection { &mut ciphertext, ) .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 } +// 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 = 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>(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] fn repository_key_cutover_reseals_secrets_and_preserves_retention_and_audit() { let dir = tempfile::tempdir().unwrap(); @@ -150,12 +439,12 @@ fn repository_key_cutover_reseals_secrets_and_preserves_retention_and_audit() { .unwrap(); assert!( 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() ); - 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!( !conn .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 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("INSERT INTO repository_secret_operations VALUES('space','host-create','intent','host_trust','host',3,'created'); - INSERT INTO repository_secret_operations VALUES('space','host-move','intent','host_trust','host',4,'updated'); - 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_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',4,'new.example.test',2222,'ssh-ed25519','host-key','host-fingerprint','updated'); - INSERT INTO repository_secret_audit_events VALUES('space','host-audit','host_trust_rotated','host',4,'host-actor','updated'); - INSERT INTO repository_secret_operations VALUES('space','delete-gone','intent','credential','gone',5,'deleted'); - INSERT INTO repository_secret_audit_events VALUES('space','delete-audit','credential_deleted','gone',5,'delete-actor','deleted'); + 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,'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,'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','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','2026-01-02T00:00:00.000Z'); + 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,'2026-01-03T00:00:00.000Z'); + 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(); conn.execute_batch(&format!( "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"; 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; - INSERT INTO repository_secret_operations VALUES('space','delete-key','delete-intent','credential','key',7,'deleted'); - INSERT INTO repository_secret_audit_events VALUES('space','delete-audit','credential_deleted','key',7,'deleting-actor','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','2026-01-03T00:00:00.000Z'); DELETE FROM repository_ssh_credentials;").unwrap(); let original = 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", "key", 1, - "2026-01-01", + "2026-01-01T00:00:00.000Z", "credential_created", ), ( @@ -675,7 +964,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei "credential", "key", 2, - "2026-01-02", + "2026-01-02T00:00:00.000Z", "credential_rotated", ), ( @@ -684,7 +973,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei "host_trust", "host", 1, - "2026-01-01", + "2026-01-01T00:00:00.000Z", "host_trust_created", ), ( @@ -693,7 +982,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei "host_trust", "host", 2, - "2026-01-02", + "2026-01-02T00:00:00.000Z", "host_trust_rotated", ), ] { @@ -708,11 +997,11 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei ) .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()); for (legacy_counter, fingerprint, private, timestamp) in [ - (1u64, &old_fp, &old_private, "2026-01-01"), - (2, &next_fp, &next_private, "2026-01-02"), + (1u64, &old_fp, &old_private, "2026-01-01T00:00:00.000Z"), + (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(); let nonce = [legacy_counter as u8; 12]; @@ -727,10 +1016,10 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei .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 [ - (1, "old.example.test", "2026-01-01"), - (2, "new.example.test", "2026-01-02"), + (1, "old.example.test", "2026-01-01T00:00:00.000Z"), + (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(); } @@ -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 // delete receipt must remain history, not acquire proof from this new key. 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_audit_events VALUES('space','old-delete-audit','credential_deleted','key',7,'actor','2025-01-01');").unwrap(); + 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-01T00:00:00.000Z');").unwrap(); migrate_repository_keys_v86_to_v87(&conn).unwrap(); // The service consumes current receipt columns; unrelated schemas are not this fixture. rename_saved_secret_receipt_counters(&conn).unwrap(); @@ -966,8 +1255,8 @@ fn repository_key_cutover_failure_preserves_schema_ciphertext_and_connection_pra } "inconsistent_retention" => { // 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'); - INSERT INTO repository_ssh_credential_revisions VALUES('space','key',8,'ssh-ed25519','other','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','2026-01-04T00:00:00.000Z'); UPDATE workdir_create_credential_revision_retentions SET credential_revision=8;").unwrap(); } "missing_audit_receipt" => { @@ -978,12 +1267,12 @@ fn repository_key_cutover_failure_preserves_schema_ciphertext_and_connection_pra .unwrap(); } "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'); - INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',4,'example.test',22,'ssh-ed25519','key','host-fingerprint','created');").unwrap(); + 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','2026-01-01T00:00:00.000Z');").unwrap(); } "foreign_key_violation" => { 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(); } _ => {} diff --git a/crates/workspace-server/src/migration_entrypoint_tests.rs b/crates/workspace-server/src/migration_entrypoint_tests.rs index 7601633e0..d0eb2375d 100644 --- a/crates/workspace-server/src/migration_entrypoint_tests.rs +++ b/crates/workspace-server/src/migration_entrypoint_tests.rs @@ -80,10 +80,10 @@ fn migration_dry_run_and_apply_share_the_original_repository_secret_authority() 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(); + 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','2026-01-01T00:00:00.000Z',NULL); + 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','2026-01-01T00:00:00.000Z');").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( @@ -92,7 +92,7 @@ fn migration_dry_run_and_apply_share_the_original_repository_secret_authority() &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(); + 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); let master_key = dir.path().join("repository-secrets.master-key"); std::fs::write(&master_key, [42; 32]).unwrap(); diff --git a/docs/report/2026-10-10-repository-key-migration-timestamp-mismatch.md b/docs/report/2026-10-10-repository-key-migration-timestamp-mismatch.md new file mode 100644 index 000000000..3c0f314e1 --- /dev/null +++ b/docs/report/2026-10-10-repository-key-migration-timestamp-mismatch.md @@ -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の案内・検証がツール上の改善点。