commit 7b3825d12fe9240e0d92232f8d09c51e8174cd26
parent 5761cd749ba19100ef522eb80b6a9d5659d79cbe
Author: triesap <tyson@radroots.org>
Date: Mon, 3 Aug 2026 22:42:19 +0000
storage: add normalized strict runtime schema
- define canonical account identity and local binding tables
- constrain foreground selection session and binding relationships
- introduce strict profile and durable-operation storage surfaces
- verify strict-table inventory and same-account binding enforcement
Diffstat:
3 files changed, 135 insertions(+), 3 deletions(-)
diff --git a/core/crates/ffi/src/commands.rs b/core/crates/ffi/src/commands.rs
@@ -385,7 +385,7 @@ mod tests {
}
#[test]
- fn v5_compatibility_fixture_matches_runtime_coordinates() {
+ fn v5_compatibility_fixture_preserves_external_coordinates() {
let fixture = include_str!("../../../compatibility/v5-baseline.properties");
let property = |key: &str| {
fixture.lines().find_map(|line| {
@@ -397,7 +397,7 @@ mod tests {
assert_eq!(property("baseline.id"), Some("studio-runtime-v5"));
assert_eq!(property("schema.version"), Some("5"));
- assert_eq!(CURRENT_SCHEMA_VERSION, 5);
+ assert_eq!(CURRENT_SCHEMA_VERSION, 6);
assert_eq!(property("ffi.contract"), Some("legacy-unversioned-v1"));
assert_eq!(property("ffi.snapshot.schema"), Some("1"));
assert_eq!(property("ffi.runtime.version"), Some("0.1.0-alpha"));
diff --git a/core/crates/storage/migrations/V6__normalized_runtime_schema.sql b/core/crates/storage/migrations/V6__normalized_runtime_schema.sql
@@ -0,0 +1,100 @@
+CREATE TABLE account_identities (
+ public_key TEXT PRIMARY KEY NOT NULL CHECK (
+ length(public_key) = 64 AND public_key = lower(public_key)
+ ),
+ npub TEXT NOT NULL UNIQUE CHECK (length(npub) = 63),
+ label TEXT CHECK (label IS NULL OR length(label) BETWEEN 1 AND 80),
+ created_at INTEGER NOT NULL CHECK (created_at >= 0),
+ last_used_at INTEGER CHECK (last_used_at IS NULL OR last_used_at >= 0)
+) STRICT;
+
+CREATE TABLE local_signer_bindings (
+ account_public_key TEXT NOT NULL,
+ binding_public_key TEXT NOT NULL,
+ binding_kind TEXT NOT NULL CHECK (binding_kind = 'local_secret'),
+ availability TEXT NOT NULL CHECK (
+ availability IN ('available', 'credential_missing', 'store_unavailable')
+ ),
+ PRIMARY KEY (account_public_key, binding_public_key),
+ UNIQUE (account_public_key, binding_kind),
+ FOREIGN KEY (account_public_key) REFERENCES account_identities(public_key) ON DELETE CASCADE,
+ CHECK (account_public_key = binding_public_key)
+) STRICT;
+
+CREATE TABLE runtime_state (
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
+ selected_public_key TEXT REFERENCES account_identities(public_key) ON DELETE SET NULL,
+ active_account_public_key TEXT,
+ active_binding_public_key TEXT,
+ session_generation INTEGER NOT NULL DEFAULT 0 CHECK (session_generation >= 0),
+ FOREIGN KEY (active_account_public_key, active_binding_public_key)
+ REFERENCES local_signer_bindings(account_public_key, binding_public_key)
+ ON DELETE SET NULL,
+ CHECK (
+ (active_account_public_key IS NULL AND active_binding_public_key IS NULL)
+ OR
+ (active_account_public_key IS NOT NULL AND active_binding_public_key IS NOT NULL)
+ )
+) STRICT;
+
+INSERT INTO runtime_state (singleton) VALUES (1);
+
+CREATE TABLE profile_cache_v6 (
+ subject_public_key TEXT PRIMARY KEY NOT NULL
+ REFERENCES account_identities(public_key) ON DELETE CASCADE,
+ event_id TEXT NOT NULL CHECK (length(event_id) = 64 AND event_id = lower(event_id)),
+ event_created_at INTEGER NOT NULL CHECK (event_created_at >= 0),
+ name TEXT,
+ display_name TEXT,
+ nip05 TEXT,
+ about TEXT,
+ picture TEXT,
+ refreshed_at INTEGER NOT NULL CHECK (refreshed_at >= 0),
+ refresh_status TEXT NOT NULL CHECK (
+ refresh_status IN ('success', 'offline', 'invalid_data')
+ )
+) STRICT;
+
+CREATE TABLE durable_operations (
+ request_id TEXT PRIMARY KEY NOT NULL CHECK (length(request_id) BETWEEN 1 AND 128),
+ operation_kind TEXT NOT NULL CHECK (
+ operation_kind IN ('create', 'import', 'repair', 'remove')
+ ),
+ account_public_key TEXT NOT NULL CHECK (
+ length(account_public_key) = 64 AND account_public_key = lower(account_public_key)
+ ),
+ binding_public_key TEXT NOT NULL CHECK (binding_public_key = account_public_key),
+ expected_revision INTEGER CHECK (expected_revision IS NULL OR expected_revision >= 0),
+ phase TEXT NOT NULL CHECK (
+ phase IN (
+ 'intent_recorded',
+ 'credential_written',
+ 'metadata_committed',
+ 'selection_committed',
+ 'compensation_pending',
+ 'credential_deleted',
+ 'metadata_deleted',
+ 'finalized'
+ )
+ ),
+ terminal_outcome TEXT CHECK (
+ terminal_outcome IS NULL OR terminal_outcome IN ('completed', 'cancelled', 'failed')
+ ),
+ prior_selected_public_key TEXT,
+ updated_at INTEGER NOT NULL CHECK (updated_at >= 0),
+ diagnostic_code TEXT CHECK (
+ diagnostic_code IS NULL OR diagnostic_code IN (
+ 'storage_unavailable',
+ 'keyring_unavailable',
+ 'credential_missing',
+ 'compensation_failed',
+ 'conflict',
+ 'expired'
+ )
+ ),
+ CHECK (
+ (phase = 'finalized' AND terminal_outcome IS NOT NULL)
+ OR
+ (phase <> 'finalized' AND terminal_outcome IS NULL)
+ )
+) STRICT;
diff --git a/core/crates/storage/src/db.rs b/core/crates/storage/src/db.rs
@@ -9,7 +9,7 @@ use radroots_studio_domain::{SafeError, SafeErrorCode, SafeMessage};
use refinery::embed_migrations;
use rusqlite::{Connection, OpenFlags};
-pub const CURRENT_SCHEMA_VERSION: u32 = 5;
+pub const CURRENT_SCHEMA_VERSION: u32 = 6;
mod migrations {
use super::embed_migrations;
@@ -269,6 +269,38 @@ mod tests {
}
#[test]
+ fn normalized_schema_is_strict_and_enforces_same_account_bindings() {
+ let database = Database::in_memory().expect("open memory database");
+ let connection = database.connection();
+ let strict_tables: i64 = connection
+ .query_row(
+ "SELECT COUNT(*) FROM pragma_table_list WHERE name IN ('account_identities', 'local_signer_bindings', 'runtime_state', 'profile_cache_v6', 'durable_operations') AND strict = 1",
+ [],
+ |row| row.get(0),
+ )
+ .expect("strict table inventory");
+ assert_eq!(strict_tables, 5);
+
+ connection
+ .execute(
+ "INSERT INTO account_identities (public_key, npub, created_at) VALUES (?1, ?2, 1)",
+ [
+ "07".repeat(32),
+ "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7".to_owned(),
+ ],
+ )
+ .expect("identity");
+ assert!(
+ connection
+ .execute(
+ "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, binding_kind, availability) VALUES (?1, ?2, 'local_secret', 'available')",
+ ["07".repeat(32), "08".repeat(32)],
+ )
+ .is_err()
+ );
+ }
+
+ #[test]
fn migration_persists_schema_version_across_file_reopen() {
let directory = tempdir().expect("temporary directory");
let path = directory.path().join("studio.sqlite3");