Skip to content

Research: Obsidian Storage Provider #44

@sirkirby

Description

@sirkirby

Obsidian Storage Provider - Research & Proposed Architecture Plan

Establish a specification to build support for Obsidian and other storage providers, including migration of existing default storage into this new model.

Executive Summary

This document outlines the research, architectural design, and proposed constitutional amendments for adding pluggable storage provider support to Ten Second Tom, with Obsidian vault integration as the first alternative provider.

Important: This is a planning document. The constitution has NOT been amended yet. The proposed Principle IX is documented here for review and will be formally added to the constitution if this feature is approved for implementation.

Key Objectives

  1. Refactor current hard-coded file system storage into a provider-based architecture
  2. Introduce IStorageProvider abstraction with automatic discovery via assembly scanning
  3. Implement two initial providers:
    • DefaultStorageProvider (current behavior, TST-native structure)
    • ObsidianStorageProvider (Obsidian vault-compatible structure)
  4. Extend setup wizard to allow users to select storage providers
  5. Enable future extensibility for additional providers (Notion, Joplin, LogSeq, etc.)

Research: Obsidian Vault Structure

Obsidian Vault Characteristics (from user screenshot)

CK/                              # Vault root
├── .obsidian/                   # App configuration (like TST's config/)
│   ├── app.json
│   ├── workspace.json
│   └── ...
├── 2025-10-28.md               # Daily notes (root level)
├── app-ideas/                   # Custom folders
│   ├── Photos.md
├── Recording_*.m4a          # Media files co-located
│   └── ...
└── ten-second-tom/              # Example TST subfolder
    ├── Obsidian Integration.md
    ├── Security Ideas.md
├── Welcome.md

Key Insights:

  1. File-based Markdown: Just like TST (compatibility ✓)
  2. Flat or hierarchical: Supports both folder structures
  3. YAML frontmatter: Obsidian natively supports frontmatter (compatibility ✓)
  4. Automatic discovery: Files added outside Obsidian appear automatically
  5. ⚠️ Naming flexibility: Obsidian allows arbitrary file naming and organization
  6. 🔧 Bidirectional sync: Changes in either app reflect in both

TST Current Structure (from codebase analysis)

/Users/chris/ten-second-tom/                         # Root (configurable via StorageOptions.MemoryDirectory)
├── config/
│   └── config.json              # App configuration
├── templates/                   # Prompt templates
│   ├── daily-entry.md
│   └── weekly-review.md
├── today/                       # Daily entries
│   └── 2025/
│       └── 10/
│           └── today-10-28-2025-1.md
├── thisweek/                    # Weekly entries
│   └── 2025/
│       └── week-43/
│           └── thisweek-week-43-2025-1.md
└── generate/                    # Generated outputs
    └── 2025/
        └── 10/
            └── 28/
                └── generate-10-28-2025-1.md

Current Issues:

  • MemoryDirectory is both root AND storage location (conflated concerns)
  • config.json sits inside memory directory (configuration ⊂ data)
  • Hard-coded in FileSystemStorageProvider (no abstraction)
  • StorageOptions directly consumed (tight coupling)

Architectural Design

1. Storage Provider Abstraction

IStorageProvider Interface (extends IMemoryStorageProvider)

namespace TenSecondTom.Infrastructure.Storage;

/// <summary>
/// Defines a pluggable storage provider for memory entries.
/// Providers are automatically discovered via assembly scanning.
/// </summary>
public interface IStorageProvider : IMemoryStorageProvider
{
    /// <summary>
    /// Unique identifier for this storage provider (e.g., "default", "obsidian").
    /// Used in configuration to select the active provider.
    /// </summary>
    string ProviderId { get; }

    /// <summary>
    /// Human-readable display name for the provider (e.g., "Default TST Storage", "Obsidian Vault").
    /// </summary>
    string DisplayName { get; }

    /// <summary>
    /// Description of the provider for user selection during setup.
    /// </summary>
    string Description { get; }

    /// <summary>
    /// Initialize the provider with configuration options.
    /// Called after provider selection during setup.
    /// </summary>
    /// <param name="options">Storage configuration options.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Result indicating success or failure with error message.</returns>
    Task<Result> InitializeAsync(StorageOptions options, CancellationToken cancellationToken);

