lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit f281fba3266f5cb50781fab37e2da04ce4531b24
parent 139760cd726da97cc898373023d395862a9a79e0
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:
Mcrates/studio_ffi/src/commands.rs | 4++--
Acrates/studio_storage/migrations/V6__normalized_runtime_schema.sql | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/studio_storage/src/db.rs | 34+++++++++++++++++++++++++++++++++-
3 files changed, 135 insertions(+), 3 deletions(-)

diff --git a/crates/studio_ffi/src/commands.rs b/crates/studio_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/crates/studio_storage/migrations/V6__normalized_runtime_schema.sql b/crates/studio_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/crates/studio_storage/src/db.rs b/crates/studio_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");