diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SecretsSqliteConnectionFactory.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SecretsSqliteConnectionFactory.cs
new file mode 100644
index 0000000..25998ee
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SecretsSqliteConnectionFactory.cs
@@ -0,0 +1,86 @@
+using Microsoft.Data.Sqlite;
+
+namespace ZB.MOM.WW.Secrets.Sqlite;
+
+///
+/// Factory for creating and opening SQLite connections to the secret store.
+///
+public sealed class SecretsSqliteConnectionFactory
+{
+ ///
+ /// Busy timeout applied to every connection. SQLite retries a busy database for
+ /// this long before surfacing SQLITE_BUSY, so concurrent writers degrade
+ /// gracefully under load instead of failing the request path.
+ ///
+ private static readonly TimeSpan BusyTimeout = TimeSpan.FromSeconds(5);
+
+ private readonly string _sqlitePath;
+
+ /// Creates a factory targeting the database at .
+ /// Filesystem path of the SQLite database file.
+ public SecretsSqliteConnectionFactory(string sqlitePath)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(sqlitePath);
+ _sqlitePath = sqlitePath;
+ }
+
+ ///
+ /// Creates an unopened SQLite connection (Mode=ReadWriteCreate). Prefer
+ /// , which also applies WAL journaling and the
+ /// busy timeout.
+ ///
+ public SqliteConnection CreateConnection()
+ {
+ string? directory = Path.GetDirectoryName(_sqlitePath);
+
+ if (!string.IsNullOrWhiteSpace(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ SqliteConnectionStringBuilder builder = new()
+ {
+ DataSource = _sqlitePath,
+ Mode = SqliteOpenMode.ReadWriteCreate,
+ Pooling = true,
+ DefaultTimeout = (int)BusyTimeout.TotalSeconds,
+ };
+
+ return new SqliteConnection(builder.ToString());
+ }
+
+ ///
+ /// Creates a SQLite connection, opens it, and configures WAL journaling and a
+ /// non-zero busy timeout so concurrent readers and writers degrade gracefully
+ /// rather than surfacing SQLITE_BUSY as a hard failure.
+ ///
+ /// Cancellation token for the operation.
+ /// An opened and configured SQLite connection.
+ public async Task OpenConnectionAsync(CancellationToken cancellationToken)
+ {
+ SqliteConnection connection = CreateConnection();
+ try
+ {
+ await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
+ await ConfigureConnectionAsync(connection, cancellationToken).ConfigureAwait(false);
+ return connection;
+ }
+ catch
+ {
+ await connection.DisposeAsync().ConfigureAwait(false);
+ throw;
+ }
+ }
+
+ private static async Task ConfigureConnectionAsync(
+ SqliteConnection connection,
+ CancellationToken cancellationToken)
+ {
+ // WAL is a persistent, database-level setting; re-applying it per connection
+ // is cheap and a no-op once set. busy_timeout is per-connection state.
+ await using SqliteCommand command = connection.CreateCommand();
+ command.CommandText =
+ $"PRAGMA journal_mode=WAL; PRAGMA busy_timeout={(int)BusyTimeout.TotalMilliseconds};";
+ await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretsSchema.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretsSchema.cs
new file mode 100644
index 0000000..4443c57
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretsSchema.cs
@@ -0,0 +1,59 @@
+namespace ZB.MOM.WW.Secrets.Sqlite;
+
+///
+/// Schema constants and table DDL for the secret SQLite store.
+///
+public static class SqliteSecretsSchema
+{
+ ///
+ /// The schema version this build creates and supports. This is the first version, so all DDL is a
+ /// single-shot CREATE ... IF NOT EXISTS. The migrator refuses an on-disk version
+ /// newer than this. Future additive columns (nullable ALTER TABLEs, gated by a
+ /// PRAGMA table_info existence probe) would bump this and register a forward migration.
+ ///
+ public const int CurrentVersion = 1;
+
+ /// Name of the single-row table tracking the applied schema version.
+ public const string SchemaVersionTable = "schema_version";
+
+ /// Name of the table storing envelope-encrypted secret records.
+ public const string SecretTable = "secret";
+
+ /// DDL creating the single-row schema-version table.
+ public const string CreateSchemaVersionTable = """
+ CREATE TABLE IF NOT EXISTS schema_version (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ version INTEGER NOT NULL,
+ applied_utc TEXT NOT NULL
+ );
+ """;
+
+ /// DDL creating the envelope-encrypted secret record table.
+ public const string CreateSecretTable = """
+ CREATE TABLE IF NOT EXISTS secret (
+ name TEXT PRIMARY KEY,
+ description TEXT NULL,
+ content_type TEXT NOT NULL,
+ ciphertext BLOB NOT NULL,
+ nonce BLOB NOT NULL,
+ tag BLOB NOT NULL,
+ wrapped_dek BLOB NOT NULL,
+ wrap_nonce BLOB NOT NULL,
+ wrap_tag BLOB NOT NULL,
+ kek_id TEXT NOT NULL,
+ revision INTEGER NOT NULL DEFAULT 0,
+ is_deleted INTEGER NOT NULL DEFAULT 0,
+ deleted_utc TEXT NULL,
+ created_utc TEXT NOT NULL,
+ updated_utc TEXT NOT NULL,
+ created_by TEXT NULL,
+ updated_by TEXT NULL
+ );
+ """;
+
+ /// DDL creating supporting indexes (idempotent).
+ public const string CreateIndexes = """
+ CREATE INDEX IF NOT EXISTS ix_secret_updated_utc
+ ON secret (updated_utc);
+ """;
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretsStoreMigrator.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretsStoreMigrator.cs
new file mode 100644
index 0000000..1198f3e
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretsStoreMigrator.cs
@@ -0,0 +1,138 @@
+using System.Globalization;
+using Microsoft.Data.Sqlite;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.Sqlite;
+
+///
+/// Creates the secret store schema and records the applied version. Idempotent: it is
+/// safe to run repeatedly. Refuses to run against a database whose on-disk version is
+/// newer than this build supports.
+///
+public sealed class SqliteSecretsStoreMigrator(SecretsSqliteConnectionFactory connectionFactory)
+{
+ /// Applies the schema migration to the secret store.
+ /// Cancellation token.
+ ///
+ /// The on-disk schema version is newer than .
+ ///
+ public async Task MigrateAsync(CancellationToken cancellationToken)
+ {
+ await using SqliteConnection connection =
+ await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
+
+ await using SqliteTransaction transaction =
+ (SqliteTransaction)await connection.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, cancellationToken).ConfigureAwait(false);
+
+ int existingVersion =
+ await ReadExistingSchemaVersionAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
+
+ if (existingVersion > SqliteSecretsSchema.CurrentVersion)
+ {
+ throw new SecretStoreMigrationException(
+ $"Secret database schema version {existingVersion} is newer than supported version {SqliteSecretsSchema.CurrentVersion}.");
+ }
+
+ await ApplySchemaAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
+ await WriteSchemaVersionAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
+
+ await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ private static async Task ReadExistingSchemaVersionAsync(
+ SqliteConnection connection,
+ SqliteTransaction transaction,
+ CancellationToken cancellationToken)
+ {
+ await using SqliteCommand tableExistsCommand = connection.CreateCommand();
+ tableExistsCommand.Transaction = transaction;
+ tableExistsCommand.CommandText = """
+ SELECT COUNT(*)
+ FROM sqlite_master
+ WHERE type = 'table' AND name = $table_name;
+ """;
+ tableExistsCommand.Parameters.AddWithValue("$table_name", SqliteSecretsSchema.SchemaVersionTable);
+
+ long tableCount =
+ (long)(await tableExistsCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false) ?? 0L);
+
+ if (tableCount == 0)
+ {
+ return 0;
+ }
+
+ await using SqliteCommand versionCommand = connection.CreateCommand();
+ versionCommand.Transaction = transaction;
+ versionCommand.CommandText = """
+ SELECT version
+ FROM schema_version
+ WHERE id = 1;
+ """;
+
+ object? version = await versionCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
+
+ return version is null || version == DBNull.Value
+ ? 0
+ : Convert.ToInt32(version, CultureInfo.InvariantCulture);
+ }
+
+ // Single-shot create of the final schema (all DDL is CREATE ... IF NOT EXISTS, so it is idempotent
+ // against an already-provisioned database). The applied version is stamped separately by
+ // WriteSchemaVersionAsync.
+ //
+ // Forward-migration hook: when CurrentVersion advances past 1, any additive column added to the
+ // secret table must ALSO be applied here to pre-existing databases via an idempotent
+ // "ALTER TABLE secret ADD COLUMN ..." guarded by a `PRAGMA table_info(secret)` column-existence
+ // probe (SQLite has no "ADD COLUMN IF NOT EXISTS"). Fully consume and dispose the PRAGMA reader
+ // before issuing the ALTER on the same connection/transaction. See the sibling Auth migrator
+ // (SqliteAuthStoreMigrator.EnsureApiKeysColumnAsync) for the proven pattern. Schema v1 has no
+ // additive ALTERs, so the CREATE above is the whole migration.
+ private static async Task ApplySchemaAsync(
+ SqliteConnection connection,
+ SqliteTransaction transaction,
+ CancellationToken cancellationToken)
+ {
+ await ExecuteNonQueryAsync(
+ connection,
+ transaction,
+ string.Join(
+ "\n",
+ SqliteSecretsSchema.CreateSchemaVersionTable,
+ SqliteSecretsSchema.CreateSecretTable,
+ SqliteSecretsSchema.CreateIndexes),
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ private static async Task WriteSchemaVersionAsync(
+ SqliteConnection connection,
+ SqliteTransaction transaction,
+ CancellationToken cancellationToken)
+ {
+ await using SqliteCommand versionCommand = connection.CreateCommand();
+ versionCommand.Transaction = transaction;
+ versionCommand.CommandText = """
+ INSERT INTO schema_version (id, version, applied_utc)
+ VALUES (1, $version, $applied_utc)
+ ON CONFLICT(id) DO UPDATE SET
+ version = excluded.version,
+ applied_utc = excluded.applied_utc;
+ """;
+ versionCommand.Parameters.AddWithValue("$version", SqliteSecretsSchema.CurrentVersion);
+ versionCommand.Parameters.AddWithValue("$applied_utc", DateTimeOffset.UtcNow.ToString("O"));
+
+ await versionCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ private static async Task ExecuteNonQueryAsync(
+ SqliteConnection connection,
+ SqliteTransaction transaction,
+ string commandText,
+ CancellationToken cancellationToken)
+ {
+ await using SqliteCommand command = connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = commandText;
+
+ await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Sqlite/SqliteSecretsStoreMigratorTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Sqlite/SqliteSecretsStoreMigratorTests.cs
new file mode 100644
index 0000000..c11672f
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Sqlite/SqliteSecretsStoreMigratorTests.cs
@@ -0,0 +1,127 @@
+using Microsoft.Data.Sqlite;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.Sqlite;
+
+namespace ZB.MOM.WW.Secrets.Tests.Sqlite;
+
+public sealed class SqliteSecretsStoreMigratorTests : IDisposable
+{
+ private readonly string _dbPath =
+ Path.Combine(Path.GetTempPath(), $"zb-secrets-migrator-{Guid.NewGuid():N}.db");
+
+ [Fact]
+ public async Task Migrate_IsIdempotent()
+ {
+ var factory = new SecretsSqliteConnectionFactory(_dbPath);
+ var migrator = new SqliteSecretsStoreMigrator(factory);
+
+ // Running twice must not throw and must leave the schema version at CurrentVersion.
+ await migrator.MigrateAsync(CancellationToken.None);
+ await migrator.MigrateAsync(CancellationToken.None);
+
+ long version = await ReadSchemaVersionAsync(factory);
+ Assert.Equal(SqliteSecretsSchema.CurrentVersion, version);
+ Assert.Equal(1, version);
+ }
+
+ [Fact]
+ public async Task Migrate_CreatesSecretTableWithExpectedColumns()
+ {
+ var factory = new SecretsSqliteConnectionFactory(_dbPath);
+ var migrator = new SqliteSecretsStoreMigrator(factory);
+
+ await migrator.MigrateAsync(CancellationToken.None);
+
+ HashSet columns = await ReadSecretColumnsAsync(factory);
+
+ string[] expected =
+ [
+ "name", "description", "content_type", "ciphertext", "nonce", "tag",
+ "wrapped_dek", "wrap_nonce", "wrap_tag", "kek_id", "revision",
+ "is_deleted", "deleted_utc", "created_utc", "updated_utc",
+ "created_by", "updated_by",
+ ];
+
+ Assert.Equal(17, expected.Length);
+ Assert.Equal(17, columns.Count);
+ foreach (string column in expected)
+ {
+ Assert.Contains(column, columns);
+ }
+ }
+
+ [Fact]
+ public async Task Migrate_RefusesNewerSchema()
+ {
+ var factory = new SecretsSqliteConnectionFactory(_dbPath);
+
+ // Manually stamp an on-disk version newer than this build supports.
+ await using (SqliteConnection connection = await factory.OpenConnectionAsync(CancellationToken.None))
+ {
+ await using SqliteCommand create = connection.CreateCommand();
+ create.CommandText = SqliteSecretsSchema.CreateSchemaVersionTable;
+ await create.ExecuteNonQueryAsync();
+
+ await using SqliteCommand insert = connection.CreateCommand();
+ insert.CommandText = """
+ INSERT INTO schema_version (id, version, applied_utc)
+ VALUES (1, $version, $applied_utc);
+ """;
+ insert.Parameters.AddWithValue("$version", 999);
+ insert.Parameters.AddWithValue("$applied_utc", DateTimeOffset.UtcNow.ToString("O"));
+ await insert.ExecuteNonQueryAsync();
+ }
+
+ var migrator = new SqliteSecretsStoreMigrator(factory);
+
+ await Assert.ThrowsAsync(
+ () => migrator.MigrateAsync(CancellationToken.None));
+ }
+
+ private static async Task ReadSchemaVersionAsync(SecretsSqliteConnectionFactory factory)
+ {
+ await using SqliteConnection connection = await factory.OpenConnectionAsync(CancellationToken.None);
+ await using SqliteCommand command = connection.CreateCommand();
+ command.CommandText = "SELECT version FROM schema_version WHERE id = 1;";
+ object? result = await command.ExecuteScalarAsync();
+ return Convert.ToInt64(result);
+ }
+
+ private static async Task> ReadSecretColumnsAsync(SecretsSqliteConnectionFactory factory)
+ {
+ await using SqliteConnection connection = await factory.OpenConnectionAsync(CancellationToken.None);
+ await using SqliteCommand command = connection.CreateCommand();
+ command.CommandText = $"PRAGMA table_info({SqliteSecretsSchema.SecretTable});";
+
+ var columns = new HashSet(StringComparer.Ordinal);
+ await using SqliteDataReader reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ // PRAGMA table_info columns: cid(0), name(1), type(2), ...
+ columns.Add(reader.GetString(1));
+ }
+
+ return columns;
+ }
+
+ public void Dispose()
+ {
+ // Drop pooled connections so the WAL/-shm/-wal sidecars release before we delete.
+ SqliteConnection.ClearAllPools();
+
+ foreach (string path in new[] { _dbPath, _dbPath + "-wal", _dbPath + "-shm" })
+ {
+ try
+ {
+ if (File.Exists(path))
+ {
+ File.Delete(path);
+ }
+ }
+ catch (IOException)
+ {
+ // Best-effort temp cleanup; a leaked temp file is not a test failure.
+ }
+ }
+ }
+}