    /// <summary>
    /// Validates the provider-specific configuration.
    /// Called during setup to ensure the storage location is valid.
    /// </summary>
    /// <param name="options">Storage configuration options to validate.</param>
    /// <returns>Result indicating whether configuration is valid.</returns>
    Task<Result> ValidateConfigurationAsync(StorageOptions options);
}

Storage Provider Discovery (Assembly Scanning)

namespace TenSecondTom.Infrastructure.Storage;

/// <summary>
/// Factory for creating and managing storage providers.
/// Automatically discovers providers via assembly scanning.
/// </summary>
public sealed class StorageProviderFactory
{
    private readonly IServiceProvider _serviceProvider;
    private readonly ILogger<StorageProviderFactory> _logger;
    private readonly Dictionary<string, Type> _providers;

    public StorageProviderFactory(IServiceProvider serviceProvider, ILogger<StorageProviderFactory> logger)
    {
        _serviceProvider = serviceProvider;
        _logger = logger;
        _providers = DiscoverProviders();
    }

    /// <summary>
    /// Discovers all IStorageProvider implementations via assembly scanning.
    /// </summary>
    private Dictionary<string, Type> DiscoverProviders()
    {
        var providers = new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase);
        var assembly = typeof(ServiceCollectionExtensions).Assembly;

        var providerTypes = assembly.GetTypes()
            .Where(t => typeof(IStorageProvider).IsAssignableFrom(t)
                     && !t.IsInterface
                     && !t.IsAbstract)
            .ToList();

        foreach (var type in providerTypes)
        {
            // Instantiate to get ProviderId (providers must have parameterless constructor for discovery)
            var instance = (IStorageProvider)Activator.CreateInstance(type)!;
            providers[instance.ProviderId] = type;
            _logger.LogInformation("Discovered storage provider: {ProviderId} ({TypeName})",
                instance.ProviderId, type.Name);
        }

        return providers;
    }

    /// <summary>
    /// Gets all available storage providers for user selection.
    /// </summary>
    public IReadOnlyList<IStorageProvider> GetAvailableProviders()
    {
        return _providers.Values
            .Select(t => (IStorageProvider)ActivatorUtilities.CreateInstance(_serviceProvider, t))
            .ToList();
    }

    /// <summary>
    /// Creates a storage provider instance by ID.
    /// </summary>
    public IStorageProvider CreateProvider(string providerId)
    {
        if (!_providers.TryGetValue(providerId, out var providerType))
        {
            throw new InvalidOperationException($"Storage provider '{providerId}' not found.");
        }

        return (IStorageProvider)ActivatorUtilities.CreateInstance(_serviceProvider, providerType);
    }
}

2. Configuration Refactoring

Current Problem

// ❌ Current: MemoryDirectory is conflated with storage root
public sealed class StorageOptions
{
    public string MemoryDirectory { get; set; } = string.Empty; // Both root AND storage location but now called RootDirectory for clarity
    public RetentionPolicy RetentionPolicy { get; set; }
    // ...
}

Proposed Solution

namespace TenSecondTom.Shared.Options;

/// <summary>
/// Configuration options for storage providers and data persistence.
/// Maps to the "TenSecondTom:Storage" configuration section.
/// </summary>
public sealed class StorageOptions
{
    public const string SectionName = "TenSecondTom:Storage";

    /// <summary>
    /// The selected storage provider ID (e.g., "default", "obsidian").
    /// Determines which IStorageProvider implementation to use.
    /// Default: "default" (TST-native storage).
    /// </summary>
    public string ProviderId { get; set; } = "default";

    /// <summary>
    /// Root directory for all TST data (config, templates, AND memory storage).
    /// For Obsidian provider: path to Obsidian vault root
    /// Environment variable: TenSecondTom__Storage__RootDirectory
    /// </summary>
    public string RootDirectory { get; set; } = string.Empty;

    /// <summary>
    /// Provider-specific subdirectory for memory entries (optional).
    /// For default provider: "" (root level organization: today/, thisweek/, etc.)
    /// For Obsidian provider: "ten-second-tom/" (subfolder in vault)
    /// </summary>
    public string? MemorySubdirectory { get; set; }

    /// <summary>
    /// Retention policy for memory entries.
    /// </summary>
    public RetentionPolicy RetentionPolicy { get; set; } = RetentionPolicy.Indefinite;

    /// <summary>
    /// Whether to automatically purge entries based on retention policy.
    /// </summary>
    public bool AutoPurge { get; set; }

    /// <summary>
    /// Optional maximum file size in bytes for memory entries.
    /// </summary>
    public long? MaxFileSizeBytes { get; set; }

    /// <summary>
    /// Whether to compress memory entries.
    /// </summary>
    public bool CompressionEnabled { get; set; }
}

3. Storage Provider Implementations

DefaultStorageProvider (TST-native)

namespace TenSecondTom.Infrastructure.Storage.Providers;

/// <summary>
/// Default Ten Second Tom storage provider using TST-native directory structure.
/// Maintains backward compatibility with existing installations.
/// </summary>
public sealed class DefaultStorageProvider : IStorageProvider
{
    public string ProviderId => "default";
    public string DisplayName => "Default TST Storage";
    public string Description => "Ten Second Tom's native storage format with hierarchical date folders.";

    private FileSystemStorageProvider? _implementation;

    // IMemoryStorageProvider implementation delegates to FileSystemStorageProvider
    // (existing implementation becomes the "default" provider's logic)

    public async Task<Result> InitializeAsync(StorageOptions options, CancellationToken cancellationToken)
    {
        // Validate directory structure
        string rootDir = options.RootDirectory;
        if (!Directory.Exists(rootDir))
        {
            Directory.CreateDirectory(rootDir);
        }

        // Ensure subdirectories exist (today/, thisweek/, generate/, templates/, config/)
        string[] subdirs = ["today", "thisweek", "generate", "templates", "config"];
        foreach (var subdir in subdirs)
        {
            string path = Path.Combine(rootDir, subdir);
            if (!Directory.Exists(path))
            {
                Directory.CreateDirectory(path);
            }
        }

        _implementation = new FileSystemStorageProvider(rootDir, logger);
        return Result.Success();
    }

    public Task<Result> ValidateConfigurationAsync(StorageOptions options)
    {
        // Validate that RootDirectory is writable
        try
        {
            string testFile = Path.Combine(options.RootDirectory, ".write-test");
            File.WriteAllText(testFile, "test");
            File.Delete(testFile);
            return Task.FromResult(Result.Success());
        }
        catch (Exception ex)
        {
            return Task.FromResult(Result.Failure($"Directory is not writable: {ex.Message}"));
        }
    }
}

ObsidianStorageProvider

namespace TenSecondTom.Infrastructure.Storage.Providers;

/// <summary>
/// Obsidian vault storage provider.
/// Stores memory entries in an Obsidian vault with optional subdirectory organization.
/// </summary>
public sealed class ObsidianStorageProvider : IStorageProvider
{
    public string ProviderId => "obsidian";
    public string DisplayName => "Obsidian Vault";
    public string Description => "Store entries in an Obsidian vault for seamless note integration.";

    public async Task<Result> InitializeAsync(StorageOptions options, CancellationToken cancellationToken)
    {
        string vaultRoot = options.RootDirectory;

        // Validate Obsidian vault structure
        string obsidianDir = Path.Combine(vaultRoot, ".obsidian");
        if (!Directory.Exists(obsidianDir))
        {
            return Result.Failure($"Not a valid Obsidian vault: .obsidian directory not found at {vaultRoot}");
        }

        // Create TST subdirectory if specified
        string memoryDir = string.IsNullOrEmpty(options.MemorySubdirectory)
            ? vaultRoot
            : Path.Combine(vaultRoot, options.MemorySubdirectory);

        if (!Directory.Exists(memoryDir))
        {
            Directory.CreateDirectory(memoryDir);
        }

        // Create organizational folders (Obsidian-friendly naming)
        string[] subdirs = ["Daily Notes", "Weekly Reviews", "Generated"]; // More Obsidian-like naming
        foreach (var subdir in subdirs)
        {
            string path = Path.Combine(memoryDir, subdir);
            if (!Directory.Exists(path))
            {
                Directory.CreateDirectory(path);
            }
        }

        return Result.Success();
    }

    public Task<Result> ValidateConfigurationAsync(StorageOptions options)
    {
        string vaultRoot = options.RootDirectory;

        // Check if directory exists
        if (!Directory.Exists(vaultRoot))
        {
            return Task.FromResult(Result.Failure($"Vault directory does not exist: {vaultRoot}"));
        }

        // Check for .obsidian directory
        string obsidianDir = Path.Combine(vaultRoot, ".obsidian");
        if (!Directory.Exists(obsidianDir))
        {
            return Task.FromResult(Result.Failure($"Not a valid Obsidian vault: .obsidian directory not found"));
        }

        // Check write permissions
        try
        {
            string testFile = Path.Combine(vaultRoot, ".tst-write-test");
            File.WriteAllText(testFile, "test");
            File.Delete(testFile);
            return Task.FromResult(Result.Success());
        }
        catch (Exception ex)
        {
            return Task.FromResult(Result.Failure($"Vault is not writable: {ex.Message}"));
        }
    }

    // File path generation uses Obsidian-friendly structure
    // E.g., "Daily Notes/2025-10-28 Entry 1.md" instead of "today/2025/10/today-10-28-2025-1.md"
}

4. Setup Wizard Integration

Updated SetupCommandHandler Flow

// Step 4: Storage Provider Selection (NEW)
_wizardUI.ShowStepHeader(4, 9, "Storage Provider Selection");

var availableProviders = _storageProviderFactory.GetAvailableProviders();
var selectedProvider = await _wizardUI.PromptForStorageProviderAsync(
    availableProviders,
    command.ExistingConfiguration?.Storage.ProviderId,
    cancellationToken);

// Step 5: Storage Location Configuration (MODIFIED)
_wizardUI.ShowStepHeader(5, 9, $"{selectedProvider.DisplayName} Location");

string rootDirectory;
if (selectedProvider.ProviderId == "obsidian")
{
    rootDirectory = await _wizardUI.PromptForObsidianVaultPathAsync(
        command.ExistingConfiguration?.Storage.RootDirectory,
        cancellationToken);

    // Validate Obsidian vault
    var validationResult = await selectedProvider.ValidateConfigurationAsync(
        new StorageOptions { RootDirectory = rootDirectory },
        cancellationToken);

    if (!validationResult.IsSuccess)
    {
        _wizardUI.ShowError($"Invalid Obsidian vault: {validationResult.Error}");
        return Result<ConfigurationSettings>.Failure(validationResult.Error);
    }

    // Optional: Ask for subdirectory
    var useSubdirectory = await _wizardUI.PromptYesNoAsync(
        "Would you like to store TST entries in a subdirectory (e.g., 'ten-second-tom/')?",
        defaultYes: true,
        cancellationToken);

    string? subdirectory = null;
    if (useSubdirectory)
    {
        subdirectory = await _wizardUI.PromptForSubdirectoryAsync(
            defaultValue: "ten-second-tom",
            cancellationToken);
    }
}
else // default provider
{
    rootDirectory = await _wizardUI.PromptForMemoryDirectoryAsync(
        command.ExistingConfiguration?.Storage.RootDirectory,
        cancellationToken);
}

ISetupWizardUI Extensions

/// <summary>
/// Prompts user to select a storage provider.
/// </summary>
Task<IStorageProvider> PromptForStorageProviderAsync(
    IReadOnlyList<IStorageProvider> availableProviders,
    string? currentProviderId,
    CancellationToken cancellationToken);

/// <summary>
/// Prompts user for Obsidian vault path with validation.
/// </summary>
Task<string> PromptForObsidianVaultPathAsync(
    string? currentPath,
    CancellationToken cancellationToken);

/// <summary>
/// Prompts user for a subdirectory within the storage root.
/// </summary>
Task<string?> PromptForSubdirectoryAsync(
    string defaultValue,
    CancellationToken cancellationToken);

5. Dependency Injection Registration

// In ServiceCollectionExtensions.cs

public static IServiceCollection AddStorageServices(this IServiceCollection services, IConfiguration configuration)
{
    // Register Options Pattern
    services.Configure<StorageOptions>(configuration.GetSection(StorageOptions.SectionName));
    services.AddSingleton<IValidateOptions<StorageOptions>, StorageOptionsValidator>();

    // Register storage provider factory
    services.AddSingleton<StorageProviderFactory>();

    // Register active storage provider (resolved at runtime based on configuration)
    services.AddSingleton<IStorageProvider>(sp =>
    {
        var options = sp.GetRequiredService<IOptions<StorageOptions>>().Value;
        var factory = sp.GetRequiredService<StorageProviderFactory>();

        var provider = factory.CreateProvider(options.ProviderId);

        // Initialize provider synchronously (or make this async with a hosted service)
        provider.InitializeAsync(options, CancellationToken.None)
            .GetAwaiter()
            .GetResult();

        return provider;
    });

    // Backward compatibility: IMemoryStorageProvider resolves to active IStorageProvider
    services.AddSingleton<IMemoryStorageProvider>(sp => sp.GetRequiredService<IStorageProvider>());

    return services;
}

Proposed Constitution Amendment (For Future Consideration)

Note: This section documents the proposed constitutional changes that SHOULD be considered when this feature is approved and implemented. The constitution has NOT been amended yet - this is a recommendation for alignment.

Proposed New Principle: IX. Extensible Storage Architecture

All storage implementations MUST use the provider-based architecture with automatic discovery.

  • Storage providers MUST implement IStorageProvider interface (which extends IMemoryStorageProvider)
  • Providers MUST be automatically discovered via assembly scanning at startup
  • Each provider MUST have a unique ProviderId string for configuration selection
  • Each provider MUST have DisplayName and Description for user selection during setup
  • Providers MUST validate their configuration before initialization via ValidateConfigurationAsync
  • Providers MUST implement InitializeAsync to set up storage-specific structures
  • Default provider MUST maintain backward compatibility with existing TST installations
  • Setup wizard MUST allow users to select from all discovered providers
  • Configuration MUST use Options Pattern with StorageOptions for provider settings
  • Storage providers belong in Infrastructure/Storage/Providers/ directory
  • Provider registration MUST use factory pattern with StorageProviderFactory
  • Cross-provider data migration tools SHOULD be provided for user convenience

Storage Provider Registration Example:

// Providers are discovered automatically via assembly scanning
public sealed class StorageProviderFactory
{
    private Dictionary<string, Type> DiscoverProviders()
    {
        var providers = new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase);
        var assembly = typeof(ServiceCollectionExtensions).Assembly;

        var providerTypes = assembly.GetTypes()
            .Where(t => typeof(IStorageProvider).IsAssignableFrom(t)
                     && !t.IsInterface && !t.IsAbstract)
            .ToList();

        foreach (var type in providerTypes)
        {
            var instance = (IStorageProvider)Activator.CreateInstance(type)!;
            providers[instance.ProviderId] = type;
        }

        return providers;
    }
}

// DI registration resolves active provider at runtime
services.AddSingleton<IStorageProvider>(sp =>
{
    var options = sp.GetRequiredService<IOptions<StorageOptions>>().Value;
    var factory = sp.GetRequiredService<StorageProviderFactory>();
    var provider = factory.CreateProvider(options.ProviderId);

    // Initialize provider
    provider.InitializeAsync(options, CancellationToken.None)
        .GetAwaiter().GetResult();

    return provider;
});

Storage Provider Implementation Example:

namespace TenSecondTom.Infrastructure.Storage.Providers;

/// <summary>
/// Obsidian vault storage provider.
/// Stores memory entries in an Obsidian vault with optional subdirectory organization.
/// </summary>
public sealed class ObsidianStorageProvider : IStorageProvider
{
    public string ProviderId => "obsidian";
    public string DisplayName => "Obsidian Vault";
    public string Description => "Store entries in an Obsidian vault for seamless note integration.";

    public async Task<Result> InitializeAsync(StorageOptions options, CancellationToken cancellationToken)
    {
        // Validate Obsidian vault structure
        string vaultRoot = options.RootDirectory;
        string obsidianDir = Path.Combine(vaultRoot, ".obsidian");

        if (!Directory.Exists(obsidianDir))
        {
            return Result.Failure($"Not a valid Obsidian vault: .obsidian directory not found");
        }

        // Create TST subdirectory if specified
        string memoryDir = string.IsNullOrEmpty(options.MemorySubdirectory)
            ? vaultRoot
            : Path.Combine(vaultRoot, options.MemorySubdirectory);

        if (!Directory.Exists(memoryDir))
        {
            Directory.CreateDirectory(memoryDir);
        }

        return Result.Success();
    }

    public async Task<Result> ValidateConfigurationAsync(StorageOptions options)
    {
        // Validate vault structure and write permissions
        // Return Result.Success() or Result.Failure(errorMessage)
    }

    // Implement IMemoryStorageProvider interface methods
    // (SaveAsync, GetEntriesAsync, SearchEntriesAsync, etc.)
}

Rationale: The provider pattern enables extensibility without modifying core application code. Automatic discovery via assembly scanning makes adding new providers a "drop-in" experience, consistent with MediatR handler and FluentValidation validator discovery patterns established in Principle IV. This architecture supports integration with third-party tools (Obsidian, Notion, Joplin, LogSeq) while maintaining TST's native storage as the default. The factory pattern ensures proper initialization and validation before use. The Options Pattern provides type-safe configuration for provider selection and settings.

Proposed Project Structure Updates

Updated Infrastructure/Storage Directory:

src/Infrastructure/Storage/
├── Providers/                     # [NEW] Storage provider implementations (auto-discovered)
│   ├── DefaultStorageProvider.cs
│   └── ObsidianStorageProvider.cs
├── IStorageProvider.cs            # [NEW] Provider abstraction interface
├── IMemoryStorageProvider.cs      # [EXISTING] Memory operations interface
├── StorageProviderFactory.cs      # [NEW] Assembly scanning factory
├── FileSystemStorageProvider.cs   # [EXISTING] Internal to DefaultStorageProvider
├── MarkdownFormatter.cs
└── AutoPurgeService.cs

tests/TenSecondTom.Tests/Infrastructure/Storage/
└── Providers/                     # [NEW] Storage provider unit tests
    ├── DefaultStorageProviderTests.cs
    └── ObsidianStorageProviderTests.cs

tests/TenSecondTom.IntegrationTests/Infrastructure/Storage/
└── Providers/                     # [NEW] Storage provider integration tests
    ├── DefaultProviderIntegrationTests.cs
    └── ObsidianProviderIntegrationTests.cs

Proposed Version Bump

Current Constitution Version: 1.4.0 (from git)
Proposed Version: 1.8.0 (MINOR bump)

Justification: Adding a new principle (Principle IX) is a MINOR version change per the constitution's governance rules. This does not break existing principles but adds new architectural guidance for storage extensibility.

Implementation Phases

Phase 0: Architecture Refactoring (Foundation)

Tasks:

  1. Create IStorageProvider interface extending IMemoryStorageProvider
  2. Create StorageProviderFactory with assembly scanning
  3. Refactor StorageOptions (add ProviderId, RootDirectory, MemorySubdirectory)
  4. Create StorageOptionsValidator updates
  5. Update DI registration to use factory pattern
  6. TEST: Ensure existing installations continue to work (backward compatibility)

No user-visible changes yet - internal refactoring only.

Phase 1: Default Provider Implementation

Tasks:

  1. Create DefaultStorageProvider wrapping existing FileSystemStorageProvider
  2. Update setup wizard to default to "default" provider (no UI changes)
  3. Migrate configuration: MemoryDirectoryStorage.RootDirectory
  4. TEST: New installations work with default provider
  5. TEST: Migration from old config format works

Phase 2: Obsidian Provider Implementation

Tasks:

  1. Create ObsidianStorageProvider with vault validation
  2. Implement Obsidian-friendly file naming and organization
  3. Add setup wizard UI for provider selection
  4. Add Obsidian vault path selection with validation
  5. Add subdirectory prompt for Obsidian vaults
  6. TEST: Obsidian vault integration end-to-end
  7. TEST: Switch between providers works correctly

Phase 3: Documentation & Polish

Tasks:

  1. Update README with storage provider documentation
  2. Add Obsidian integration guide
  3. Add migration guide for existing users
  4. Update constitution (version 1.8.0)
  5. Update CLAUDE.md with storage provider guidance
  6. Add integration tests for both providers

Migration Strategy for Existing Users

Automatic Migration

// In ApplicationBootstrapper or migration service
public async Task MigrateStorageConfigurationAsync()
{
    var legacyMemoryDir = _configuration["TenSecondTom:MemoryDirectory"];

    if (!string.IsNullOrEmpty(legacyMemoryDir))
    {
        // Migrate to new format
        var newConfig = new
        {
            TenSecondTom = new
            {
                Storage = new
                {
                    ProviderId = "default",
                    RootDirectory = legacyMemoryDir,
                    MemorySubdirectory = (string?)null
                }
            }
        };

        // Save to user secrets or config file
        await _configStorageService.MigrateAsync(newConfig);

        _logger.LogInformation("Migrated legacy MemoryDirectory configuration to Storage.RootDirectory");
    }
}

User Communication

On first run after upgrade:

Ten Second Tom has been upgraded to support multiple storage providers!

Your existing data location (/Users/chris/ten-second-tom) has been preserved.
You can now optionally switch to Obsidian vault storage:

  tom config --set storage.providerid obsidian
  tom setup  # Re-run setup to configure Obsidian vault

Learn more: https://github.com/sirkirby/ten-second-tom/wiki/Storage-Providers

Testing Requirements

Unit Tests

  • StorageProviderFactoryTests: Assembly scanning, provider discovery
  • DefaultStorageProviderTests: Backward compatibility, initialization
  • ObsidianStorageProviderTests: Vault validation, file structure
  • StorageOptionsValidatorTests: Configuration validation

Integration Tests

  • DefaultProviderIntegrationTests: End-to-end with default provider
  • ObsidianProviderIntegrationTests: End-to-end with Obsidian vault
  • ProviderSwitchingTests: Switch between providers without data loss
  • MigrationTests: Legacy config migration

Test Coverage Goal

85% minimum (exceeds constitutional 80% requirement due to architectural significance)

Future Extensibility

Potential Future Providers

  1. NotionStorageProvider: Sync entries to Notion databases
  2. JoplinStorageProvider: Store in Joplin note application
  3. LogSeqStorageProvider: LogSeq outliner integration
  4. RemoteStorageProvider: Sync to remote Git repository
  5. DatabaseStorageProvider: PostgreSQL/SQLite for advanced querying
  6. CloudStorageProvider: S3/Azure Blob/Google Cloud Storage

Provider Discovery Convention

// Providers are discovered by:
// 1. Implementing IStorageProvider
// 2. Being in the main assembly (src/TenSecondTom.csproj)
// 3. Having a public parameterless constructor for factory discovery

// Future: Could support external provider assemblies via plugin system

Risks & Mitigation

Risk Impact Mitigation
Breaking existing installations HIGH Phase 0 includes backward compatibility layer; automatic migration
Obsidian vault corruption HIGH Read-only validation mode; extensive integration tests; user confirmation prompts
Configuration migration failure MEDIUM Graceful fallback to legacy config; clear error messages
Performance degradation LOW Providers use same underlying FileSystemStorageProvider implementation
User confusion during setup MEDIUM Clear UI prompts; default to existing behavior; comprehensive documentation

Success Criteria

  1. ✅ Existing TST installations continue working without user action
  2. ✅ New users can select storage provider during initial setup
  3. ✅ Obsidian users can store TST entries in their vaults
  4. ✅ Files created in Obsidian appear in TST and vice versa (bidirectional)
  5. ✅ Configuration follows Options Pattern (no magic strings)
  6. ✅ Test coverage ≥ 85%
  7. ✅ Constitution updated to version 1.8.0 with new principle
  8. ✅ Documentation complete and comprehensive

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions