--- url: /adr/ADR-001-capabilities-system.md description: >- Use the Cocoar.Capabilities library for type-safe metadata attachment to builders across assembly boundaries, avoiding circular dependencies --- # ADR-001: Using Cocoar.Capabilities for Cross-Assembly Extensibility **Status:** Accepted\ **Date:** 2024-09-14\ **Decision Makers:** Core Team *** ## Context Cocoar.Configuration has a **fundamental architectural requirement**: extension methods from separate assemblies (like `Cocoar.Configuration.DI`) need to attach metadata to builder objects from the core assembly without creating circular dependencies. ### The Problem Consider this user-facing API: ```csharp builder.AddCocoarConfiguration(rule => [ rule.For().FromFile("config.json") ], setup => [ setup.ConcreteType().ExposeAs(), // Core assembly setup.ConcreteType().AsSingleton() // DI assembly ]); ``` **Requirements:** 1. Core assembly defines `ConcreteTypeSetup` with `.ExposeAs()` method 2. DI assembly adds `.AsSingleton()` extension method to the same builder type 3. Both methods must attach metadata to the **same builder instance** 4. Core assembly **cannot reference** DI assembly (would create circular dependency) 5. DI assembly needs to retrieve **all metadata** from all builders at registration time 6. The fluent API must remain clean and chainable 7. Third parties should be able to add their own extensions ### Why Standard .NET Patterns Don't Work | Pattern | Why It Fails | |---------|--------------| | **Dictionary\** | No type safety, can't compose multiple metadata types, external global state | | **Reflection Attributes** | Compile-time only, can't configure same type differently in different contexts | | **Method Parameters** | Destroys fluent API, parameter explosion, not extensible from other assemblies | | **Builder Internal State** | Core must know about ALL extension metadata types → circular dependencies | After several days exploring alternatives, we concluded: **There is no standard .NET pattern that solves this problem without compromising on quality.** *** ## Decision We will use **[Cocoar.Capabilities](https://github.com/cocoar-dev/Cocoar.Capabilities)**, a separate open-source library that enables type-safe metadata attachment across assembly boundaries. ### Why This Library? 1. **Solves the exact problem** - Designed specifically for cross-assembly metadata composition 2. **Zero coupling** - Core and DI assemblies remain completely independent 3. **Type-safe** - All metadata is strongly typed at compile time 4. **Proven pattern** - Used successfully in production 5. **Reusable** - Separate library means it can be used in other projects 6. **Third-party extensible** - Anyone can add capabilities without modifying our code ### How We Use It **Core Assembly** attaches primary and core metadata: ```csharp public sealed class ConcreteTypeSetup : SetupDefinition { internal ConcreteTypeSetup(ConfigManagerCapabilityScope capabilityScope) : base(capabilityScope) { capabilityScope.Compose(this).WithPrimary( new ConcreteTypePrimary(typeof(T))); } public ConcreteTypeSetup ExposeAs() { GetComposer(this).Add( new ExposeAsCapability(typeof(TInterface))); return this; } } ``` **DI Assembly** extends without coupling: ```csharp public static class ConcreteTypeSetupExtensions { public static ConcreteTypeSetup AsSingleton(this ConcreteTypeSetup builder) { SetupDefinition.GetComposer(builder).Add( new ServiceLifetimeCapability(ServiceLifetime.Singleton, null)); return builder; } } ``` **Registration Time** retrieves all metadata: ```csharp foreach (var builder in configManager.SetupDefinitions) { if (!scope.Compositions.TryGet(builder, out var composition)) continue; // Get metadata from ANY assembly that added capabilities var typeCapability = composition.TryGetPrimaryAs>(); var lifetimes = composition.GetAll>(); var exposures = composition.GetAll>(); // Use all metadata for registration } ``` *** ## Consequences ### Positive ✅ **Zero Coupling** - Core and DI assemblies are completely independent\ ✅ **Type Safety** - All capabilities are strongly typed at compile time\ ✅ **Fluent API Preserved** - Method chaining works seamlessly\ ✅ **Extensible** - Third parties can add their own capabilities\ ✅ **Testable** - Capabilities can be mocked and verified independently ### Trade-offs ⚠️ **Additional Dependency** - Requires Cocoar.Capabilities package\ ⚠️ **Learning Curve** - Contributors must understand the Capabilities pattern\ ⚠️ **Debugging Indirection** - Capability composition harder to trace than direct fields **Mitigation:** This ADR and inline documentation explain the pattern. The complexity is hidden from end users - they just use the fluent API. *** ## Alternatives Considered ### Alternative 1: Accept Circular Dependencies Make Core reference DI, DI reference Core. **Rejected:** Violates clean architecture, makes testing difficult, prevents third-party extensions. ### Alternative 2: ConditionalWeakTable Use .NET's ConditionalWeakTable for metadata storage. **Rejected:** Still lacks type safety and composition. Cannot distinguish primary vs secondary metadata. ### Alternative 3: Event-Based Registration Use events to notify about builder creation and metadata. **Rejected:** Ordering issues, no clear ownership, difficult to query later, thread safety nightmares. ### Alternative 4: Custom Metadata Interface Create `IMetadataCarrier` interface for builders. **Rejected:** Requires all builders to implement interface (intrusive), string-keyed dictionaries lose type safety, doesn't solve cross-assembly attachment. *** ## Implementation Details ### Key Classes * `ConfigManagerCapabilityScope` - Manages capability lifetime for this ConfigManager instance * `SetupDefinition` - Abstract base that provides access to `CapabilityScope` and `Composer` * `ConcreteTypeSetup` / `InterfaceTypeSetup` - Builders that compose capabilities * `ExposureRegistry` - Reads capabilities at registration time ### Capability Records * `ConcreteTypePrimary` - Primary: The type being configured * `ExposeAsCapability` - Secondary: Interface exposure for DI * `DeserializeToCapability` - Secondary: Interface→Concrete mapping for deserialization * `ServiceLifetimeCapability` - Secondary (DI): Service lifetime metadata ### Thread Safety Cocoar.Capabilities handles thread safety internally using concurrent collections. Composers are immutable after Build(). *** ## References ### External * **Cocoar.Capabilities Repository:** https://github.com/cocoar-dev/Cocoar.Capabilities * **Blog Post (Context):** [The Cross-Assembly Metadata Problem in .NET](https://dev.to/bwi/the-cross-assembly-metadata-problem-in-net-and-how-i-solved-it-14eo) ### Internal * `Core/ConfigManagerCapabilityScope.cs` - Scope implementation * `Configure/SetupBuilder.cs` - Builder API using Capabilities * `Infrastructure/ExposureRegistry.cs` - Capability retrieval example * `DI/ConcreteTypeSetupExtensions.cs` - Cross-assembly extension example *** ## FAQ **Q: Why not just use a dictionary and accept the type-safety loss?**\ A: Type safety isn't just about compile-time errors - it's about maintainability. When adding a new capability type, the compiler helps find all places that need updates. With untyped dictionaries, we lose that safety net. **Q: Doesn't this feel like over-engineering?**\ A: The problem only appears simple because the requirements are subtle. We explored simpler solutions for several days before choosing Capabilities - none worked without compromising on architecture or extensibility. **Q: What if Cocoar.Capabilities changes or breaks compatibility?**\ A: Both libraries are maintained by the same author/team. Breaking changes would be coordinated across both projects. The separation provides architectural benefits (reusability, clear boundaries) without introducing external dependency risk. **Q: Why create a separate library instead of keeping it internal?**\ A: (1) The pattern is reusable across other projects. (2) Clear separation of concerns - Capabilities is a general-purpose library. (3) Forces a clean API boundary. (4) Can be used by third-party extensions to this project. *** **Status:** ✅ Accepted and Implemented\ **Next Review:** When significant new extension patterns emerge or if third-party extension requirements change *** **Revision History:** | Date | Version | Changes | Author | |------|---------|---------|--------| | 2025-11-12 | 1.0 | Initial ADR | Core Team | --- --- url: /adr/ADR-002-atomic-reactive-updates.md description: >- Transactional all-or-nothing recompute with tuple-reactive subscriptions and reference-equality change detection; no IOptionsMonitor partial-update races --- # ADR-002: Atomic Reactive Configuration Updates **Status:** Accepted\ **Date:** 2024-09-14\ **Decision Makers:** Core Team\ **Related:** ADR-001 (Capabilities System) *** ## Context Configuration in distributed systems has a fundamental challenge: **how do you notify subscribers of changes across multiple related configuration types without exposing them to inconsistent state?** ### The Problem: Partial Updates Consider a typical application with related configurations: ```csharp public class AppSettings { public string ApiUrl { get; set; } public int Timeout { get; set; } } public class DatabaseSettings { public string ConnectionString { get; set; } public int PoolSize { get; set; } } ``` When a configuration file changes that affects **both** types, subscribers need to see them update **atomically**. Seeing new `AppSettings` with old `DatabaseSettings` could cause: * API calls to new endpoints with old timeouts * Database connections with mismatched pool sizes * Race conditions during the update window * Unpredictable behavior in dependent services ### Why Standard Patterns Fail **Microsoft's IOptionsMonitor\:** ```csharp services.Configure(config.GetSection("App")); services.Configure(config.GetSection("Database")); // In your service: _appMonitor.OnChange(newApp => { /* update */ }); _dbMonitor.OnChange(newDb => { /* update */ }); ``` **Problems:** * ❌ Two separate notification streams - no atomicity guarantee * ❌ Observer for `AppSettings` fires before `DatabaseSettings` is ready * ❌ Time window where state is inconsistent * ❌ Manual coordination required across monitors * ❌ Race conditions in multi-threaded scenarios **System.Reactive (Rx) CombineLatest:** ```csharp Observable.CombineLatest( appObservable, dbObservable, (app, db) => (app, db)) ``` **Problems:** * ❌ Emits on **any** source change, even if only one changed * ❌ No transaction semantics - can see partial source updates * ❌ No rollback on failure * ❌ Complex subscription management ### Real-World Impact **Without Atomic Updates (IOptionsMonitor):** ``` Time: 0ms → File changes (both App + DB sections modified) Time: 5ms → AppSettings reloads → Observer 1 fires Time: 10ms → Service uses new App + OLD Db ❌ INCONSISTENT Time: 15ms → DatabaseSettings reloads → Observer 2 fires Time: 20ms → Service uses new App + new Db ✓ Consistent again ``` **Window of Inconsistency:** 10ms where state is partially updated. **With Atomic Updates (Cocoar.Configuration):** ``` Time: 0ms → File changes Time: 5ms → Recompute transaction begins → AppSettings computes → DatabaseSettings computes → Both commit atomically Time: 15ms → Single emission with (newApp, newDb) ✓ ATOMIC ``` **No inconsistency window.** Subscribers **never** see partial state. *** ## Decision We implement **tuple-reactive atomic updates** using a transaction-based recomputation pipeline with the following guarantees: ### 1. Transactional Recompute All configuration changes process as an **all-or-nothing transaction**: ```csharp // Recompute Pipeline BeginTransaction() ├─ Rule 1: Compute AppSettings ├─ Rule 2: Compute DatabaseSettings ├─ Rule 3: Compute CacheSettings └─ CommitOrRollback() ``` **If any required rule fails:** * ❌ Entire transaction rolls back * ✅ Consumers keep previous good configuration * ✅ **Zero emissions** - no observer is notified * ✅ Health status → Unhealthy **If all rules succeed:** * ✅ All changes commit atomically * ✅ **Single emission** with new snapshot * ✅ All subscribers see consistent state * ✅ Health status → Healthy ### 2. Tuple-Reactive API Consumers can subscribe to **multiple configurations atomically**: ```csharp public class MyService { public MyService(IReactiveConfig<(AppSettings, DatabaseSettings, CacheSettings)> config) { config.Subscribe(tuple => { var (app, db, cache) = tuple; // GUARANTEED: All three are from the same recompute pass // GUARANTEED: No partial updates // GUARANTEED: If one changed, this fires; if all unchanged, no emission RebuildClient(app, db, cache); }); } } ``` ### 3. Reference-Equality Change Detection Only emit when configuration **actually changes**. Each recompute produces fresh config instances; the engine compares the new per-type **instance reference** against the last published reference and suppresses emission when they are reference-equal: ```csharp // Recompute produces new snapshot oldSnapshot = { AppSettings: v1, DatabaseSettings: v1 } newSnapshot = { AppSettings: v2, DatabaseSettings: v1 } // Only App got a new instance // Per-Type Change Detection (DistinctUntilChanged with ReferenceEquals): - ReferenceEquals(AppSettings v2, AppSettings v1) == false → Changed - ReferenceEquals(DatabaseSettings v1, DatabaseSettings v1) == true → Unchanged // Emission: - IReactiveConfig → Emits (reference changed) - IReactiveConfig → No emission (reference unchanged) - IReactiveConfig<(AppSettings, DatabaseSettings)> → Emits (tuple member changed) ``` **Benefits:** * Avoids spurious emissions on non-changes * Subscribers only react when a type gets a new instance * O(1) reference comparison — no hashing or serialization on the emit path (`MasterBackplane.CreateTypeProjection` ends in `.DistinctUntilChanged(ReferenceEqualityComparer.Instance)`) *** ## Implementation ### Core Components **1. MasterBackplane** (single source of truth) `MasterBackplane` holds the current `ConfigSnapshot` in a `SimpleBehaviorSubject` and atomically publishes new snapshots. Per-type reactive consumers subscribe to a **type projection** built lazily over that snapshot stream. The projection selects the type out of each snapshot and gates emissions by reference equality: ```csharp internal sealed class MasterBackplane : IDisposable { private readonly SimpleBehaviorSubject _snapshotSubject; private readonly ConcurrentDictionary _typeProjectionCache = new(); // Atomic publish: all type projections update from a single snapshot public void Publish(ConfigSnapshot snapshot) => _snapshotSubject.OnNext(snapshot); private IObservable CreateTypeProjection() where T : class => _snapshotSubject .Select(snapshot => snapshot.GetConfig() /* + interface mapping */) .Where(config => config != null) // Uses ReferenceEquals — no hashing on the emit path .DistinctUntilChanged(ReferenceEqualityComparer.Instance); } ``` **2. ReactiveConfigManager** (wrapper cache over the backplane) Holds the `MasterBackplane` plus a single `_reactiveConfigs` dictionary of per-type wrappers. `GetReactiveConfig` returns a cached, backplane-backed `BackplaneReactiveConfig` whose `CurrentValue` reads from the backplane and whose `Subscribe` forwards to the type projection: ```csharp internal sealed class ReactiveConfigManager : IDisposable { private readonly ConcurrentDictionary _reactiveConfigs = new(); private MasterBackplane? _backplane; public IReactiveConfig GetReactiveConfig(Func fallbackAccessor) where T : class => (IReactiveConfig)_reactiveConfigs.GetOrAdd( typeof(T), _ => new BackplaneReactiveConfig(_backplane!)); private sealed class BackplaneReactiveConfig : IReactiveConfig, IDisposable where T : class { private readonly MasterBackplane _backplane; private readonly IObservable _observable; public BackplaneReactiveConfig(MasterBackplane backplane) { _backplane = backplane; _observable = backplane.GetTypeProjection(); } public T CurrentValue => _backplane.GetConfig() ?? throw new InvalidOperationException(...); public IDisposable Subscribe(IObserver observer) => _observable.Subscribe(observer); } } ``` There are no per-type subjects, hash dictionaries, or per-pass subjects — a single snapshot subject plus reference-equality projections provide change detection. **3. Tuple Reactive Factory** Handles flattening of nested tuples for atomic subscriptions. The factory reflects over the `ValueTuple` fields to discover the element types (recursing into `Rest` for tuples larger than 7), validates each element is a configured / exposed type, primes each element's reactive config, then instantiates a `ReactiveTupleConfig<>` over the same `MasterBackplane`. There is no `Observable.CombineLatest` — the tuple reads all members from one atomic snapshot: ```csharp internal class ReactiveConfigurationFactory(/* ... */) { private object CreateTupleReactiveConfig(Type tupleType) { var elementTypes = FlattenTuple(tupleType).ToArray(); // reflection-flatten // Validate + prime each distinct element's reactive config foreach (var et in elementTypes.Distinct()) { /* prime element type */ } // One ReactiveTupleConfig over the backplane — all members from one snapshot var generic = typeof(ReactiveTupleConfig<>).MakeGenericType(tupleType); return Activator.CreateInstance( generic, accessor, backplaneAccessor(), reactiveConfigManager, logger, bindingRegistry)!; } } ``` ### Atomic Recompute Flow ``` 1. Change Detection └─ Provider signals change (file modified, HTTP poll, etc.) 2. Recompute Transaction ├─ ConfigurationEngine.BeginUpdate() ├─ Execute all rules sequentially ├─ Build candidate snapshot └─ Decision: ├─ Success → CommitUpdate(snapshot) └─ Failure → RollbackUpdate() 3. Change Detection (on success only) ├─ Publish the new snapshot to the MasterBackplane ├─ For each registered type projection: │ ├─ Select the type's new instance from the snapshot │ ├─ Compare with last published reference (ReferenceEquals) │ └─ If reference changed → emit └─ Emit changed types atomically 4. Subscriber Notification ├─ Single-type: Emits if that type changed ├─ Tuple-type: Emits if ANY member changed └─ All emissions use same snapshot (atomic guarantee) ``` *** ## Consequences ### Positive ✅ **Atomic Consistency**: Subscribers **never** see partial updates\ ✅ **Transactional Safety**: Failed recomputes don't corrupt state\ ✅ **Type-Safe**: Compile-time checked tuple subscriptions\ ✅ **Reference-Equality Efficiency**: O(1) change detection, no spurious emissions on non-changes\ ✅ **Flexible Granularity**: Subscribe to single types or tuples\ ✅ **Automatic Rollback**: Errors preserve last known good state\ ✅ **Zero External Dependencies**: `IReactiveConfig : IObservable` uses only BCL types — no System.Reactive in shipped packages\ ✅ **Observable by Design**: Configuration as first-class reactive stream ### Trade-offs ⚠️ **Complexity**: A backplane plus per-type projections and tuple flattening (justified by correctness)\ ⚠️ **Memory**: One snapshot subject plus one cached wrapper/projection per type — a single dictionary, not dual\ ⚠️ **Tuple Limitation**: C# supports tuples up to 8 members (combine with nesting if needed)\ ⚠️ **Reflection**: Tuple flattening uses reflection (results are cached per type) **Why Complexity Is Acceptable:** * Atomic guarantees are **non-negotiable** for correctness * Memory overhead is **negligible** (one wrapper/projection per type) * Reference-equality change detection is **O(1)** — no hashing or serialization on the emit path * Alternative (IOptionsMonitor) has **unfixable race conditions** ### Negative ❌ **Learning Curve**: Developers must understand tuple-reactive pattern\ ❌ **Debugging**: Rx stack traces can be difficult to follow\ ❌ **Expression Trees**: Tuple factory uses reflection (cached, but still indirection) **Mitigation:** * Comprehensive documentation in this ADR * Examples demonstrating both single-type and tuple usage * Health monitoring integration for observability *** ## Alternatives Considered ### Alternative 1: Manual Coordination with IOptionsMonitor ```csharp private AppSettings? _app; private DatabaseSettings? _db; private bool _appReady, _dbReady; _appMonitor.OnChange(newApp => { _app = newApp; _appReady = true; TryRebuild(); }); _dbMonitor.OnChange(newDb => { _db = newDb; _dbReady = true; TryRebuild(); }); void TryRebuild() { if (_appReady && _dbReady) { RebuildClient(_app, _db); _appReady = _dbReady = false; } } ``` **Rejected because:** * Boilerplate for every subscriber * Race conditions (what if only one changes?) * No transactional rollback * No hash-based change detection * Doesn't scale to 3+ types ### Alternative 2: Polling with Locks ```csharp private readonly object _lock = new(); private (AppSettings, DatabaseSettings) _snapshot; // Poll every 100ms while (true) { var newApp = LoadApp(); var newDb = LoadDb(); lock (_lock) { _snapshot = (newApp, newDb); } await Task.Delay(100); } ``` **Rejected because:** * Wastes CPU cycles polling * 100ms latency for changes * No reactive push notifications * Still no atomicity guarantee (lock only helps readers) ### Alternative 3: Rx CombineLatest (Naive) ```csharp Observable.CombineLatest( appObservable, dbObservable, (app, db) => (app, db)) ``` **Rejected because:** * Emits on **every** source change (spurious emissions) * No hash-based change detection * No transactional rollback on failure * Doesn't integrate with our recompute pipeline ### Alternative 4: Event Sourcing ```csharp public record ConfigChanged(Type ConfigType, object NewValue, long Version); // Emit events, rebuild snapshots ``` **Rejected because:** * Massive architectural change * Requires event store * Overkill for configuration (not business events) * No built-in Rx integration *** ## Usage Examples ### Example 1: Single Configuration (Change-Based) ```csharp public class ApiClient { public ApiClient(IReactiveConfig config, ILogger logger) { config.Subscribe(newSettings => { logger.LogInformation("API config changed: {Url}", newSettings.ApiUrl); RebuildClient(newSettings); }); } } ``` **Behavior:** * Emits **only when AppSettings value changes** (hash-based) * No emission if recompute produces same AppSettings * Automatic on initialization (BehaviorSubject) ### Example 2: Atomic Multi-Config (Tuple) ```csharp public class DatabasePool { public DatabasePool( IReactiveConfig<(AppSettings App, DatabaseSettings Db, CacheSettings Cache)> config, ILogger logger) { config.Subscribe(tuple => { var (app, db, cache) = tuple; logger.LogInformation( "Config changed atomically: {AppUrl}, {DbConn}, {CacheTtl}", app.ApiUrl, db.ConnectionString, cache.TtlSeconds); // GUARANTEED: All three are consistent (same recompute pass) RebuildPool(app, db, cache); }); } } ``` **Behavior:** * Emits **when any member changes** * **All members are from the same snapshot** (atomic) * If only `AppSettings` changed, still get all three (but only `AppSettings` has a new reference) ### Example 3: Health Monitoring Integration ```csharp public class ConfigHealthService { public ConfigHealthService( IReactiveConfig<(AppSettings, DatabaseSettings)> config, ConfigManager configManager) { // Monitor config changes config.Subscribe(tuple => { var (app, db) = tuple; ValidateConsistency(app, db); }); // Check recompute health after a change config.Subscribe(_ => { if (configManager.HealthStatus == HealthStatus.Unhealthy) { AlertOps("Configuration recompute failed!"); } }); } } ``` `ConfigManager` exposes the current health as `HealthStatus` (`Unknown`/`Healthy`/`Degraded`/`Unhealthy`) plus the `IsHealthy` convenience flag. A failed required rule leaves the last good configuration in place and sets `HealthStatus` to `Unhealthy`. *** ## Performance Characteristics ### Benchmarks (Typical Scenario) **Recompute Transaction:** * 3 rules (File + Env + HTTP) * 3 config types * Total time: ~50-200ms (dominated by HTTP polling) **Change Detection:** * Reference comparison per type (`DistinctUntilChanged(ReferenceEquals)`): O(1), effectively free * No hashing or serialization on the emit path * **Negligible** compared to provider I/O **Emission Overhead:** * Subject.OnNext: ~10-50 microseconds per subscriber * 10 subscribers: ~100-500 microseconds total * **Trivial** overhead **Memory per Type:** * One cached `BackplaneReactiveConfig` wrapper + its type projection * No per-type hash storage, no per-pass subject * A single shared snapshot subject backs all types **Typical app (10 config types, 20 subscribers):** * Memory: one snapshot subject + ~10 cached wrappers/projections = negligible * Recompute time: ~50-200ms (provider I/O) * Change detection: O(1) reference compares (effectively free) * Emission time: ~1-10ms (notification) **Conclusion:** Performance overhead is **negligible** compared to correctness benefits. *** ## Testing Strategy **Unit Tests:** * Atomic emission on multi-config change * No emission when a type's instance reference is unchanged * Rollback on required rule failure **Integration Tests:** * File change triggers atomic tuple update * Failed HTTP poll rolls back entire transaction * Concurrent subscribers see same snapshot **Property Tests:** * No subscriber ever sees partial state * A type emits if and only if it gets a new instance reference * Transaction never commits partial updates *** ## Migration Notes ### From IOptionsMonitor (Microsoft) **Before:** ```csharp public class MyService( IOptionsMonitor appMonitor, IOptionsMonitor dbMonitor) { private AppSettings? _app; private DatabaseSettings? _db; public MyService(...) { appMonitor.OnChange(a => _app = a); // Separate notifications dbMonitor.OnChange(d => _db = d); // Race condition risk } } ``` **After:** ```csharp public class MyService( IReactiveConfig<(AppSettings, DatabaseSettings)> config) // Atomic tuple { public MyService(...) { config.Subscribe(tuple => { var (app, db) = tuple; // Always consistent RebuildState(app, db); }); } } ``` ### From System.Reactive (CombineLatest) **Before:** ```csharp Observable.CombineLatest( appObservable.DistinctUntilChanged(), // Manual change detection dbObservable.DistinctUntilChanged(), (app, db) => (app, db)) .Subscribe(tuple => RebuildState(tuple.app, tuple.db)); ``` **After:** ```csharp config.Subscribe(tuple => { var (app, db) = tuple; // Built-in reference-equality change detection RebuildState(app, db); }); ``` *** ## Future Enhancements The following are aspirational sketches — **none are implemented yet**. **1. Snapshot Diffing API** ```csharp config.ObserveDiffs().Subscribe(diff => { Console.WriteLine($"Changed properties: {diff.ChangedPaths}"); }); ``` **2. Conditional Subscriptions** ```csharp config.SubscribeWhen(tuple => tuple.App.IsFeatureEnabled, tuple => { // Only fires when condition is true AND value changed }); ``` **3. Backpressure Control** ```csharp config.Sample(TimeSpan.FromSeconds(1)) // At most once per second .Subscribe(tuple => /* ... */); ``` *** ## References ### Internal * `Core/MasterBackplane.cs` - Snapshot subject + per-type reference-equality projections * `Reactive/ReactiveConfigManager.cs` - Backplane-backed wrapper cache (`BackplaneReactiveConfig`) * `Reactive/ReactiveConfigurationFactory.cs` - Tuple flattening (reflection) over the backplane * `Reactive/ReactiveTupleConfig.cs` - Tuple wrapper * `Core/ConfigurationEngine.cs` - Recompute transaction (BeginUpdate/CommitUpdate/RollbackUpdate) ### External * [System.Reactive Documentation](https://github.com/dotnet/reactive) * [Rx Design Guidelines](https://github.com/dotnet/reactive/blob/main/Rx.NET/Documentation/DesignGuidelines.md) * [BehaviorSubject Semantics](https://reactivex.io/documentation/subject.html) ### Articles * [Reactive Configuration Part 1](https://dev.to/bwi/reactive-strongly-typed-configuration-in-net-introducing-cocoarconfiguration-v30-3gbn) * [Config-Aware Rules Part 2](https://dev.to/bwi/config-aware-rules-in-net-the-power-feature-of-cocoarconfiguration-part-2-2ibk) *** ## Conclusion The Reactive System's complexity is **intentional and necessary** to provide atomic consistency guarantees that are impossible with standard patterns like `IOptionsMonitor`. **Key Insight:**\ Configuration changes are **transactions**, not isolated events. Subscribers must see consistent snapshots or risk undefined behavior. **The Alternative:**\ Manual coordination with `IOptionsMonitor` is error-prone, doesn't scale, and fundamentally cannot provide atomicity guarantees. **The Decision:**\ Accept ~250 lines of well-tested reactive infrastructure to provide **bulletproof atomic updates** that work correctly in production under concurrent load. *** **Status:** ✅ Accepted and Implemented\ **Complexity Justified:** Yes - Atomic consistency is non-negotiable\ **Next Review:** If alternative patterns emerge that provide atomicity without complexity ## Revision History | Date | Version | Changes | Author | |------|---------|---------|--------| | 2024-09-14 | 1.0 | Initial ADR documenting atomic reactive design | Core Team | *** --- --- url: /adr/ADR-003-provider-consistency-empty-objects.md description: >- All providers return empty JSON objects on missing/unavailable data so types always carry C# defaults; failures tracked separately via health monitoring --- # ADR-003: Fix Provider Inconsistency - Optional Rules Always Return Objects **Status:** Accepted **Date:** 2025-01-11 **Decision Makers:** Core Team **Type:** Bug Fix **Related:** PART2 Article (Optional vs Required Rules) *** ## Context Cocoar.Configuration had a **bug where providers handled missing or unavailable data inconsistently**, leading to unpredictable behavior and requiring workarounds. ### The Problem: Inconsistent Provider Behavior Currently, providers handle "no data" scenarios differently based on their source type: **Collection-Based Providers** (Always succeed with empty): ```csharp // EnvironmentVariableProvider rule.For().FromEnvironment("APP_") // No matching env vars → Returns {} → Config with C# defaults // CommandLineArgumentProvider rule.For().FromCommandLine("--app:") // No matching args → Returns {} → Config with C# defaults ``` **Source-Based Providers** (Throw when source unavailable): ```csharp // FileSourceProvider rule.For().FromFile("config.json") // File doesn't exist → Throws FileNotFoundException → null (unavailable) // HttpProvider rule.For().FromHttp("http://api/config") // Endpoint down → Throws → null (unavailable) ``` ### Real-World Impact **User reports inability to access configuration:** ```csharp builder.AddCocoarConfiguration(rule => [ rule.For().FromFile("config.json") ]); var config = builder.GetCocoarConfigManager().GetConfig(); // config is NULL when file doesn't exist ``` **Current workarounds:** ```csharp // Workaround 1: Add fake environment rule rule.For().FromFile("config.json"), rule.For().FromEnvironment("NONEXISTENT_") // Hack: always returns {} ``` ```csharp // Workaround 2: Explicit FromStatic for defaults rule.For().FromStatic(_ => new Config()), // Explicit defaults rule.For().FromFile("config.json") ``` The first workaround is **implicit and unclear**. The second is better but required for every optional configuration. ### Semantic Confusion The system conflates two orthogonal concepts: 1. **Source Availability**: Does the data source exist/respond? 2. **Data Availability**: Does the source contain data for this type? Example scenarios that are semantically different but treated the same: ```csharp rule.For().FromFile("config.json").Select("App") ``` * File doesn't exist → Throws → null * File exists, "App" section missing → Throws KeyNotFoundException → null * File exists, "App" is `{}` → Returns empty object **All three should behave differently!** ### Current "Last Known Good" Behavior The system has **asymmetric failure handling**: **Required Rules** (`Required: true`): * Provider throws → Exception propagates to `RecomputeAllConfigurationsSafe` * Entire recompute is **rolled back** via `_state.RollbackUpdate()` * **All types preserve their previous values** from `_configs` dictionary * App continues with last known good configuration for all types **Optional Rules** (`Required: false`, default): * Provider throws → `HandleFailure()` skips the rule's contribution * `LastJsonContribution` is left `null` for that rule * Recompute **continues** with other rules * If this was the **only rule** for a type → Type not in `mergedConfigs` → **GetConfig returns null** From PART2 article (`.local/dev.to/PART2...`): > **When an optional rule fails:** > > * Rule is skipped for that recompute > * App uses **last known good** value for that type **(if none exists, that config type is unavailable)** This behavior is **intentional per documentation**, but creates the problem: **"last known good" only exists if the rule succeeded at least once.** **First-time failures have no history** → null instead of defaults. *** ## Decision **All providers return empty JSON objects (`{}`) when they have no data, regardless of reason. Health monitoring tracks source availability separately.** This fixes the inconsistency bug and aligns all providers with the correct "graceful degradation" behavior that was already working for collection-based providers. ### Core Principle **Data Flow** (always flows): * Provider → Always returns valid JSON (possibly `{}`) * RuleManager → Always contributes data (include: true) * Type → Always available with at least C# defaults **Health Flow** (tracks issues): * Provider throws → Exception caught by RuleManager * RuleManager → Tracks exception in `LastFailureException` * Health Status → Degraded with error details * Multiple issues can be tracked per rule ### Behavior Changes | Scenario | Old Behavior | New Behavior | |----------|-------------|--------------| | File doesn't exist | Throws → Skip → null | Returns `{}` → Object with defaults, Health = Degraded | | HTTP endpoint down | Throws → Skip → null | Returns `{}` → Object with defaults, Health = Degraded | | No matching env vars | Returns `{}` → Object | Returns `{}` → Object, Health = Healthy | | Select path missing | Throws → Skip → null | Returns `{}` → Object with defaults, Health = Degraded | ### Benefits **1. Consistency** ```csharp // All providers work the same way rule.For().FromFile("config.json") // Always returns object rule.For().FromEnvironment("APP_") // Always returns object rule.For().FromHttp("http://api") // Always returns object ``` **2. Predictability** ```csharp var config = manager.GetConfig(); // Never null if rule is defined // C# property defaults always present (even on first failure) ``` **3. True "Last Known Good" Semantics** ```csharp // Before: First failure → null (no history), second failure → last good value // After: Always has value (empty object with C# defaults is the baseline) // Optional file rule fails on startup: rule.For().FromFile("config.json") // OLD: null (no last good) → app must handle null // NEW: Config with C# defaults → app always works, health shows Degraded // File becomes available later → reactive update merges over defaults // File fails again → keeps last merged value (true last known good) ``` **4. No Hacks Needed** ```csharp // Before: Hack to get empty object rule.For().FromFile("config.json"), rule.For().FromEnvironment("FAKE_PREFIX_") // ❌ Unclear intent // After: Explicit if you want custom defaults rule.For().FromStatic(_ => new Config { /* custom defaults */ }), rule.For().FromFile("config.json") // ✅ Clear intent ``` **5. Better Observability** ```csharp // Data still flows (Config has C# defaults), but health reflects the real issue. // Overall status is derived from per-rule outcomes by the health tracker: manager.HealthStatus; // HealthStatus.Degraded — an optional rule failed manager.IsHealthy; // false // Per-rule detail is tracked on the rule manager: // LastOutcome → RuleExecutionOutcome.Failed // LastFailureException → FileNotFoundException("config.json") ``` **6. Graceful Degradation** ```csharp // App continues with defaults while source is unavailable // Auto-recovers when source comes back online (reactive updates) // "Last known good" becomes meaningful: empty object → first merge → subsequent merges ``` ### Implementation Approach **Change in RuleManager:** ```csharp // Before: private ReadOnlyMemory HandleFailure(Exception ex) { LastOutcome = RuleExecutionOutcome.Failed; LastFailureException = ex; if (Required) { throw new InvalidOperationException(...); } _logger.OptionalRuleFailed(ex, ...); // ❌ Skipped the rule's contribution → type may be absent → null } // After: private ReadOnlyMemory HandleFailure(Exception ex) { LastOutcome = RuleExecutionOutcome.Failed; LastFailureException = ex; // ✅ Still tracked for health if (Required) { throw new InvalidOperationException(...); } _logger.OptionalRuleFailed(ex, ...); return EmptyObjectResult(); // ✅ Contributes "{}"u8 → object with C# defaults } ``` **Similarly for HandleSelectFailure** (when Select path missing on optional rules). **Important distinction for `Select(...)` paths:** * **Required rules**: Missing Select path still causes hard failure and rolls back entire recompute to last known good (preserves required rule safety guarantees) * **Optional rules**: Missing Select path returns `{}` and marks rule as Degraded in health This maintains required rules as a strong guardrail against misconfiguration while giving optional rules graceful degradation. **`include: false` Reserved for `.When()` Only:** ```csharp rule.For().FromFile("premium.json") .When(accessor => accessor.GetRequiredConfig().IsPremium) // When condition = false → include: false (intentional semantic skip) // Provider not called, no health impact ``` ### Impact on GetRequiredConfig With this change, `GetRequiredConfig()` throws only when: * No rules are defined for type `T` * Interface `T` is not exposed via `ExposeAs()` It becomes a **static configuration safety check** rather than a runtime availability check. *** ## Consequences ### Positive ✅ **Bug fixed** - All providers now behave identically (as intended) ✅ **Predictability** - Types are always available if configured ✅ **No workarounds needed** - Eliminates environment var hacks ✅ **No null checks** - Simpler consumer code ✅ **Better defaults** - C# property defaults always present ✅ **Richer health** - Separate concern from data flow ✅ **True graceful degradation** - Apps continue with defaults during failures ### Potential Impact ⚠️ **Behavioral change** - Code checking for null to detect optional rule failures will no longer see null ⚠️ **Documentation update** - PART2 article needs revision to reflect correct behavior ### Not a Breaking Change (In Practice) Users who were working around the bug by checking for null to detect failures may need to adjust, but this is not a breaking change because: * The documented intent was "graceful degradation" for optional rules * Collection providers already demonstrated the correct behavior (returning `{}`) * The null return was inconsistent and required hacky workarounds * All 349 tests passed without modification after the fix * No legitimate use case for "optional rule returns null" that isn't better served by health monitoring ### Migration (If Needed) Replace null checks (which were a workaround for the bug) with the proper health API: ```csharp var config = manager.GetConfig(); UseConfig(config); // Always works, may have defaults // Proper way to check if the configuration is healthy: if (!manager.IsHealthy) { // manager.HealthStatus is Degraded when an optional rule failed. // Per-rule detail (LastOutcome, LastFailureException) is exposed through // the rule managers for diagnostics and ConfigHub observability. _logger.LogWarning("Configuration is degraded: {Status}", manager.HealthStatus); } ``` **Note:** Most code won't need changes - checking for null was a workaround for the bug, and most users either: 1. Used DI injection (never saw null) 2. Used the config directly (relied on defaults) 3. Had workarounds like adding `FromEnvironment("FAKE_")` rules (no longer needed) ### Testing Impact Existing tests checking for `null` from optional rules will need updates: ```csharp // Before: var result = manager.GetConfig(); Assert.Null(result); // File doesn't exist // After: var result = manager.GetConfig(); Assert.NotNull(result); // Returns empty object Assert.Equal(default, result.SomeProperty); // C# defaults present // Check health instead: Assert.Equal(HealthStatus.Degraded, manager.HealthStatus); Assert.False(manager.IsHealthy); ``` *** ## Alternatives Considered ### 1. Keep Current Behavior, Document FromStatic Pattern **Decision:** Reject **Reason:** Doesn't solve the EnvironmentVariable workaround hack, maintains inconsistency ### 2. Make Providers Return `null` or `{}` **Decision:** Reject **Reason:** Provider API becomes ambiguous, mixes concerns ### 3. Change Only FileSourceProvider to Return `{}` **Decision:** Reject **Reason:** Partial solution, doesn't address root cause ### 4. Add `.WithDefaults()` Fluent API ```csharp rule.For().FromFile("config.json") .WithDefaults(new Config { /* ... */ }) ``` **Decision:** Defer **Reason:** Could be added later as enhancement, doesn't solve core consistency issue *** ## Related Issues * **Original bug report:** Single optional rule (FromFile) with missing file returns null inconsistently * **Workaround that revealed the bug:** Adding FromEnvironment with fake prefix creates empty object (exposing that collection providers were already correct) * **Design principle:** Collection providers (Env/CLI) already demonstrated the correct behavior *** ## References * `.local/dev.to/PART2-config-aware-rules-in-net-the-power-feature-of-cocoarconfiguration-part-2.md` (Lines 46-85, 175-200) * `src/Cocoar.Configuration/Rules/RuleManager.cs` (HandleFailure, HandleSelectFailure methods) * `src/Cocoar.Configuration/Providers/` (Provider implementations) *** ## Notes This ADR documents a **bug fix** that corrects inconsistent provider behavior. While framed as an architectural decision, it's fundamentally fixing a defect where source-based providers (File, HTTP) had different failure semantics than collection-based providers (Environment, CommandLine). The key insight: **Separation of concerns** - data flow (always flows) vs health monitoring (tracks issues separately) - was always the intended design, but source-based providers weren't implementing it correctly. --- --- url: /adr/ADR-004-aggregate-rules.md description: >- AggregateRuleManager wraps N sub-rules, byte-merges their results, and contains inner Required failures within the aggregate boundary; FromFiles sugar --- # ADR-004: Aggregate Rules with Isolated Execution Boundary **Status:** Accepted **Date:** 2026-03-24 **Decision Makers:** Core Team **Type:** Feature / Architecture **Related:** File layering verbosity, ConfigHub observability requirements *** ## Context Real-world Cocoar.Configuration setups are verbose when layering base files with environment-specific overlays. Each configuration type typically needs 2-3 separate rules (base file, environment override, environment variables). With 7+ config types, this leads to 20+ rules where the relationship between base and overlay is implicit. Additionally, independent rules have no shared error boundary. If a Required rule fails, it rolls back the entire recompute — there is no way to express "this group of rules should fail together or succeed together." ### Approaches Considered **1. Template strings (e.g., `FromFile("config(.{env}).json")`):** Rejected — requires a mini DSL/parser inside strings, not discoverable, no IntelliSense. **2. `.WithOverlay()` fluent chain:** Rejected — repeats the full file path for each overlay, only slightly less verbose. **3. Flat expansion (initial implementation):** Rejected after analysis — expands `AggregateConfigRule` into individual `ConfigRule` instances with group metadata. Inner Required semantics were incorrect: a Required sub-rule would throw and kill the entire recompute even when the aggregate was optional. This would require a behavior change later, creating a silent breaking change. **4. AggregateRuleManager (chosen):** A dedicated manager that wraps N internal `RuleManager` instances, executes them internally, merges their results, and presents one output to the engine. Inner failures are contained within the aggregate boundary. ## Decision Introduce `AggregateRuleManager` implementing `IRuleManager` — the same interface as `RuleManager`. The engine treats both uniformly. Internally, `AggregateRuleManager`: 1. Creates one `RuleManager` per sub-rule, sharing the same `ProviderRegistry` (no duplicate FileWatchers). 2. On `ComputeAsync`: executes each internal manager sequentially, parses results to `MutableJsonObject`, deep-merges them, and serializes back to bytes via `MutableJsonDocument.ToUtf8Bytes`. 3. Catches inner Required failures within the aggregate boundary. If the aggregate is optional, failures are absorbed (Degraded). If required, they propagate (Rollback). 4. Subscribes to all internal managers' `Changes` and forwards signals to the outer engine. Internal managers hit cache for unchanged sources — only the changed source re-fetches. 5. Exposes `SubManagers` for ConfigHub drill-down into the aggregate structure. ### Public API ```csharp // FromFiles — syntactic sugar rule.For() .FromFiles("db.json", $"db.{env}.json") .Required() // Aggregate — full control rule.For() .Aggregate(r => [ r.FromFile("db.json").Required(), r.FromFile($"db.{env}.json") ]) ``` The `Aggregate` lambda receives `TypedProviderBuilder` (not `TypedRuleBuilder`), preventing recursive nesting. ### Builder Hierarchy ``` TypedProviderBuilder ← base, provider extensions target this └── TypedRuleBuilder ← adds Aggregate(), FromFiles() ``` Existing extension methods retargeted from `TypedRuleBuilder` to `TypedProviderBuilder`. No breaking changes — `TypedRuleBuilder` inherits all methods. ## Consequences ✅ Correct Required semantics from day one — no silent behavior changes later ✅ Full observability via `SubManagers` for ConfigHub tree display ✅ One change signal per aggregate (not N signals for N sub-rules) ✅ Provider sharing via existing `ProviderRegistry` — no resource duplication ✅ Byte-level merge path — no string allocations (`MutableJsonMerge` + `Utf8JsonWriter`) ✅ Minor version — purely additive, existing rules unchanged ⚠️ Additional complexity: `IRuleManager` interface extraction, `AggregateRuleManager` implementation ⚠️ Internal merge adds a parse → merge → serialize step (negligible for 2-3 sub-rules) ## References * `src/Cocoar.Configuration/Rules/IRuleManager.cs` — Common interface * `src/Cocoar.Configuration/Rules/AggregateRuleManager.cs` — Implementation * `src/Cocoar.Configuration/Rules/AggregateConfigRule.cs` — Rule definition * `src/Cocoar.Configuration/Fluent/AggregateRulesExtensions.cs` — `Aggregate()` + `FromFiles()` API * `src/Cocoar.Configuration/Fluent/TypedProviderBuilder.cs` — Base builder preventing recursive nesting --- --- url: /adr/ADR-005-multi-tenant-configuration.md description: >- Per-tenant pipeline bundles on a shared global base, one flat rule list with .TenantScoped(), explicit …ForTenant(id) reads, automatic fan-out, eventual consistency --- # ADR-005: Multi-Tenant Configuration **Status:** Accepted — implemented on `feature/multitenant` **Date:** 2026-05-29 (updated 2026-05-30) **Decision Makers:** Core Team **Type:** Feature / Architecture **Related:** ADR-001 (Capabilities), ADR-002 (Atomic Reactive Updates), ADR-004 (Aggregate Rules), PR #47 (WritableStore sparse override overlay), Secrets encryption-key publishing *** ## Context Multi-tenant applications need the **same configuration type to resolve to different values per tenant**. Three kinds of configuration coexist: 1. **Global-only** — e.g. the master-table connection, the service's bind IP/port. One value, no tenant. 2. **Tenant-scoped** — valid per tenant. 3. **Global, but per-tenant overridable** (the common, preferred case) — a global value for everything, where a tenant overrides only the keys it sets and inherits the rest. ### Constraints from the target environment * The host owns the tenant list (e.g. Marten with db-per-tenant); tenant connection strings live there, **not** in config. * **Tenants are added and removed at runtime** — configuration cannot be static or precomputed for a fixed tenant set. * Therefore **Cocoar.Configuration must be tenant-list-agnostic**: it is handed a tenant id and builds that tenant's configuration **on demand**, never enumerating or syncing a tenant registry. ### Feasibility (engine-verified) The recompute/state/reactive engine was audited against this model. All pipeline building blocks (`ConfigurationEngine`, `ConfigurationState`, `MasterBackplane`, `ConfigSnapshot`, `ConfigJsonRepository`, `RecomputeScheduler`, `RuleManager`, `BackplaneReactiveConfig`) are **per-instance with no static singletons**, and the merge primitive (`MutableJsonMerge.Merge`, ordered last-write-wins — already used by `ConfigManager.BuildBaseJson`) already expresses the required layering. The model is feasible without rewriting the recompute, snapshot, or reactive cores. *** ## Decision Introduce a **tenant dimension** carried by a per-tenant rule factory and a per-tenant pipeline bundle layered on the shared global state. ### 1. Declaration — one flat rule list, per-rule `.TenantScoped()` > **Settled authoring API.** An earlier draft of this ADR used a `ForEachTenant((r, tenant) => …)` block. That, and a tiered builder, a `{tenant}` path token, and a top-level `(c, tenant)` lambda, were all rejected (see *Alternatives considered*). The final shape keeps **one flat rule list** and adds exactly one new primitive — a tenant marker on the rule — plus a tenant id on the accessor. Two pieces: * **`Tenant` on `IConfigurationAccessor`** — a default-interface member returning `null` in the global pipeline and the tenant id in a tenant pipeline. Tenant-varying rule factories interpolate it; `.TenantScoped()` keys off it. * **`.TenantScoped()` on the rule builder** — marks a rule to run **only** when a tenant is present (skipped in the global, tenant-agnostic pipeline). Shorthand for `.When(a => !string.IsNullOrWhiteSpace(a.Tenant))`, AND-composed with any existing `When`. Existing providers compose unchanged — a tenant-varying source is just the existing `Func` factory with the id interpolated. No provider becomes "tenant-aware", and no new `Func` overloads are added. ```csharp services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ // Global-only (single state, injectable as today): rules.For().FromStaticJson(masterDefaults), // Global base for a type that is ALSO tenant-overridable: rules.For().FromStaticJson(smtpDefaults), rules.For().FromStore(), // global app-override // Tenant-scoped overlays — same flat list, marked .TenantScoped(); the id flows via the accessor: rules.For().FromFile(a => $"tenants/{a.Tenant}/smtp.json").TenantScoped(), rules.For().FromStore((a, _) => BackendFor(a.Tenant)).TenantScoped(), // per-tenant backend ])); ``` A `.TenantScoped()` rule is registered once but contributes nothing in the global pipeline (its `When` is false there); in a tenant pipeline the same rule runs with `Tenant = id`. There is **no resolver in the definition** — the tenant id is supplied at query time (see §5). ### 2. Per-tenant pipeline bundle on a shared global base The single global `ConfigManager` keeps exactly one global pipeline (unchanged, byte-identical). Each initialized tenant gets its own **pipeline bundle** (engine + state + backplane + reactive manager + rule managers), held in a `ConcurrentDictionary`. Tenant pipelines share the single, read-only `ExposureRegistry`. `ConfigSnapshot` stays keyed by `Type` only — the tenant dimension is the registry key, not a composite snapshot key. ### 3. Rule composition — FLATTEN, not blob-overlay For a tenant-scoped type `T`, the effective layer stack is the **flattened rule list** `[global rules for T] ++ [tenant rules for T]`, run through the **same recompute pipeline** as a normal config. > We do **not** merge a pre-computed tenant-JSON blob onto a pre-computed global-JSON blob. The recompute pipeline is the single proven path that turns an ordered rule list into a value — including transforms (`Mount`/`Select`), required-rule rollback, and dependency ordering. Flattening reuses that one path; a "merge two blobs" step would fork it and break those transforms. The tenant segment is a **positioned segment** of the flattened list. Placing it last (the default) gives "tenant wins per key, else inherits global". Placing a global rule after it would let a global value override tenants (forced/compliance policy) — supported by construction, not a special case. > **v1 implementation: full-list-per-tenant (seed-from-global deferred).** Each tenant pipeline runs the **entire** flat rule list — global rules included — with its own rule managers and providers. This is correct and, crucially, gives **automatic fan-out** (§6): each tenant holds its own subscription to a live global base source, so a base change reaches the tenant with no cross-pipeline machinery. The cost is linear: N tenants re-run the base rules. The originally-planned **seed-from-global** optimization (read the global managers' last contribution lock-free and recompute only the tenant-scoped suffix) is **deferred** — it would save the re-run but trade away automatic fan-out for an explicit coordinator and a lock-ordering hazard against the global recompute semaphore. It remains a clean, isolated optimization to add behind the unchanged public API if tenant/rule counts ever make the re-run cost matter. ### 4. Lifecycle — explicit, async at init, sync at read Mirrors `ConfigManager.CreateAsync` (build async, then serve sync), scoped per tenant: ```csharp await mgr.InitializeTenantAsync(tenantId); // build the tenant's pipeline (async), at tenant creation await mgr.EnsureTenantInitializedAsync(tenantId); // idempotent warmup (e.g. request-start middleware) await mgr.RemoveTenantAsync(tenantId); // dispose the tenant's bundle, at tenant removal ``` **Reads stay synchronous** — `InitializeTenantAsync` does the async work once; afterward the tenant snapshot is read synchronously, exactly like the global config. Global reads and existing single-tenant apps are unchanged. Async is confined to the dynamic-tenant materialization moment. ### 5. Consumption — explicit `…ForTenant(id)`, never injection Tenant-scoped values are obtained by **passing the tenant id**, never by DI injection: ```csharp var smtp = mgr.GetConfigForTenant(tenantId); // sync var live = mgr.GetReactiveConfigForTenant(tenantId); var store = mgr.GetWritableStoreForTenant(tenantId); // per-tenant write facade var flags = mgr.GetFeatureFlagsForTenant(tenantId); var ents = mgr.GetEntitlementsForTenant(tenantId); ``` **Tenant-scoped types/flags are NOT DI-injectable.** Injecting one into a long-lived (Singleton) consumer would be a captive-dependency bug — it would freeze one tenant forever, since the container cannot know the runtime tenant. The `ServiceRegistrationPlanner` therefore tags and **excludes** types whose every rule is `.TenantScoped()` from the normal DI plan. Global types remain injectable as today. A consuming service injects the `ConfigManager` / `ITenantConfigurationAccessor` and calls `GetFeatureFlagsForTenant(currentTenant)` — explicitly tenant-aware, which is the correct shape for multi-tenant code. ### 6. Fan-out — automatic via per-tenant subscriptions (v1) Each tenant snapshot layers on the global base, so a change to the **global** base must propagate to initialized tenants. **v1 (implemented):** because each tenant pipeline runs the full flat rule list (§3) with its **own** provider instances and **own** change subscriptions, a live global base source (file / observable / http) propagates to every initialized tenant **automatically** — each tenant's own base subscription fires its own debounced recompute, re-seeding from the new base and re-emitting on its own `IReactiveConfig` (content-gated by the engine's reference-equality change detection: a tenant that masks the changed key with its own override does not emit). No cross-pipeline coordinator runs, and there is no inline cross-pipeline read to deadlock. Consistency is per-tenant eventual, as decided below. This is verified by `TenantFanOutTests`. **Deferred (only relevant if seed-from-global lands):** if the base is ever shared rather than re-run per tenant (§3), tenants would no longer self-subscribe and an explicit **fan-out coordinator** becomes necessary — observing the global commit via `MasterBackplane.SnapshotStream` strictly **after** the global Publish and semaphore release (never inline), recomputing subscribed tenants and stale-marking idle ones. That coordinator is **not built** in v1 because the full-list-per-tenant model makes it unnecessary. ### 7. Reach across the library The tenant dimension is unified by the factory + bundle: * **Feature Flags / Entitlements** become tenant-aware **without a source-generator change**: the generated flag class already reads an injected `IReactiveConfig`; tenant-awareness means constructing it with the **tenant's** `IReactiveConfig`. `GetFeatureFlagsForTenant(id)` is a per-`(tenant, TFlags)` factory/cache over the existing generated class. The context-aware evaluator and the REST endpoints (`MapFeatureFlagEndpoints`) gain a tenant dimension (e.g. a route segment). * **WritableStore** per tenant: reads fall out of the factory (`FromStore(BackendFor(tenant))`; file backend = a folder per tenant); writes go through a per-tenant `GetWritableStoreForTenant(id)` facade pointing at the tenant's backend. * **Secrets** are tenant-capable via folder mode (`kid` = tenant subfolder routes decryption); a tenant writes its encrypted envelope to its own backend, decrypted with its own cert. **Encryption-key publishing is per tenant too:** `GetCurrentKeyForTenant(tenantId)` / `MapTenantSecretEncryptionKey` return that tenant's **single current public key** — the newest cert in its subfolder (older certs stay decrypt-only for rotation) — resolved from `ITenantContext`, never a list and never another tenant's key. See [Publishing Encryption Keys](/guide/secrets/key-publishing). ### 8. No-DI core preserved Tenant methods live on a **new** `ITenantConfigurationAccessor` that `ConfigManager` also implements; the existing `IConfigurationAccessor` stays byte-identical. The whole tenant lifecycle is explicit method calls — usable with zero DI container. ### Settled product decisions * **Authoring:** one flat rule list + per-rule `.TenantScoped()` + `Tenant` on `IConfigurationAccessor` (§1); `ForEachTenant`/tiered-builder/`{tenant}`-token/top-level-`(c, tenant)` rejected (*Alternatives considered*). * **Fan-out:** automatic via per-tenant subscriptions in v1 (§6); explicit coordinator deferred with seed-from-global. * **Precedence:** two-layer `[global]++[tenant]`, tenant on top; a tenant's plan/license is a config **value/flag**, not a precedence tier. * **Consistency:** per-tenant **eventual consistency** — a global change propagates tenant-by-tenant as each rebuild finishes (a deliberate relaxation of ADR-002's single-snapshot atomicity, which still holds *within* each tenant and within the global state). * **Eviction:** explicit `RemoveTenantAsync` only — no idle-eviction or cap (the active-tenant set is host-bounded). *** ## Engine impact | File / Area | Change | Kind | |---|---|---| | `Core/ConfigManager.cs` | Single-pipeline ownership → global bundle + `ConcurrentDictionary>>`; add `InitializeTenantAsync`/`EnsureTenantInitializedAsync`/`IsTenantInitialized`/`RemoveTenantAsync`/`GetConfigForTenant`/`GetReactiveConfigForTenant`; extend dispose | **Structural** — *done (b2)* | | **NEW** `TenantPipeline` | Bundle of per-tenant engine/state/backplane/reactive/rules on the shared scope + frozen registry | **Structural / new** — *done (a/b2)* | | ~~`TenantFanOutCoordinator`~~ | **Not built in v1** — full-list-per-tenant gives automatic fan-out (§6); coordinator only needed if seed-from-global lands | Deferred | | `Core/ConfigurationEngine.cs` | seed-from-global recompute variant — **deferred** (§3); v1 re-runs the full list per tenant (correct, unoptimized) | Deferred | | `Core/MasterBackplane.cs`, `ConfigurationState.cs`, `ConfigurationAccessor.cs` | Instantiated per tenant (no internal change); per-tenant accessor so the recompute-window fallback reads tenant JSON, not global | Additive | | `Rules/ConfigRule.cs` (+ Fluent) | `.TenantScoped()` marker on the rule builder (AND-composed with any `When`); `Tenant` on the accessor | Additive | | `DI/ServiceRegistrationPlanner.cs` | Tag/exclude types whose every rule is `.TenantScoped()` from the normal DI plan | Additive | | `DI/ServiceDescriptorEmitter.cs` | (Only if/when ambient injection is ever wanted — currently **out of scope**, see §5) | — | | Abstractions | New `ITenantConfigurationAccessor`; existing `IConfigurationAccessor` unchanged | Additive | | Flags/Entitlements | `GetFeatureFlagsForTenant`/`GetEntitlementsForTenant` factory/cache (no generator change); tenant dimension on evaluator + REST endpoints | Additive | **Net:** one structural change (`ConfigManager` ownership → per-tenant `TenantPipeline` bundles). Everything else is additive reuse of existing per-instance machinery — fan-out is automatic via each tenant's own subscriptions, with **no coordinator subsystem in v1** (§6; the coordinator is deferred with seed-from-global). No rewrite of the recompute/snapshot/reactive cores. *** ## Consequences ✅ Reuses the existing recompute/merge/reactive engine wholesale; the global pipeline and single-tenant apps are unchanged ✅ Reuses PR #47's overlay/merge primitives (`BuildBaseJson`, `MutableJsonMerge`) — that work is on-path for tenancy, not superseded ✅ Reads stay synchronous; async is confined to explicit tenant init ✅ No source-generator change for tenant-aware flags ✅ No-DI core preserved; tenant API additive on a new interface ✅ Captive-dependency class of bugs avoided by design (explicit `…ForTenant(id)` only) ⚠️ Structural rework of `ConfigManager`'s "one state per manager" ownership model (mechanical but not an additive extension; `ConfigManager` is sealed) — **done**: extracted into `TenantPipeline`, global path byte-identical ⚠️ Per-tenant eventual consistency (vs. ADR-002 global atomicity) — a global base change lands tenant-by-tenant. Tuples stay atomic *within* a pipeline; mixed-scope tuples ARE supported (each element is read from one pipeline's snapshot), and a tuple's global-only element read per tenant is just an eventual-consistent copy — not a tuple-internal skew. ⚠️ Resource use scales linearly with initialized tenants × base rules (each tenant re-runs the global base); acceptable for a host-bounded active-tenant set, and the seed-from-global optimization can reclaim it later without an API change ⚠️ Each tenant holds its own subscription to live base sources — for an SSE/HTTP base that is N connections to the config server; document and revisit with seed-from-global if it bites at scale *** ## Open questions (implementation-level) * **Fan-out throttle at scale:** with full-list-per-tenant, a global base change fans out as one independent debounced recompute per initialized tenant; whether a cross-pipeline throttle is needed depends on tenant/subscriber counts. Becomes pressing only if seed-from-global lands (a single coordinator then drives all tenants). * **Tuples across scopes:** **resolved — supported.** Each element is read from the relevant pipeline's atomic snapshot (global skips `.TenantScoped()` overlays; per-tenant is effective). A *global* tuple with a type whose every rule is `.TenantScoped()` throws (no global value) — read it per tenant. ("Scope" is a rule property, not a type property; the earlier "not supported" framing was wrong.) * **Idle-read freshness contract:** moot in v1 — idle initialized tenants self-update via their own subscriptions, so a sync read is current. (Re-opens only with seed-from-global's stale-mark model.) *** ## Alternatives considered (authoring API) All rejected in favor of *one flat rule list + per-rule `.TenantScoped()` + `Tenant` on the accessor* (§1): * **`ForEachTenant((r, tenant) => ConfigRule[])` block** (this ADR's first draft) — a second nested rule surface and a `Func` shape, duplicating the builder. Rejected: a whole parallel authoring path for what is one bit of metadata on a rule. The flat list with `.TenantScoped()` expresses the same precedence (position in the list) without a sub-builder. * **Tiered builder** (`UseConfiguration` / `UseTenantConfiguration` / `WithNonNegotiable`, as in the POC) — makes precedence three named tiers. Rejected: "non-negotiable" is just a global rule placed after the tenant overlay in the flat list (§3), so the third tier is redundant; the two extra entry points add API surface without new capability. * **`{tenant}` path token** (magic string interpolated by providers) — rejected: pushes tenancy into every provider's option parsing, exactly the "tenant-aware provider" coupling §1 avoids. The existing `Func` factory already interpolates `a.Tenant` with no provider change. * **Top-level `(c, tenant)` lambda** on `AddCocoarConfiguration` — rejected: forces the tenant id into the *definition* phase, whereas the id is a *query-time* value (§5); it also can't express "global base + tenant overlay for the same type" in one list. *** ## References * PR #47 — WritableStore sparse override overlay (`ConfigManager.BuildBaseJson`, `MutableJsonMerge`) — the merge/overlay foundation reused here * `src/Cocoar.Configuration/Core/ConfigurationEngine.cs` — recompute pipeline (per-instance semaphore + scheduler) * `src/Cocoar.Configuration/Core/MasterBackplane.cs` — `SnapshotStream` (fan-out hook), per-instance publish/dispose * `src/Cocoar.Configuration/Core/ConfigManager.cs` — current single-pipeline ownership to be extended * ADR-002 — atomic reactive updates (relaxed to per-tenant eventual consistency here) * ADR-004 — aggregate rules (`AggregateConfigRule` precedent for grouping sub-rules) --- --- url: /adr/ADR-006-di-aware-configuration.md description: >- Two-layer model — eager no-DI UseConfiguration plus lazy UseServiceBackedConfiguration whose (sp,a) factories resolve container services, activated by a hosted service --- # ADR-006: DI-aware Configuration (Two-Layer Model) **Status:** Accepted — implemented on `feature/multitenant` **Date:** 2026-05-30 **Decision Makers:** Core Team **Type:** Feature / Architecture **Related:** ADR-005 (multi-tenancy), the "No-DI core" principle (CLAUDE.md), Microsoft `IConfiguration`/`IOptions`, the HTTP/WritableStore/Marten provider discussion > **Implementation note (delivered).** Shipped as `UseServiceBackedConfiguration` (Layer 2) + `FromStore((sp,a)=>IStoreBackend)` (DI package) + `FromHttp((sp,a)=>HttpClient)` (Http package), activated by `ServiceBackedConfigurationActivator : IHostedLifecycleService` and the manual `IServiceProvider.ActivateServiceBackedConfigurationAsync()`. The sp-gate is a dedicated, non-clobberable `ConfigRuleOptions.ActivationGate` enforced in `RuleManager.ShouldSkip` (mirrors the `.TenantScoped()` marker — fluent-order-proof). Activation wiring lives in the DI **instance** overload `AddCocoarConfiguration(IServiceCollection, ConfigManager)` — the single point all entry paths (DI, AspNetCore, manual) funnel through. > The `(sp,a)` overloads are **type-scoped, not ambient**: `UseServiceBackedConfiguration(rules => …)` hands each `rules.For()` a public `ServiceBackedProviderBuilder : TypedProviderBuilder` carrying a public `ServiceBackedRuleContext` (`IsActive` + `ServiceProvider`). `FromStore`/`FromHttp((sp,a)=>…)` are extensions on *that* type, so using them in Layer-1 `UseConfiguration` is a **compile error**, not a runtime throw. The seam is **public**: a third-party provider package authors its own `FromX((sp,a)=>…)` extension on `ServiceBackedProviderBuilder` (read `Context.ServiceProvider`, gate with the public `WithActivationGate(_ => Context.IsActive)`) and exposes a slot for the resolved artifact on its provider options. The provider class (`ConfigurationProvider<,>`) stays DI-free. Whether a provider is service-backable is the provider author's choice. §11 (scoped `ITenantReactiveConfig` + `ITenantContext`) shipped in `Cocoar.Configuration.AspNetCore`. Covered by `Cocoar.Configuration.ServiceBacked.Tests` + AspNetCore tenant-adapter tests. See "Open questions" below for the resolved decisions. *** ## Context ### The No-DI core (which we keep) `Cocoar.Configuration` (core) has **zero dependency on `Microsoft.Extensions.DependencyInjection`** — only `Microsoft.Extensions.Logging.Abstractions` + first-party packages. This is load-bearing, not decorative: * **Test ergonomics** — the bulk of the suite uses `ConfigManager.Create(...)` directly, no `ServiceProvider`. * **Embedding moat** — a *library* can use Cocoar internally without forcing a DI container on its consumers. * **CLI / workers / AOT / alt-containers** — `Cocoar.Configuration.Secrets.Cli` is a real no-DI consumer; Autofac/Lamar/DryIoc shops stay supported via the thin `.DI` adapter. **We do not delete or weaken the No-DI core.** This ADR adds DI capability *on top*, as an opt-in satellite. ### The limitation this ADR removes `AddCocoarConfiguration(Action)` today does: ```csharp var configManager = ConfigManager.Create(configure); // builds AND initializes EAGERLY, here services.AddCocoarConfiguration(configManager); // registers the already-built INSTANCE (not a sp => factory) ``` `ConfigManager.Create` runs `Configure` **and** `Initialize` synchronously — instantiating every provider and running the initial recompute **before `BuildServiceProvider()` ever runs.** Consequences: * Config providers are built **pre-container** → they **cannot resolve services from the app container.** * Evidence: the HTTP provider does `new HttpClient()` (`HttpProvider.cs`); it cannot use `IHttpClientFactory`. The only DI seam (`IProviderServiceRegistration`) is **registration-time and one-way** ("Called once during DI setup — not on every recompute") — providers register services *into* DI, they cannot resolve *from* it at recompute time. * The most important enterprise providers therefore can't be done cleanly: **DB-backed config** (Marten `IDocumentStore`, EF `IDbContextFactory`) and **HTTP via `IHttpClientFactory`**. ### The hard logical boundary (true in every framework) Config that needs a DI service *to load* cannot be used to *bootstrap the DI container* — that is circular. So **pre-container config must come from dependency-free sources** (file, env, command-line, static). This is not a Cocoar limitation; Microsoft has the same boundary. ### How Microsoft solves it (the pattern we follow) A **two-layer** architecture: | Layer | When | DI? | |---|---|---| | `IConfiguration` (raw key-values, sources) | eager, pre-container | **no** — dumb providers; a provider that needs a dependency (Key Vault credential, EF context) is **hand-fed** it / news its own | | `IOptions` / `IOptionsMonitor` (typed binding + post-processing) | **lazy, resolved from the container** | **yes** — `IConfigureOptions` are DI services that can inject dependencies | Cocoar currently **fuses** both layers (providers + typed binding + reactive, all eager in the `ConfigManager`). That is more powerful in some ways (layering, transforms, reactive in one model) but it inherits the eager-source limitation **without** Microsoft's lazy `IOptions` escape hatch. This ADR adds that lazy layer — in Cocoar's own ordered-layer idiom. > For the Marten/DB-per-tenant case, Cocoar with this layer would actually **exceed** Microsoft's built-ins: Microsoft's EF config provider news up its *own* `DbContext`; Cocoar would use the app's real, DI-managed `IDocumentStore`, tenant-scoped. *** ## Decision Introduce a **two-layer configuration model**. The core stays DI-free; the DI integration is a **satellite extension on the DI package**, exactly like `UseSecretsSetup()` / `UseFeatureFlags()`. ### 1. Two authoring surfaces ```csharp services.AddCocoarConfiguration(c => c // Layer 1 — eager, no IServiceProvider, available pre-container. Wires the DI plan + bootstrap config. .UseConfiguration(rule => [ rule.For().FromFile("appsettings.json"), // bootstrap log level (eager) rule.For().FromFile(a => $"tenants/{a.Tenant}/db.json").TenantScoped(), // tenant, no DI ]) // Layer 2 — extension method FROM the DI package; rules whose factories receive the IServiceProvider. .UseServiceBackedConfiguration(rule => [ rule.For().FromHttp((sp, a) => sp.GetRequiredService().CreateClient("cocoar-config"), "logging.json"), rule.For().FromStore((sp, a) => new MartenConfigBackend(sp.GetRequiredService(), a.Tenant)).TenantScoped(), ])); ``` * `UseConfiguration` — **Layer 1**, core, unchanged. No `sp`. * `UseServiceBackedConfiguration` — **Layer 2**, **defined in `Cocoar.Configuration.DI`** as an extension on `ConfigManagerBuilder`. Its rules' provider factories receive the `IServiceProvider`. `IServiceProvider` never appears in the core public surface. ### 2. Mechanism — holder + per-rule `sp`-gate + activation hosted service Three pieces, **all in the DI package**: 1. **`ServiceProviderHolder`** (DI-package singleton): `null` until the container is built; afterward holds the **root** `IServiceProvider`. 2. **`sp`-using factory overloads** (`FromStore((sp,a)=>…)`, `FromHttp` with `IHttpClientFactory`, …): each wraps a core provider-options factory `accessor => userFactory(holder.ServiceProvider!, accessor)` **and** composes a gate `.When(_ => holder.HasServiceProvider)`. The gate reuses the `ShouldSkip` machinery hardened in ADR-005 (a rule that skips while its precondition is absent, contributing nothing). 3. **An activation `IHostedService`** (registered by the DI package's `AddCocoarConfiguration`, where the `IServiceCollection` is available; it is container-constructed so it receives `sp`): on host start it sets `holder.ServiceProvider = sp` and triggers a **recompute from the Layer-2 start index**. **Core touch is minimal:** reuse `ShouldSkip` (the `sp`-gate is expressed via the existing `When` predicate) and the already-internal `ScheduleRecompute(startIndex)` + `RestorePrefixContributions`. The only likely new core seam is a small **internal hook to append satellite-supplied rules** to the builder (consistent with how satellites already extend it). `InternalsVisibleTo("Cocoar.Configuration.DI")` already exists, so the DI package can drive the post-container recompute. ### 3. Lifecycle — two-phase for the global pipeline, single-phase for tenants * **Global pipeline:** Layer 1 runs **eager** at registration (for the DI plan + bootstrap config). Layer-2 rules are **dormant** (`sp`-gated) until host start; the hosted service then sets the holder and triggers `ScheduleRecompute(layer2Index)` → Layer 2 activates, merges on top, reactive subscribers emit. * **Tenant pipelines:** always built at runtime (`InitializeTenantAsync`, post-container, `sp` already present) → a **single** recompute runs Layer 1 + Layer 2 together; the `sp`-gate is automatically satisfied. The two-phase split is a *global-pipeline* concern only. ### 4. Precedence and gating are separable (key clarification) "Layer 2" bundles two **independent** properties — keep them separate in the implementation: * **Precedence** = position in the combined list. The Layer-2 bucket sits *after* Layer 1 → Layer 2 wins per key. * **Gating** = **per rule**, and only for rules whose factory actually uses `sp`. A non-`sp` rule placed in Layer 2 is **not** gated → it runs eagerly **and** gains the later precedence. Consequence — "a non-DI rule must beat a DI-backed rule" needs **no duplication**: declare the non-DI rule once, in Layer 2, *after* the DI-backed rule. It runs eagerly (no `sp`) and wins by position. **Decision:** gate **per-`sp`-usage** (recommended), not per-bucket. Per-bucket is a simpler mental model but needlessly defers non-`sp` rules placed in Layer 2. ### 5. Tenancy is orthogonal to the layer Two independent axes; the layer is chosen by `sp`-need, **not** by tenancy: | | no `sp` (Layer 1) | needs `sp` (Layer 2) | |---|---|---| | **global** | `FromFile("app.json")` | `FromHttp((sp,a)=>factory…)` | | **tenant** | `FromFile(a=>$"t/{a.Tenant}/db.json").TenantScoped()` *(works today)* | `FromStore((sp,a)=>new Marten(store,a.Tenant)).TenantScoped()` | `.TenantScoped()` is a layer-agnostic modifier, valid in **both** methods. The gates **compose**: `.TenantScoped()` adds a "tenant present" gate; Layer 2 adds an "`sp` present" gate. **Marten-per-tenant** = both gates → runs only in a tenant pipeline post-container. Do **not** restrict tenant rules to Layer 2 — that would couple tenancy to DI and kill no-DI multi-tenant scenarios (file-per-tenant in a CLI / embedded lib). ### 6. Reactive contract (load-bearing) * **Layer-2 activation is a RECOMPUTE on the existing pipeline (same backplane), never a rebuild.** A rebuild would orphan every previously-obtained reactive view. * Therefore **all live `IReactiveConfig` views receive the Layer-2 update, regardless of when they were obtained** — they are all views over the same `MasterBackplane.SnapshotStream`, and Layer-2 activation is just another committed snapshot. A view obtained *pre-container* (e.g. to drive a Serilog `LoggingLevelSwitch`) gets the Layer-2 value when it lands, then every subsequent poll change. ```csharp var levelSwitch = new LoggingLevelSwitch(); var live = mgr.GetReactiveConfig(); // pre-container is fine live.Subscribe(c => levelSwitch.MinimumLevel = Map(c.Level)); // fires: now (Layer-1 file level) → on Layer-2 activation (remote level) → on every poll change after ``` Note: this requires a **subscription** (push), not a one-time `.CurrentValue` read. (Driving the actual MEL `ILogger` filters still needs an explicit bridge from the reactive value to `LoggerFilterOptions` — that is a logging-integration concern, not part of this ADR.) ### 7. Readiness contract (= `IOptions` semantics) * Layer-2 values are **guaranteed after host start**. * A snapshot read (`GetConfig()`) **before** host start returns the **Layer-1 base** value; a reactive subscriber gets the upgrade when Layer 2 activates. * A type that exists **only** in Layer 2 is unresolved (null) before host start. ### 8. Failure semantics Layer-2 rules should typically be **optional**: if a Layer-2 source fails (DB/HTTP down), the recompute rolls back to the last good state → **Layer-1 values persist**, health is degraded. A remote outage must not nuke the whole config. ### 9. Lifetime discipline (the holder is the ROOT provider) The holder's `sp` is the **root** `IServiceProvider` (the activation hosted service is root-constructed; we are **not** in a request scope). Therefore: * Resolve **singletons / factories only** — `IDocumentStore` (Marten), `IDbContextFactory` (EF), `IHttpClientFactory`. Open **short-lived units per read** on the recompute thread (`store.QuerySession(a.Tenant)`, `factory.CreateDbContext()`, `factory.CreateClient(...)`). **Never** resolve a scoped service from root (captive-dependency bug). * This is correct, not a limitation: config is computed **once per tenant/global, cached, reactive — not per request.** The request scope is irrelevant to a config recompute. (If a source ever genuinely needs a scoped service, create a scope per recompute — rarely needed.) ### 10. HTTP provider gains an `IHttpClientFactory`-backed path Today `HttpProvider` does `new HttpClient()`. Add a Layer-2 overload that resolves `IHttpClientFactory` from the holder and uses a named client — gaining handler pooling/rotation, Polly via `AddHttpClient`, etc. The current `new HttpClient()` / `HttpMessageHandler?` path stays for Layer 1 / no-DI. ### 11. Consumption-tenant adapter (implemented) Distinct from the **source-tenant** flow above (`a.Tenant`, build side) is the **consumption-tenant** flow: "this request's tenant's config via injection." A *separate* concern from this ADR's core, built on top of the existing `GetReactiveConfigForTenant`: * The **`ITenantContext { string? Current }`** abstraction ("who is the current tenant for this request/scope") is **ambient tenant resolution** — a container/scope concern, so it lives in **`Cocoar.Configuration.DI`**. No-DI hosts have no ambient scope; they pass the tenant explicitly via `…ForTenant(id)`. * **DI:** a scoped **`ITenantReactiveConfig`** adapter (in **`Cocoar.Configuration.DI`**) reads `ITenantContext.Current` and delegates to `mgr.GetReactiveConfigForTenant(tenant)`. The app registers a scoped `ITenantContext` with `AddCocoarTenantResolver(s => s.TenantId)` — pointing at whatever already knows the tenant, no adapter to hand-write. HTTP is simply `AddCocoarTenantResolver(a => a.HttpContext?...)`; there is **no** AspNetCore-specific resolver API. * Scoped/transient consumers only; a singleton can never have an ambient tenant → it uses explicit `GetReactiveConfigForTenant(id)`. * **Trap:** do **not** re-register `IReactiveConfig` itself as scoped (it is a singleton; that would break singletons injecting it). Use a **distinct** `ITenantReactiveConfig`. *** ## Non-breaking guarantees Existing consumers (Layer-1-only) are untouched, **if** we hold three rules: 1. **Layer 1 (`UseConfiguration`) stays eager and identical** — readiness, timing, even the "I/O at registration" behavior. Only the new opt-in Layer 2 is lazy. 2. **Everything new is additive** — new builder extension, new `(sp,a)=>` factory overloads, new types. **No existing signature changes** (do not touch `IConfigurationAccessor` / `ConfigurationProvider` in a breaking way). 3. **The activation hosted service is registered only when Layer-2 rules exist** → zero impact for apps that do not opt in. Plus the §11 trap: never re-register `IReactiveConfig` as scoped. *** ## Engine / package impact | Area | Change | Kind | |---|---|---| | Core `RuleManager.ShouldSkip` | reused for the `sp`-gate (via the existing `When` predicate) — no change needed | Reuse | | Core `ConfigurationEngine.ScheduleRecompute(startIndex)` + `RestorePrefixContributions` | reused to run the Layer-2 activation recompute | Reuse | | Core `ConfigManagerBuilder` | likely **one small internal hook** to append satellite-supplied rules | Additive (internal) | | **NEW** `Cocoar.Configuration.DI`: `ServiceProviderHolder` + `UseServiceBackedConfiguration` extension + `sp`-aware factory overloads (`FromStore`, …) + activation `IHostedService` | the whole Layer-2 mechanism | **New (satellite)** | | `Cocoar.Configuration.Http` | `FromHttp((sp,a)=>…)` overload resolving `IHttpClientFactory` | Additive | | `Cocoar.Configuration.DI` | scoped `ITenantReactiveConfig` + `AddCocoarTenantResolver` (§11) | Additive | **Net:** the core gains essentially nothing DI-specific (a small internal append hook at most); the entire DI integration lives in the satellite packages. The No-DI core is preserved. *** ## Consequences ✅ DB-backed config (Marten/EF) and `IHttpClientFactory`-backed HTTP become possible — the headline enterprise scenarios ✅ Marten-per-tenant config falls out of composing the tenant gate + the `sp` gate ✅ Bootstrap config (eager, Layer 1) + remote/DI-backed override (lazy, Layer 2) in **one** reactive value — nicer than juggling `IConfiguration`/`IOptions`/`IOptionsMonitor` ✅ No-DI core preserved; fully additive/opt-in; non-breaking for existing consumers ✅ Removes a latent smell: today file/HTTP I/O runs at *service registration* time; Layer-2 work moves to container-owned time ⚠️ A readiness contract exists (Layer-2 values after host start) — must be documented; consumers needing the upgrade must subscribe, not snapshot ⚠️ The activation timing vs. consumers resolved *during* `BuildServiceProvider` needs care (hosted service runs after build, before serving) ⚠️ DB sources have no push change-detection by default → poll or Postgres `LISTEN/NOTIFY` (separate work) ⚠️ Precedence is bucketed (all Layer 1 before all Layer 2); the rare "non-DI must beat DI-backed" is handled by placing the non-DI rule in Layer 2 (§4), not across the bucket boundary *** ## Open questions — resolved in the implementation * **Gating granularity:** ✅ per-`sp`-usage. Each `sp`-using overload attaches a dedicated `ActivationGate`; a non-`sp` rule placed in Layer 2 runs eagerly and still wins by position. * **Naming:** ✅ `UseServiceBackedConfiguration`; factory overloads `FromStore((sp,a)=>IStoreBackend)` and `FromHttp((sp,a)=>HttpClient)`. * **Activation hook:** ✅ `IHostedLifecycleService`, acting in `StartingAsync` (before any regular `IHostedService.StartAsync`), so Layer 2 is live before app/hosted-service code reads config. A manual `IServiceProvider.ActivateServiceBackedConfigurationAsync()` covers non-host scenarios; both are idempotent (the holder publishes the provider exactly once). Consumers that read a snapshot *during* container build see the Layer-1 base; the readiness contract (§7) requires a **subscription** to receive the upgrade. * **Append-rules core seam:** ✅ `ConfigManagerBuilder.AddServiceBackedRules(IEnumerable)` appends after Layer 1 and records `ConfigManager.ServiceBackedLayerStartIndex`. The sp-gate seam is the **public**, **type-scoped** (not ambient) `ServiceBackedRuleContext` (BCL `IServiceProvider` only — the core never names a DI type): it is carried by the public `ServiceBackedProviderBuilder.Context` and read by the DI, Http, and third-party `(sp,a)` overloads. * **DB change-detection:** still out of scope here — poll (via `FromStore` on a polling backend) or app-driven re-init (`RemoveTenantAsync` then `InitializeTenantAsync`; there is no in-place reload). Push (`LISTEN/NOTIFY`) remains separate, future work. *** ## Alternatives considered * **Make DI mandatory / delete the No-DI core** — rejected. The No-DI core is the test/CLI/embedding/alt-container moat; the DI majority is served by making the DI path the blessed default, not by removing No-DI. * **Make everything lazy (Layer 1 too)** — rejected. Breaks config-driven DI registration (you must read config *while* building the `ServiceCollection`), breaks "config ready when `AddCocoarConfiguration` returns", and would be a breaking change. * **`sp` on the outer rule-list lambda (`(rule, sp) => […]`)** — rejected. The DI plan needs the Layer-2 *type list* at registration (no `sp`); `sp` must flow into the provider *factories*, not the enumerable lambda. * **Build an intermediate `ServiceProvider` at registration to feed providers** — rejected (well-known anti-pattern: a second container, duplicate singletons, disposal chaos). * **Restrict tenant rules to Layer 2** — rejected (couples tenancy to DI; kills no-DI multi-tenant; §5). *** ## References * ADR-005 — multi-tenancy (the `.TenantScoped()` gate + `GetReactiveConfigForTenant` this builds on) * `src/Cocoar.Configuration.DI/CocoarConfigurationExtensions.cs` — the eager `ConfigManager.Create` + `AddSingleton(instance)` to be made container-owned for Layer 2 * `src/Cocoar.Configuration.Http/HttpProvider.cs` — the `new HttpClient()` to get an `IHttpClientFactory` overload * `src/Cocoar.Configuration/Rules/RuleManager.cs` — `ShouldSkip` (the gate machinery) * `src/Cocoar.Configuration/Core/ConfigurationEngine.cs` — `ScheduleRecompute(startIndex)` + `RestorePrefixContributions` (the activation recompute) * Microsoft `IConfiguration` / `IOptions` — the proven two-layer precedent --- --- url: /guide/configuration/aggregate-rules.md description: >- Group sub-rules into one unit with FromFiles file-layering shorthand and Aggregate() over mixed providers, aggregate-level vs sub-rule Required semantics --- # Aggregate Rules Aggregate rules group multiple sub-rules into a single logical unit. The group merges its sub-rules internally and presents one result to the configuration engine. ## The Problem File-layering setups are verbose — each config type needs separate rules for base, environment overlay, and local overrides: ```csharp rule => [ rule.For().FromFile("db.json"), rule.For().FromFile($"db.{env}.json"), rule.For().FromFile("db.local.json"), rule.For().FromEnvironment("DB_"), ] ``` More importantly, these rules are independent — if one fails, it fails in isolation with no concept of "this group of files belongs together." ## FromFiles — File Layering Shorthand `FromFiles` bundles multiple file paths into one aggregate rule: ```csharp rule => [ rule.For() .FromFiles("db.json", $"db.{env}.json", "db.local.json") .Required(), rule.For().FromEnvironment("DB_"), ] ``` Files are merged in order (left to right). Files that don't exist are silently skipped. `.Required()` means the aggregate must produce data — at least one file must exist. ## Aggregate — Full Control For non-file sources or mixed providers, use `.Aggregate()`: ```csharp rule.For() .Aggregate(r => [ r.FromFile("db.json").Required(), r.FromFile($"db.{env}.json"), r.FromEnvironment("DB_") ]) .Required() ``` The lambda receives a `TypedProviderBuilder` — all provider methods (`FromFile`, `FromEnvironment`, `FromHttp`, etc.) are available, but `Aggregate` and `FromFiles` are not. This prevents recursive nesting. ## Required Semantics Required works at two independent levels: ### Aggregate-Level Required `.Required()` on the aggregate means "the merged result must not be empty": ```csharp rule.For() .FromFiles("db.json", $"db.{env}.json") .Required() // At least one file must contribute data ``` ### Sub-Rule Required `.Required()` on an inner sub-rule means "this source is critical for the group": ```csharp rule.For() .Aggregate(r => [ r.FromFile("db.json").Required(), // Must exist r.FromFile($"db.{env}.json") // Optional overlay ]) ``` ### Interaction | Aggregate | Sub-Rule | Sub-Rule Fails | Behavior | |-----------|----------|----------------|----------| | optional | optional | — | Degraded, continues | | optional | required | — | Failure absorbed, Degraded | | required | optional | — | OK if others contribute | | required | required | — | Rollback (exception) | Inner Required failures **never escape** an optional aggregate. This is the key difference from independent rules — the aggregate acts as a boundary. ## Health & Observability The aggregate reports its own health status. Internally failed sub-rules are not visible to the health tracker — the aggregate is the unit: * Aggregate produces data → **Healthy** * Aggregate fails (optional) → **Degraded** * Aggregate fails (required) → **Unhealthy** For detailed drill-down (e.g., in ConfigHub), the aggregate exposes its sub-rule managers via `SubManagers`. ## Conditional & Config-Aware Sub-Rules `.When()` and config-aware provider options work inside aggregates: ```csharp rule.For().Aggregate(r => [ r.FromFile("db.json"), r.FromFile("db.prod.json") .When(accessor => accessor.GetConfig()?.IsProduction == true), r.FromFile(accessor => { var region = accessor.GetConfig(); return FileSourceRuleOptions.FromFilePath($"db.{region?.Name}.json"); }) ]) ``` ::: warning Accessor Timing Sub-rules inside an aggregate see the configuration state from **before** the aggregate — not from sibling sub-rules. The aggregate is an atomic unit: it merges internally first, then contributes its result to the engine as a whole. Config-aware dependencies on other types that were resolved by rules **before** the aggregate work correctly. ::: ## When to Use What | Scenario | Use | |----------|-----| | Base + environment overlay files | `FromFiles("base.json", $"base.{env}.json")` | | Mixed providers in one group | `.Aggregate(r => [...])` | | Independent sources, no grouping needed | Separate rules (existing behavior) | --- --- url: /reference/analyzer-diagnostics.md description: >- Roslyn diagnostics reference — COCFG001-006 (secret conflicts, rule ordering, required rules, duplicates) and COCFLAG001-003 flags; severities and suppression --- # Analyzer Diagnostics Reference All diagnostics ship with the `Cocoar.Configuration` package. No separate install needed. ## Configuration Diagnostics (COCFG) ### COCFG001 — Secret Path Conflict | | | |---|---| | **Severity** | Warning | | **Category** | Cocoar.Configuration | | **Code Fix** | No | **Message:** Property '{0}' conflicts with secret property '{1}'. Consider using Secret\ or renaming to avoid plaintext exposure. A non-secret property has the same configuration path as a `Secret` property. The non-secret rule could overwrite the encrypted value with plaintext. See [guide](/guide/analyzers/configuration#cocfg001) for examples. *** ### COCFG002 — Rule Dependency Ordering | | | |---|---| | **Severity** | Error | | **Category** | Cocoar.Configuration | | **Code Fix** | No | **Message:** Rule for '{0}' depends on '{1}' which is not available yet. Move this rule after the '{1}' rule. A rule uses `GetConfig()` to read a type whose rule appears later in the list. Rules execute sequentially — dependencies must appear first. See [guide](/guide/analyzers/configuration#cocfg002) for examples. *** ### COCFG003 — Required Rule Validation | | | |---|---| | **Severity** | Warning | | **Category** | Cocoar.Configuration | | **Code Fix** | No | **Message:** Required rule for '{0}' references '{1}' which may not exist. Application will fail to start if this resource is missing. A `.Required()` rule references a file or resource that may not exist at runtime. If the resource is missing, the application will fail to start. See [guide](/guide/analyzers/configuration#cocfg003) for examples. *** ### COCFG005 — Duplicate Unconditional Rules | | | |---|---| | **Severity** | Info | | **Category** | Cocoar.Configuration | | **Code Fix** | No | **Message:** Multiple unconditional rules for type '{0}'. Last rule will override earlier rules. Consider using .When() conditions or removing duplicates. Multiple rules target the same type without conditions. Since rules merge with last-write-wins, earlier unconditional rules are fully overwritten — wasting provider I/O. See [guide](/guide/analyzers/configuration#cocfg005) for examples. *** ### COCFG006 — Static Provider Ordering | | | |---|---| | **Severity** | Info | | **Category** | Cocoar.Configuration | | **Code Fix** | No | **Message:** Static/seed rule found after dynamic rules. Consider moving static rules first to ensure they're available to dynamic rules. A static rule appears after dynamic rules. Since rules merge property by property (later wins), a static rule at the end always overrides dynamic sources. See [guide](/guide/analyzers/configuration#cocfg006) for examples. *** ## Feature Flags Diagnostics (COCFLAG) ### COCFLAG001 — Non-Static ExpiresAt | | | |---|---| | **Severity** | Warning | | **Category** | CocoarFlags | | **Code Fix** | No | **Message:** '{0}.ExpiresAt' could not be statically determined. The class will be registered with ExpiresAt = DateTimeOffset.MinValue (treated as expired). Use a DateTimeOffset literal: `new DateTimeOffset(year, month, day, 0, 0, 0, TimeSpan.Zero)`. The source generator couldn't evaluate `ExpiresAt` at compile time. The class defaults to `DateTimeOffset.MinValue` — treated as already expired, causing health to report `Degraded`. See [guide](/guide/analyzers/flags#cocflag001) for examples. *** ### COCFLAG002 — Abstract Type Registered | | | |---|---| | **Severity** | Warning | | **Category** | CocoarFlags | | **Code Fix** | No | **Message:** '{0}' is abstract and cannot be used with Register\(). Use a concrete subclass instead. `Register()` was called with an abstract class. Abstract classes can't be instantiated as flag or entitlement instances. See [guide](/guide/analyzers/flags#cocflag002) for examples. *** ### COCFLAG003 — Missing Description | | | |---|---| | **Severity** | Info | | **Category** | CocoarFlags | | **Code Fix** | No | **Message:** Property '{0}' on '{1}' has no \ XML doc comment. Add a description so it appears in flag/entitlement descriptors. A `FeatureFlag` or `Entitlement` property has no `` XML doc comment. Descriptions are surfaced through `IFeatureFlagsDescriptors` / `IEntitlementsDescriptors` and the REST API. See [guide](/guide/analyzers/flags#cocflag003) for examples. *** ## Summary Table | ID | Severity | Category | Code Fix | What It Catches | |---|---|---|---|---| | COCFG001 | Warning | Configuration | No | Secret path conflicts | | COCFG002 | Error | Configuration | No | Rule dependency ordering | | COCFG003 | Warning | Configuration | No | Required rule missing resource | | COCFG005 | Info | Configuration | No | Duplicate unconditional rules | | COCFG006 | Info | Configuration | No | Static provider ordering | | COCFLAG001 | Warning | CocoarFlags | No | Non-static ExpiresAt | | COCFLAG002 | Warning | CocoarFlags | No | Abstract type registered | | COCFLAG003 | Info | CocoarFlags | No | Missing property description | ## Suppressing Diagnostics ```csharp // In code #pragma warning disable COCFG005 rules.For().FromFile("a.json"), rules.For().FromFile("b.json") #pragma warning restore COCFG005 // Via attribute [SuppressMessage("Cocoar.Configuration", "COCFG005")] // Via .editorconfig [*.cs] dotnet_diagnostic.COCFG005.severity = none ``` --- --- url: /guide/analyzers/overview.md description: >- Built-in Roslyn analyzers (COCFG001-006) and the flags/entitlements source generator, diagnostics-at-a-glance table, suppression via pragma, attribute, and .editorconfig --- # Analyzers & Source Generator Cocoar.Configuration includes Roslyn analyzers and a source generator out of the box — no separate package install needed. They ship as part of the `Cocoar.Configuration` NuGet package. The **analyzers** catch configuration mistakes at compile time. The **source generator** produces descriptor metadata for feature flags and entitlements — it's required for flags to work (expiry tracking, health reporting, REST endpoint generation all depend on the generated descriptors). Both run during compilation — in your IDE and in CI. No runtime cost. ## Diagnostics at a Glance ### Configuration (COCFG) | ID | Severity | What It Catches | |---|---|---| | [COCFG001](/guide/analyzers/configuration#cocfg001) | Warning | Secret path conflicts — non-secret property shadows a `Secret` | | [COCFG002](/guide/analyzers/configuration#cocfg002) | Error | Rule dependency ordering — rule uses config that isn't loaded yet | | [COCFG003](/guide/analyzers/configuration#cocfg003) | Warning | Required rule references a resource that may not exist | | [COCFG005](/guide/analyzers/configuration#cocfg005) | Info | Duplicate unconditional rules for the same type | | [COCFG006](/guide/analyzers/configuration#cocfg006) | Info | Static provider after dynamic providers (ordering suggestion) | ### Feature Flags (COCFLAG) | ID | Severity | What It Catches | |---|---|---| | [COCFLAG001](/guide/analyzers/flags#cocflag001) | Warning | `ExpiresAt` is not a static `DateTimeOffset` literal | | [COCFLAG002](/guide/analyzers/flags#cocflag002) | Warning | Abstract type passed to `Register()` | | [COCFLAG003](/guide/analyzers/flags#cocflag003) | Info | Flag or entitlement property missing `` XML doc | ## Source Generator The package also includes a source generator that produces descriptor metadata for registered feature flags and entitlements. See [Flags Diagnostics](/guide/analyzers/flags#source-generator) for details. ## Suppressing Diagnostics Standard C# suppression mechanisms work: **In code:** ```csharp #pragma warning disable COCFG005 rules.For().FromFile("a.json"), rules.For().FromFile("b.json") #pragma warning restore COCFG005 ``` **Via attribute:** ```csharp [SuppressMessage("Cocoar.Configuration", "COCFG005")] ``` **Via .editorconfig:** ```ini [*.cs] dotnet_diagnostic.COCFG005.severity = none ``` --- --- url: /adr.md description: >- Index of Cocoar.Configuration's Architecture Decision Records (ADR-001 through ADR-006) capturing the rationale behind key design choices --- # Architecture Decision Records These ADRs capture the **why** behind Cocoar.Configuration's key design choices — the context, the decision, the trade-offs, and the alternatives that were rejected. They are decision *records* (point-in-time rationale and history), not how-to guides; for usage, start with the [Guide](/guide/getting-started). | ADR | Decision | |---|---| | [ADR-001](/adr/ADR-001-capabilities-system) | Capabilities system for cross-assembly extensibility | | [ADR-002](/adr/ADR-002-atomic-reactive-updates) | Atomic reactive configuration updates (tuple semantics) | | [ADR-003](/adr/ADR-003-provider-consistency-empty-objects) | Provider consistency — empty objects on failure | | [ADR-004](/adr/ADR-004-aggregate-rules) | Aggregate rules with an isolated execution boundary | | [ADR-005](/adr/ADR-005-multi-tenant-configuration) | Multi-tenant configuration — per-tenant pipelines on a shared global base | | [ADR-006](/adr/ADR-006-di-aware-configuration) | DI-aware (service-backed) two-layer configuration | --- --- url: /guide/health/aspnetcore.md description: >- AddCocoarConfigurationHealthCheck() for ASP.NET Core health checks, custom name and tags, HealthStatus to HealthCheckResult mapping, /health endpoint integration --- # ASP.NET Core Health Checks Cocoar.Configuration integrates with the standard ASP.NET Core health check system. One line registers it; the health endpoint then reflects configuration status alongside your other health checks. ::: info Package Requires `Cocoar.Configuration.AspNetCore`. ::: ## Setup ```csharp builder.Services .AddHealthChecks() .AddCocoarConfigurationHealthCheck(); ``` That's it. The `/health` endpoint now includes Cocoar's configuration health. ## Customization ### Name and Tags ```csharp builder.Services .AddHealthChecks() .AddCocoarConfigurationHealthCheck( name: "cocoar", tags: ["config", "startup"]); ``` Tags let you filter health checks in mapped endpoints: ```csharp app.MapHealthChecks("/health/startup", new() { Predicate = check => check.Tags.Contains("startup") }); ``` ## Status Mapping The health check maps Cocoar's `HealthStatus` to ASP.NET Core's `HealthCheckResult`: | Cocoar Status | ASP.NET Core Result | Description | |---|---|---| | `Healthy` | `Healthy` | "All rules healthy" | | `Degraded` | `Degraded` | Details (e.g., "1 optional rule(s) failed; expired feature flags detected") | | `Unhealthy` | `Unhealthy` | Details (e.g., "1 required rule(s) failed") | | `Unknown` | `Degraded` | "Health status unknown" | `Unknown` maps to `Degraded` rather than `Unhealthy` because it typically means initialization is still in progress, not that something has failed. ## Example Response A healthy response: ```json { "status": "Healthy", "entries": { "cocoar-configuration": { "status": "Healthy", "description": "All rules healthy" } } } ``` A degraded response (optional rule failed + expired flags): ```json { "status": "Degraded", "entries": { "cocoar-configuration": { "status": "Degraded", "description": "1 optional rule(s) failed; expired feature flags detected" } } } ``` ## Combining with Other Health Checks The Cocoar health check works alongside any other ASP.NET Core health checks: ```csharp builder.Services .AddHealthChecks() .AddCocoarConfigurationHealthCheck() .AddDbContextCheck() .AddRedis(connectionString); ``` The overall health endpoint reports the worst status across all registered checks. --- --- url: /guide/di/aspnetcore.md description: >- Cocoar.Configuration.AspNetCore — WebApplicationBuilder.AddCocoarConfiguration, health endpoint, feature flag and entitlement REST endpoints, injecting config --- # ASP.NET Core Integration The `Cocoar.Configuration.AspNetCore` package adds ASP.NET Core-specific features on top of the DI package: health endpoints, feature flag endpoints, and `WebApplicationBuilder` extensions. ::: info Package `Cocoar.Configuration.AspNetCore` includes `Cocoar.Configuration.DI` transitively — you only need one package reference. ::: ## WebApplicationBuilder Extension Instead of `builder.Services.AddCocoarConfiguration(...)`, you can call it directly on the builder: ```csharp var builder = WebApplication.CreateBuilder(args); builder.AddCocoarConfiguration(c => c .UseConfiguration(rules => [ rules.For().FromFile("appsettings.json") ]) .UseFeatureFlags(flags => [flags.Register()])); ``` Both forms are equivalent. The `WebApplicationBuilder` extension delegates to `builder.Services.AddCocoarConfiguration(...)`. ## Health Endpoint See [ASP.NET Core Health Checks](/guide/health/aspnetcore) for setup. ```csharp builder.Services .AddHealthChecks() .AddCocoarConfigurationHealthCheck(); var app = builder.Build(); app.MapHealthChecks("/health"); ``` ## Feature Flag & Entitlement Endpoints See [REST Evaluation Endpoints](/guide/flags/rest-endpoints) for details. ```csharp app.MapFeatureFlagEndpoints(); // → /flags/{Class}/{Property} app.MapEntitlementEndpoints(); // → /entitlements/{Class}/{Property} ``` Both return a `RouteGroupBuilder` for chaining middleware: ```csharp app.MapFeatureFlagEndpoints() .RequireAuthorization("AdminPolicy") .RequireRateLimiting("fixed"); ``` ## Full Example ```csharp var builder = WebApplication.CreateBuilder(args); // Register Cocoar configuration builder.AddCocoarConfiguration(c => c .UseConfiguration( rules => [ rules.For().FromFile("appsettings.json").Required(), rules.For().FromFile("features.json") ]) .UseFeatureFlags( flags => [flags.Register()], resolvers => [resolvers.Global()]) .UseEntitlements( entitlements => [entitlements.Register()])); // Health checks builder.Services .AddHealthChecks() .AddCocoarConfigurationHealthCheck(); var app = builder.Build(); // Endpoints app.MapHealthChecks("/health"); app.MapFeatureFlagEndpoints().RequireAuthorization(); app.MapEntitlementEndpoints().RequireAuthorization(); app.Run(); ``` ## Injecting Configuration Once registered, inject configuration types as you would any other service: ```csharp public class OrderService(AppSettings settings, IReactiveConfig reactive) { public void Process() { // Scoped snapshot — stable for this request var maxRetries = settings.MaxRetries; // Live subscription — for background work reactive.Subscribe(updated => Console.WriteLine($"Config changed: {updated.MaxRetries}")); } } ``` Feature flags and entitlements are injected the same way: ```csharp public class CheckoutController(BillingFlags flags, PlanEntitlements entitlements) { public IActionResult Index() { if (flags.NewDashboard()) return View("NewDashboard"); var maxUsers = entitlements.MaxUsers(); // ... } } ``` --- --- url: /guide/secrets/client-encryption.md description: >- @cocoar/secrets TypeScript library — fetchEncryptionKey and encryptSecret build cocoar.secret envelopes client-side via WebCrypto so plaintext never reaches the server, multi-tenant --- # Browser & Client Encryption [Publishing Encryption Keys](/guide/secrets/key-publishing) exposes your server's **public** key so an external producer can build a `cocoar.secret` envelope. **`@cocoar/secrets`** is that producer for the browser and Node — a tiny, zero-dependency TypeScript library that encrypts a value with the published key so the **plaintext never reaches your server**; only the encrypted envelope does. It pairs with the publishing endpoint: the server holds the private key and decrypts on `Secret.Open()`; the client only ever sees the public key. ## Install ```bash npm install @cocoar/secrets ``` Requires WebCrypto (`globalThis.crypto.subtle`) — every modern browser and Node 18+. No runtime dependencies. ## Usage ```ts import { fetchEncryptionKey, encryptSecret } from "@cocoar/secrets"; // 1. Fetch the server's published public key. const key = await fetchEncryptionKey("/.well-known/cocoar/encryption-key"); // 2. Encrypt a secret value (a string, or any JSON-serializable object). const envelope = await encryptSecret(key, "my-oauth-client-secret"); // 3. POST the envelope to your API — the plaintext never left the browser. await fetch("/admin/config/oauth-secret", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(envelope), }); ``` The server stores the envelope as-is — for example through a [WritableStore](/guide/providers/writable-store) overlay via `SetSecretEnvelopeAsync` / `SetSecretAsync` — and decrypts it only when the typed `Secret` is opened. ## Multi-tenant No client change is needed. The [per-tenant endpoint](/guide/secrets/key-publishing#multi-tenant) returns the current tenant's key, resolved server-side from `ITenantContext`. The browser fetches the same URL and gets the right key; the resulting envelope is stored against that tenant — e.g. `GetWritableStoreForTenant(tenantId).SetSecretAsync(...)`. ## What it does `encryptSecret` performs hybrid encryption matching the server's decryption contract: * generates a one-time **AES-256-GCM** data key and a 96-bit IV, * seals the JSON-serialized value with it (a string round-trips as a quoted JSON string), * wraps the data key with the server's **RSA-OAEP-SHA256** public key, * assembles a `cocoar.secret` envelope (all binary fields base64url, no padding). The wire format is the same one under [Custom Providers → Secrets](/guide/providers/custom#secrets-in-custom-providers); a TS→.NET round-trip is covered by a cross-language test so the two stacks stay byte-compatible. ## API | Export | Purpose | |---|---| | `fetchEncryptionKey(url, init?)` | fetch the published key document | | `encryptSecret(key, value)` | seal a value into a `cocoar.secret` envelope | | `base64UrlEncode` / `base64UrlDecode` | base64url (no padding) helpers | ## Versioning `@cocoar/secrets` is published to npm and versioned **independently** of the .NET (NuGet) packages — it codes against the stable published-key contract, not a specific library release. --- --- url: /guide/providers/custom.md description: >- Extend ConfigurationProvider, FetchConfigurationBytesAsync/ChangesAsBytes, GenerateProviderKey caching, fluent FromProvider extension, service-backed DI providers, change detection, secret envelopes --- # Building Custom Providers If the built-in providers don't cover your data source, you can build your own by extending `ConfigurationProvider`. ## The Provider Contract Every provider implements two methods: ```csharp public abstract class ConfigurationProvider where TProviderConfiguration : IProviderConfiguration { protected TProviderConfiguration ProviderOptions { get; } // One-time fetch — called during recompute public abstract Task FetchConfigurationBytesAsync( TProviderQuery query, CancellationToken ct = default); // Change stream — called once, returns ongoing notifications public abstract IObservable ChangesAsBytes(TProviderQuery query); } ``` * **`FetchConfigurationBytesAsync`** — returns the current configuration as UTF-8 JSON bytes * **`ChangesAsBytes`** — returns an observable that emits new bytes whenever the source changes Both methods receive a query object. The split between provider options and query options is important: | Level | Scope | Example | |---|---|---| | `TProviderConfiguration` | Shared across rules | Base URL, directory path, connection string | | `TProviderQuery` | Per-rule | Specific URL path, filename, key prefix | ## Example: Database Provider A provider that reads configuration from a database table: ```csharp // Provider options — shared, one instance per connection string public record DatabaseProviderOptions(string ConnectionString) : IProviderConfiguration { public string GenerateProviderKey() => ConnectionString; } // Query options — per-rule public record DatabaseProviderQuery(string ConfigKey) : IProviderQuery; ``` ```csharp public class DatabaseConfigProvider : ConfigurationProvider { public DatabaseConfigProvider(DatabaseProviderOptions options) : base(options) { } public override async Task FetchConfigurationBytesAsync( DatabaseProviderQuery query, CancellationToken ct = default) { await using var conn = new SqlConnection(ProviderOptions.ConnectionString); await conn.OpenAsync(ct); var json = await conn.QuerySingleOrDefaultAsync( "SELECT JsonValue FROM Configuration WHERE ConfigKey = @Key", new { Key = query.ConfigKey }); return json is not null ? Encoding.UTF8.GetBytes(json) : "{}"u8.ToArray(); } public override IObservable ChangesAsBytes(DatabaseProviderQuery query) { // No change detection — static until next recompute return ProviderObservable.Never(); // Or: implement SqlDependency, polling, etc. } } ``` ::: info Helper utilities `ProviderObservable` and `ProviderDisposable` (in `Cocoar.Configuration.Providers.Abstractions`, the same namespace as `ConfigurationProvider`) are public helpers for building a provider's change stream without referencing System.Reactive: `Never()`, `Empty()`, `Create()` for observables and `Empty` / `Create(Action)` for disposables. ::: ## Provider Key and Instance Caching `IProviderConfiguration.GenerateProviderKey()` controls provider reuse: * **Return a string** — providers with the same key share one instance. Use this when the provider manages a shared resource (file watcher, database connection, HTTP client). * **Return null** — each rule gets its own provider instance. Use this for providers with no shared state. ```csharp public record DatabaseProviderOptions(string ConnectionString) : IProviderConfiguration { // Same connection string = same provider instance public string GenerateProviderKey() => ConnectionString; } ``` ## Registering via Fluent API Create an extension method on `TypedRuleBuilder`: ```csharp public static class DatabaseProviderRulesExtensions { public static ProviderRuleBuilder FromDatabase(this TypedRuleBuilder builder, string connectionString, string configKey) { return builder.FromProvider( _ => new DatabaseProviderOptions(connectionString), _ => new DatabaseProviderQuery(configKey)); } } ``` Now it works like any built-in provider: ```csharp rule.For() .FromDatabase("Server=localhost;Database=Config", "AppSettings") .Required() .Named("Database Config") ``` ## Service-Backed Providers (DI-aware) The `FromDatabase` above takes a **connection string** and `new`s its own `SqlConnection` — so it works at registration time, before the container exists. But what if your provider should use a **DI-managed** resource — an `IDbContextFactory`, a Marten `IDocumentStore`, an `IHttpClientFactory`? Those don't exist when `AddCocoarConfiguration` runs. That's the [two-layer / service-backed model](/guide/di/service-backed): a custom provider opts into it, and **whether it does is entirely your choice as the author**. A service-backed provider is **its own provider** — a separate class from the no-DI one above — and carries only what the DI path needs, often just the resolved service. The framework builds your provider as `new YourProvider(options)`, so what you resolve has to travel on the **options** (the provider's only input). The natural shape: resolve the DI-managed **factory or store** — a singleton like `IDbConnectionFactory`, `IHttpClientFactory`, or a Marten `IDocumentStore` — and pass it on the options as a plain value; the provider opens a short-lived unit per read from it. ```csharp // Options carry the resolved DI singleton (a connection factory). public sealed record DbConfigOptions(IDbConnectionFactory Connections) : IProviderConfiguration { public string? GenerateProviderKey() => null; // carries a DI-resolved dependency → never share this provider } public sealed record DbConfigQuery(string Key) : IProviderQuery; public sealed class DbConfigProvider(DbConfigOptions options) : ConfigurationProvider(options) { public override async Task FetchConfigurationBytesAsync( DbConfigQuery query, CancellationToken ct = default) { await using var conn = ProviderOptions.Connections.Create(); // short-lived unit, opened per read await conn.OpenAsync(ct); var json = await conn.QuerySingleOrDefaultAsync( "SELECT JsonValue FROM Configuration WHERE ConfigKey = @Key", new { Key = query.Key }); return json is not null ? Encoding.UTF8.GetBytes(json) : "{}"u8.ToArray(); } public override IObservable ChangesAsBytes(DbConfigQuery query) => ProviderObservable.Never(); } ``` The fluent overload uses the `ServiceBacked(...)` helper. The `(sp, _) => …` you pass is a **factory, not an eager call** — `ServiceBacked` invokes it later, at recompute time, so nothing is resolved when you author the rule. You're describing *how* to build the options, not building them now: ```csharp public static ProviderRuleBuilder FromDatabase(this ServiceBackedProviderBuilder builder, string configKey) where T : class => builder.ServiceBacked( (sp, _) => new DbConfigOptions(sp.GetRequiredService()), // runs at recompute, not here _ => new DbConfigQuery(configKey)); ``` ::: warning Nothing is resolved in the method body `FromDatabase` returns immediately at registration — it just hands `ServiceBacked` the `(sp, _) => …` factory. `sp.GetRequiredService<…>()` runs only when the framework calls that factory during a recompute, after the host has started. (For per-read freshness, resolve a **factory/store** and call it inside the provider — `Connections.Create()` above — rather than resolving a live connection here.) ::: That's the whole pattern: a Layer-1 provider built against `TypedRuleBuilder`, and a Layer-2 provider built against `ServiceBackedProviderBuilder` — two small providers, one per layer. (If their inputs happen to overlap you can put both overloads on one class, but you rarely need to.) Now the provider can pull a DI-managed resource — only inside `UseServiceBackedConfiguration`: ```csharp services.AddCocoarConfiguration(c => c .UseConfiguration(rules => [ /* eager, no-DI bootstrap */ ]) .UseServiceBackedConfiguration(rules => [ rules.For().FromDatabase("AppSettings"), ])); ``` ::: tip Type-safe, not stringly-gated The Layer-2 overload targets `ServiceBackedProviderBuilder`. Calling it inside the Layer-1 `UseConfiguration` (a plain `TypedRuleBuilder`) is a **compile error** — the type system keeps DI-backed loading out of the eager layer. The whole seam (`ServiceBackedProviderBuilder.Context`, `ServiceBackedRuleContext`, `WithActivationGate`) is **public**, so this needs no internals. ::: Lifetime discipline ([ADR-006](/adr/ADR-006-di-aware-configuration) §9): the `IServiceProvider` is the **root** — resolve singletons / factories (`IDbContextFactory`, `IDocumentStore`, `IHttpClientFactory`) and open a short-lived unit per read (as the `await using var conn` above does). Never resolve a scoped service from root. Combine with `.TenantScoped()` for DB-config-per-tenant. See [Service-Backed Configuration](/guide/di/service-backed) for the full lifecycle, readiness, and failure contracts. ## Change Detection For reactive providers, return an `IObservable` that emits when the source changes: ```csharp public override IObservable ChangesAsBytes(DatabaseProviderQuery query) { return ObservableHelpers.Create(observer => { var cts = new CancellationTokenSource(); var timer = new PeriodicTimer(TimeSpan.FromSeconds(30)); Task.Run(async () => { while (await timer.WaitForNextTickAsync(cts.Token)) { var bytes = await FetchConfigurationBytesAsync(query, cts.Token); observer.OnNext(bytes); } }, cts.Token); return ProviderDisposable.Create(() => { cts.Cancel(); timer.Dispose(); }); }); } ``` The engine compares bytes by hash — if the content hasn't changed, no recompute is triggered. So it's safe to emit on every poll even when nothing changed. ## Complete Example Here is a full custom provider as a single, self-contained block you can copy and adapt: ```csharp // Complete DatabaseConfigProvider — copy and adapt public record DatabaseProviderOptions(string ConnectionString) : IProviderConfiguration { public string? GenerateProviderKey() => ConnectionString; } public record DatabaseProviderQuery(string ConfigKey) : IProviderQuery; public class DatabaseConfigProvider : ConfigurationProvider { public DatabaseConfigProvider(DatabaseProviderOptions options) : base(options) { } public override async Task FetchConfigurationBytesAsync( DatabaseProviderQuery query, CancellationToken ct = default) { await using var conn = new SqlConnection(ProviderOptions.ConnectionString); await conn.OpenAsync(ct); var json = await conn.QuerySingleOrDefaultAsync( "SELECT JsonValue FROM Configuration WHERE ConfigKey = @Key", new { Key = query.ConfigKey }); return json is not null ? Encoding.UTF8.GetBytes(json) : "{}"u8.ToArray(); // Return empty JSON on failure, never null } public override IObservable ChangesAsBytes(DatabaseProviderQuery query) { return ProviderObservable.Never(); // Or implement polling/notifications } } // Extension method for fluent API public static class DatabaseProviderExtensions { public static ProviderRuleBuilder FromDatabase(this TypedRuleBuilder builder, string connectionString, string configKey) where T : class { return builder.FromProvider( _ => new DatabaseProviderOptions(connectionString), _ => new DatabaseProviderQuery(configKey)); } } ``` Usage in rules: ```csharp rule.For() .FromDatabase("Server=localhost;Database=Config", "AppSettings") .Required() .Named("Database Config") ``` ## Guidelines * Always return `"{}"u8.ToArray()` on failure, never null — this keeps optional rules working * Use `CancellationToken` throughout — providers are cancelled during shutdown * Keep `FetchConfigurationBytesAsync` idempotent — it may be called multiple times * Use `GenerateProviderKey()` to enable instance sharing when your provider holds expensive resources * The change observable should not error — if it does, the subscription is lost ## Secrets in Custom Providers When your provider delivers secrets, emit them as encrypted envelopes. The engine stores envelopes as-is and decrypts on `Secret.Open()`. ### X.509 Hybrid Envelope The provider holds only the public certificate; the app holds the private key. The provider generates a random AES-256 DEK, encrypts content with AES-GCM, and wraps the DEK with RSA-OAEP-256: ```json { "type": "cocoar.secret", "version": 1, "kid": "prod-secrets", "alg": "RSA-OAEP-AES256-GCM", "walg": "RSA-OAEP-256", "iv": "", "ct": "", "tag": "", "wk": "" } ``` The `kid` links to the certificate registered via `UseSecretsSetup()`. The provider never sees the private key. ### Rotation Use a new `kid` for each key rotation. Apps register both old and new certificates during the overlap period via `WithAdditionalKeyId()`. New secrets use the new kid; old secrets remain decryptable until the old certificate is removed. --- --- url: /guide/secrets/certificate-caching.md description: >- UseCertificatesFromFolder time-limited private-key caching (cacheDurationSeconds), FileSystemWatcher auto-discovery, two-level cache, zero-downtime certificate rotation --- # Certificate Caching Folder-based certificate mode (`UseCertificatesFromFolder`) provides intelligent caching and automatic certificate rotation for zero-downtime key management. **Key benefits:** * **Time-limited memory exposure** — private keys cached only for a configurable duration (default: 30s) * **Automatic discovery** — `FileSystemWatcher` detects new/removed certificates without restart * **Zero-downtime rotation** — add a new cert while old secrets still decrypt with the old one * **Performance** — two-level cache (envelope hash to cert path to loaded cert) eliminates redundant I/O ## Basic Usage ```csharp var manager = ConfigManager.Create(c => c .UseConfiguration(rule => [ /* ... */ ]) .UseSecretsSetup(secrets => secrets .UseCertificatesFromFolder("certs/", cacheDurationSeconds: 30))); // 30 seconds (default) ``` **How it works:** 1. **First decrypt** — scans folder, tries certificates, caches which cert works for each secret 2. **Subsequent decrypts** — uses cached certificate (no folder scan, no file I/O) 3. **After TTL expires** — reloads certificate from disk, knows exact file to load 4. **New cert added** — `FileSystemWatcher` detects it, adds to inventory automatically 5. **Old cert removed** — cache evicted, secrets encrypted with it will fail to decrypt ## Cache Duration Guidelines | Security Level | Cache Duration | Use Case | |---|---|---| | **Critical** | 0 seconds | Payment data, passwords, PCI-DSS | | **High** | 5-30 seconds | API keys, session tokens (default) | | **Medium** | 60-300 seconds | Application secrets | | **Low** | 300-3600+ seconds | Feature flags, non-sensitive config | ::: tip Start Secure Begin with `cacheDurationSeconds: 0` and increase only if performance testing proves it necessary. A zero-duration cache still avoids redundant folder scans — it just reloads the certificate file on every decrypt. ::: ```csharp // Critical secrets — no cache, load fresh every time .UseSecretsSetup(secrets => secrets .UseCertificatesFromFolder("certs/pci/", cacheDurationSeconds: 0)) // Standard secrets — balanced 30-second cache .UseSecretsSetup(secrets => secrets .UseCertificatesFromFolder("certs/api/", cacheDurationSeconds: 30)) ``` ## Certificate Rotation Zero-downtime rotation process: 1. Add new certificate to folder (e.g., `cert-2024-12.pfx`) 2. `FileSystemWatcher` detects it (within ~1 second) 3. Old secrets still decrypt with `cert-2024-11.pfx` (backward compatibility) 4. New secrets automatically encrypted with newest cert 5. Re-encrypt old secrets in background (optional) 6. Remove old cert after all secrets are migrated ::: warning Keep Old Certificates During Transition Do not remove old certificates from the folder until all secrets encrypted with them have been re-encrypted with the new certificate. Removing a certificate makes any secrets encrypted with it permanently undecryptable. ::: ## Folder Mode vs File Mode | Feature | `UseCertificateFromFile` | `UseCertificatesFromFolder` | |---|---|---| | **Certificate loading** | Single file, stays in memory forever | Multiple files, cached with TTL | | **Dynamic discovery** | No (restart required) | Yes (`FileSystemWatcher`) | | **Rotation support** | Manual restart | Automatic | | **Memory exposure** | Continuous (keys always in memory) | Time-limited (configurable) | | **Performance** | Fastest (no overhead) | Fast (cache eliminates most I/O) | | **Best for** | Single cert, max performance, dev | Multiple certs, rotation, production | ## Best Practices 1. **Separate by classification** — use different folders for different security levels 2. **Rotate regularly** — schedule certificate rotation every 90 days 3. **Keep old certs** — during rotation, keep old certs in folder for backward compatibility 4. **Monitor** — log certificate loads and decrypt failures ## See Also * [Encryption Setup](/guide/secrets/encryption-setup) — certificate configuration and encrypted envelope format * [Security Model](/guide/secrets/security-model) — full rotation workflow and threat model * [CLI Tools](/guide/secrets/cli) — encrypt, decrypt, and manage certificates from the command line --- --- url: /reference/cli-commands.md description: >- cocoar-secrets CLI reference — encrypt, decrypt, generate-cert, convert-cert, cert-info; options, exit codes, RSA-OAEP-SHA256 + AES-256-GCM envelope --- # CLI Commands Reference ## Installation ```shell dotnet tool install -g Cocoar.Configuration.Secrets.Cli ``` All commands are invoked as `cocoar-secrets `. ## encrypt Encrypt a value and set it at a property path in a JSON file. ```shell cocoar-secrets encrypt --file --path --cert [options] ``` | Option | Alias | Type | Default | Description | |---|---|---|---|---| | `--file` | `-f` | string | *required* | Path to the JSON configuration file | | `--path` | `-p` | string | *required* | Property path (e.g. `Database:ConnectionString`) | | `--cert` | `-c` | string | *required* | Path to the PFX certificate file | | `--value` | `-v` | string | — | Plaintext value to encrypt. If omitted, encrypts the existing value at the path | | `--password` | `-pwd` | string | — | Certificate password (prompts if not provided) | | `--kid` | | string | `"default"` | Key identifier for the certificate | | `--create` | | bool | `false` | Create the JSON file if it doesn't exist | **Examples:** ```shell # Encrypt a connection string cocoar-secrets encrypt \ --file appsettings.json \ --path "Database:ConnectionString" \ --value "Server=prod;Database=mydb;Password=secret" \ --cert cert.pfx \ --kid "prod-2026" # Encrypt from stdin (avoids shell history) echo -n "my-secret-value" | cocoar-secrets encrypt \ --file appsettings.json \ --path "ApiKeys:Stripe" \ --cert cert.pfx # Encrypt existing plaintext value in-place cocoar-secrets encrypt \ --file appsettings.json \ --path "Database:ConnectionString" \ --cert cert.pfx ``` ## decrypt Decrypt an encrypted value from a JSON file. ```shell cocoar-secrets decrypt --file --path --cert [options] ``` | Option | Alias | Type | Default | Description | |---|---|---|---|---| | `--file` | `-f` | string | *required* | Path to the JSON configuration file | | `--path` | `-p` | string | *required* | Property path of the encrypted value | | `--cert` | `-c` | string | *required* | Path to the PFX certificate file | | `--password` | `-pwd` | string | — | Certificate password (prompts if not provided) | | `--replace` | | bool | `false` | Replace the encrypted value with plaintext in the file | ::: warning `--replace` modifies the file irreversibly. The encrypted envelope is replaced with the plaintext value. ::: **Examples:** ```shell # Display decrypted value (read-only) cocoar-secrets decrypt \ --file appsettings.json \ --path "Database:ConnectionString" \ --cert cert.pfx # Replace encrypted value with plaintext in-place cocoar-secrets decrypt \ --file appsettings.json \ --path "Database:ConnectionString" \ --cert cert.pfx \ --replace ``` ## generate-cert Generate a self-signed certificate for encryption. ```shell cocoar-secrets generate-cert --output [options] ``` | Option | Alias | Type | Default | Description | |---|---|---|---|---| | `--output` | `-o` | string | *required* | Output path for certificate file(s) | | `--password` | `-pwd` | string | — | Password for PFX file (omit for password-less) | | `--format` | `-fmt` | string | `"auto"` | Output format: `pfx`, `pem`, or `auto` (infer from extension) | | `--subject` | `-s` | string | `"CN=Cocoar Secrets"` | Certificate subject | | `--valid-years` | | int | `1` | Validity period in years | | `--key-size` | | int | `2048` | RSA key size (2048, 3072, or 4096) | | `--overwrite` | | bool | `false` | Overwrite existing file without prompt | **Examples:** ```shell # Generate a password-less PFX certificate cocoar-secrets generate-cert --output certs/config.pfx # Generate a PEM certificate with custom subject cocoar-secrets generate-cert \ --output certs/config.pem \ --subject "CN=My App Secrets" \ --valid-years 5 \ --key-size 4096 ``` ::: tip Password-less certificates are recommended. Protect them with file permissions instead: * **Windows:** `icacls cert.pfx /inheritance:r /grant:r "YourUser:(R)"` * **Linux/macOS:** `chmod 600 cert.pfx` ::: ## convert-cert Convert a certificate between PFX and PEM formats. ```shell cocoar-secrets convert-cert --input --output [options] ``` | Option | Alias | Type | Default | Description | |---|---|---|---|---| | `--input` | `-i` | string | *required* | Input certificate file | | `--output` | `-o` | string | *required* | Output certificate file | | `--input-password` | `--ipass` | string | — | Password for input PFX file | | `--output-password` | `--opass` | string | — | Password for output PFX file (omit for password-less) | | `--format` | `-f` | string | `"auto"` | Output format: `pfx`, `pem`, or `auto` | | `--overwrite` | | bool | `false` | Overwrite existing output file(s) | **Examples:** ```shell # Convert password-protected PFX to password-less PFX cocoar-secrets convert-cert \ --input cert.pfx \ --ipass "OldPassword" \ --output cert-nopwd.pfx # Convert PFX to PEM cocoar-secrets convert-cert \ --input cert.pfx \ --output cert.pem ``` ## cert-info Display detailed information about a certificate. ```shell cocoar-secrets cert-info --input [options] ``` | Option | Alias | Type | Default | Description | |---|---|---|---|---| | `--input` | `-i` | string | *required* | Certificate file path (PFX or PEM) | | `--password` | `-pwd` | string | — | Certificate password (if password-protected) | **Output includes:** * Certificate details: Subject, Issuer, Serial Number, Thumbprint * Validity: Not Before, Not After, status (Valid/Expired/Not yet valid) * Key information: Algorithm, Key Size, Private Key presence, Password protection * File information: Size, format, timestamps **Example:** ```shell cocoar-secrets cert-info --input certs/config.pfx ``` ## Exit Codes All commands use consistent exit codes: | Code | Meaning | |---|---| | 0 | Success | | 1 | Argument error | | 2 | I/O error (file not found, permission denied) | | 3 | Cryptographic error (wrong certificate, corrupt data) | | 4 | General error | ## Encryption Details All commands use the same encryption scheme: | Purpose | Algorithm | |---|---| | Key wrapping | RSA-OAEP-SHA256 | | Data encryption | AES-256-GCM | The encrypted value is stored as a JSON envelope with fields: `type`, `version`, `kid`, `alg`, `wk`, `walg`, `iv`, `ct`, `tag`. --- --- url: /guide/secrets/cli.md description: >- cocoar-secrets global .NET tool — encrypt values to JSON envelopes (incl. from stdin), generate self-signed certs, convert password-protected PFX to password-less --- # CLI Tools The `cocoar-secrets` CLI tool encrypts values and manages certificates from the command line. ::: info Installation ```shell dotnet tool install -g Cocoar.Configuration.Secrets.Cli ``` ::: ## Encrypting a Value ```shell cocoar-secrets encrypt \ --value "Server=prod;Password=s3cret" \ --cert certs/prod.pfx \ --kid prod-secrets ``` The output is a JSON envelope you paste into your config file: ```json { "type": "cocoar.secret", "version": 1, "kid": "prod-secrets", "alg": "RSA-OAEP-AES256-GCM", "wk": "...", "iv": "...", "ct": "...", "tag": "..." } ``` ### Encrypt from stdin Pipe values to avoid them appearing in shell history: ```shell echo -n "s3cret" | cocoar-secrets encrypt --cert certs/prod.pfx --kid prod-secrets ``` ## Generating a Certificate ```shell cocoar-secrets generate-cert -o certs/prod.pfx ``` Generates a self-signed X.509 certificate suitable for secret encryption. The output is a password-less PFX file. ## Converting Certificates Convert password-protected certificates to password-less format: ```shell cocoar-secrets convert-cert \ --input certs/protected.pfx \ --output certs/prod.pfx ``` The library requires password-less certificates — protection is handled by file system ACLs, not passwords embedded in the certificate file. ## Decrypting a Value For debugging or migration: ```shell cocoar-secrets decrypt \ --value '{"type":"cocoar.secret",...}' \ --cert certs/prod.pfx ``` Or from a file: ```shell cocoar-secrets decrypt --file appsettings.json --path "Database:Password" --cert certs/prod.pfx ``` --- --- url: /roadmap/cloud-providers.md description: >- Planned native cloud KMS providers — FromAzureKeyVault (Managed Identity, rotation) and FromAwsSecretsManager (IAM, ARN), layered via the existing rule system --- # Cloud Providers Native providers for Azure Key Vault and AWS Secrets Manager — so you can load secrets and configuration from your existing cloud KMS without building a custom provider. ## Why Today, `Secret` uses X.509 certificates for encryption. That works well for single deployments and on-premise setups. But many teams already have secrets in Azure Key Vault or AWS Secrets Manager and don't want to migrate them into certificate-encrypted JSON files. Cloud providers bridge this gap: keep your secrets where they are, load them through Cocoar's rule system. ## Azure Key Vault Provider ```csharp rule.For().FromAzureKeyVault(vault => { vault.VaultUri = new Uri("https://my-vault.vault.azure.net/"); vault.SecretName = "db-config"; }) ``` Planned capabilities: * Load individual secrets or entire secret groups * Automatic refresh on secret rotation (via Key Vault change notifications) * Managed Identity authentication (no credentials in config) * Works alongside file-based rules — Key Vault overrides local defaults via normal layering ## AWS Secrets Manager Provider ```csharp rule.For().FromAwsSecretsManager(aws => { aws.SecretId = "prod/db-config"; aws.Region = "eu-central-1"; }) ``` Planned capabilities: * Load secrets by ID or ARN * Automatic rotation support * IAM role-based authentication * Regional failover support ## How They Fit In Cloud providers are just providers — they plug into the existing rule system. You can layer them freely: ```csharp rule => [ rule.For().FromFile("appsettings.json"), // Base config rule.For().FromAzureKeyVault(v => v.SecretName = "app"), // Cloud overrides rule.For().FromEnvironment("APP_"), // Local overrides ] ``` Same merge semantics, same health monitoring, same reactive updates. ## Can't Wait? The [custom provider contract](/guide/providers/custom) is two methods. If you need Azure Key Vault or AWS today, you can build a provider in ~200 lines. The planned native providers will offer a polished, tested, production-ready experience — but you're not blocked. ## Status Planned. These are the highest-priority items on the roadmap — they're the most common request and the primary adoption blocker for teams with existing cloud infrastructure. --- --- url: /guide/providers/command-line.md description: >- FromCommandLine provider, switch-prefix filtering, key=value/key value/boolean-flag formats, : and __ nesting, custom prefixes, highest-priority override --- # Command Line Provider The command line provider parses command-line arguments into JSON configuration. ```csharp rule.For().FromCommandLine("--app:") ``` ## How It Works 1. Scans arguments for entries matching the switch prefix (default `--`) 2. Parses key-value pairs from the matched arguments 3. Converts flat keys to nested JSON (same nesting rules as environment variables) This provider is **static** — command-line arguments don't change during process lifetime. ## Argument Formats The parser supports several formats: ```shell # Key=value (single argument) --MaxRetries=10 # Key value (two arguments) --MaxRetries 10 # Boolean flag (no value = "true") --EnableLogging # Nested keys with : or __ --Database:Host=localhost --Database__Port=5432 ``` ::: warning Values that start with a switch prefix In the two-argument `--key value` form, a value that itself begins with a switch prefix (e.g. `--port -5`) is parsed as a **boolean flag** (`--port` → `true`), not as the value `-5`. Use the `=` form for such values: `--port=-5`. ::: ## Prefix Filtering Filter arguments by a prefix to avoid collisions: ```csharp // Only arguments starting with "--app:" rule.For().FromCommandLine("--app:") ``` ```shell dotnet run --app:MaxRetries=10 --app:Debug=true --other:Ignored=yes ``` Produces `{ "MaxRetries": 10, "Debug": true }` for `AppSettings`. Arguments starting with `--other:` are ignored. ## Custom Switch Prefixes By default, the parser looks for `--`. You can add other prefixes: ```csharp // Accept both - and -- as switch prefixes rule.For().FromCommandLine(["-", "--"]) ``` ```shell dotnet run -MaxRetries=10 --Debug=true ``` When multiple prefixes match, the longest prefix wins. ## Common Pattern Command-line arguments as the highest-priority override: ```csharp rule => [ rule.For().FromFile("appsettings.json").Required(), rule.For().FromEnvironment("APP_"), rule.For().FromCommandLine("--app:"), // Highest priority ] ``` ## Dynamic Options Use the factory overload for custom parsing: ```csharp rule.For().FromCommandLine(accessor => new CommandLineRuleOptions( Args: args, SwitchPrefixes: ["--", "-"], Prefix: "app")) ``` --- --- url: /guide/configuration/conditional-rules.md description: >- Conditionally enable rules with .When(accessor) over earlier config state, Skipped health status, dynamic source selection, COCFG002 rule-order checking --- # Conditional Rules Rules can be conditionally enabled based on the current configuration state. This is one of the most powerful features in Cocoar.Configuration — rules can depend on the results of earlier rules. ## Basic Conditional Rules Use `.When()` to conditionally include a rule: ```csharp rule => [ rule.For().FromFile("tenant.json"), rule.For().FromFile("premium.json") .When(accessor => accessor.GetConfig().IsPremium), ] ``` When the predicate returns `false`, the rule is skipped entirely — no provider call, no merge, no deserialization. Health monitoring reports the rule as `Skipped`. ## How It Works The `.When()` callback receives an `IConfigurationAccessor` — the same interface used to read config at runtime. At rule evaluation time, it reflects the state from all **earlier** rules that have already executed. ```csharp rule => [ rule.For().FromFile("appsettings.json"), // Rule 1 — always runs rule.For().FromFile("tenant.json"), // Rule 2 — always runs rule.For().FromFile("premium.json") .When(a => a.GetConfig().Tier == "Premium"), // ↑ Can access AppSettings and TenantSettings from rules 1-2 rule.For().FromFile("advanced.json") .When(a => a.GetConfig().EnableAdvanced), // ↑ Can access AppSettings, TenantSettings, and PremiumFeatures ] ``` ::: warning Rule Order Matters A `.When()` predicate can only read config types from rules that appear **before** it in the list. If you reference a type from a later rule, `GetConfig()` will throw because the type isn't loaded yet. The Roslyn analyzer **COCFG002** catches this at compile time. ::: ## Dynamic Configuration Conditional rules enable dynamic scenarios where the source itself depends on configuration: ```csharp rule => [ rule.For().FromFile("tenant.json"), // Load config from a region-specific endpoint rule.For().FromHttp(accessor => { var tenant = accessor.GetConfig(); return new HttpRuleOptions( $"https://{tenant.Region}.api.example.com/config", pollInterval: TimeSpan.FromMinutes(5)); }), ] ``` The HTTP endpoint URL is derived from `TenantSettings.Region`. When the tenant file changes and the region changes, the HTTP polling rule automatically switches to the new endpoint. ## Re-evaluation on Change Conditional predicates are re-evaluated during every recompute. If a previously-false condition becomes true (e.g., tenant upgrades to Premium), the rule activates and its provider starts contributing data. If a previously-true condition becomes false, the rule is skipped and its contribution is removed. This means `.When()` is not a one-time check — it's a live decision that adapts as configuration changes. ## Common Patterns ### Environment-based rules ```csharp rule.For().FromFile("debug.json") .When(_ => Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development") ``` ### Feature-gated configuration ```csharp rule.For().FromFile("experimental.json") .When(a => a.GetConfig().EnableExperiments) ``` ### Multi-tenant overrides ```csharp rule.For().FromHttp(a => { var tenant = a.GetConfig(); return new HttpRuleOptions($"https://config.example.com/{tenant.TenantId}", pollInterval: TimeSpan.FromMinutes(5)); }) .When(a => a.GetConfig().HasCustomConfig) ``` --- --- url: /guide/configuration/config-aware.md description: >- Rules that read earlier results via IConfigurationAccessor (GetConfig/TryGetConfig) to derive dynamic file paths and HTTP endpoints, with COCFG002 order enforcement --- # Config-Aware Rules Rules can read the results of earlier rules to make decisions. This turns the rule list into a pipeline where configuration drives configuration. ## The Idea Consider a multi-tenant application. You load tenant settings first, then use the tenant's region to determine which API endpoint to poll: ```csharp rule => [ rule.For().FromFile("tenant.json"), rule.For().FromHttp(accessor => { var tenant = accessor.GetConfig(); return new HttpRuleOptions( $"https://{tenant.Region}.api.example.com/config", pollInterval: TimeSpan.FromMinutes(5)); }), ] ``` The second rule doesn't hardcode a URL — it derives it from `TenantSettings`, which was loaded by the first rule. When the tenant file changes and the region changes, the HTTP rule automatically switches endpoints. ## IConfigurationAccessor Every rule factory receives an `IConfigurationAccessor`. This interface provides access to configuration from all rules that have already executed: ```csharp public interface IConfigurationAccessor { T? GetConfig() where T : class; bool TryGetConfig(out T? value) where T : class; } ``` | Method | Behavior | |---|---| | `GetConfig()` | Returns the config instance. Throws if no rule is registered for `T`. | | `TryGetConfig()` | Returns `true` and the instance if available, `false` otherwise. | ::: warning Rule Order Matters The accessor only sees types from rules that appear **before** the current rule. If you reference a type from a later rule, `GetConfig()` throws because the type isn't loaded yet. The Roslyn analyzer **COCFG002** catches this at compile time. ::: ## Where It Works The accessor is available in **every** provider factory overload and in `.When()`: ### Dynamic file paths ```csharp rule.For().FromFile(accessor => { var tenant = accessor.GetConfig(); return FileSourceRuleOptions.FromFilePath($"tenants/{tenant.TenantId}.json"); }) ``` ### Dynamic HTTP endpoints ```csharp rule.For().FromHttp(accessor => { var tenant = accessor.GetConfig(); return new HttpRuleOptions( $"https://{tenant.Region}.config.example.com/api", pollInterval: TimeSpan.FromMinutes(5)); }) ``` ### Dynamic environment prefixes ```csharp rule.For().FromEnvironment(accessor => { var tenant = accessor.GetConfig(); return new EnvironmentVariableRuleOptions($"TENANT_{tenant.TenantId}_"); }) ``` ### Conditional execution ```csharp rule.For().FromFile("premium.json") .When(accessor => accessor.GetConfig().IsPremium) ``` See [Conditional Rules](/guide/configuration/conditional-rules) for the full `.When()` documentation. ### Derived values ```csharp rule.For().FromStatic(accessor => { var app = accessor.GetConfig(); var db = accessor.GetConfig(); return new ComputedConfig { ConnectionString = $"Server={db.Host};Database={app.AppName}_db" }; }) ``` ## Re-evaluation on Change Config-aware factories are re-evaluated during every recompute. When the upstream config changes, the dependent rule sees the new values and adapts: 1. `tenant.json` changes — region goes from `us-east` to `eu-west` 2. Engine starts recompute, re-evaluates all rules in order 3. The HTTP polling rule's factory runs again, reads the new region 4. It returns a new URL → the provider switches to the EU endpoint 5. New config is fetched from the EU endpoint This happens automatically. No manual wiring, no event handlers, no polling logic. ## Patterns ### Feature-gated sources Load additional configuration only when a feature is enabled: ```csharp rule => [ rule.For().FromFile("appsettings.json"), rule.For().FromHttp(accessor => { var app = accessor.GetConfig(); return new HttpRuleOptions( app.ExperimentalEndpoint, pollInterval: TimeSpan.FromMinutes(10)); }) .When(accessor => accessor.GetConfig().EnableExperiments), ] ``` The `.When()` and the factory both use the accessor. The rule only executes when the feature is enabled, and the URL comes from config. ### Multi-tenant overrides Base config + tenant-specific overrides from different sources: ```csharp rule => [ rule.For().FromFile("tenant.json").Required(), rule.For().FromFile("appsettings.json").Required(), rule.For().FromHttp(accessor => { var tenant = accessor.GetConfig(); return new HttpRuleOptions( $"https://config.example.com/tenants/{tenant.TenantId}", pollInterval: TimeSpan.FromMinutes(5)); }) .When(accessor => accessor.GetConfig().HasCustomConfig), ] ``` ### Safe access with TryGetConfig When you're unsure whether a type has rules defined: ```csharp rule.For().FromFile(accessor => { if (accessor.TryGetConfig(out var tenant)) return FileSourceRuleOptions.FromFilePath($"overrides/{tenant.TenantId}.json"); return FileSourceRuleOptions.FromFilePath("overrides/default.json"); }) ``` ## How It Differs from Conditional Rules [Conditional Rules](/guide/configuration/conditional-rules) (`.When()`) are one application of the accessor — they decide **whether** a rule runs. Config-aware rules are the broader concept: the accessor can influence **what** to load, **where** to load it from, and **how** to configure the provider. `.When()` is a boolean gate; the accessor in factory overloads controls everything else. --- --- url: /roadmap/confighub.md description: >- ConfigHub management portal (commercial) — push config to fleets via FromConfigHub(), secret/cert lifecycle, feature flag control, health dashboard, OTLP telemetry --- # ConfigHub The most common question: *"This is great, but how do I manage configuration across 100 instances without SSH-ing into each one?"* **ConfigHub** is the answer — a management portal for Cocoar.Configuration deployments. ## The Problem Cocoar.Configuration handles the runtime side: loading, merging, reacting, encrypting. But when you operate dozens or hundreds of instances, the operational questions are different: * How do I push a config change to all production instances? * Which instances have expired feature flags? * When was the last certificate rotation, and which instances still use the old cert? * A customer reports an issue — what's their current configuration state? File-based config with manual deploys doesn't scale. You need a control plane. ## What ConfigHub Provides ### Configuration Management Push config changes to instances or groups of instances without redeployment. Version history, diff view, rollback. ### Secrets Lifecycle Certificate management, automated key rotation, encrypted secret distribution. No more managing N certificates for N instances manually — ConfigHub handles the lifecycle centrally and distributes keys to instances. ### Feature Flag Control Enable/disable flags per instance, tenant, or environment. See which flags are active, which are expired, audit who changed what and when. ### Health Dashboard Real-time health across all instances. Drill down from fleet overview to individual rule failures. Alert on degraded/unhealthy state before customers notice. ### Telemetry Rich per-rule health snapshots, recompute timing, provider error rates, configuration drift detection — beyond what OpenTelemetry counters provide. ## Architecture ConfigHub connects to your instances via the standard provider model. It's just another configuration source — the library doesn't know or care whether the bytes come from a file, HTTP endpoint, or ConfigHub: ```csharp builder.AddCocoarConfiguration(c => c .UseConfiguration(rule => [ rule.For().FromFile("appsettings.json"), // Local defaults rule.For().FromConfigHub(), // Remote overrides from ConfigHub ])); ``` The `FromConfigHub()` provider uses the existing reactive pipeline — changes pushed from ConfigHub trigger the same recompute/merge/notify cycle as a file change. No special runtime behavior. ### Data Flow ``` ConfigHub Portal Your Instances ┌──────────────┐ ┌──────────────────┐ │ Dashboard │ │ ConfigManager │ │ Config UI │ ── push/pull ──→ │ FromConfigHub() │ │ Flag Control│ │ reactive merge │ │ Health View │ ←── telemetry ── │ OpenTelemetry │ └──────────────┘ └──────────────────┘ ``` Instances report health via standard OpenTelemetry. ConfigHub connects via OTLP — no proprietary agent or sidecar. ## Licensing | | Library | ConfigHub | |---|---|---| | **License** | Apache-2.0 (free, forever) | Commercial (free tier available) | | **What you get** | Full config, flags, entitlements, secrets, providers, analyzers | Management UI, push delivery, cert lifecycle, telemetry dashboards | | **Dependency** | Standalone | Needs the library | | **Required?** | N/A | No — the library works fully without it | The library does **not** phone home, require a license key, or degrade without ConfigHub. It's a complete product on its own. ConfigHub is for teams that need the operational layer. ## Status ConfigHub is in the design phase. Architecture, data model, and provider protocol are being defined. A private preview is planned after the cloud providers ship. If you're interested in early access, watch the [GitHub repository](https://github.com/cocoar-dev/Cocoar.Configuration) for announcements. --- --- url: /guide/analyzers/configuration.md description: >- COCFG diagnostics reference — COCFG001 secret path conflicts, COCFG002 rule dependency ordering, COCFG003 required-rule validation, COCFG005/006 duplicate and static-provider ordering --- # Configuration Diagnostics ## COCFG001 — Secret Path Conflict {#cocfg001} **Severity:** Warning A non-secret property has the same path as a `Secret` property, risking plaintext exposure. ```csharp // ❌ Warning: ConnectionString conflicts with Secret ConnectionString rules.For().FromFile("base.json"), rules.For().FromFile("local.json") ``` If `DbConfig.ConnectionString` is a `Secret`, both rules target the same path — the second rule could overwrite the encrypted value with plaintext. **Fix:** Use distinct paths or ensure both sources provide properly encrypted values. ## COCFG002 — Rule Dependency Ordering {#cocfg002} **Severity:** Error A rule depends on configuration that hasn't been loaded yet. Rules execute sequentially — if a rule uses `GetConfig()` to read a type, that type's rule must appear earlier. ```csharp // ❌ Error: DbConfig depends on AppConfig, but AppConfig isn't loaded yet rules.For().FromFile(cm => cm.GetConfig()!.DbConfigPath), rules.For().FromFile("appsettings.json") ``` ```csharp // ✓ Fix: AppConfig first, then DbConfig can read it rules.For().FromFile("appsettings.json"), rules.For().FromFile(cm => cm.GetConfig()!.DbConfigPath) ``` ## COCFG003 — Required Rule Validation {#cocfg003} **Severity:** Warning A required rule references a file or resource that may not exist. If the resource is missing at startup, the application will fail to start. ```csharp // ⚠️ Warning: Application won't start if missing.json doesn't exist rules.For().FromFile("missing.json").Required() ``` **Fix:** Verify the file exists in your deployment, or use `.Optional()` if the file is not critical. ## COCFG005 — Duplicate Unconditional Rules {#cocfg005} **Severity:** Info Multiple rules configure the same type without conditions. Since rules merge (last-write-wins), earlier unconditional rules are fully overwritten by later ones — wasting provider I/O. ```csharp // ℹ️ Info: Second rule overwrites first entirely rules.For().FromFile("appsettings.json"), rules.For().FromFile("appsettings.json") ``` ```csharp // ✓ Fix: Use conditions to differentiate rules.For().FromFile("appsettings.json"), rules.For() .FromFile("appsettings.Production.json") .When(_ => IsProduction()) ``` This diagnostic is informational — sometimes duplicates are intentional (e.g., base + environment overlay). Suppress it if the duplication is deliberate. ## COCFG006 — Static Provider Ordering {#cocfg006} **Severity:** Info A static rule appears after dynamic rules. Since rules merge property by property (later rules overlay earlier ones), a static rule at the end always wins — dynamic sources can never override it. ```csharp // ℹ️ Info: Static rule after dynamic — static values always win rules.For().FromFile("appsettings.json"), rules.For().FromStatic(_ => new AppSettings { LogLevel = "Debug" }) ``` ```csharp // ✓ Fix: Static defaults first, dynamic overrides after rules.For().FromStatic(_ => new AppSettings { LogLevel = "Info" }), rules.For().FromFile("appsettings.json") ``` The typical pattern is: static defaults first, then file/environment/HTTP sources that can override them. --- --- url: /guide/flags/context-resolvers.md description: >- IContextResolver hydrating request DTOs into domain context, global/class/property registration levels, Scoped lifetime, evaluation pipeline for contextual flags --- # Context Resolvers Context resolvers bridge HTTP request data to your domain model. They turn a simple request DTO into a rich context object that flags and entitlements use for evaluation. ## The Problem A contextual flag needs domain context to make a decision: ```csharp public FeatureFlag BetaFeature { get; } BetaFeature = user => user.IsBeta && user.PlanTier == "pro"; ``` But an HTTP request only carries an identifier — not the full `UserContext`. Something needs to hydrate the context. This is where resolvers come in. They are the **only place** where side effects (database calls, API lookups, claim parsing) should happen. Flags themselves are [pure functions](/guide/flags/concepts#why-delegates) — they receive config and context, nothing else. ## IContextResolver\ A resolver converts a request DTO into a domain context: ```csharp public interface IContextResolver { Task ResolveAsync(TRequest request); } ``` ```csharp public record UserIdRequest(string UserId); public class UserByIdResolver(IUserRepository users) : IContextResolver { public async Task ResolveAsync(UserIdRequest request) { var user = await users.GetByIdAsync(request.UserId); return new UserContext(user.Id, user.Email, user.PlanTier, user.IsBeta); } } ``` When an HTTP request arrives with `{ "userId": "123" }`, the resolver loads the full user from the database and creates the `UserContext` that the flag delegate expects. ## Registration Levels Resolvers are registered via the second parameter of `UseFeatureFlags()` / `UseEntitlements()`, which is a DI extension method (requires `Cocoar.Configuration.DI` or `Cocoar.Configuration.AspNetCore`). Resolvers can be registered at three levels of specificity: ### Global Fallback for all flag/entitlement properties with matching `TContext`: ```csharp .UseFeatureFlags( flags => [ flags.Register(), flags.Register() ], resolvers => [ resolvers.Global() ]) ``` Every `FeatureFlag` across all classes uses `UserByIdResolver` unless overridden. ### Class-level Applies to all contextual properties in one class: ```csharp .UseFeatureFlags( flags => [flags.Register()], resolvers => [ resolvers.For(r => r .Use()) ]) ``` ### Property-level Most specific — overrides class and global for one property: ```csharp .UseFeatureFlags( flags => [flags.Register()], resolvers => [ resolvers.For(r => r .ForProperty(f => f.BetaByEmail).Use()) ]) ``` ### Priority When evaluating a contextual flag, the resolver is selected by priority: 1. **Property-level** (most specific) 2. **Class-level** 3. **Global** (fallback) ## Resolver Lifetime Resolvers are registered as **Scoped** in DI by default. One instance is created per request scope. This allows resolvers to depend on scoped services like `DbContext`: ```csharp public class TenantByIdResolver(AppDbContext db) : IContextResolver { public async Task ResolveAsync(TenantIdRequest request) { var tenant = await db.Tenants.FindAsync(request.TenantId); return new TenantContext(tenant.Id, tenant.Tier, tenant.Region); } } ``` You can customize the lifetime on individual resolver registrations: ```csharp resolvers => [ resolvers.Global().AsSingleton(), resolvers.For(r => r .Use().AsTransient()) ] ``` Available lifetime methods: `.AsScoped()` (default), `.AsSingleton()`, `.AsTransient()`. ## How Evaluation Works When a contextual flag is evaluated via the REST API or `IFeatureFlagEvaluator`: 1. The request body is deserialized to `TRequest` 2. The resolver is instantiated from DI 3. `ResolveAsync(request)` hydrates the domain context 4. The flag delegate is invoked with the context 5. The result is returned ``` POST /flags/AppFlags/BetaFeature { "userId": "123" } → UserByIdResolver.ResolveAsync({ UserId: "123" }) → UserContext { Id: "123", IsBeta: true, PlanTier: "pro" } → BetaFeature(userContext) → true → { "value": true } ``` ## Multiple Resolver Types Different properties in the same class can use different resolver types: ```csharp resolvers.For(r => r .Use() // Default for this class .ForProperty(f => f.BetaByEmail).Use()) // Override for one property ``` The only requirement is that the resolver's `TContext` matches the flag property's `TContext`. --- --- url: /roadmap/database-provider.md description: >- Planned FromDatabase() provider over ADO.NET (SQL Server, PostgreSQL, MySQL, SQLite) — JSON columns, polling, config-aware queries, LISTEN/NOTIFY, multi-tenant config --- # Database Provider A native provider for loading configuration from relational databases — SQL Server, PostgreSQL, MySQL, and others via ADO.NET. ## Why Many multi-tenant applications store per-tenant configuration in the database. Today, this requires a [custom provider](/guide/providers/custom). A native database provider would make this a one-liner. ## Planned API ```csharp rule.For().FromDatabase(db => { db.ConnectionString = "Server=localhost;Database=myapp"; db.Query = "SELECT config_json FROM tenant_config WHERE tenant_id = @tenantId"; db.Parameters = new { tenantId = currentTenant.Id }; db.PollInterval = TimeSpan.FromMinutes(1); }) ``` Or with config-aware connection strings: ```csharp rule.For().FromDatabase(accessor => { var app = accessor.GetConfig(); return new DatabaseRuleOptions { ConnectionString = app.DatabaseConnectionString, Query = "SELECT config_json FROM tenant_config WHERE tenant_id = @id", Parameters = new { id = app.TenantId }, }; }) ``` ## Planned Capabilities * **Any ADO.NET provider** — SQL Server, PostgreSQL, MySQL, SQLite via standard `DbConnection` * **JSON column support** — query returns a JSON string, merged into the config pipeline * **Polling for changes** — configurable poll interval with hash-based change detection * **Config-aware queries** — derive connection strings and query parameters from earlier rules * **Change notifications** — optional SQL dependency / listen-notify support for push-based updates (PostgreSQL `LISTEN/NOTIFY`, SQL Server `SqlDependency`) ## Use Case: Multi-Tenant Configuration The primary use case is ISVs that operate per-customer instances. Each customer has configuration stored in a shared or per-customer database: ```csharp rule => [ rule.For().FromFile("appsettings.json"), // Base defaults rule.For().FromFile("tenant.json"), // Which tenant is this? rule.For().FromDatabase(accessor => // Tenant-specific config { var tenant = accessor.GetConfig(); return new DatabaseRuleOptions { ConnectionString = tenant.ConfigDbConnectionString, Query = "SELECT config FROM tenants WHERE id = @id", Parameters = new { id = tenant.TenantId }, }; }), ] ``` The tenant's database config overrides the file defaults — same merge semantics as any other provider. ## Status Planned. This is the second-highest priority after cloud providers. --- --- url: /guide/reactive/debouncing.md description: >- Trailing-edge debounce coalescing rapid source changes — 300ms default, UseDebounce config, cross-provider coalescing, recompute-from-earliest-changed-rule, during-run changes --- # Debouncing When a configuration source changes, the engine doesn't recompute immediately. It waits for a quiet period to coalesce rapid changes into a single recompute. ## Why Debounce? File saves often trigger multiple file system events in quick succession. An editor saving a file might emit Created, Changed, Changed within milliseconds. Without debouncing, each event would trigger a full recompute — wasting resources and flooding subscribers with intermediate states. ## Default Behavior The default debounce interval is **300 milliseconds**: 1. A change is detected (file modified, HTTP poll returned new data, etc.) 2. The engine starts a 300ms timer 3. If more changes arrive during those 300ms, the timer resets 4. After 300ms of quiet, the recompute runs This is **trailing-edge debounce** — the recompute fires after the storm of changes passes. ## Configuring the Interval Set the debounce interval when creating the ConfigManager: ```csharp var manager = ConfigManager.Create(c => c .UseDebounce(500) // 500ms debounce .UseConfiguration(rule => [ rule.For().FromFile("appsettings.json"), ])); ``` ```csharp // ASP.NET Core builder.AddCocoarConfiguration(c => c .UseDebounce(500) .UseConfiguration(rule => [ /* ... */ ])); ``` | Value | Effect | |---|---| | `0` | No debounce — recompute fires immediately on every change | | `300` (default) | Good balance for most applications | | `1000+` | Use when sources are noisy or recomputes are expensive | ## What Gets Coalesced Debouncing operates across **all providers**. If a file change and an HTTP poll arrive within the debounce window, they're coalesced into one recompute. The engine tracks which rule triggered the change and recomputes from the **earliest changed rule** forward. This ensures all downstream dependencies are updated correctly. ## Changes During Recompute If a change arrives while a recompute is already running: 1. The change is noted (tracked as "during-run") 2. The current recompute finishes 3. A new recompute is scheduled with a trailing debounce 4. The trailing recompute picks up the changes that arrived during the previous run This prevents lost updates without causing recompute storms. --- --- url: /guide/flags/defining-entitlements.md description: >- Defining IEntitlements partial classes, Entitlement and Entitlement delegates, tuple multi-config, permanent business logic with no ExpiresAt --- # Defining Entitlements Entitlements are `partial class` types that implement `IEntitlements` and define entitlement properties as delegates. Unlike feature flags, entitlements have no expiration date — they represent permanent business logic. ## Basic Structure ```csharp public partial class PlanEntitlements : IEntitlements { /// Maximum allowed team members for this plan. public Entitlement MaxUsers => () => Config.UserLimit; /// Whether this plan can export data. public Entitlement CanExport => () => Config.Tier != "free"; /// Storage limit in gigabytes. public Entitlement StorageLimitGb => () => Config.Tier switch { "enterprise" => 1000, "pro" => 100, _ => 5 }; } ``` The class must be `partial` so the source generator can emit a constructor that accepts `IReactiveConfig`. The generated `Config` property returns `IReactiveConfig.CurrentValue`, so it always reflects the latest configuration. ### Key Elements | Element | Purpose | |---|---| | `IEntitlements` | Marks this as an entitlement class; source generator produces constructor and `Config` property | | `partial class` | Required — the source generator emits the other half | | `Config` | Source-generated property — reads `IReactiveConfig.CurrentValue` | | `Entitlement` | A no-context entitlement — returns a value based on current config | | XML `` | Description extracted by the source generator for REST endpoints | ## Entitlement Types ### No-context entitlements `Entitlement` is a parameterless delegate: ```csharp /// Whether API access is enabled. public Entitlement ApiAccess => () => Config.Tier != "free"; ``` ### Contextual entitlements `Entitlement` takes a context parameter — for per-tenant or per-user decisions: ```csharp /// Maximum API requests per minute for a specific tenant. public Entitlement RateLimit => tenant => tenant.Tier switch { "enterprise" => 10000, "pro" => 1000, _ => 100 }; ``` The `TContext` is resolved at evaluation time via a [Context Resolver](/guide/flags/context-resolvers). ## Multiple Config Sources Entitlements can combine multiple configuration types using a tuple: ```csharp public partial class AccessEntitlements : IEntitlements<(PlanConfig, FeatureConfig)> { /// Whether advanced analytics are available. public Entitlement AdvancedAnalytics => () => Config.Item1.Tier == "enterprise" && Config.Item2.AnalyticsEnabled; } ``` The source generator injects `IReactiveConfig<(PlanConfig, FeatureConfig)>` and `Config` returns the combined tuple. When either config changes, the next entitlement invocation returns the updated result. You can also use named tuple elements for readability: ```csharp public partial class AccessEntitlements : IEntitlements<(PlanConfig Plan, FeatureConfig Features)> { public Entitlement AdvancedAnalytics => () => Config.Plan.Tier == "enterprise" && Config.Features.AnalyticsEnabled; } ``` ## No ExpiresAt The key difference from feature flags: entitlements have **no expiration**. They represent permanent product logic that doesn't need cleanup: ```csharp // Feature flag — temporary, must expire public partial class BetaFlags : IFeatureFlags { public DateTimeOffset ExpiresAt => new(2026, 6, 1, ...); } // Entitlement — permanent, no expiry public partial class PlanEntitlements : IEntitlements { // No ExpiresAt needed } ``` ## Using Entitlements Directly Inject the entitlement class and invoke properties: ```csharp public class ExportService(PlanEntitlements entitlements) { public async Task ExportAsync(ExportRequest request) { if (!entitlements.CanExport()) throw new ForbiddenException("Export not available on your plan"); var limit = entitlements.StorageLimitGb(); // ... } } ``` Entitlement classes are **Singleton** — safe to inject anywhere. --- --- url: /guide/flags/defining-flags.md description: >- Defining IFeatureFlags partial classes, FeatureFlag and FeatureFlag delegates, tuple multi-config, ExpiresAt, source-generated Config property --- # Defining Feature Flags Feature flags are `partial class` types that implement `IFeatureFlags` and define flag properties as delegates. The source generator produces the constructor and `Config` property automatically. ## Basic Structure ```csharp public partial class AppFeatureFlags : IFeatureFlags { // Required: when should these flags be cleaned up? public DateTimeOffset ExpiresAt => new(2026, 6, 1, 0, 0, 0, TimeSpan.Zero); /// Enables the new onboarding flow. public FeatureFlag NewOnboarding => () => Config.EnableNewOnboarding; /// Maximum items shown in the new list view. public FeatureFlag NewListViewMaxItems => () => Config.ListViewMax; } ``` The class must be `partial` so the source generator can emit a constructor that accepts `IReactiveConfig`. The generated `Config` property returns `IReactiveConfig.CurrentValue`, so it always reflects the latest configuration. ### Key Elements | Element | Purpose | |---|---| | `IFeatureFlags` | Marks this as a feature flag class; source generator produces constructor and `Config` property | | `partial class` | Required — the source generator emits the other half | | `ExpiresAt` | Class-level expiration date — when should these flags be removed? | | `Config` | Source-generated property — reads `IReactiveConfig.CurrentValue` | | `FeatureFlag` | A no-context flag — returns a value based on current config | | XML `` | Description extracted by the source generator for health/REST endpoints | ## Flag Types ### No-context flags `FeatureFlag` is a parameterless delegate. It reads from `Config` and returns a result: ```csharp /// Enables dark mode for all users. public FeatureFlag DarkMode => () => Config.DarkModeEnabled; ``` ### Contextual flags `FeatureFlag` takes a context parameter — for decisions that depend on the current user, tenant, or request: ```csharp /// Gates the beta feature for specific users. public FeatureFlag BetaFeature => user => Config.BetaEnabled && user.IsBeta; ``` The `TContext` is resolved at evaluation time via a [Context Resolver](/guide/flags/context-resolvers). ## Multiple Config Sources A flag class can depend on multiple configuration types using a tuple: ```csharp public partial class RolloutFlags : IFeatureFlags<(FeatureConfig, TenantConfig)> { public DateTimeOffset ExpiresAt => new(2026, 9, 1, 0, 0, 0, TimeSpan.Zero); /// Enables new checkout when both feature and tenant allow it. public FeatureFlag NewCheckout => () => Config.Item1.NewCheckoutEnabled && Config.Item2.AllowExperiments; } ``` The source generator injects `IReactiveConfig<(FeatureConfig, TenantConfig)>` and `Config` returns the combined tuple. When either config changes, the next flag invocation returns the updated result. You can also use named tuple elements for readability: ```csharp public partial class RolloutFlags : IFeatureFlags<(FeatureConfig Features, TenantConfig Tenant)> { public DateTimeOffset ExpiresAt => new(2026, 9, 1, 0, 0, 0, TimeSpan.Zero); public FeatureFlag NewCheckout => () => Config.Features.NewCheckoutEnabled && Config.Tenant.AllowExperiments; } ``` ## Return Types Flags can return any type, not just booleans: ```csharp /// Which checkout variant to show (A/B test). public FeatureFlag CheckoutVariant => () => Config.CheckoutVariant; /// Rate limit for the new API (requests per minute). public FeatureFlag NewApiRateLimit => () => Config.NewApiRpm; /// Full feature configuration for the experiment. public FeatureFlag ExperimentSettings => () => Config.Experiment; ``` ## ExpiresAt Every feature flag class must declare when its flags should be cleaned up: ```csharp public DateTimeOffset ExpiresAt => new(2026, 6, 1, 0, 0, 0, TimeSpan.Zero); ``` This is a **class-level** expiration. A flag class groups flags belonging to one feature — the expiry date applies to the entire feature, not individual flags. When the feature is fully rolled out, the whole class should be removed. After the expiry date: * Flags continue to work normally * The health API reports `Degraded` * This signals to the team that the feature rollout is complete and the flags should be cleaned up The source generator validates that `ExpiresAt` returns a static value. Dynamic expressions are rejected at compile time. ## Using Flags Directly Inject the flag class and invoke properties directly: ```csharp public class CheckoutService(AppFeatureFlags flags) { public async Task ProcessAsync(Order order) { if (flags.NewCheckout()) return await NewCheckoutFlow(order); return await LegacyCheckoutFlow(order); } } ``` Flag classes are **Singleton** — safe to inject anywhere. The delegate reads from `Config` (backed by `IReactiveConfig.CurrentValue`), which always reflects the latest configuration. :::warning Namespace collision with Config property The source generator creates a `Config` property on your class. If your namespace contains `.Config` (e.g., `MyApp.Config`), the compiler may confuse the namespace with the property. Avoid naming namespaces `Config` when using `IFeatureFlags` or `IEntitlements`. ::: --- --- url: /guide/di/setup.md description: >- AddCocoarConfiguration for Microsoft.Extensions.DI — auto-registration, ConcreteType/ExposeAs/Interface DeserializeTo, DisableAutoRegistration, flags, secrets --- # DI Setup `AddCocoarConfiguration()` registers everything into Microsoft.Extensions.DependencyInjection — configuration types, reactive wrappers, feature flags, and entitlements. ::: info Package Requires `Cocoar.Configuration.DI` (or `Cocoar.Configuration.AspNetCore`, which includes it). ::: ## Basic Registration ```csharp builder.Services.AddCocoarConfiguration(c => c .UseConfiguration(rules => [ rules.For().FromFile("appsettings.json"), rules.For().FromFile("db.json") ])); ``` Every type from your rules is automatically registered: * `AppSettings` and `DbConfig` as **Scoped** services (one snapshot per request) * `IReactiveConfig` and `IReactiveConfig` as **Singleton** services (live updates) No setup lambda needed — auto-registration handles the defaults. ## Setup Options The optional setup lambda is for when you need to **customize** registration — expose interfaces, change lifetimes, or disable auto-registration. If the defaults work, skip it. ### ConcreteType `ConcreteType()` is the entry point for customizing a type's registration. On its own it does nothing beyond the default — it's useful as a starting point to chain further options: ```csharp setup.ConcreteType().AsSingleton() ``` ### ExposeAs Expose a concrete type through an interface: ```csharp setup.ConcreteType().ExposeAs() ``` This registers both `AppSettings` and `IAppSettings` — the interface resolves to the same instance within a scope. ### Interface with DeserializeTo When your configuration model uses an interface property, tell the deserializer which concrete type to use: ```csharp setup.Interface().DeserializeTo() ``` ### DisableAutoRegistration Prevent a type from being registered in DI while still keeping its rules: ```csharp setup.ConcreteType().DisableAutoRegistration() ``` The type is loaded and available via `ConfigManager.GetConfig()`, but not injected via DI. Note that `IReactiveConfig` is still registered as Singleton — only the concrete type registration is disabled. ## With Feature Flags ```csharp builder.Services.AddCocoarConfiguration(c => c .UseConfiguration(rules => [ rules.For().FromFile("appsettings.json") ]) .UseFeatureFlags( flags => [flags.Register()], resolvers => [resolvers.Global()]) .UseEntitlements( entitlements => [entitlements.Register()])); ``` This additionally registers: * `AppFlags` as Singleton * `PlanEntitlements` as Singleton * `IFeatureFlagsDescriptors` and `IEntitlementsDescriptors` as Singleton * `IFeatureFlagEvaluator` and `IEntitlementEvaluator` as Scoped * All context resolvers as Scoped (customizable via `.AsSingleton()`, `.AsTransient()`) ## With Secrets ```csharp builder.Services.AddCocoarConfiguration(c => c .UseConfiguration(rules => [...]) .UseSecretsSetup(s => s.WithX509Certificate("certs/config.pfx"))); ``` ## Pre-Built ConfigManager If you need to create the `ConfigManager` separately (e.g., for use before DI is available): ```csharp var manager = ConfigManager.Create(c => c.UseConfiguration(rules => [...])); // Use manager directly... // Then register it builder.Services.AddCocoarConfiguration(manager); ``` ## Duplicate Prevention `AddCocoarConfiguration()` can only be called once per `IServiceCollection`. A second call throws `InvalidOperationException`. This prevents accidental double-registration from multiple startup paths. --- --- url: /guide/providers/dotenv.md description: >- FromDotEnv provider (core, no dependency) — .env KEY=value parsing, # comments, export prefix, single/double quotes, inline comments, :/__ key nesting, reactive file-watching --- # Dotenv (.env) Provider `FromDotEnv` reads a 12-factor-style `.env` file into the configuration pipeline. It is **built into the core package** (no extra dependency) and uses the same reactive file-watching as the [File provider](/guide/providers/file) — including `followSymlinks: true` for [Kubernetes ConfigMap / Secret mounts](/guide/providers/file#kubernetes-configmap-secret-mounts). ```csharp builder.AddCocoarConfiguration(c => c .UseConfiguration(rules => [ rules.For().FromDotEnv(), // defaults to ".env" rules.For().FromDotEnv("local.env"), ])); ``` ## Format ```shell # comments and blank lines are ignored NAME=myapp export TOKEN=abc123 # an optional `export` prefix is stripped DQ="hello world" # double quotes; supports \n \t \" \\ escapes SQ='literal $x' # single quotes are literal INLINE=value # trailing comment (stripped — needs a leading space) # Nested keys with : or __ (like environment variables) Db__Port=5432 # → { "Db": { "Port": "5432" } } Db:Host=localhost ``` Values are emitted as strings; the binder coerces them to the target type (e.g. `Db__Port=5432` binds to an `int`). Keys nest on `:` or `__`, matching the [Environment Variables provider](/guide/providers/environment). ## Reactivity & per-tenant paths Editing the file triggers a recompute. A config-aware overload resolves the path per recompute: ```csharp rules.For().FromDotEnv(a => $"tenants/{a.Tenant}/.env").TenantScoped() ``` ## YAML? For `.yaml` / `.yml` files see the [YAML provider](/guide/providers/yaml) (`Cocoar.Configuration.Yaml`). --- --- url: /guide/secrets/encryption-setup.md description: >- UseSecretsSetup with X.509 hybrid encryption (RSA-OAEP + AES-256-GCM), UseCertificateFromFile/WithKeyId single-cert, PFX/PEM formats, certificate-folder mode --- # Encryption Setup Secrets are encrypted with X.509 certificates using hybrid encryption: RSA-OAEP wraps an AES-256-GCM data encryption key. ## Single Certificate The simplest setup — one certificate for all secrets: ```csharp builder.AddCocoarConfiguration(c => c .UseConfiguration(rule => [ /* ... */ ]) .UseSecretsSetup(secrets => secrets .UseCertificateFromFile("certs/secrets.pfx") .WithKeyId("my-app"))); ``` | Method | Description | |---|---| | `UseCertificateFromFile(path)` | Load a PFX or PEM certificate file | | `WithKeyId(kid)` | Set the key identifier embedded in each encrypted secret | The `kid` links encrypted envelopes to the certificate that can decrypt them. Each envelope records which `kid` was used to encrypt it. ### Supported Formats | Format | Extensions | Notes | |---|---|---| | PKCS#12 | `.pfx`, `.p12` | Contains both public and private key | | PEM | `.pem`, `.crt`, `.cer` | Requires matching `.key` file with same base name | Certificates must be **password-less** at runtime and protected by file system permissions. See [Working with Certificates](/guide/certificates) for why and how. ### Path Resolution Paths are resolved relative to `AppContext.BaseDirectory`: ```csharp // Relative — from app base directory .UseCertificateFromFile("certs/secrets.pfx") // Absolute — used as-is .UseCertificateFromFile("/etc/myapp/certs/secrets.pfx") ``` ## Certificate Folder For multi-certificate setups and [rotation](/guide/secrets/security-model#rotation): ```csharp .UseSecretsSetup(secrets => secrets .UseCertificatesFromFolder("certs/", searchPattern: "*.pfx")) ``` The system monitors the folder and automatically discovers certificates: | Parameter | Default | Description | |---|---|---| | `basePath` | (required) | Directory to scan for certificates | | `searchPattern` | `"*"` | File filter — `"*.pfx"`, `"*.pem"`, or `"*"` for auto-discovery | | `cacheDurationSeconds` | `30` | How long loaded certificates are cached | | `certificateComparer` | null | Custom ordering for certificate priority | ### Multi-Tenant (Kid Subdirectories) Organize certificates by key ID using subdirectories: ``` certs/ ├── tenant-a/ │ └── cert.pfx ├── tenant-b/ │ └── cert.pfx └── shared/ └── cert.pfx ``` Each subdirectory name becomes a `kid`. Secrets encrypted with `kid: "tenant-a"` are decrypted with the certificate in `certs/tenant-a/`. ## AllowPlaintext (Development) For local development, skip encryption entirely: ```csharp .UseSecretsSetup(secrets => secrets.AllowPlaintext()) ``` With this enabled, `Secret` properties deserialize from plain JSON values: ```json { "Password": "my-dev-password" } ``` A trace warning is emitted when `AllowPlaintext()` is active. Conditionally enable it: ```csharp .UseSecretsSetup(secrets => { if (env.IsDevelopment()) return secrets.AllowPlaintext(); return secrets .UseCertificateFromFile("certs/prod.pfx") .WithKeyId("prod"); }) ``` ## The Encrypted Envelope When you encrypt a value, it produces this JSON structure: ```json { "type": "cocoar.secret", "version": 1, "kid": "prod-secrets", "alg": "RSA-OAEP-AES256-GCM", "wk": "", "walg": "RSA-OAEP-256", "iv": "", "ct": "", "tag": "" } ``` | Field | Purpose | |---|---| | `type` | Discriminator — always `"cocoar.secret"` | | `version` | Format version (currently `1`) | | `kid` | Key identifier — which certificate to use | | `alg` | Overall algorithm | | `wk` | Wrapped (encrypted) AES-256 data encryption key | | `walg` | Key wrapping algorithm (RSA-OAEP-SHA256) | | `iv` | 96-bit AES-GCM initialization vector | | `ct` | Encrypted ciphertext | | `tag` | 128-bit AES-GCM authentication tag | The public key encrypts. The private key decrypts. You can safely commit the encrypted envelope to source control — without the private key, it's unreadable. ## How Decryption Works 1. Deserializer detects `"type": "cocoar.secret"` in the JSON 2. Reads the `kid` to find the matching certificate 3. Uses RSA-OAEP-SHA256 to unwrap the AES-256 data encryption key 4. Uses AES-256-GCM to decrypt the ciphertext (with authentication) 5. Returns the plaintext as `byte[]` inside a `Secret` wrapper 6. The AES key is zeroed immediately after use ## Additional Key IDs Accept secrets encrypted with multiple certificates (during rotation): ```csharp .UseCertificateFromFile("certs/prod-v2.pfx") .WithKeyId("prod-v2") .WithAdditionalKeyId("prod-v1") // Still accept old certificate ``` See [Security Model](/guide/secrets/security-model#rotation) for the full rotation workflow. --- --- url: /guide/providers/environment.md description: >- FromEnvironment provider, case-insensitive prefix filtering, __ and : nesting to JSON, final-override pattern, dynamic per-tenant prefix --- # Environment Variables Provider The environment variable provider reads environment variables and converts them to nested JSON. ```csharp rule.For().FromEnvironment("APP_") ``` ## How It Works 1. Reads all environment variables at evaluation time 2. Filters by prefix (if specified) 3. Strips the prefix from matching keys 4. Converts flat key-value pairs to nested JSON This provider is **static** — it reads environment variables once per recompute and does not watch for changes. Environment variables typically don't change during a process lifetime. ## Prefix Filtering The prefix filters which variables are included. Matching is case-insensitive: ```csharp // Only variables starting with "APP_" rule.For().FromEnvironment("APP_") // All environment variables (no filtering) rule.For().FromEnvironment() ``` With prefix `APP_`, the variable `APP_MaxRetries=10` becomes `{ "MaxRetries": 10 }`. ## Nesting Convention Use `__` (double underscore) or `:` to create nested JSON structures. A single `_` is treated as a literal character: ```shell # Double underscore = nesting APP_Database__Host=localhost APP_Database__Port=5432 # Produces: # { "Database": { "Host": "localhost", "Port": 5432 } } ``` ```shell # Single underscore = literal APP_App_Name=MyApp # Produces: # { "App_Name": "MyApp" } ``` ```shell # Colon also works APP_Database:Host=localhost # Produces: # { "Database": { "Host": "localhost" } } ``` ## Common Pattern Environment variables are typically the last rule, overriding everything else: ```csharp rule => [ rule.For().FromFile("appsettings.json").Required(), rule.For().FromFile($"appsettings.{env}.json"), rule.For().FromEnvironment("APP_"), // Final override ] ``` This lets you override any config property via environment variables without touching files — useful for containers, CI/CD, and local development. ## Dynamic Prefix Use the factory overload to derive the prefix from earlier config: ```csharp rule.For().FromEnvironment(accessor => { var tenant = accessor.GetConfig(); return new EnvironmentVariableRuleOptions($"TENANT_{tenant.TenantId}_"); }) ``` --- --- url: /reference/examples.md description: >- Runnable example projects in src/Examples — file layering, conditional rules, providers (command-line, HTTP, custom), tuple reactive, secrets, ASP.NET Core, testing overrides --- # Examples Runnable example projects demonstrating individual features. Each is a standalone .NET project in [`src/Examples/`](https://github.com/cocoar-dev/Cocoar.Configuration/tree/develop/src/Examples). ## Configuration | Example | What It Shows | |---------|--------------| | [BasicUsage](https://github.com/cocoar-dev/Cocoar.Configuration/tree/develop/src/Examples/BasicUsage) | ASP.NET Core with file + environment layering | | [FileLayering](https://github.com/cocoar-dev/Cocoar.Configuration/tree/develop/src/Examples/FileLayering) | Multi-file layering (base / environment / local) | | [ConditionalRulesExample](https://github.com/cocoar-dev/Cocoar.Configuration/tree/develop/src/Examples/ConditionalRulesExample) | Config-aware conditional rules with `.When()` | | [DynamicDependencies](https://github.com/cocoar-dev/Cocoar.Configuration/tree/develop/src/Examples/DynamicDependencies) | Rules derived from earlier config | | [ExposeExample](https://github.com/cocoar-dev/Cocoar.Configuration/tree/develop/src/Examples/ExposeExample) | Interface exposure with `.ExposeAs()` | | [SimplifiedCoreExample](https://github.com/cocoar-dev/Cocoar.Configuration/tree/develop/src/Examples/SimplifiedCoreExample) | Console app without DI using `ConfigManager.Create()` | ## Providers | Example | What It Shows | |---------|--------------| | [CommandLineExample](https://github.com/cocoar-dev/Cocoar.Configuration/tree/develop/src/Examples/CommandLineExample) | Command-line argument parsing with prefix filtering | | [StaticProviderExample](https://github.com/cocoar-dev/Cocoar.Configuration/tree/develop/src/Examples/StaticProviderExample) | Static and observable providers | | [HttpPollingExample](https://github.com/cocoar-dev/Cocoar.Configuration/tree/develop/src/Examples/HttpPollingExample) | Remote HTTP config (polling, SSE, one-time fetch) | | [MicrosoftAdapterExample](https://github.com/cocoar-dev/Cocoar.Configuration/tree/develop/src/Examples/MicrosoftAdapterExample) | Bridge existing `IConfiguration` sources | | [GenericProviderAPI](https://github.com/cocoar-dev/Cocoar.Configuration/tree/develop/src/Examples/GenericProviderAPI) | Building a custom provider | ## Reactive & ASP.NET Core | Example | What It Shows | |---------|--------------| | [TupleReactiveExample](https://github.com/cocoar-dev/Cocoar.Configuration/tree/develop/src/Examples/TupleReactiveExample) | Atomic multi-config snapshots with `IReactiveConfig<(T1, T2)>` | | [AspNetCoreExample](https://github.com/cocoar-dev/Cocoar.Configuration/tree/develop/src/Examples/AspNetCoreExample) | Full ASP.NET Core integration with health checks | ## Secrets | Example | What It Shows | |---------|--------------| | [SecretsBasicExample](https://github.com/cocoar-dev/Cocoar.Configuration/tree/develop/src/Examples/SecretsBasicExample) | `Secret` with plaintext and lease pattern | | [SecretsCertificateExample](https://github.com/cocoar-dev/Cocoar.Configuration/tree/develop/src/Examples/SecretsCertificateExample) | Pre-encrypted secrets with X.509 certificates | ## Testing | Example | What It Shows | |---------|--------------| | [TestingOverridesExample](https://github.com/cocoar-dev/Cocoar.Configuration/tree/develop/src/Examples/TestingOverridesExample) | `CocoarTestConfiguration` with Replace/Append modes | --- --- url: /guide/flags/expiry-health.md description: >- Flag ExpiresAt lifecycle, Degraded health when expired, IFeatureFlagsDescriptors.All/Expired, health endpoint integration, compile-time static-date validation, define-to-cleanup lifecycle --- # Expiry & Health Feature flags have a built-in lifecycle: they are created, deployed, and eventually removed. The expiry system tracks this lifecycle and signals when cleanup is overdue. ## How Expiry Works Every feature flags class declares an expiration date: ```csharp public partial class BillingFlags : IFeatureFlags { public DateTimeOffset ExpiresAt => new(2026, 6, 1, 0, 0, 0, TimeSpan.Zero); // ... } ``` When `DateTimeOffset.UtcNow` passes `ExpiresAt`: * The flags **keep working** — no behavior change * The health API reports `Degraded` * `IFeatureFlagsDescriptors.Expired` includes the class This is a cleanup signal, not a kill switch. Expired flags don't stop functioning — they just remind you to remove the code. ## Checking Expiry ### Via IFeatureFlagsDescriptors ```csharp public class CleanupService(IFeatureFlagsDescriptors descriptors) { public void CheckExpired() { foreach (var expired in descriptors.Expired) { Console.WriteLine($"{expired.Type.Name} expired at {expired.ExpiresAt}"); foreach (var flag in expired.Flags) { Console.WriteLine($" - {flag.Name}: {flag.Description}"); } } } } ``` ### Via Descriptors ```csharp // All registered flag classes IReadOnlyList all = descriptors.All; // Only expired classes IReadOnlyList expired = descriptors.Expired; // Per-class check bool isExpired = descriptor.IsExpired; ``` ## Health Integration The flags system contributes to the overall health status: | Condition | Health Status | |---|---| | All flags within expiry | `Healthy` | | One or more flag classes expired | `Degraded` | This integrates with the standard [Health Monitoring](/guide/health/overview) system. In ASP.NET Core, expired flags show up in the health endpoint response. ## Entitlements Have No Expiry Entitlements are permanent business logic — they don't expire. `IEntitlementsDescriptors` has an `All` property but no `Expired` property: ```csharp public interface IEntitlementsDescriptors { IReadOnlyList All { get; } // No Expired — entitlements don't expire } ``` ## Source Generator Validation The source generator validates `ExpiresAt` at compile time: * The return value must be a static `DateTimeOffset` literal * Dynamic expressions (e.g., `DateTimeOffset.UtcNow.AddMonths(6)`) emit a diagnostic error * This ensures the expiry date is deterministic and visible in generated descriptors ## Lifecycle The intended lifecycle for feature flags: 1. **Define** — create the flag class with an `ExpiresAt` in the near future 2. **Roll out** — gradually enable the feature via config changes 3. **Stabilize** — once fully rolled out, the flag always returns `true` 4. **Expire** — `ExpiresAt` passes, health reports `Degraded` 5. **Clean up** — remove the flag class, inline the behavior, delete the config If a flag is still needed after expiry, either extend the date (conscious decision) or convert it to an entitlement (permanent business logic). --- --- url: /guide/flags/concepts.md description: >- Feature flags vs entitlements as pure functions over config, FeatureFlag/Entitlement delegates, ExpiresAt health signal, the litmus test, Cocoar vs LaunchDarkly --- # Feature Flags vs Entitlements Cocoar.Configuration has built-in support for two related but distinct concepts: **feature flags** and **entitlements**. ## What They Are Feature flags and entitlements are **computed values**, not stored values. They don't exist as their own entries in a config file — they are derived from configuration at runtime. A flag or entitlement is a function that reads one or more configuration values and returns a result. Sometimes this is a simple pass-through (a single boolean from config), sometimes it's a computation combining multiple config sources: ```csharp // Simple: passes through a single config value public FeatureFlag NewDashboard => () => Config.UseNewDashboard; // Computed: combines multiple values into a decision public FeatureFlag BetaCheckout => user => Config.BetaEnabled && Config.BetaRegions.Contains(user.Region); ``` The key insight: **you cannot set a flag directly**. You change the underlying configuration values, and the flag recomputes. This keeps the config layer as the single source of truth. ## Feature Flags Feature flags answer: **"Does this code run?"** They represent temporary, operational toggles — rollouts, A/B tests, kill switches. They are owned by engineering/ops and must have an explicit expiration date. ```csharp public partial class BillingFlags : IFeatureFlags { public DateTimeOffset ExpiresAt => new(2026, 6, 1, 0, 0, 0, TimeSpan.Zero); /// Enables the redesigned billing dashboard. public FeatureFlag NewDashboard => () => Config.UseNewDashboard; /// Gates beta checkout for specific users. public FeatureFlag BetaCheckout => user => Config.BetaEnabled && user.IsBeta; } ``` The `partial class` implements `IFeatureFlags`, and the source generator produces the constructor and `Config` property. `Config` reads `IReactiveConfig.CurrentValue`, so it always reflects the latest configuration. After the expiration date, the flags **keep working** — but the health API reports `Degraded`, signaling that cleanup is overdue. ## Entitlements Entitlements answer: **"May this actor do this?"** They represent permanent business logic — plan tiers, feature availability, permission limits. They are owned by product/business and have **no expiration date**. ```csharp public partial class PlanEntitlements : IEntitlements { /// Maximum allowed team members. public Entitlement MaxUsers => () => Config.UserLimit; /// Whether this plan can export data. public Entitlement CanExport => () => Config.Tier != "free"; } ``` ## The Litmus Test > A feature flag without an expiration date is an entitlement in disguise. If you're unsure which to use, ask: will this toggle ever be removed? If yes, it's a feature flag. If it's permanent product logic, it's an entitlement. ## How They Work Both are **pure functions over configuration state**: 1. Each property is a delegate that reads from `Config` (the source-generated property backed by `IReactiveConfig.CurrentValue`) 2. When configuration changes, the next invocation returns the new result 3. No per-request state, no caching — just a function call 4. No constructor needed — the source generator handles wiring ## Why Delegates? Flag and entitlement properties use delegate types (`FeatureFlag`, `Entitlement`) instead of plain properties or methods. This is a deliberate architectural constraint: **Enforced simplicity.** A `FeatureFlag` takes zero parameters. A `FeatureFlag` takes exactly one. You cannot accidentally add extra parameters — the type system prevents it. **REST API compatibility.** The REST evaluation pipeline maps directly to delegates: * `FeatureFlag` → `GET /flags/{Class}/{Property}` — no input needed * `FeatureFlag` → `POST /flags/{Class}/{Property}` — request body is deserialized, passed through a [Context Resolver](/guide/flags/context-resolvers), and the resolved context becomes the single delegate argument **Pure functions.** Flags must not inject services or call databases. All they receive is configuration (via `Config`) and optionally a context object. This keeps evaluation fast, deterministic, and debuggable — no hidden I/O, no async, no side effects. :::warning Flags are pure — side effects belong in resolvers If a flag needs data from a database or external service, that logic belongs in a [Context Resolver](/guide/flags/context-resolvers). The resolver hydrates a rich context object; the flag makes a decision based on it. ```csharp // ✗ WRONG — DB call in a flag BetaCheckout = async user => await db.Users.IsBeta(user.Id); // ✓ RIGHT — Resolver fetches data, flag decides // Resolver: public async Task ResolveAsync(UserIdRequest req) => new UserContext(await db.Users.FindAsync(req.UserId)); // Flag: pure function BetaCheckout = user => Config.BetaEnabled && user.IsBeta; ``` ::: ## With Context Both support contextual evaluation — flags and entitlements that depend on runtime context (current user, tenant, request): ```csharp /// Gates beta checkout for specific users. public FeatureFlag BetaCheckout => user => Config.BetaEnabled && user.IsBeta; ``` The context is resolved via [Context Resolvers](/guide/flags/context-resolvers) — a bridge between HTTP request data and your domain model. ## When to Use Cocoar Flags vs. Dedicated Flag Services Cocoar flags and dedicated feature flag services (LaunchDarkly, Unleash, Flagsmith, etc.) solve different problems. Understanding the trade-offs helps you pick the right tool — or use both. **Cocoar flags are a good fit when:** * Flags are **derived from your own configuration** — plan tiers, tenant settings, deployment environment. The flag is a pure function over config you already manage. * You need **tight integration with your config layer** — flags recompute automatically when configuration changes, share the same health monitoring, and follow the same lifecycle. * You're an **ISV controlling feature rollout across your own instances** — each instance has its own config, and flags reflect that instance's state. * You want **type-safe, compile-time validated flags** — the source generator catches missing flags, wrong return types, and expired flags at build time. **Dedicated flag services are a better fit when:** * You need a **management UI for non-developers** — product managers toggling flags without code changes or deployments. * You need **percentage rollouts, A/B testing, or experimentation infrastructure** — statistical analysis, cohort management, and gradual rollout are their core competency. * You need **cross-platform flag evaluation** — the same flag evaluated consistently across mobile apps, web frontends, and backend services with server-side SDKs. **They can coexist.** Use Cocoar flags for config-derived decisions (plan limits, tenant features, deployment-specific toggles) and a dedicated service for user-targeting and experimentation. They solve different problems and don't conflict — a Cocoar entitlement might gate whether a feature is available on a plan, while a LaunchDarkly flag controls whether that feature's new UI is shown to 10% of users. ## Comparison | | Feature Flags | Entitlements | |---|---|---| | Purpose | Temporary operational toggles | Permanent business logic | | Expiry | Required (`ExpiresAt`) | None | | Health signal | `Degraded` when expired | None | | Interface | `IFeatureFlags` | `IEntitlements` | | Property type | `FeatureFlag` or `FeatureFlag` | `Entitlement` or `Entitlement` | | Owned by | Engineering / Ops | Product / Business | | Lifecycle | Create → roll out → expire → remove | Create → keep forever | --- --- url: /guide/providers/file.md description: >- FromFile JSON provider, directory file watcher, AppContext.BaseDirectory path resolution, debouncing, path-traversal protection, Kubernetes ConfigMap symlink support (followSymlinks), optional vs Required, dynamic paths --- # File Provider The file provider reads JSON configuration files from disk and watches for changes. ```csharp rule.For().FromFile("appsettings.json") ``` ## How It Works 1. Reads the file as UTF-8 bytes (strips BOM if present) 2. Starts a file system watcher on the directory 3. When the file changes on disk, triggers a recompute The provider watches the **directory**, not individual files. Multiple rules reading from the same directory share one watcher. ## File Path Resolution Paths are resolved relative to the application's base directory (`AppContext.BaseDirectory`): ```csharp // Relative — resolved from app base directory rule.For().FromFile("appsettings.json") rule.For().FromFile("config/settings.json") // Absolute — used as-is rule.For().FromFile("/etc/myapp/config.json") ``` ## Debouncing File saves often trigger multiple file system events in rapid succession. The engine applies configurable debouncing at two levels: * **Recompute debounce** — the engine's global debounce (default 300ms) coalesces rapid changes across all providers * **Per-file debounce** — available via the advanced API for file-specific throttling ## Security The file provider includes path traversal protection: * Resolves the full path and validates it stays within the configured directory * **Rejects symlinks and reparse points by default** to prevent symlink-escape attacks * Throws `UnauthorizedAccessException` on violations To read symlinked files — e.g. [Kubernetes ConfigMap / Secret mounts](#kubernetes-configmap-secret-mounts) — opt in with `followSymlinks`. Even then, a symlink whose resolved target escapes the configured directory is still rejected. ## Advanced Options Use the factory overload for dynamic file paths or custom options: ```csharp rule.For().FromFile(accessor => { var tenant = accessor.GetConfig(); return FileSourceRuleOptions.FromFilePath( $"config/{tenant.TenantId}.json", debounceTime: TimeSpan.FromMilliseconds(500), pollingInterval: TimeSpan.FromSeconds(30)); }) ``` | Option | Default | Description | |---|---|---| | `DebounceTime` | None (uses engine default) | Per-file debounce for change events | | `PollingInterval` | 10 seconds | Fallback polling interval when file system events are unreliable | | `FollowSymlinks` | `false` | Read symlinked files and detect atomic symlink-target swaps (see [Kubernetes ConfigMap / Secret mounts](#kubernetes-configmap-secret-mounts)) | ## Kubernetes ConfigMap / Secret mounts A file mounted from a Kubernetes **ConfigMap** or **Secret** is a *symlink*: each key (e.g. `appsettings.json`) links through a sibling `..data` symlink to the real file in a timestamped directory. Kubernetes updates the volume by **atomically swapping the `..data` symlink** — it never rewrites the file you point at. Because symlinks are rejected by default, opt in with `followSymlinks: true`: ```csharp rule.For().FromFile("/etc/config/appsettings.json", followSymlinks: true) ``` This does two things: * **Reads** the symlinked file. The resolved final target must still resolve *within* the mount directory — an escaping symlink is rejected, so the path-traversal guarantees hold. * **Hot-reloads** on the atomic `..data` swap, even though the watched file's name and timestamp are unchanged — the resolved symlink target is tracked for change detection. `followSymlinks` is available on `FromFile`, [`FromYamlFile`](/guide/providers/yaml), and [`FromDotEnv`](/guide/providers/dotenv) (and as `FollowSymlinks` on `FileSourceProviderOptions`). It is **off by default**, so non-Kubernetes deployments keep the stricter symlink rejection. ::: tip Reload latency The atomic `..data` swap does **not** trigger the instant OS file watcher — the file you point at is unchanged, only its symlink target moves. So a ConfigMap update is caught by the directory watcher's **periodic re-scan** (roughly every minute), plus kubelet's own propagation delay, rather than instantly. This interval is not currently tunable, and it's in line with how Kubernetes itself propagates ConfigMap changes (typically tens of seconds). Plan for **~1–2 minutes** end-to-end, not sub-second. ::: ## Missing Files When the file doesn't exist: * **Optional rules** (default) — the provider returns `{}`, contributing nothing. Values from earlier rules remain; if no earlier rule set a value, C# defaults apply * **Required rules** — the recompute fails and rolls back (at startup, throws an exception) This is by design. Use `.Required()` for files that must exist, and leave the default for optional overrides like `appsettings.local.json`. ## Common Patterns ### Base + environment overrides ```csharp rule => [ rule.For().FromFile("appsettings.json").Required(), rule.For().FromFile($"appsettings.{env}.json"), ] ``` ### Per-tenant configuration ```csharp rule.For().FromFile(accessor => { var tenant = accessor.GetConfig(); return FileSourceRuleOptions.FromFilePath($"tenants/{tenant.TenantId}.json"); }) ``` ### Shared file, multiple types ```csharp rule => [ rule.For().FromFile("appsettings.json").Select("App"), rule.For().FromFile("appsettings.json").Select("Database"), ] ``` Both rules share one file watcher because they read from the same directory. --- --- url: /guide/analyzers/flags.md description: >- COCFLAG diagnostics — COCFLAG001 non-static ExpiresAt, COCFLAG002 abstract type in Register, COCFLAG003 missing summary docs, plus the flags/entitlements source generator --- # Flags Diagnostics & Source Generator ## COCFLAG001 — Non-Static ExpiresAt {#cocflag001} **Severity:** Warning `ExpiresAt` must be a static `DateTimeOffset` literal so the source generator can embed it in the generated descriptor. Dynamic expressions can't be evaluated at compile time. ```csharp // ❌ Warning: ExpiresAt can't be determined at compile time public partial class MyFlags : IFeatureFlags { public DateTimeOffset ExpiresAt => DateTimeOffset.UtcNow.AddMonths(6); } ``` ```csharp // ✓ Compliant: Static literal public partial class MyFlags : IFeatureFlags { public DateTimeOffset ExpiresAt => new(2026, 6, 1, 0, 0, 0, TimeSpan.Zero); } ``` If the generator can't determine `ExpiresAt`, it defaults to `DateTimeOffset.MinValue` — the class is treated as already expired and health reports `Degraded`. ## COCFLAG002 — Abstract Type Registered {#cocflag002} **Severity:** Warning `Register()` was called with an abstract class. Abstract classes can't be instantiated as flag or entitlement instances. ```csharp // ❌ Warning: Can't instantiate abstract class public abstract partial class BaseFlags : IFeatureFlags { } .UseFeatureFlags(f => f.Register()) ``` ```csharp // ✓ Fix: Register the concrete subclass public partial class AppFlags : IFeatureFlags { } .UseFeatureFlags(f => f.Register()) ``` ## COCFLAG003 — Missing Description {#cocflag003} **Severity:** Info A flag or entitlement property has no `` XML doc comment. Descriptions are surfaced through `IFeatureFlagsDescriptors` and `IEntitlementsDescriptors` — without them, operators see empty descriptions in tooling and the REST API. ```csharp // ℹ️ Info: No description public partial class AppFlags : IFeatureFlags { public FeatureFlag NewDashboard { get; } } ``` ```csharp // ✓ Fix: Add a summary public partial class AppFlags : IFeatureFlags { /// Enables the redesigned billing dashboard. public FeatureFlag NewDashboard { get; } } ``` ## Source Generator {#source-generator} The source generator is a core part of the flags system — not optional. It produces the descriptor metadata that the runtime uses for health monitoring, REST endpoints, and the `IFeatureFlagsDescriptors` / `IEntitlementsDescriptors` APIs. It ships with the `Cocoar.Configuration` package and runs automatically at compile time. ### What It Generates When you call `Register()` in your `UseFeatureFlags()` or `UseEntitlements()` setup, the generator produces a `CocoarFlagsDescriptors` class containing: * A dictionary of `FeatureFlagClassDescriptor` entries (one per flag class) * A dictionary of `EntitlementClassDescriptor` entries (one per entitlement class) Each descriptor includes: * The class `Type` * `ExpiresAt` (flags only) * A list of property descriptors (name + description from XML doc) ### How It's Used The generated descriptors are consumed internally by: * **`IFeatureFlagsDescriptors`** — provides `All` and `Expired` collections for querying flag metadata * **`IEntitlementsDescriptors`** — provides `All` collection for entitlement metadata * **Health monitoring** — checks `Expired` to determine if health should report `Degraded` * **REST endpoints** — uses descriptors to generate endpoint routes You don't reference the generated class directly — it's wired up automatically during DI registration. ### Deterministic Output The generator sorts all types alphabetically by full name, ensuring the same input always produces identical output. This prevents unnecessary rebuilds and diff noise in source control. --- --- url: /guide/getting-started.md description: >- Install Cocoar.Configuration packages, define a POCO config class, wire FromFile rules in ASP.NET Core, console, and DI setups --- # Getting Started ## Install Pick the package that matches your scenario — each one includes everything above it: ```shell dotnet add package Cocoar.Configuration # Core library (console apps, no DI) dotnet add package Cocoar.Configuration.DI # ↑ + Microsoft.Extensions.DI integration dotnet add package Cocoar.Configuration.AspNetCore # ↑ + health endpoints, feature flag endpoints ``` You only need **one** of these — install the highest one you need. Optional packages for additional providers: ```shell dotnet add package Cocoar.Configuration.Http # Remote config via HTTP dotnet add package Cocoar.Configuration.MicrosoftAdapter # Bridge existing IConfiguration ``` ## Your First Configuration ### 1. Define a configuration class ```csharp public class AppSettings { public string AppName { get; set; } = "MyApp"; public int MaxRetries { get; set; } = 3; public bool EnableLogging { get; set; } = true; } ``` No base class, no attributes, no interfaces. Just a plain C# class. ### 2. Create a JSON config file **appsettings.json:** ```json { "AppName": "My Application", "MaxRetries": 5, "EnableLogging": true } ``` ### 3. Wire it up ::: code-group ```csharp [ASP.NET Core] var builder = WebApplication.CreateBuilder(args); builder.AddCocoarConfiguration(c => c .UseConfiguration(rule => [ rule.For().FromFile("appsettings.json") ])); var app = builder.Build(); // Inject directly — no IOptions wrapper app.MapGet("/settings", (AppSettings settings) => new { settings.AppName, settings.MaxRetries }); app.Run(); ``` ```csharp [Console App] using var manager = ConfigManager.Create(c => c .UseConfiguration(rule => [ rule.For().FromFile("appsettings.json") ])); var settings = manager.GetConfig(); Console.WriteLine($"App: {settings.AppName}, Retries: {settings.MaxRetries}"); ``` ::: That's it. `AppSettings` is loaded and ready to inject. ## Layering Multiple Sources The real power comes from layering. Rules execute in order — last write wins: ```csharp builder.AddCocoarConfiguration(c => c .UseConfiguration(rule => [ rule.For().FromFile("appsettings.json"), // Base rule.For().FromFile("appsettings.Production.json"), // Override per environment rule.For().FromEnvironment("APP_"), // Override from env vars ])); ``` With this setup: * `appsettings.json` provides defaults * `appsettings.Production.json` overrides what it sets * Environment variables like `APP_MaxRetries=10` override everything Properties merge at the JSON level. A later rule only overrides the properties it defines — everything else keeps the value from earlier rules. ## Live Reloading When a file changes on disk, configuration updates automatically. Subscribe to changes: ```csharp public class NotificationService(IReactiveConfig config) { public void Start() { // Called immediately with current value, then on every change config.Subscribe(settings => { Console.WriteLine($"Config updated: MaxRetries={settings.MaxRetries}"); }); } } ``` `IReactiveConfig` is automatically registered in DI for every configuration type. ## What Happens Under the Hood When you call `ConfigManager.Create()` or `AddCocoarConfiguration()`: 1. **Rules are evaluated** in order — each provider fetches its data (reads file, scans env vars, etc.) 2. **JSON is merged** — later rules overlay earlier ones, property by property 3. **Types are deserialized** — the merged JSON becomes your strongly-typed C# object 4. **Change detection starts** — file watchers, polling timers, etc. monitor for changes 5. **Updates are atomic** — when a source changes, the full recompute runs and all subscribers get the new snapshot at once ## Next Steps * [Rules & Layering](/guide/configuration/rules) — Deep dive into the rule system * [Providers](/guide/providers/overview) — All available configuration sources * [Reactive Updates](/guide/reactive/basics) — Subscribe to live config changes * [DI Integration](/guide/di/setup) — Lifetimes, type exposure, ASP.NET Core setup --- --- url: /reference/health-api.md description: >- Health API reference — HealthStatus enum, ConfigManager.IsHealthy, IFlagsHealthSource, ASP.NET Core health check, OpenTelemetry meters and Activity source --- # Health API Reference ## HealthStatus Enum ```csharp namespace Cocoar.Configuration.Health; public enum HealthStatus { Unknown = 0, Healthy = 1, Degraded = 2, Unhealthy = 3 } ``` | Value | Meaning | |---|---| | `Unknown` | Not yet initialized or no rules evaluated | | `Healthy` | All rules healthy, no expired flags | | `Degraded` | Optional rule failed or expired feature flags detected | | `Unhealthy` | Required rule failed | Worst status wins — if any rule is `Unhealthy`, the overall status is `Unhealthy` regardless of other rules. ## ConfigManager Properties ```csharp public sealed class ConfigManager { /// Current overall health status. public HealthStatus HealthStatus { get; } /// True when HealthStatus is Healthy. public bool IsHealthy { get; } } ``` Access after initialization: ```csharp var manager = ConfigManager.Create(c => c.UseConfiguration(rules)); var status = manager.HealthStatus; // HealthStatus.Healthy var ok = manager.IsHealthy; // true ``` ## IFlagsHealthSource ```csharp namespace Cocoar.Configuration.Health; public interface IFlagsHealthSource { /// /// Returns true if any registered feature flag class has expired. /// bool HasExpiredFlags(); } ``` Implemented internally by `FeatureFlagsHealthSource`. Reads from `IFeatureFlagsDescriptors.Expired` to detect expired flag classes. Wired automatically when feature flags are registered. ## Health Determination Logic The health tracker evaluates after each recompute: 1. **Required rules** — any failure → `Unhealthy` 2. **Optional rules** — any failure → `Degraded` 3. **Expired feature flags** — any expired → `Degraded` 4. **Unevaluated rules** — any not yet evaluated → `Unknown` 5. **All passing** → `Healthy` Skipped rules (condition returned `false` via `.When()`) do not affect health. ## ASP.NET Core Health Check ### Registration ```csharp public static class CocoarHealthCheckExtensions { public static IHealthChecksBuilder AddCocoarConfigurationHealthCheck( this IHealthChecksBuilder builder, string name = "cocoar-configuration", params string[] tags); } ``` ### Status Mapping | Cocoar `HealthStatus` | ASP.NET Core `HealthCheckResult` | |---|---| | `Healthy` | `Healthy` — "All rules healthy" | | `Degraded` | `Degraded` — includes description | | `Unhealthy` | `Unhealthy` — includes description | | `Unknown` | `Degraded` — "Health status unknown" | ### Example ```csharp builder.Services.AddHealthChecks() .AddCocoarConfigurationHealthCheck( name: "cocoar-config", tags: ["live", "ready"]); app.MapHealthChecks("/health"); ``` ## OpenTelemetry Metrics Meter name: `Cocoar.Configuration` | Instrument | Type | Unit | Description | |---|---|---|---| | `cocoar.config.health.status` | ObservableGauge\ | — | Current health: 1=Healthy, 2=Degraded, 3=Unhealthy | | `cocoar.config.recompute.count` | Counter\ | — | Configuration recompute cycles | | `cocoar.config.recompute.duration` | Histogram\ | ms | Duration of recompute cycles | | `cocoar.config.provider.errors` | Counter\ | — | Provider errors encountered | | `cocoar.config.flags.evaluations` | Counter\ | — | Feature flag evaluations | ### Subscribing to Metrics ```csharp builder.Services.AddOpenTelemetry() .WithMetrics(m => m.AddMeter("Cocoar.Configuration")); ``` ## Activity Source Name: `Cocoar.Configuration` (v1.0.0) — used for distributed tracing of recompute cycles. --- --- url: /guide/health/overview.md description: >- HealthStatus enum (Unknown/Healthy/Degraded/Unhealthy), per-rule required vs optional outcomes, expired feature flags, startup-throw vs runtime-rollback, accessing health via ConfigManager --- # Health Monitoring Cocoar.Configuration tracks the health of every configuration rule and reports an overall status. This lets you detect misconfigurations, missing files, and stale feature flags — at startup and at runtime. ## HealthStatus The system reports one of four states: | Status | Meaning | |---|---| | `Unknown` | Not yet initialized (no rules have been evaluated) | | `Healthy` | All rules succeeded | | `Degraded` | One or more optional rules failed, or feature flags have expired | | `Unhealthy` | One or more required rules failed | ```csharp public enum HealthStatus { Unknown = 0, Healthy = 1, Degraded = 2, Unhealthy = 3 } ``` ## How Health Is Determined After every recompute cycle, the health tracker examines each rule's last execution outcome: | Rule Outcome | Required Rule | Optional Rule | |---|---|---| | Succeeded | No effect | No effect | | Failed | **Unhealthy** | **Degraded** | | Skipped (via `.When()`) | No effect | No effect | Additionally, if any [feature flag class has expired](/guide/flags/expiry-health), health reports **Degraded**. The worst status wins: if any required rule fails, the overall status is `Unhealthy` regardless of optional rule results. ## Startup vs Runtime Health behaves differently depending on when a failure occurs: **During startup:** * Required rule failures **throw immediately** — the application won't start with missing critical configuration * Optional rule failures are recorded and health starts as `Degraded` **At runtime (after a config change):** * Required rule failures **roll back** the entire recompute — the last known good configuration is preserved * Optional rule failures keep the last good value for that rule * Health status updates to reflect the current state ## Accessing Health ### Via ConfigManager ```csharp var manager = ConfigManager.Create(c => c.UseConfiguration(rules => [ rules.For().FromFile("appsettings.json").Required(), rules.For().FromFile("features.json") ])); // Check health status HealthStatus status = manager.HealthStatus; bool isHealthy = manager.IsHealthy; ``` ### Via DI In an ASP.NET Core application, use the [health check integration](/guide/health/aspnetcore) to expose health via the standard `/health` endpoint. ## Skipped Rules Rules that are skipped via `.When()` conditions do **not** affect health. A skipped rule means the condition evaluated to `false` — the rule was intentionally not needed: ```csharp rules.For() .FromFile("cloud.json") .When(cm => cm.GetConfig()!.IsCloud) ``` If `IsCloud` is `false`, this rule is skipped and health remains `Healthy`. ## OpenTelemetry Metrics The health system emits metrics via `System.Diagnostics.Metrics`: | Metric | Type | Description | |---|---|---| | `cocoar.config.health.status` | ObservableGauge | Current status (1=Healthy, 2=Degraded, 3=Unhealthy) | | `cocoar.config.recompute.count` | Counter | Number of recompute cycles | | `cocoar.config.recompute.duration` | Histogram (ms) | Duration of recompute cycles | | `cocoar.config.provider.errors` | Counter | Provider failures (tags: `provider_type`, `required`) | The meter name is `Cocoar.Configuration`. To collect these metrics, register the meter with your OpenTelemetry provider: ```csharp builder.Services.AddOpenTelemetry() .WithMetrics(m => m.AddMeter("Cocoar.Configuration")); ``` --- --- url: /guide/providers/http-polling.md description: >- FromHttp provider — one-time fetch, polling, SSE, SSE-with-fallback, failure threshold, dynamic endpoints, client-certificate and encrypted-secret token auth --- # HTTP Provider The HTTP provider fetches configuration from a remote endpoint. It supports one-time fetch, periodic polling, and real-time Server-Sent Events (SSE). ```csharp rule.For().FromHttp("https://config.example.com/features", pollInterval: TimeSpan.FromMinutes(5)) ``` ::: info Package Requires the `Cocoar.Configuration.Http` package: ```shell dotnet add package Cocoar.Configuration.Http ``` ::: ## Modes ### One-Time Fetch Fetches configuration once at startup. No background polling, no persistent connection: ```csharp rule.For().FromHttp("https://config.example.com/features") ``` Good for: static remote config that doesn't change during process lifetime, or config that changes rarely enough that a restart is acceptable. ### Polling Fetches configuration at regular intervals, detecting changes automatically: ```csharp rule.For().FromHttp("https://config.example.com/features", pollInterval: TimeSpan.FromMinutes(5)) ``` 1. Makes an initial HTTP GET to fetch configuration 2. Starts a background polling loop at the configured interval 3. On each poll, fetches the endpoint and emits the response bytes 4. The engine detects content changes and triggers a recompute when data differs ### Server-Sent Events (SSE) Opens a persistent connection to the server. The server pushes updates in real time when configuration changes: ```csharp rule.For().FromHttp("https://config.example.com/features", serverSentEvents: true) ``` Good for: web-scale deployments where sub-second propagation matters. Works through proxies and load balancers. The provider handles reconnection automatically. ### SSE with Polling Fallback Combines SSE for real-time updates with periodic polling as a safety net. If the SSE connection drops and reconnection fails, the provider falls back to polling: ```csharp rule.For().FromHttp("https://config.example.com/features", serverSentEvents: true, fallbackPollInterval: TimeSpan.FromMinutes(5)) ``` Good for: production deployments where you want real-time updates but need a guaranteed fallback if the SSE connection is unreliable. ## Options | Option | Default | Description | |---|---|---| | `url` | (required) | Absolute URL of the configuration endpoint | | `pollInterval` | None | Time between poll requests. Omit for one-time fetch. | | `serverSentEvents` | `false` | Enable SSE mode for real-time push updates | | `fallbackPollInterval` | None | Polling interval to use when SSE connection fails | | `headers` | None | Custom HTTP headers (e.g., API keys, auth tokens) | ## Error Handling The provider tracks consecutive failures: * Individual failures are logged but don't affect configuration * After reaching the failure threshold (default 3 consecutive), the provider emits empty bytes `{}` * This triggers health degradation for optional rules, or rollback for required rules * On the next successful fetch, the failure counter resets This prevents a single network blip from disrupting your app while still detecting sustained outages. ## Dynamic Endpoints Use the `IConfigurationAccessor` to derive URLs from earlier config: ```csharp rule => [ rule.For().FromFile("tenant.json"), rule.For().FromHttp(accessor => { var tenant = accessor.GetConfig(); return new HttpRuleOptions( $"https://{tenant.Region}.config.example.com/api", pollInterval: TimeSpan.FromMinutes(5)); }), ] ``` When the tenant's region changes, the provider automatically switches to the new endpoint. ## Authentication ### Certificate-Based Auth (Recommended) Client certificate authentication avoids tokens in application memory. Pass a certificate via `HttpMessageHandler`: ```csharp var cert = new X509Certificate2("certs/client.pfx"); var handler = new HttpClientHandler(); handler.ClientCertificates.Add(cert); rule.For().FromHttp("https://config.example.com/settings", pollInterval: TimeSpan.FromMinutes(5), handler: handler) ``` Use password-less certificates — see [Working with Certificates](/guide/certificates) for why and how to protect them. The `HttpMessageHandler` parameter gives you full control over the HTTP pipeline — use it for mutual TLS, custom retry policies, or any other `DelegatingHandler` chain. ### Token from Encrypted Secret If the server requires a bearer token or API key, load it from an encrypted `Secret` in an earlier rule. This keeps the token encrypted at rest and decrypted only at startup: ```csharp rule => [ rule.For().FromFile("secrets.json").Required(), rule.For().FromHttp(accessor => { using var lease = accessor.GetConfig()!.AuthToken.Open(); return new HttpRuleOptions( "https://config.example.com/settings", pollInterval: TimeSpan.FromMinutes(5), headers: new Dictionary { ["Authorization"] = $"Bearer {lease.Value}" }); }) ] ``` :::warning Token becomes a string Once the token is placed in an HTTP header, it exists as a `string` in memory — managed by `HttpClient`, outside of Cocoar's control. This is unavoidable for header-based authentication. For maximum security, prefer certificate-based auth where no secret enters managed string memory. ::: ### Plain Headers For non-sensitive headers (API version, tenant ID, etc.): ```csharp rule.For().FromHttp("https://config.example.com/features", pollInterval: TimeSpan.FromMinutes(5), headers: new Dictionary { ["X-Api-Version"] = "2", ["X-Tenant-Id"] = "tenant-123" }) ``` ## Common Pattern Remote config with local fallback: ```csharp rule => [ rule.For().FromFile("features-defaults.json").Required(), rule.For().FromHttp("https://config.example.com/features", pollInterval: TimeSpan.FromMinutes(5)), ] ``` The file provides defaults. The HTTP endpoint overrides what it sets. If the endpoint goes down, the defaults remain active. --- --- url: /guide/providers/ini.md description: >- FromIniFile provider (core, no dependency) — .ini [section] headers, key=value, ;/# whole-line comments, :/. nesting, quote stripping, connection-string-safe (no inline-comment stripping), reactive watching --- # INI Provider `FromIniFile` reads a classic `.ini` file into the configuration pipeline. It is **built into the core package** (no extra dependency) and uses the same reactive file-watching as the [File provider](/guide/providers/file) — including `followSymlinks: true` for [Kubernetes ConfigMap / Secret mounts](/guide/providers/file#kubernetes-configmap-secret-mounts). ```csharp builder.AddCocoarConfiguration(c => c .UseConfiguration(rules => [ rules.For().FromIniFile("appsettings.ini"), ])); ``` ## Format ```ini ; whole-line comments start with ; or # # both are ignored app = myapp ; keys before any [section] sit at the root [Db] Host = localhost Port = 5432 ; values are strings; the binder coerces (→ int) Conn = Server=db;Database=app ; ';' / '#' inside a value are kept [Db.Primary] ; section names nest on '.' or ':' Weight = 1 ``` * `[Section]` headers and keys nest on `.` or `:` (e.g. `[Db.Primary]` → `{ "Db": { "Primary": { … } } }`), matching the [Environment Variables provider](/guide/providers/environment) convention. * Surrounding single/double quotes are stripped. * **Whole-line comments only** (a line starting with `;` or `#`). Inline comments are *not* stripped, so a value containing `;` or `#` — like a connection string — survives intact. * Values are emitted as strings; the binder coerces them to the target type. ## Reactivity & per-tenant paths Editing the file triggers a recompute. A config-aware overload resolves the path per recompute: ```csharp rules.For().FromIniFile(a => $"tenants/{a.Tenant}/db.ini").TenantScoped() ``` ## Other formats For `.toml` see the [TOML provider](/guide/providers/toml); `.yaml` / `.yml` → [YAML](/guide/providers/yaml); `.env` → [Dotenv](/guide/providers/dotenv). --- --- url: /guide/testing/integration.md description: >- Bridging the xUnit AsyncLocal context gap, TestConfigurationContext fixture pattern, CocoarTestConfiguration.Apply/Clear in constructors, WebApplicationFactory integration tests --- # Integration Testing ## The AsyncLocal Gap `AsyncLocal` flows through `async`/`await` within the same async context. However, xUnit creates **separate async contexts** for fixture setup and test methods. Configuration set in `InitializeAsync()` is **not visible** in test methods. The solution: build the context once, apply it in each test's constructor. ## Fixture Pattern ```csharp public class IntegrationTestFixture { public TestConfigurationContext TestContext { get; } = TestConfigurationContext.Replace(rule => [ rule.For().FromStatic(_ => new DbConfig { ConnectionString = "Server=localhost;Database=integration_test" }), rule.For().FromStatic(_ => new AppSettings { LogLevel = "Debug" }) ]); } public class OrderTests : IClassFixture, IDisposable { public OrderTests(IntegrationTestFixture fixture) { // Bridge the async context gap CocoarTestConfiguration.Apply(fixture.TestContext); } public void Dispose() => CocoarTestConfiguration.Clear(); [Fact] public async Task PlaceOrder_WithValidConfig_Succeeds() { // Configuration is visible here var manager = ConfigManager.Create(c => c.UseConfiguration(rules => [ rules.For().FromFile("db.json") ])); // Uses test values, not real db.json var config = manager.GetConfig(); Assert.Equal("Server=localhost;Database=integration_test", config!.ConnectionString); } } ``` ## ASP.NET Core Integration Tests Use the same fixture pattern with `WebApplicationFactory`: ```csharp public class ApiTests : IClassFixture, IDisposable { public ApiTests(IntegrationTestFixture fixture) { CocoarTestConfiguration.Apply(fixture.TestContext); } public void Dispose() => CocoarTestConfiguration.Clear(); [Fact] public async Task GetSettings_ReturnsTestValues() { await using var factory = new WebApplicationFactory(); using var client = factory.CreateClient(); var response = await client.GetAsync("/api/settings"); response.EnsureSuccessStatusCode(); // App inside WebApplicationFactory uses test configuration } } ``` The `AsyncLocal` flows from the test method into the `WebApplicationFactory` startup, so `ConfigManager` inside the app sees the test configuration. ## Per-Test Overrides For tests that need different configuration from the fixture: ```csharp [Fact] public async Task HandlesHighRetryCount() { // Override just for this test using var _ = CocoarTestConfiguration.ReplaceConfiguration(rule => [ rule.For().FromStatic(_ => new AppSettings { MaxRetries = 100 }) ]); // This test sees MaxRetries = 100 // Other parallel tests are unaffected } ``` ## Secrets in Tests ```csharp public class SecretsTestFixture { public TestConfigurationContext TestContext { get; } public SecretsTestFixture() { TestContext = new TestOverrideBuilder() .ReplaceConfiguration(rule => [ rule.For().FromStatic(_ => new ApiConfig { ApiKey = "test-key-plaintext" }) ]) .ReplaceSecretsSetup(s => s.AllowPlaintext()) .Build(); } } ``` ## Avoiding Timing Issues When testing reactive configuration updates, use active polling instead of `Task.Delay()`: ```csharp // ❌ Fragile — depends on timing await Task.Delay(500); Assert.Equal(expected, config.Value); // ✓ Deterministic — polls until condition is met await ActiveWaitHelpers.WaitForValueAsync( () => reactiveConfig.CurrentValue.MaxRetries, expectedValue: 42, timeout: TimeSpan.FromSeconds(5)); ``` Active waiting with short poll intervals (50ms default) gives you fast tests that don't flake under load. ## Test Provider For tests that simulate provider failures: ```csharp var provider = FailableProvider.FailAfterNCalls( json: """{"MaxRetries": 3}""", callsBeforeFailure: 2); var manager = ConfigManager.Create(c => c.UseConfiguration(rules => [ rules.For().FromProvider(provider) ])); // First 2 calls succeed, then provider starts failing // Health transitions from Healthy → Degraded (if optional) or rolls back (if required) ``` --- --- url: /guide/reactive/basics.md description: >- IReactiveConfig : IObservable — CurrentValue, Subscribe, replay-1 BehaviorSubject semantics, reference-equality change detection, atomic swap, Scoped vs Singleton --- # IReactiveConfig\ Every configuration type automatically gets a reactive counterpart. `IReactiveConfig` lets you subscribe to live configuration changes without polling. ## The Interface ```csharp public interface IReactiveConfig : IObservable { T CurrentValue { get; } } ``` Two capabilities: | Member | Purpose | |---|---| | `CurrentValue` | Synchronously returns the latest configuration snapshot | | `Subscribe(IObserver)` | Inherited from `IObservable` — receive notifications on change | `IReactiveConfig` extends `IObservable` from the BCL — no dependency on System.Reactive. Consumers are free to use System.Reactive, Rx.NET, or plain `IObserver` on their side. ## Getting an IReactiveConfig\ ### Via DI (most common) `IReactiveConfig` is registered as **Singleton** automatically for every configuration type: ```csharp public class NotificationService(IReactiveConfig config) { public void Start() { config.Subscribe(settings => { Console.WriteLine($"MaxRetries changed to {settings.MaxRetries}"); }); } } ``` ### Without DI ```csharp using var manager = ConfigManager.Create(c => c .UseConfiguration(rule => [ rule.For().FromFile("appsettings.json"), ])); var reactive = manager.GetReactiveConfig(); reactive.Subscribe(settings => Console.WriteLine($"Updated: {settings.AppName}")); ``` ## Subscription Behavior Subscribers receive the **current value immediately** on subscribe, then every subsequent change: ```csharp config.Subscribe(settings => { // Called immediately with the current value // Called again whenever config changes }); ``` This is replay-1 / BehaviorSubject semantics — you never miss the initial state. ## Change Detection Changes are detected by **reference equality**, not by comparing property values. Each recompute creates a new instance — so if the underlying data changed, you get a new object reference and the subscriber fires. If the data hasn't changed (same JSON content), the same instance reference is reused and the subscriber is **not** called. This prevents unnecessary notifications. ## Concurrency & Thread Safety ### Immutable Swap When a recompute completes, the new configuration instance atomically replaces the old one via a reference swap. There is no lock, no mutex — just an atomic reference assignment (`Interlocked.Exchange`). Readers always see either the old value or the new value, never a torn or partially-updated state. ### What Happens During a Recompute If a request starts while a recompute is in progress, it reads the **current (old) snapshot**. The recompute runs on a background thread; nothing blocks. When it finishes, the next property access or subscription callback sees the new snapshot. There is never any contention between request threads and the recompute pipeline. ### Scoped Injection When you inject `T` directly (registered as **Scoped**), you get a snapshot that is fixed for the duration of the request. Even if a recompute completes mid-request, your injected `T` does not change — it was captured at scope creation time. ```csharp public class OrderController(AppSettings settings) : ControllerBase { // settings is a fixed snapshot — it won't change during this request, // even if a recompute happens while the request is in flight. public IActionResult Get() => Ok(settings.MaxRetries); } ``` This is the recommended approach for request-handling code: inject `T`, not `IReactiveConfig`, and enjoy a stable view of configuration throughout the request. ### IReactiveConfig\.CurrentValue `CurrentValue` always returns the latest snapshot at the moment you read it. If you call it twice, the second call **might** return a different instance if a recompute completed between the two calls. For most use cases this is perfectly fine — the window is microseconds, and both values are individually consistent. If you need a stable reference across multiple reads within the same scope of work, capture it once: ```csharp var snapshot = config.CurrentValue; // Use snapshot.Foo and snapshot.Bar — guaranteed to be from the same recompute. ``` ## CurrentValue `CurrentValue` gives synchronous access to the latest configuration without subscribing: ```csharp public class MyService(IReactiveConfig config) { public string GetAppName() => config.CurrentValue.AppName; } ``` This is useful when you need a one-off read without ongoing notifications. The value is always up-to-date — it reflects the latest recompute. ## Lifetimes | Registration | Lifetime | Why | |---|---|---| | `T` (concrete config) | Scoped | Stable snapshot per request | | `IReactiveConfig` | Singleton | Continuous stream, shared across requests | The concrete type `T` is scoped so each request sees a consistent snapshot. `IReactiveConfig` is a singleton because it represents the live stream — it wouldn't make sense to create a new stream per request. ## Common Patterns ### React to changes ```csharp public class CacheService(IReactiveConfig config) : IHostedService { private IDisposable? _subscription; public Task StartAsync(CancellationToken ct) { _subscription = config.Subscribe(settings => { ResizeCache(settings.MaxSize); }); return Task.CompletedTask; } public Task StopAsync(CancellationToken ct) { _subscription?.Dispose(); return Task.CompletedTask; } } ``` ### Expose via interface If you used `.ExposeAs()` in setup, you can inject the reactive version via the interface: ```csharp public class MyService(IReactiveConfig config) { // Works because ExposeAs registered the interface mapping } ``` ### One-off read vs subscription ```csharp // One-off: read current value var current = config.CurrentValue; // Ongoing: react to changes config.Subscribe(newValue => { /* ... */ }); ``` ## Disposing Subscriptions `Subscribe()` returns an `IDisposable`. Dispose it to stop receiving notifications: ```csharp var subscription = config.Subscribe(settings => { /* ... */ }); // Later, when done: subscription.Dispose(); ``` In hosted services or long-lived components, dispose subscriptions in your cleanup/shutdown path. --- --- url: /guide/di/lifetimes.md description: >- DI lifetimes — Scoped config types, Singleton IReactiveConfig, AsSingleton/AsTransient/AsScoped, keyed services, exposed-type lifetimes, deterministic ordering --- # Lifetimes & Registration ## How Resolution Works Understanding this is key: **DI resolution does not recompute or re-deserialize configuration.** When you inject `AppSettings`, here's what happens: 1. DI calls the registered factory 2. Factory calls `ConfigManager.GetConfig()` 3. ConfigManager returns the **already-deserialized, cached instance** That's it. No JSON parsing, no provider calls, no computation. The `ConfigManager` holds the current instance in memory and updates it only when a provider signals a change (file modified, HTTP poll, etc.). Resolution is a dictionary lookup — effectively free. ::: tip Key Insight **Scoped ≠ recomputed per request.** Scoped means the DI factory calls `ConfigManager.GetConfig()` once per scope and caches the result. That call is a dictionary lookup — no parsing, no computation. **This is why Scoped is the correct default.** Each scope gets the current instance at scope creation. When configuration changes, the next scope gets the new instance automatically. Switching to Singleton does **not** improve performance — it makes things worse: the factory is called once at startup, and the DI container caches that result forever. After a configuration change, Singleton consumers still see the old instance. ::: ## Default Lifetimes | Service | Lifetime | Why | |---|---|---| | Configuration types (`AppSettings`, etc.) | **Scoped** | Consistent snapshot per request | | `IReactiveConfig` | **Singleton** | Live subscription to changes | | Feature flag / entitlement classes | **Singleton** | Pure functions over reactive config | | `IFeatureFlagEvaluator` / `IEntitlementEvaluator` | **Scoped** | Needs request-scoped `IServiceProvider` for resolvers | | Context resolvers | **Scoped** | May depend on scoped services (e.g., `DbContext`); customizable via `.AsSingleton()`, `.AsTransient()` | | `IFeatureFlagsDescriptors` / `IEntitlementsDescriptors` | **Singleton** | Immutable metadata | ## Injection Patterns Choose based on what you need: ```csharp // 1. Direct injection (most common) — stable snapshot within scope public class OrderService(AppSettings settings) { // settings is the same instance for the entire request } // 2. IReactiveConfig — live updates, for long-lived services public class BackgroundMonitor(IReactiveConfig config) { // config.CurrentValue always returns the latest // config.Subscribe(...) emits on every change } // 3. IConfigurationAccessor — access any config type dynamically public class PluginHost(IConfigurationAccessor accessor) { var db = accessor.GetConfig(); var app = accessor.GetConfig(); } ``` ## Customizing Lifetimes ### AsSingleton Register a configuration type as Singleton instead of the default Scoped: ```csharp setup.ConcreteType().AsSingleton() ``` The DI container calls the factory once and caches the result. **After a configuration change, Singleton consumers keep the old instance.** Only use this for config types that genuinely never change at runtime. For live updates, use `IReactiveConfig` instead. ::: danger Don't use AsSingleton for "performance" Scoped resolution is already a dictionary lookup — there is no performance benefit to Singleton. Singleton only means you miss configuration updates. If an AI tool or colleague suggests `.AsSingleton()` to "avoid repeated resolution," that's a misconception. ::: ### AsTransient ```csharp setup.ConcreteType().AsTransient() ``` A fresh instance on every resolution. Rarely needed for configuration types. ### AsScoped (explicit default) ```csharp setup.ConcreteType().AsScoped() ``` Same as the default — useful for being explicit or overriding an interface's lifetime. ## Keyed Services Register the same configuration type under different keys (keyed services, .NET 9+): ```csharp setup.ConcreteType().AsSingleton("primary"), setup.ConcreteType().AsScoped("per-request") ``` Consume via `[FromKeyedServices]`: ```csharp public class MyService( [FromKeyedServices("primary")] AppSettings primary, [FromKeyedServices("per-request")] AppSettings perRequest) { } ``` ## Exposed Types When exposing a concrete type through an interface, you can customize the interface's lifetime independently: ```csharp setup.ConcreteType().ExposeAs(), setup.ExposedType().AsSingleton() ``` Here `AppSettings` is Scoped (default) but `IAppSettings` is Singleton. ::: warning Lifetime Mismatch — Be Careful If the concrete type is Scoped but the exposed interface is Singleton, the interface will resolve once and cache the startup instance forever — it **will** become stale after a config change. Keep exposed interfaces at the same lifetime as their concrete type, or explicitly choose Scoped for both. ::: ## DisableAutoRegistration Prevent the default Scoped registration while still allowing custom registrations: ```csharp setup.ConcreteType().DisableAutoRegistration().AsSingleton() ``` This registers only the Singleton — no Scoped default is emitted. Without `AsSingleton()`, the type wouldn't be in DI at all (but still accessible via `ConfigManager.GetConfig()` directly). ## Registration Order Service descriptors are emitted in **deterministic order** — sorted alphabetically by type full name. This means the same configuration always produces the same `IServiceCollection` contents, regardless of rule declaration order. This is important for reproducibility and debugging. ## What Gets Registered For each configuration type, up to two services are registered: 1. **The type itself** (Scoped by default) — resolves via `ConfigManager.GetConfig()` 2. **`IReactiveConfig`** (always Singleton) — resolves via `ConfigManager.GetReactiveConfig()` The reactive wrapper is always registered as Singleton because it represents a live subscription, not a snapshot. --- --- url: /guide/health/logging.md description: >- Microsoft.Extensions.Logging with source-generated LoggerMessage, Cocoar.Configuration log categories, event IDs by Debug/Information/Warning level, filtering by prefix --- # Logging & Diagnostics Cocoar.Configuration uses `Microsoft.Extensions.Logging` with source-generated log messages (`[LoggerMessage]`) for structured, high-performance logging. This page covers how to configure log output and interpret the diagnostics the library emits. ## Log Categories Each internal class creates its log messages under its own category (the fully-qualified class name). The main categories you will see are: | Category | Area | |---|---| | `Cocoar.Configuration.Core.ConfigurationEngine` | Recompute lifecycle (start, finish, cancellation, errors) | | `Cocoar.Configuration.Core.ConfigurationState` | Snapshot publication, deserialization failures | | `Cocoar.Configuration.Core.ConfigurationAccessor` | Fallback deserialization during recompute phase | | `Cocoar.Configuration.Rules.RuleManager` | Rule evaluation, provider failures, transform caching | | `Cocoar.Configuration.Infrastructure.RecomputeCoalescer` | Debounce timer errors | | `Cocoar.Configuration.Infrastructure.ChangeSubscriptionManager` | Change subscription errors | | `Cocoar.Configuration.Infrastructure.ExposureRegistry` | Interface-to-concrete type mapping | | `Cocoar.Configuration.Infrastructure.ProviderRegistry` | Provider creation, acquire/release, disposal | | `Cocoar.Configuration.Reactive.ReactiveConfigManager` | Reactive config wrapper creation | | `Cocoar.Configuration.Reactive.ReactiveConfigurationFactory` | Reactive priming, tuple element resolution | | `Cocoar.Configuration.Reactive.ReactiveTupleConfig` | Tuple stream errors, tuple emission failures | Because all categories start with `Cocoar.Configuration`, you can configure them with a single filter prefix. ## Log Levels ### Debug Debug messages trace the internal mechanics of the configuration engine. These are useful when troubleshooting why a recompute fired or why a provider was recreated. | Event ID | Source | Message | |---|---|---| | 2002 | `ConfigurationEngine` | Recompute started | | 2003 | `ConfigurationEngine` | Recompute cancelled | | 2004 | `ConfigurationEngine` | Recompute finished | | 5003 | `RuleManager` | Query key hash failed for {QueryType}; falling back to JSON serialization | | 5004 | `RuleManager` | Transform key computation failed; falling back to empty key | | 3000 | `ConfigurationAccessor` | Fallback deserialization for {TypeName} during recompute phase | | 3000 | `ExposureRegistry` | ConfigureSpec does not have a valid primary type capability, skipping | | 3002 | `ExposureRegistry` | Exposed interface {InterfaceType} -> {ConcreteType} | | 3004 | `ExposureRegistry` | Interface deserialization mapping: {InterfaceType} -> {ConcreteType} | | 1000-1004 | `ProviderRegistry` | Provider creation, acquire, release, disposal (when diagnostics are enabled) | | 6006 | `ReactiveConfigManager` | Created reactive config wrapper for type {Type} | ### Information Information messages mark significant lifecycle events. | Event ID | Source | Message | |---|---|---| | 2006 | `ConfigurationEngine` | Startup phase complete - switching to resilient mode | | 4002 | `ConfigurationState` | Configuration snapshot published: version={Version}, types={TypeCount} | | 3005 | `ExposureRegistry` | Built exposure registry with {ExposureCount} DI mappings and {DeserializationCount} deserialization mappings | | 6000 | `ReactiveConfigManager` | Recreating dead observable for configuration type {Type} | ### Warning Warnings indicate degraded conditions that the library recovers from automatically. | Event ID | Source | Message | |---|---|---| | 4001 | `ConfigurationState` | Runtime deserialization failed for {FailureCount} types, keeping last good configuration | | 5000 | `RuleManager` | Selection path '{SelectPath}' failed; skipping optional rule | | 5002 | `RuleManager` | Optional rule failed and will be skipped: {Provider}->{Config} | | 3001 | `ConfigurationAccessor` | Fallback deserialization failed for {TypeName}: {Message} | | 3001 | `ExposureRegistry` | Interface {InterfaceType} was already exposed by {ExistingConcreteType}, now overridden by {NewConcreteType} | | 3003 | `ExposureRegistry` | Interface {InterfaceType} deserialization was already mapped to {ExistingConcreteType}, now overridden by {NewConcreteType} | | 6001 | `ReactiveConfigManager` | Failed to get initial config for type {Type}, using default value | | 6100 | `ReactiveTupleConfig` | Tuple reactive config stream error ignored to keep alive for {TupleType} | | 6101 | `ReactiveTupleConfig` | Failed to build CurrentValue for tuple {TupleType} | | 6103 | `ReactiveTupleConfig` | Failed building tuple emission for {TupleType} | | 6400 | `ReactiveConfigurationFactory` | Failed to locate GetReactiveConfig for type {Type} | | 6401 | `ReactiveConfigurationFactory` | Failed to locate GetConfig for type {Type} | | 6402 | `ReactiveConfigurationFactory` | Failed to prime reactive configuration for tuple element {Type} | | 6403 | `ReactiveConfigurationFactory` | Type {Type} is not a class, skipping reactive priming | ### Error Errors indicate failures that may require attention. Required rule failures prevent the application from starting (during startup) or roll back the recompute (at runtime). | Event ID | Source | Message | |---|---|---| | 2000 | `ConfigurationEngine` | ConfigManager initialization failed | | 2001 | `ConfigurationEngine` | Runtime recompute failed - preserving current configuration | | 2005 | `ConfigurationEngine` | Recompute failed from change trigger | | 4000 | `ConfigurationState` | Deserialization failed for {TypeName}: {Message} | | 5001 | `RuleManager` | Required rule failed: {Provider}->{Config} | | 4000 | `RecomputeCoalescer` | Recompute failed from initial debounce trigger | | 4001 | `RecomputeCoalescer` | Recompute failed from trailing trigger | | 4100 | `ChangeSubscriptionManager` | Recompute failed from change trigger | ## Configuring Log Levels ### Filter by Prefix Because every log category starts with `Cocoar.Configuration`, you can control verbosity with a single filter: ```csharp // In code builder.Logging.AddFilter("Cocoar.Configuration", LogLevel.Debug); ``` ```json // In appsettings.json { "Logging": { "LogLevel": { "Cocoar.Configuration": "Debug" } } } ``` ### Fine-Grained Control You can also filter individual subsystems: ```json { "Logging": { "LogLevel": { "Cocoar.Configuration": "Warning", "Cocoar.Configuration.Core.ConfigurationEngine": "Debug", "Cocoar.Configuration.Rules.RuleManager": "Debug" } } } ``` ### Passing the Logger When using `ConfigManager` directly (without DI), pass a logger via the builder: ```csharp var loggerFactory = LoggerFactory.Create(b => b .AddConsole() .AddFilter("Cocoar.Configuration", LogLevel.Debug)); var manager = ConfigManager.Create(c => c .UseLogger(loggerFactory.CreateLogger()) .UseConfiguration(rules => [ rules.For().FromFile("appsettings.json") ])); ``` When using DI via `AddCocoarConfiguration`, the logger is resolved from the service container automatically. ## OpenTelemetry Integration ### Metrics The library exposes metrics via `System.Diagnostics.Metrics` under the meter name **`Cocoar.Configuration`**. See the [Health Overview](/guide/health/overview#opentelemetry-metrics) for the full list of counters and histograms. ```csharp builder.Services.AddOpenTelemetry() .WithMetrics(m => m.AddMeter("Cocoar.Configuration")); ``` ### Distributed Tracing The library emits traces via a `System.Diagnostics.ActivitySource` named **`Cocoar.Configuration`**. To collect these traces, add the source to your OpenTelemetry configuration: ```csharp builder.Services.AddOpenTelemetry() .WithTracing(t => t.AddSource("Cocoar.Configuration")); ``` The following activities are emitted: | Activity Name | Description | Tags | |---|---|---| | `cocoar.config.recompute` | Full recompute cycle | `rule_count`, `start_index`, `status` | | `cocoar.config.rule` | Individual rule evaluation within a recompute | `rule_type`, `rule_index`, `required` | | `cocoar.feature_flag.evaluate` | Feature flag evaluation | `flag.key`, `flag.kind` | | `cocoar.entitlement.evaluate` | Entitlement evaluation | `flag.key`, `flag.kind` | The `status` tag on `cocoar.config.recompute` is one of `success`, `cancelled`, or `failure`. ## Debounce Timing The debounce interval controls how rapidly the engine reacts to configuration source changes. See [Debouncing](/guide/reactive/debouncing) for configuration details. The `RecomputeCoalescer` logs errors at `Error` level if the debounce timer callback or trailing pass fails. The recompute lifecycle itself (start, cancel, finish) is logged at `Debug` level by `ConfigurationEngine`, so enabling Debug logging lets you observe when debounce windows close and recomputes fire. ```csharp var manager = ConfigManager.Create(c => c .UseDebounce(500) // 500ms debounce .UseLogger(logger) .UseConfiguration(rules => [ /* ... */ ])); ``` --- --- url: /guide/providers/marten-store.md description: >- Marten/PostgreSQL writable-store backend, FromMartenStore service-backed rule, database-per-tenant via .TenantScoped, CocoarConfigDocument storage model, single-process reactivity and HA notes --- # Marten Store `Cocoar.Configuration.WritableStore.Marten` is a ready-made [Writable Store](/guide/providers/writable-store) backend that persists overrides in [Marten](https://martendb.io/) (a PostgreSQL document store). Its headline feature is **tenant-aware, database-per-tenant** storage: with Marten multi-tenancy, each tenant's configuration overlay lives in that tenant's own database. ```shell dotnet add package Cocoar.Configuration.WritableStore.Marten ``` It is an opt-in integration package — it intentionally takes a Marten dependency. Consumers who don't reference it pay nothing. ## Why it is service-backed The backend needs a Marten `IDocumentStore`, which lives in the DI container — so the rule must resolve it *after* the container is built. That is exactly what [service-backed (Layer-2) configuration](/guide/di/service-backed) is for. Author the rule inside `UseServiceBackedConfiguration`, where `FromMartenStore()` is available: ```csharp builder.AddCocoarConfiguration(c => c .UseServiceBackedConfiguration(rules => [ rules.For().FromMartenStore().TenantScoped().Build(), ])); ``` `FromMartenStore()` resolves the `IDocumentStore` from DI and uses the current tenant (`accessor.Tenant`) to select the tenant database. The rule stays dormant until the host starts; the document store is never touched before the container exists. Because it reuses the writable-store pipeline, you also get the `IWritableStore` write façade (per tenant) for writing overrides at runtime. ## Tenant-aware, database-per-tenant Configure Marten with database-per-tenant multi-tenancy as you normally would (`MultiTenantedDatabases` / `AddSingleTenantDatabase`), then combine `FromMartenStore()` with `.TenantScoped()`: ```csharp services.AddMarten(opts => { opts.MultiTenantedDatabases(x => { x.AddSingleTenantDatabase(contosoConnectionString, "contoso"); x.AddSingleTenantDatabase(globexConnectionString, "globex"); }); opts.RegisterDocumentType(); }); ``` At recompute time, the backend opens its Marten session for `accessor.Tenant`, so a write for tenant `contoso` lands in Contoso's database and is invisible to `globex`. Each tenant pipeline keeps its own writable store, so overlays never alias across tenants. See [Multi-Tenancy](/guide/multi-tenancy/overview) for how tenant pipelines are built and consumed. A `null`/blank tenant uses Marten's default tenant — the single-database case, when you use `FromMartenStore()` without `.TenantScoped()`. ## Storage model Overrides are stored as one [`CocoarConfigDocument`](https://github.com/cocoar-dev/cocoar.configuration/tree/develop/src/Cocoar.Configuration.WritableStore.Marten) per configuration type: * `Id` — the storage key (the configuration type's full name, e.g. `MyApp.Settings.TenantSettings`). * `Json` — the sparse overlay JSON the writable store reads and writes. Register the document type with Marten (`RegisterDocumentType()`) so its table is created in each tenant database, or rely on Marten's runtime auto-creation. ## Reactivity and HA A write is reactive **within the writing process**: it signals the provider's change observable and the pipeline recomputes, so every `IReactiveConfig` view on that instance updates. In a multi-instance (HA) deployment all pointing at the same database, a write on instance A does **not** automatically propagate to B/C — cross-instance reactivity needs a database notification (e.g. PostgreSQL `LISTEN/NOTIFY`) routed into the provider's change stream. That is a separate, additive enhancement and is not part of this backend. ## See also * [Writable Store](/guide/providers/writable-store) — the override-layer concept and write API this builds on. * [Service-Backed Configuration](/guide/di/service-backed) — why DB-backed rules are Layer-2. * [Multi-Tenancy](/guide/multi-tenancy/overview) — per-tenant pipelines. --- --- url: /guide/providers/microsoft-adapter.md description: >- FromIConfiguration adapter bridging Microsoft IConfiguration, colon-key flattening to nested JSON, .Select section filtering, GetReloadToken change detection, gradual migration --- # Microsoft IConfiguration Adapter The Microsoft adapter bridges an existing `IConfiguration` into Cocoar's rule system. Use it to migrate gradually or to reuse configuration sources that only exist as Microsoft providers. ```csharp rule.For().FromIConfiguration(builder.Configuration) ``` ::: info Package Requires the `Cocoar.Configuration.MicrosoftAdapter` package: ```shell dotnet add package Cocoar.Configuration.MicrosoftAdapter ``` ::: ## How It Works 1. Reads all key-value pairs from the given `IConfiguration` 2. Reconstructs nested JSON from the flat key structure (keys split by `:`) 3. Watches for changes via `IConfiguration.GetReloadToken()` ## Key Flattening Microsoft's configuration system uses flat, colon-delimited keys. The adapter converts them back to nested JSON: ``` ConnectionStrings:Default = "Server=localhost" Logging:LogLevel:Default = "Warning" ``` Becomes: ```json { "ConnectionStrings": { "Default": "Server=localhost" }, "Logging": { "LogLevel": { "Default": "Warning" } } } ``` ## Section Filtering Use `.Select()` to extract a subsection of the Microsoft configuration: ```csharp rule.For().FromIConfiguration(builder.Configuration).Select("Logging") ``` The adapter reads the full `IConfiguration` tree, and `.Select("Logging")` extracts only the `Logging` subtree before deserializing. This is the same `.Select()` method available on all Cocoar providers. ## Change Detection The adapter watches for changes via `IConfiguration.GetReloadToken()`. When the underlying configuration signals a reload, the adapter re-reads all values and emits new bytes, triggering a recompute. This means any `IConfiguration` backed by sources with `ReloadOnChange = true` works as expected. ## Common Patterns ### Gradual migration Use the adapter for sources you haven't migrated yet, alongside native Cocoar providers: ```csharp rule => [ // Native Cocoar provider rule.For().FromFile("appsettings.json"), // Legacy Microsoft configuration you're not ready to replace rule.For().FromIConfiguration(builder.Configuration).Select("App"), ] ``` ### Custom Microsoft providers If a third-party library provides configuration through `IConfiguration`, pass it directly: ```csharp rule.For().FromIConfiguration(vaultConfiguration) ``` ## Limitations * **Array keys**: Microsoft uses `Key:0`, `Key:1` for arrays. The adapter converts these to JSON object properties (`"0": "value"`, `"1": "value"`), not JSON arrays. This matches Microsoft's own `IConfiguration` behavior. * **Performance**: The adapter reads ALL key-value pairs from `IConfiguration` on each fetch. For very large configurations (thousands of keys), consider using `.Select()` to scope to the relevant section. * **One-way bridge**: Changes flow FROM Microsoft configuration TO Cocoar. Cocoar does not write back to `IConfiguration`. --- --- url: /guide/how-to/from-ioptions.md description: >- Incremental IOptions/IConfiguration migration — FromIConfiguration bridge, IOptionsMonitor to IReactiveConfig, PostConfigure as last-write-wins rule, mapping table --- # Migrating from IOptions This guide walks you through migrating an existing ASP.NET Core application from Microsoft's `IOptions` / `IConfiguration` to Cocoar.Configuration. The migration is **incremental** -- you can move one type at a time, and both systems run side by side throughout the process. ## Why Migrate? | Microsoft Pain Point | Cocoar Equivalent | Benefit | |---|---|---| | `IOptions` requires `.Value` unwrapping | Inject `T` directly | Less ceremony, cleaner constructors | | `IOptionsSnapshot` for per-request | `T` is Scoped by default | Same behavior, no wrapper | | `IOptionsMonitor` + `.OnChange()` | `IReactiveConfig` + `.Subscribe()` | Standard `IObservable` semantics | | No atomic multi-config updates | `IReactiveConfig<(T1, T2)>` | Consistent reads guaranteed | | Manual `Configure()` per type | Declarative rules with layering | One place defines all sources | For a deeper comparison, see [Why Cocoar?](/guide/why-cocoar). ## Step 1: Install & Coexist Add the Cocoar packages alongside your existing Microsoft configuration. **Nothing changes for existing code.** ```shell dotnet add package Cocoar.Configuration.DI dotnet add package Cocoar.Configuration.MicrosoftAdapter ``` Then register Cocoar in `Program.cs`, bridging from the `IConfiguration` you already have: ```csharp var builder = WebApplication.CreateBuilder(args); // Your existing Microsoft configuration still works. // builder.Configuration already has appsettings.json, env vars, etc. // Add Cocoar — reads from the SAME IConfiguration builder.AddCocoarConfiguration(c => c .UseConfiguration(rules => [ rules.For().FromIConfiguration(builder.Configuration).Select("App"), rules.For().FromIConfiguration(builder.Configuration).Select("Database") ])); ``` At this point both systems are active. Old services using `IOptions` keep working. New services can inject `AppSettings` directly. No conflicts, no breaking changes. ::: tip `FromIConfiguration` watches `IConfiguration.GetReloadToken()`, so file-change notifications flow through automatically. See [Microsoft IConfiguration Adapter](/guide/providers/microsoft-adapter) for details. ::: ## Step 2: Migrate Services (Type by Type) Pick a single service and swap the wrapper for direct injection. You do not need to migrate everything at once. **Before:** ```csharp public class OrderService(IOptions options) { public void Process() { var settings = options.Value; // use settings... } } ``` **After:** ```csharp public class OrderService(AppSettings settings) { public void Process() { // Just use settings directly — no .Value unwrapping } } ``` Both injection patterns work at the same time in the same DI container. Migrate one service, run your tests, then move to the next. ::: info Start with **leaf services** -- services that don't inject other config-dependent services. These are the simplest to migrate and verify in isolation. ::: ## Step 3: Upgrade to Reactive (Where Needed) For services that used `IOptionsMonitor` to react to configuration changes at runtime, switch to `IReactiveConfig`: **Before:** ```csharp public class CacheService : IDisposable { private readonly IDisposable _subscription; public CacheService(IOptionsMonitor monitor) { _subscription = monitor.OnChange(settings => RebuildCache(settings)); } public void Dispose() => _subscription.Dispose(); } ``` **After:** ```csharp public class CacheService : IDisposable { private readonly IDisposable _subscription; public CacheService(IReactiveConfig config) { _subscription = config.Subscribe(settings => RebuildCache(settings)); } public void Dispose() => _subscription.Dispose(); } ``` `IReactiveConfig` implements `IObservable`, so you get standard Rx semantics -- including the ability to combine multiple configs atomically with [reactive tuples](/guide/reactive/tuples). ## Step 4: Switch to Native Providers (Optional) Once your services are migrated, you can optionally replace the `FromIConfiguration` bridge with native Cocoar providers. This gives you full reactive support, better performance, and no dependency on the Microsoft configuration pipeline. ```csharp // Before: bridged through Microsoft rules.For() .FromIConfiguration(builder.Configuration) .Select("App") // After: native Cocoar providers with full reactive support rules.For() .FromFile("appsettings.json").Select("App"), rules.For() .FromEnvironment("APP_"), ``` This step is entirely optional. `FromIConfiguration` works correctly long-term and there is no pressure to remove it. ## Mapping Table | Microsoft | Cocoar | Notes | |---|---|---| | `IOptions` | `T` (inject directly) | No wrapper needed | | `IOptionsSnapshot` | `T` (Scoped) | Same behavior -- stable per request | | `IOptionsMonitor` | `IReactiveConfig` | `.Subscribe()` instead of `.OnChange()` | | `Configure(section)` | `rules.For().FromIConfiguration(config).Select(section)` | Declarative rules | | `builder.Configuration.GetSection("X")` | `.Select("X")` | Standard Cocoar pattern | | `PostConfigure()` | Additional rule (last-write-wins) | [Layering](/guide/configuration/rules) handles this naturally | | `ValidateDataAnnotations()` | `.Required()` + C# validation | `.Required()` ensures the source exists. For property validation (`[Range]`, `[Url]`, etc.), use standard C# validation in your config class constructor or a factory method. Cocoar does not run Data Annotation validators automatically. | ### PostConfigure example The `PostConfigure()` pattern translates to an additional rule. Because Cocoar uses last-write-wins layering, a second rule for the same type overrides specific properties: ```csharp // Microsoft: PostConfigure overrides values after Configure services.Configure(config.GetSection("App")); services.PostConfigure(s => s.MaxRetries = 10); // Cocoar: additional rule — last write wins rules.For().FromIConfiguration(builder.Configuration).Select("App"), rules.For().FromStaticJson("""{ "MaxRetries": 10 }"""), ``` ## Tips * **Start with leaf services.** Services that don't inject other config types are the easiest to migrate and verify. * **Don't remove `Configure()` calls** until every consumer of that type has been migrated to direct injection. * **Both registration systems coexist.** Microsoft DI handles both `IOptions` and direct `T` injection simultaneously with no conflicts. * **Tests:** Use `CocoarTestConfiguration.ReplaceConfiguration()` for migrated services. Existing services keep using their current test patterns. See [Test Overrides](/guide/testing/overrides) for details. * **Gradual is fine.** There is no deadline to finish the migration. A codebase that uses both systems works correctly and is fully supported. --- --- url: /guide/migration/v2-to-v3.md description: >- v2 to v3 Type-First API migration — rule.File().For() becomes rule.For().FromFile(), config-aware .When(IConfigurationAccessor), provider-method rename table --- # Migration v2 → v3 v3.0 introduces the **Type-First API pattern** for rule building and **config-aware conditional rules**. ## Overview | v2.0 API (Provider-First) | v3.0 API (Type-First) | |---|---| | `rule.File("...").For()` | `rule.For().FromFile("...")` | | `rule.Environment("...").For()` | `rule.For().FromEnvironment("...")` | | `rule.StaticJson("...").For()` | `rule.For().FromStaticJson("...")` | | `rule.Static(factory).For()` | `rule.For().FromStatic(factory)` | | `rule.Observable(obs).For()` | `rule.For().FromObservable(obs)` | | `rule.HttpPolling(...).For()` | `rule.For().FromHttp(...)` | | `rule.MicrosoftSource(...).For()` | `rule.For().FromMicrosoft(config)` | | `.When(Func)` | `.When(Func)` | ## Why Type-First? The Type-First pattern puts the configuration type at the beginning of the chain: * **More discoverable** — IntelliSense shows all provider options after `For()` * **Type-safe first** — the type is mandatory and declared upfront * **Natural to read** — flows like "For AppSettings from file config.json" * **Consistent with .NET** — similar to `services.AddScoped()`, `builder.Entity()` ## Quick Example **Before (v2.0):** ```csharp services.AddCocoarConfiguration(rule => [ rule.File("config.json").Select("App").For(), rule.Environment("APP_").For() ], setup => [ setup.ConcreteType().ExposeAs() ]); ``` **After (v3.0):** ```csharp services.AddCocoarConfiguration(rule => [ rule.For().FromFile("config.json").Select("App"), rule.For().FromEnvironment("APP_") ], setup => [ setup.ConcreteType().ExposeAs() ]); ``` ## Migration Examples ### Simple File Rule ```csharp // v2.0 rule.File("appsettings.json").For() // v3.0 rule.For().FromFile("appsettings.json") ``` ### With Select ```csharp // v2.0 rule.File("config.json").Select("Database").For() // v3.0 rule.For().FromFile("config.json").Select("Database") ``` ### With Multiple Modifiers ```csharp // v2.0 rule.File("config.json") .Select("Database") .MountAt("Db") .Required() .For() // v3.0 rule.For().FromFile("config.json") .Select("Database") .MountAt("Db") .Required() ``` ### Dynamic Rules with Accessor ```csharp // v2.0 rule.File(accessor => { var tenant = accessor.GetConfig(); return FileSourceRuleOptions.FromFilePath($"tenant-{tenant.Id}.json"); }).For() // v3.0 rule.For().FromFile(accessor => { var tenant = accessor.GetConfig(); return FileSourceRuleOptions.FromFilePath($"tenant-{tenant.Id}.json"); }) ``` ### Conditional Rules The `.When()` method signature changed to receive `IConfigurationAccessor`: ```csharp // v2.0 — When(Func) rule.File("premium-features.json") .When(() => isPremium) .For() // v3.0 — When(Func) rule.For().FromFile("premium-features.json") .When(accessor => { var tenant = accessor.GetConfig(); return tenant.Tier == "Premium"; }) ``` If you were using simple conditions without configuration access, use an underscore discard: ```csharp // v2.0 .When(() => Environment.GetEnvironmentVariable("DEBUG") == "true") // v3.0 .When(_ => Environment.GetEnvironmentVariable("DEBUG") == "true") ``` ### Environment Variables ```csharp // v2.0 rule.Environment("APP_").For() // v3.0 rule.For().FromEnvironment("APP_") ``` ### Static Configuration ```csharp // v2.0 rule.StaticJson("""{"Key": "Value"}""").For() rule.Static(_ => new Config { Key = "Value" }).For() // v3.0 rule.For().FromStaticJson("""{"Key": "Value"}""") rule.For().FromStatic(_ => new Config { Key = "Value" }) ``` ### HTTP ```csharp // v2.0 rule.HttpPolling(_ => new HttpPollingRuleOptions( urlPathOrAbsolute: "https://api.example.com/config", pollInterval: TimeSpan.FromMinutes(5) )).For() // v3.0 rule.For().FromHttp("https://api.example.com/config", pollInterval: TimeSpan.FromMinutes(5)) ``` ### Custom Provider ```csharp // v2.0 rule.FromProvider( instanceOptions: _ => new MyOptions(), queryOptions: _ => new MyQuery() ).For() // v3.0 rule.For().FromProvider( instanceOptions: _ => new MyOptions(), queryOptions: _ => new MyQuery() ) ``` Note: `FromProvider` now also includes the type parameter `T` for consistency. ## Automated Migration For simple patterns, regex find/replace works: | Pattern | Find | Replace | |---|---|---| | File | `rule\.File\("([^"]+)"\)\.For<([^>]+)>\(\)` | `rule.For<$2>().FromFile("$1")` | | Environment | `rule\.Environment\("([^"]*)"\)\.For<([^>]+)>\(\)` | `rule.For<$2>().FromEnvironment("$1")` | | StaticJson | `rule\.StaticJson\(([^)]+)\)\.For<([^>]+)>\(\)` | `rule.For<$2>().FromStaticJson($1)` | | Observable | `rule\.Observable\(([^)]+)\)\.For<([^>]+)>\(\)` | `rule.For<$2>().FromObservable($1)` | ::: tip For complex patterns with `.Select()`, `.When()`, `.Required()`, or dynamic accessors, manual migration is recommended to ensure correct method placement. ::: ## What Stays the Same * Setup builder API: `setup.ConcreteType().ExposeAs()` * All method modifiers (`.Select()`, `.MountAt()`, `.Required()`) work the same * Rule execution order and semantics are identical * Health monitoring and reactive configuration unchanged ## New in v3.0: Config-Aware Conditional Rules The `.When()` method now receives `IConfigurationAccessor`, enabling conditional logic based on other configuration: ```csharp builder.AddCocoarConfiguration(rule => [ rule.For().FromFile("tenant.json"), rule.For().FromFile("premium.json") .When(accessor => { var tenant = accessor.GetConfig(); return tenant.Tier == "Premium"; }), rule.For().FromFile("debug.json") .When(_ => Environment.GetEnvironmentVariable("DEBUG_MODE") == "true") ]); ``` --- --- url: /guide/migration/v3-to-v4.md description: >- v3 to v4 — no public API breaks; adds test overrides, Secret X.509 encryption, secrets CLI, COCFG analyzers; internal provider contract moves from JsonElement to byte[] (custom providers only) --- # Migration v3 → v4 v3.x to v4.0 was an incremental release. There are **no breaking changes** to the public API. ## What Changed v4.0 added new capabilities without modifying existing APIs: * **Testing Configuration Overrides** — `CocoarTestConfiguration` with `AsyncLocal` isolation * **Secrets Package** — `Secret` with X.509 hybrid encryption * **Secrets CLI** — `cocoar-secrets` global tool for encrypting/decrypting * **Roslyn Analyzers** — COCFG001–006 for compile-time validation ## Internal Breaking Change The **provider contract** changed from `JsonElement` to `byte[]`: * `FetchConfigurationAsync` → `FetchConfigurationBytesAsync` (returns `byte[]`) * `Changes` → `ChangesAsBytes` (emits `byte[]`) This only affects you if you built a **custom provider** against the v3 contract. Built-in providers were updated automatically. ## Migration For most applications: update the NuGet package version. No code changes required. If you have a custom provider, update the two method signatures to use `byte[]` instead of `JsonElement`. See [Building Custom Providers](/guide/providers/custom) for the current contract. --- --- url: /guide/migration/v4-to-v5.md description: >- v4 to v5 — ConfigManager.Create builder API, 10+ packages consolidated to 7, feature flags & entitlements, HttpPolling renamed to Http, Flag to FeatureFlag, health and resolver API renames --- # Migration v4 → v5 v5.0 introduces the **ConfigManager Builder API**, **package consolidation** (10+ packages → 7), **feature flags & entitlements**, **HTTP provider rename**, and several **API renames**. ## Breaking Changes 1. **`ConfigManager` constructors and `Initialize()` are now `internal`** — use `ConfigManager.Create()` instead 2. **`AddCocoarConfiguration()` uses the builder API** — wrap rules in `c => c.UseConfiguration(...)` 3. **Secrets setup moved** from the `setup` lambda to a dedicated `.UseSecretsSetup()` builder method 4. **Testing API renamed** — `ReplaceAllRules()` → `ReplaceConfiguration()`, `AppendTestRules()` → `AppendConfiguration()` 5. **Package renamed** — `Cocoar.Configuration.HttpPolling` → `Cocoar.Configuration.Http` 6. **`Flag` renamed** to `FeatureFlag` 7. **`FromHttpPolling()` renamed** to `FromHttp()` 8. **`FromMicrosoftSource()` deprecated** — use `FromIConfiguration()` instead 9. **Health API simplified** — `GetHealthService()` replaced by `ConfigManager.HealthStatus` / `ConfigManager.IsHealthy` 10. **Resolver registration changed** — `RegisterGlobalContextResolver()` / `WithContextResolver()` replaced by `ResolverBuilder` with collection expressions ## Package Changes ```bash # Remove old/merged packages dotnet remove package Cocoar.Configuration.Secrets dotnet remove package Cocoar.Configuration.Secrets.Abstractions dotnet remove package Cocoar.Configuration.HttpPolling dotnet remove package Cocoar.Configuration.Analyzers # Now bundled in Cocoar.Configuration # Update/add new packages dotnet add package Cocoar.Configuration # Now includes Secrets + Flags + Analyzers dotnet add package Cocoar.Configuration.Abstractions # Now includes Secrets.Abstractions dotnet add package Cocoar.Configuration.Http # Was HttpPolling ``` :::warning Remove Cocoar.Configuration.Analyzers The analyzers and source generator are now bundled inside the `Cocoar.Configuration` package. If you keep a separate `Cocoar.Configuration.Analyzers` PackageReference, you will get **duplicate type errors** (CS0101/CS0102) because the source generator runs twice. Remove the separate reference. ::: Same types, same namespaces — just fewer packages to install. | v4.x Package | v5.0 | |---|---| | `Cocoar.Configuration.Secrets` | Merged into `Cocoar.Configuration` | | `Cocoar.Configuration.X509Encryption` | Merged into `Cocoar.Configuration` | | `Cocoar.Configuration.Flags` | Merged into `Cocoar.Configuration` | | `Cocoar.Configuration.Flags.Generator` | Merged into `Cocoar.Configuration.Analyzers` | | `Cocoar.Configuration.Secrets.Abstractions` | Merged into `Cocoar.Configuration.Abstractions` | | `Cocoar.Configuration.HttpPolling` | Renamed to `Cocoar.Configuration.Http` | ## API Renames | v4.x | v5.0 | |---|---| | `Flag` | `FeatureFlag` | | `Flag` | `FeatureFlag` | | `FromHttpPolling(...)` | `FromHttp(url, ...)` | | `FromMicrosoftSource(...)` | `FromIConfiguration(config)` | | `HttpPollingRuleOptions` | `HttpRuleOptions` | | `manager.GetHealthService()` | `manager.HealthStatus` / `manager.IsHealthy` | | `IConfigurationHealthService` | Removed — use `ConfigManager.HealthStatus` directly | | `FeatureFlagsSetupBuilder` | `FlagsBuilder` | | `FlagClassRegistrationBuilder` | `FlagsBuilder` | | `EntitlementClassRegistrationBuilder` | `EntitlementsBuilder` | | `RegisterGlobalContextResolver()` | `resolvers.Global()` | | `WithContextResolver()` | `resolvers.For(r => r.Use())` | ## Namespace Changes ```csharp // v4.x using Cocoar.Configuration.HttpPolling; // v5.0 using Cocoar.Configuration.Http; ``` ## Migration Table | v4.x | v5.0 | |---|---| | `new ConfigManager(rules).Initialize()` | `ConfigManager.Create(c => c.UseConfiguration(rules))` | | `new ConfigManager(rules, setup).Initialize()` | `ConfigManager.Create(c => c.UseConfiguration(rules, setup))` | | `new ConfigManager(rules, logger: l).Initialize()` | `ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(l))` | | `new ConfigManager(rules, debounceMilliseconds: 50).Initialize()` | `ConfigManager.Create(c => c.UseConfiguration(rules).UseDebounce(50))` | | `services.AddCocoarConfiguration(rule => [...])` | `services.AddCocoarConfiguration(c => c.UseConfiguration(rule => [...]))` | | `services.AddCocoarConfiguration(rule => [...], setup => [...])` | `services.AddCocoarConfiguration(c => c.UseConfiguration(rule => [...], setup => [...]))` | | `builder.AddCocoarConfiguration(rule => [...])` | `builder.AddCocoarConfiguration(c => c.UseConfiguration(rule => [...]))` | ## ConfigManager.Create() The old API split construction and initialization — `new ConfigManager(...)` created an uninitialized object requiring a separate `.Initialize()`. Forgetting `Initialize()` caused subtle bugs. The new API returns a fully-initialized instance. No `Initialize()` needed. ### Basic Rules ```csharp // v4.x var manager = new ConfigManager(rule => [ rule.For().FromFile("config.json"), rule.For().FromEnvironment("DB_") ]).Initialize(); // v5.0 var manager = ConfigManager.Create(c => c .UseConfiguration(rule => [ rule.For().FromFile("config.json"), rule.For().FromEnvironment("DB_") ])); ``` ### Rules with Setup ```csharp // v4.x var manager = new ConfigManager( rule => [rule.For().FromFile("config.json")], setup => [setup.ConcreteType().ExposeAs()] ).Initialize(); // v5.0 var manager = ConfigManager.Create(c => c .UseConfiguration( rule => [rule.For().FromFile("config.json")], setup => [setup.ConcreteType().ExposeAs()])); ``` ### With Logger and Debounce ```csharp // v4.x var manager = new ConfigManager( rule => [rule.For().FromFile("config.json")], logger: myLogger, debounceMilliseconds: 50 ).Initialize(); // v5.0 var manager = ConfigManager.Create(c => c .UseConfiguration(rule => [ rule.For().FromFile("config.json") ]) .UseLogger(myLogger) .UseDebounce(50)); ``` ### Async Initialization (New) v5.0 adds `CreateAsync()` for scenarios where blocking during provider I/O is undesirable: ```csharp var manager = await ConfigManager.CreateAsync(c => c .UseConfiguration(rule => [ rule.For().FromFile("config.json") ]), cancellationToken); ``` ## DI and ASP.NET Core `AddCocoarConfiguration()` uses the same builder API: ```csharp // v4.x services.AddCocoarConfiguration( rule => [rule.For().FromFile("config.json")], setup => [setup.ConcreteType().ExposeAs()]); // v5.0 services.AddCocoarConfiguration(c => c .UseConfiguration( rule => [rule.For().FromFile("config.json")], setup => [setup.ConcreteType().ExposeAs()])); ``` **ASP.NET Core:** ```csharp // v4.x builder.AddCocoarConfiguration(rule => [...]); // v5.0 builder.AddCocoarConfiguration(c => c .UseConfiguration(rule => [...])); ``` ## Secrets Setup Secrets configuration moved from the `setup` lambda to a dedicated builder method: ```csharp // v4.x var manager = new ConfigManager( rule => [rule.For().FromFile("config.json")], setup => [ setup.Secrets() .UseCertificateFromFile("secrets.pfx") .WithKeyId("dev-secrets") ] ).Initialize(); // v5.0 var manager = ConfigManager.Create(c => c .UseConfiguration( rule => [rule.For().FromFile("config.json")]) .UseSecretsSetup(secrets => secrets .UseCertificateFromFile("secrets.pfx") .WithKeyId("dev-secrets"))); ``` ## HTTP Provider (was HttpPolling) The package is renamed and the API simplified: ```csharp // v4.x using Cocoar.Configuration.HttpPolling; rule.For().FromHttpPolling(accessor => HttpPollingRuleOptions.FromPath("https://api.example.com/config", pollInterval: TimeSpan.FromSeconds(30))) // v5.0 using Cocoar.Configuration.Http; rule.For().FromHttp( "https://api.example.com/config", pollInterval: TimeSpan.FromSeconds(30)) ``` **New: SSE (Server-Sent Events) mode:** ```csharp rule.For().FromHttp( "https://api.example.com/config", serverSentEvents: true) ``` **New: One-time fetch (no polling, no SSE):** ```csharp rule.For().FromHttp("https://api.example.com/config") ``` ## Microsoft Adapter `FromMicrosoftSource()` is deprecated. Use the simpler `FromIConfiguration()`: ```csharp // v4.x rule.For().FromMicrosoftSource(accessor => MicrosoftConfigurationSourceRuleOptions.From(configuration)) // v5.0 rule.For().FromIConfiguration(configuration) ``` ## Health API Health access simplified from a service to direct properties: ```csharp // v4.x var healthService = manager.GetHealthService(); var snapshot = healthService.Snapshot; var status = snapshot.Status; // v5.0 var status = manager.HealthStatus; var isHealthy = manager.IsHealthy; ``` ## Feature Flags Registration The registration API uses collection expressions with `FlagsBuilder` / `EntitlementsBuilder`: ```csharp // v4.x (FeatureFlagsSetupBuilder) .UseFeatureFlags(f => f .RegisterClass() .RegisterClass()) // v5.0 (FlagsBuilder with collection expressions) .UseFeatureFlags(flags => [ flags.Register(), flags.Register() ]) ``` `Flag` properties are renamed to `FeatureFlag`: ```csharp // v4.x public class AppFeatureFlags : FeatureFlags { public Flag DarkMode { get; set; } = () => false; } // v5.0 public partial class AppFeatureFlags : IFeatureFlags { public FeatureFlag DarkMode => () => Config.DarkModeEnabled; } ``` ## Resolver Registration Context resolvers now use `ResolverBuilder` with collection expressions, registered via a second parameter: ```csharp // v4.x .UseFeatureFlags(f => f .RegisterClass() .RegisterGlobalContextResolver() .WithContextResolver()) // v5.0 .UseFeatureFlags( flags => [ flags.Register() ], resolvers => [ resolvers.Global(), resolvers.For(r => r .Use() .ForProperty(f => f.BetaCheckout).Use()) ]) ``` The default resolver lifetime changed from Transient to Scoped. Customize with `.AsSingleton()` or `.AsTransient()`. ## Testing API Method names changed and the API is now fluent and per-concern: | v4.x | v5.0 | |---|---| | `CocoarTestConfiguration.ReplaceAllRules(rule => [...])` | `CocoarTestConfiguration.ReplaceConfiguration(rule => [...])` | | `CocoarTestConfiguration.AppendTestRules(rule => [...])` | `CocoarTestConfiguration.AppendConfiguration(rule => [...])` | | `CocoarTestConfiguration.WithSetup(setup => [...])` | Removed — use `ReplaceSecretsSetup()` on the builder | ```csharp // v4.x using var _ = CocoarTestConfiguration.ReplaceAllRules(rule => [ rule.For().FromStatic(_ => testDbConfig) ]); // v5.0 using var _ = CocoarTestConfiguration.ReplaceConfiguration(rule => [ rule.For().FromStatic(_ => testDbConfig) ]); ``` **With secrets override (v5.0):** ```csharp using var _ = CocoarTestConfiguration .ReplaceConfiguration(rule => [ rule.For().FromStatic(_ => testDbConfig) ]) .ReplaceSecretsSetup(secrets => secrets.AllowPlaintext()); ``` Each concern (configuration, secrets) is independent — chain freely on the returned `TestOverrideBuilder`. ## Automated Migration For most cases, find/replace works: **Pattern 1:** Replace `new ConfigManager(rule => [` with `ConfigManager.Create(c => c.UseConfiguration(rule => [`, then replace the closing `).Initialize()` with `))`. **Pattern 2:** Replace `services.AddCocoarConfiguration(rule =>` with `services.AddCocoarConfiguration(c => c.UseConfiguration(rule =>`, and add the closing `)` before the final `)`. **Pattern 3:** Replace `Flag<` with `FeatureFlag<` in flag class definitions. **Pattern 4:** Replace `using Cocoar.Configuration.HttpPolling` with `using Cocoar.Configuration.Http`. **Pattern 5:** Replace `.FromHttpPolling(` with `.FromHttp(` and update the options pattern to use the simplified parameters. ## What Stays the Same * Rule building API: `rule.For().FromFile(...)`, `.Select()`, `.Required()`, `.When()` * Setup builder API: `setup.ConcreteType().ExposeAs()` * Reactive configuration: `IReactiveConfig` * All provider implementations (File, Environment, CommandLine, Static, Observable) ## New in v5.0 These are purely additive — no migration needed: * **.NET 8 LTS support** — all library packages now multi-target `net8.0` and `net9.0`, so v5 works on both .NET 8 and .NET 9 * **[Feature Flags & Entitlements](/guide/flags/concepts)** — strongly-typed, computed feature flags and entitlements built into the core package * **`ConfigManager.CreateAsync()`** — async factory for non-blocking initialization * **Zero external dependencies** — `System.Reactive` removed from all shipped packages * **Runtime recomputes are fully async** — no more sync-over-async in the recompute pipeline * **SSE support** — `FromHttp(url, serverSentEvents: true)` for live push updates * **OpenTelemetry metrics** — `cocoar.config.recompute.count`, `cocoar.config.recompute.duration`, `cocoar.config.provider.errors`, `cocoar.config.flags.evaluations`, `cocoar.config.health.status` * **Distributed tracing** — Activity source `Cocoar.Configuration` * **ASP.NET Core health check** — `AddCocoarConfigurationHealthCheck()` * **REST evaluation endpoints** — `MapFeatureFlagEndpoints()`, `MapEntitlementEndpoints()` * **[Aggregate Rules](/guide/configuration/aggregate-rules)** — `FromFiles()` for file layering, `.Aggregate()` for general-purpose rule grouping with isolated error handling * **VitePress documentation site** with complete guide, reference, and roadmap --- --- url: /guide/multi-tenancy/overview.md description: >- Per-tenant pipeline bundles on a shared global base, .TenantScoped() rules, accessor.Tenant, …ForTenant reads, scoped ITenantReactiveConfig, per-tenant flags/secrets/WritableStore, global fan-out --- # Multi-Tenancy Multi-tenant applications need the **same configuration type to resolve to different values per tenant** — a global default for everything, with each tenant overriding only the keys it sets and inheriting the rest. Cocoar.Configuration models this as **per-tenant pipeline bundles layered on a shared global base** (see [ADR-005](/adr/ADR-005-multi-tenant-configuration)). You author **one flat rule list** and mark the per-tenant rules with `.TenantScoped()`; the tenant id flows in through the configuration accessor. There is no second authoring surface and no provider becomes "tenant-aware". ::: tip When do I need this? Only when one process serves many tenants and the **same type** must differ per tenant at runtime, with tenants added/removed dynamically. A single-tenant app needs none of this — the global pipeline is unchanged. ::: ## The two primitives * **`IConfigurationAccessor.Tenant`** — `null` in the global (tenant-agnostic) pipeline, the tenant id inside a tenant pipeline. Tenant-varying rule factories interpolate it. * **`.TenantScoped()`** on a rule — the rule runs **only** for a tenant and is skipped in the global pipeline. It is shorthand for `.When(a => !string.IsNullOrWhiteSpace(a.Tenant))`. ```csharp var manager = ConfigManager.Create(c => c.UseConfiguration(rules => [ // Global base — applies to everything, injectable as usual: rules.For().FromStaticJson(smtpDefaults), // Per-tenant overlay — wins per key, inherits the rest. The id flows via the accessor: rules.For().FromFile(a => $"tenants/{a.Tenant}/smtp.json").TenantScoped(), ])); ``` The effective value for a tenant is `[global rules] ++ [tenant-scoped rules]`, run through the **same** recompute/merge pipeline as any config — so transforms, required-rule rollback and dependency ordering all behave identically. Placing a global rule **after** the tenant overlay makes it a non-negotiable platform ceiling (it wins over the tenant) — no special tier, just list position. ## Lifecycle The host owns the tenant list. A tenant's configuration is materialized **on demand** and async is confined to that init moment — reads stay synchronous, exactly like the global config. ```csharp var tenants = (ITenantConfigurationAccessor)manager; // ConfigManager implements it await tenants.InitializeTenantAsync("acme"); // build the tenant pipeline (at tenant creation) await tenants.EnsureTenantInitializedAsync("acme"); // idempotent warmup (e.g. request-start middleware) bool ready = tenants.IsTenantInitialized("acme"); await tenants.RemoveTenantAsync("acme"); // dispose the tenant bundle (at tenant removal) ``` `InitializeTenantAsync` is idempotent and safe under concurrency — a tenant is built exactly once. ## Consuming a tenant's configuration Tenant-scoped values are obtained by **passing the tenant id**, never by DI injection: ```csharp var smtp = manager.GetConfigForTenant("acme"); // sync read var live = manager.GetReactiveConfigForTenant("acme"); // IReactiveConfig for this tenant var flags = manager.GetFeatureFlagsForTenant("acme"); var ents = manager.GetEntitlementsForTenant("acme"); var store = manager.GetWritableStoreForTenant("acme"); // per-tenant write facade ``` ### Not DI-injectable — by design A type whose **every** rule is `.TenantScoped()` has no global value. Injecting it into a long-lived (singleton) consumer would be a captive-dependency bug — it would freeze one tenant forever, since the container cannot know the runtime tenant. The DI planner therefore **excludes** purely tenant-scoped types from the global plan. A type that *also* has a global base rule stays injectable (its base value is a valid global config). Consuming services inject the `ConfigManager` / `ITenantConfigurationAccessor` and call `…ForTenant(currentTenant)`. ### Scoped per-request injection (DI) So scoped/transient services don't have to thread the tenant id by hand, a **scoped** `ITenantReactiveConfig` (in `Cocoar.Configuration.DI`) resolves the *current* tenant for you. It reads the tenant from a scoped `ITenantContext` and delegates to `GetReactiveConfigForTenant(tenant)`. You don't hand-write an `ITenantContext` — point a **resolver** at whatever already knows the tenant: ```csharp // Register the scoped adapter: builder.Services.AddCocoarTenantReactiveConfig(); // ...then point a resolver at your existing tenant service: builder.Services.AddCocoarTenantResolver(s => s.TenantId); // ...or, for plain HTTP, at IHttpContextAccessor — no AspNetCore-specific API needed: builder.Services.AddHttpContextAccessor(); builder.Services.AddCocoarTenantResolver( a => a.HttpContext?.Request.RouteValues["tenant"]?.ToString()); // Ensure the tenant pipeline is warm before it is consumed (e.g. request-start middleware): app.Use(async (ctx, next) => { if (ctx.Request.RouteValues["tenant"] is string t) await app.Services.GetRequiredService().EnsureTenantInitializedAsync(t); await next(); }); // In any scoped/transient service — no tenant id threaded by hand: public sealed class SmtpSender(ITenantReactiveConfig smtp) { public void Send() => Connect(smtp.CurrentValue.Host); // this request's tenant } ``` The selector is re-evaluated on every access, so the tenant can become known after the scope starts (e.g. post-auth-middleware). The singleton `IReactiveConfig` is **untouched** — it stays the global view, so singletons keep working. A singleton that needs a specific tenant still calls `GetReactiveConfigForTenant(id)` explicitly (it has no ambient request tenant). Without DI there is no ambient scope to resolve from — pass the tenant explicitly with the `…ForTenant(id)` methods. ## Feature flags & entitlements per tenant The same source-generated flag/entitlement class is constructed with the **tenant's** `IReactiveConfig`, so it evaluates against that tenant's effective config — **no source-generator change**: ```csharp public partial class BillingFlags : IFeatureFlags { public FeatureFlag PremiumEnabled => () => Config.PremiumBilling; } bool premium = manager.GetFeatureFlagsForTenant("acme").PremiumEnabled(); ``` In ASP.NET Core, map the tenant-dimensioned REST endpoints (a `{tenant}` route segment; the handler warms the tenant up and evaluates per tenant): ```csharp app.MapTenantFeatureFlagEndpoints(); // GET /tenants/{tenant}/flags/{FlagClass}/{FlagName} app.MapTenantEntitlementEndpoints(); // GET /tenants/{tenant}/entitlements/{Class}/{Name} ``` ## Per-tenant WritableStore Give each tenant its own backend via the factory overload (the store is keyed by `accessor.Tenant`), and write through the per-tenant facade: ```csharp rules.For().FromStore((a, _) => BackendFor(a.Tenant)).TenantScoped() await manager.GetWritableStoreForTenant("acme").SetAsync(x => x.Port, 587); ``` A write triggers only that tenant's recompute; other tenants are untouched. Provenance (`DescribeAsync`) is computed over the tenant's own layers. ### DB-backed config per tenant When the per-tenant source is a database (Marten / EF) reached through a DI-managed store, use `FromStore((sp, a) => …).TenantScoped()` — the tenant gate and the service-provider gate compose, so the rule runs only inside a tenant pipeline, after the host has started. See [Service-Backed Configuration](/guide/di/service-backed#db-backed-config-with-fromstorage). ## Per-tenant secrets Per-tenant secrets reuse the existing **multi-kid certificate folder** — `kid = tenant`. Lay certificates out as `certsRoot/{tenant}/cert.pfx` and each tenant's overlay carries an envelope tagged with its own kid: ```csharp c.UseSecretsSetup(secrets => secrets.UseCertificatesFromFolder(certsRoot)); using var lease = manager.GetConfigForTenant("acme").ApiKey!.Open(); // decrypts via certsRoot/acme ``` A tenant decrypts its own secret with its own certificate; it cannot decrypt another tenant's. ## Fan-out: global changes reach tenants automatically Each tenant pipeline runs the full rule list with its **own** provider subscriptions, so a change to a live global base source (file / observable / HTTP) propagates to every initialized tenant on its own debounced recompute and re-emits on that tenant's `IReactiveConfig`. A tenant that masks the changed key with its own override does not emit. No coordinator to configure; consistency is **per-tenant eventual** (a global change lands tenant-by-tenant as each rebuild finishes). ## Tuples across tenant scopes A `ValueTuple` mixing a global-only type and a tenant-overridable one is **fully supported** — each element is read from the relevant pipeline's atomic snapshot. The global accessor skips `.TenantScoped()` overlays (you get base values); the per-tenant accessor gives effective values. The same holds for tuple-typed `IFeatureFlags` / `IEntitlements`. The one case that errors is a *global* tuple containing a type whose **every** rule is `.TenantScoped()` — it has no global value, so read it per tenant (`GetReactiveConfigForTenant<…>(id)`). ("Scope" is a property of a rule, not of a type — a type can carry both global and tenant-scoped rules.) ## Limits in this version * **Resource use** scales linearly with initialized tenants × base rules (each tenant re-runs the base). Acceptable for a host-bounded active-tenant set; a shared seed-from-global optimization is a future, API-compatible change. * **Eviction** is explicit (`RemoveTenantAsync`) only — no idle eviction. --- --- url: /reference/packages.md description: >- NuGet package breakdown — Abstractions, Core, DI, AspNetCore, Http, MicrosoftAdapter, WritableStore.Marten, Analyzers, Secrets CLI; dependency graph and which to install --- # Package Overview ## Packages ### Cocoar.Configuration.Abstractions Lightweight interfaces for decoupled architecture. Reference this from libraries that need to accept configuration without depending on the full implementation. * **Target:** .NET 9.0 / .NET 10.0 * **Dependencies:** None * **Key types:** `IConfigurationAccessor`, `IReactiveConfig`, `ISecret`, `SecretLease` ```xml ``` ### Cocoar.Configuration The core library. Includes providers, reactive engine, secrets, feature flags, and entitlements. * **Target:** .NET 9.0 / .NET 10.0 * **Dependencies:** Cocoar.Configuration.Abstractions, Cocoar.Configuration.Analyzers (build-time), Cocoar.Capabilities, Cocoar.FileSystem, Cocoar.Json.Mutable, Microsoft.Extensions.Logging.Abstractions * **Key types:** `ConfigManager`, `Secret`, `IFeatureFlags`, `IEntitlements`, `FeatureFlag`, `Entitlement` ```xml ``` ### Cocoar.Configuration.DI Microsoft.Extensions.DependencyInjection integration. * **Target:** .NET 9.0 / .NET 10.0 * **Dependencies:** Cocoar.Configuration, Cocoar.Capabilities * **Key types:** `AddCocoarConfiguration()` extension method ```xml ``` ### Cocoar.Configuration.AspNetCore ASP.NET Core integration — includes DI and adds health checks, feature flag/entitlement REST endpoints. * **Target:** .NET 9.0 / .NET 10.0 * **Dependencies:** Cocoar.Configuration, Cocoar.Configuration.DI, Microsoft.AspNetCore.App (FrameworkReference) * **Key types:** `AddCocoarConfigurationHealthCheck()`, `MapFeatureFlagEndpoints()`, `MapEntitlementEndpoints()` ```xml ``` ::: tip AspNetCore includes DI, which includes Core — you only need one `PackageReference`. ::: ### Cocoar.Configuration.Http Remote configuration provider with support for one-time fetch, polling, and Server-Sent Events (SSE). Separate package to avoid forcing an HTTP dependency on all consumers. * **Target:** .NET 9.0 / .NET 10.0 * **Dependencies:** Cocoar.Configuration * **Key types:** `FromHttp()` extension method, `HttpRuleOptions` ```xml ``` ### Cocoar.Configuration.MicrosoftAdapter Bridge from `Microsoft.Extensions.Configuration` sources (Azure Key Vault, custom providers, etc.) into Cocoar.Configuration. * **Target:** .NET 9.0 / .NET 10.0 * **Dependencies:** Cocoar.Configuration, Microsoft.Extensions.Configuration.\* * **Key types:** `FromIConfiguration()` extension method ```xml ``` ### Cocoar.Configuration.WritableStore.Marten Marten (PostgreSQL document store) backend for the WritableStore. Persists writable configuration overlays as documents, with first-class support for Marten database-per-tenant multi-tenancy so each tenant's configuration lives in its own database. Opt-in package — it intentionally takes a Marten dependency; consumers who don't reference it pay nothing. * **Target:** .NET 9.0 / .NET 10.0 * **Dependencies:** Cocoar.Configuration.DI, Marten * **Key types:** `MartenStoreBackend`, `CocoarConfigDocument`, `FromMartenStore()` extension method ```xml ``` ### Cocoar.Configuration.Yaml YAML file provider. Reads `.yaml`/`.yml` files into the configuration pipeline with reactive file-watching. Plain YAML scalars are mapped to their JSON types (booleans, numbers, null) so they bind like JSON; quoted and block scalars stay strings. Opt-in package — it takes a YamlDotNet dependency. (The `.env` / dotenv provider, `FromDotEnv()`, is built into the core package and needs no dependency.) * **Target:** .NET 9.0 / .NET 10.0 * **Dependencies:** Cocoar.Configuration, YamlDotNet * **Key types:** `YamlFileProvider`, `FromYamlFile()` extension method ```xml ``` ### Cocoar.Configuration.Toml TOML file provider. Reads `.toml` files into the configuration pipeline with reactive file-watching. TOML's typed values (strings, integers, floats, booleans, dates, arrays, tables, arrays-of-tables) map unambiguously to JSON so they bind like JSON. Opt-in package — it takes a Tomlyn dependency. (The `.ini` and `.env` providers are built into the core package and need no dependency.) * **Target:** .NET 9.0 / .NET 10.0 * **Dependencies:** Cocoar.Configuration, Tomlyn * **Key types:** `TomlFileProvider`, `FromTomlFile()` extension method ```xml ``` ### Cocoar.Configuration.Analyzers Roslyn analyzers (COCFG001–006) and source generator (COCFLAG001–003). Ships as a build-time dependency of the core package — you don't need to install it separately. * **Target:** .NET Standard 2.0 (Roslyn requirement) * **Dependencies:** Microsoft.CodeAnalysis.CSharp (build-time only) * **Key types:** 5 configuration analyzers, 3 flags diagnostics, 1 incremental source generator ### Cocoar.Configuration.Secrets.Cli Global .NET tool for encrypting and decrypting secrets in JSON configuration files. ```shell dotnet tool install -g Cocoar.Configuration.Secrets.Cli ``` * **Target:** .NET 9.0 * **Commands:** `encrypt`, `decrypt`, `generate-cert`, `convert-cert`, `cert-info` ## Dependency Graph ``` Abstractions (no deps) │ ▼ Core ◄──── Analyzers (build-time) │ ├──► Http ├──► MicrosoftAdapter │ ▼ DI │ ▼ AspNetCore ``` Each arrow means "depends on". Installing a downstream package brings all upstream packages transitively. ## Which Package Do I Need? | Scenario | Package | |---|---| | ASP.NET Core application | `Cocoar.Configuration.AspNetCore` | | Console app or library with DI | `Cocoar.Configuration.DI` | | Library without DI | `Cocoar.Configuration` | | Interface-only dependency | `Cocoar.Configuration.Abstractions` | | Remote config (polling / SSE) | Add `Cocoar.Configuration.Http` | | Existing `IConfiguration` sources | Add `Cocoar.Configuration.MicrosoftAdapter` | ## External Dependencies All shipped packages have **zero non-Microsoft external dependencies**. The only third-party packages are Cocoar ecosystem libraries (`Cocoar.Capabilities`, `Cocoar.FileSystem`, `Cocoar.Json.Mutable`). `System.Reactive` is **not** a dependency — the library uses lightweight internal reactive primitives. Consumers are free to use System.Reactive on their side (the public API is `IObservable`, which is BCL). ## License All packages are licensed under **Apache-2.0**. --- --- url: /guide/health/performance.md description: >- Partial re-evaluation, SHA-256 hash-based change detection, zero steady-state cost, one instance per config type, provider sharing by key, reference-equality reactive pipeline, 300ms debounce --- # Performance Characteristics This page describes the qualitative performance characteristics of Cocoar.Configuration — what the system does (and avoids doing) at each stage so you can reason about cost in your application. ## Recompute Cost Recomputes are triggered by provider changes: a file is modified on disk, an HTTP poll returns new data, or an observable emits a new value. Between changes, the system is completely idle. **Partial re-evaluation.** When a provider signals a change, the recompute starts from the earliest changed rule forward. Rules before that index replay their last contribution from cache — they are not re-fetched or re-deserialized. **Hash-based change detection.** The `TransformCache` computes a SHA-256 hash of the transformed bytes after Select/Mount processing. If the hash matches the previous value, the provider change is discarded and no recompute is triggered at all. This means file saves that do not change content, or HTTP polls that return the same payload, are free. **Steady-state cost is zero.** When no provider signals a change, nothing runs — no timers fire, no polling occurs (file providers use OS-level file system notifications), and no background work is performed. ## Memory Footprint **One live instance per config type.** Each configuration type has exactly one deserialized instance at any time. Instances are immutable and replaced atomically during recompute. The previous instance becomes eligible for garbage collection immediately. **Thin reactive wrappers.** `IReactiveConfig` is backed by a `BackplaneReactiveConfig` that holds only a reference to the shared `MasterBackplane` and a cached observable projection — one object per type, allocated once. **Feature flag singletons.** Feature flag and entitlement classes are created once and cached in a `ConcurrentDictionary` for the lifetime of the application. They hold references to `IReactiveConfig` instances internally, which are themselves singletons. ## Scaling Characteristics **Provider sharing.** Provider instances are shared by key through the `ProviderRegistry`. For file-based configuration, the key is `{Directory}|{PollingInterval}` — so all rules reading from the same directory share a single `FileSourceProvider` and a single file system monitor. Multiple config types in the same directory do not multiply the number of watchers. **Linear rule cost.** Rules execute sequentially during a recompute. The total cost scales linearly with the number of rules that need re-evaluation (from the earliest changed rule forward). Rules before the change point replay from cache. **Independent config types.** Each config type is tracked independently in the `ConfigSnapshot`. There is no cross-type overhead — adding a new config type does not affect the cost of existing types. **Subscriber filtering.** The reactive pipeline uses `DistinctUntilChanged` with reference equality. When a snapshot is published but a particular type's instance has not changed (same reference), subscribers for that type are not notified. This means subscribers only fire on actual changes to their specific type. ## Reactive Pipeline **Reference-equality change detection.** The `MasterBackplane` projects each type from the snapshot stream using `DistinctUntilChanged` with a `ReferenceEqualityComparer` — a single `ReferenceEquals` call per type per snapshot, which is O(1). **Tuple change detection.** `ReactiveTupleConfig` checks each element of the tuple independently using `ReferenceEquals`. A tuple emission occurs only when at least one element's reference has changed. This avoids unnecessary downstream processing when a snapshot update does not affect the types in the tuple. **Source-generated flag descriptors.** Flag and entitlement metadata (names, descriptions, expiry dates) is emitted by a Roslyn source generator at compile time. The generated `CocoarFlagsDescriptors` class is read once at startup during `Register()` — no reflection occurs during flag evaluation at runtime. ## Debouncing Rapid changes are coalesced by the `RecomputeCoalescer`. The default debounce interval is **300ms** (configurable via `UseDebounce()`). When multiple providers signal changes within the debounce window, only one recompute fires, starting from the earliest changed rule index. An additional trailing pass (40ms) catches changes that arrive during a running recompute. This prevents missed updates without doubling work. ## What to Watch For **Slow required providers block the pipeline.** Rules execute sequentially, and the recompute holds a semaphore. A single slow provider (e.g., an HTTP endpoint with high latency) delays the entire recompute. If this is a concern, consider making the rule optional or increasing the timeout on the provider. **Large JSON with Select.** When using `.Select("path")` on a large JSON document, the full document is still fetched and parsed — the selection happens after parsing. If only a small subsection is needed from a very large file, consider splitting the file. **Debounce interval too low.** Setting `UseDebounce()` below the default 300ms can cause rapid recomputes under heavy file modification (e.g., during deployment). The default is a good balance for most workloads. If you observe excessive recompute cycles in your metrics (`cocoar.config.recompute.count`), increase the debounce interval. **Many rules from different directories.** Each unique directory gets its own `FileSourceProvider` and file system monitor. If your rules read from dozens of separate directories, each one adds a watcher. Consolidating config files into fewer directories reduces OS-level resource usage. --- --- url: /guide/providers/overview.md description: >- Provider contract (FetchConfigurationBytesAsync, ChangesAsBytes), built-in providers, key-based instance caching, provider vs query options, lifecycle --- # Providers Overview A provider is a data source that delivers configuration as JSON. Every rule connects a configuration type to a provider: ```csharp rule.For() // What type to populate .FromFile("appsettings.json") // Which provider to use ``` ## The Provider Contract All providers implement two methods: | Method | Purpose | |---|---| | `FetchConfigurationBytesAsync()` | One-time fetch — returns UTF-8 JSON bytes | | `ChangesAsBytes()` | Change stream — returns `IObservable` that emits when data changes | Providers always return **raw UTF-8 bytes**, never strings. This avoids unnecessary allocations and keeps sensitive data out of managed string memory. On failure, providers return an empty JSON object `{}` — never null. This means a failed optional rule contributes nothing, and values from earlier rules remain unchanged. ## Built-in Providers | Provider | Fluent API | Reactive | Package | |---|---|---|---| | [File](/guide/providers/file) | `.FromFile("path")` | File watcher | Core | | [Environment Variables](/guide/providers/environment) | `.FromEnvironment("PREFIX_")` | No | Core | | [Command Line](/guide/providers/command-line) | `.FromCommandLine("--prefix")` | No | Core | | [Static JSON](/guide/providers/static-observable#static-json) | `.FromStaticJson("{...}")` | No | Core | | [Observable](/guide/providers/static-observable#observable) | `.FromObservable(obs)` | Yes | Core | | [Writable Store](/guide/providers/writable-store) | `.FromStore()` | Yes (on write) | Core | | [HTTP](/guide/providers/http-polling) | `.FromHttp(url)` | Polling / SSE / one-time | Http | | [Microsoft IConfiguration](/guide/providers/microsoft-adapter) | `.FromIConfiguration(config)` | IConfiguration reload token | MicrosoftAdapter | **Reactive** means the provider can detect changes and trigger a recompute automatically. Environment variables, command-line arguments, and static JSON are inherently immutable during process lifetime — they're read once and don't change, so there's nothing to watch. ## Provider Lifecycle Providers are managed by the rule engine: 1. **Created** when a rule first needs the provider 2. **Cached** by provider key — multiple rules sharing the same source (e.g., same file directory) reuse one provider instance 3. **Subscribed** for changes after the initial fetch 4. **Disposed** when no more rules reference the provider The caching is key-based. For example, two rules reading different files from the same directory share one `FileSourceProvider` because the directory is the provider key. The filenames are query-level parameters. ## Provider vs Query Each provider splits its configuration into two levels: * **Provider options** — shared, instance-level settings (e.g., which directory to watch, HTTP base address) * **Query options** — per-rule settings (e.g., which filename, which URL path, which env var prefix) This split enables efficient resource sharing. One file watcher monitors an entire directory; individual rules query specific files within it. ## Building Your Own If the built-in providers don't cover your use case, you can [build a custom provider](/guide/providers/custom) by extending `ConfigurationProvider`. --- --- url: /guide/secrets/key-publishing.md description: >- Exposing public keys via MapSecretEncryptionKey and MapTenantSecretEncryptionKey on /.well-known/cocoar/encryption-key, single- vs multi-tenant, ITenantContext, response shape --- # Publishing Encryption Keys Secrets are encrypted with the **public** half of an X.509 certificate and decrypted server-side with the private half (see [Encryption Setup](/guide/secrets/encryption-setup)). To let an **external producer** — a browser form, a CLI, another service — build a `cocoar.secret` envelope your server can later decrypt, you publish the **public key** over an HTTP endpoint. Only public-key material is ever exposed. The private key never leaves the server, and no plaintext is reachable through this API. Each endpoint returns **exactly one key** — never a list — so one tenant's key can never expose another's. ## Single-tenant When secrets are configured with one current key (single-kid mode via `UseCertificateFromFile`), map the single-key endpoint: ```csharp app.MapSecretEncryptionKey(); // GET /.well-known/cocoar/encryption-key ``` | Route | Returns | |---|---| | `GET /.well-known/cocoar/encryption-key` | the current public key, or `404` ProblemDetails when nothing is publishable | Pass a custom pattern if the default route doesn't fit: ```csharp app.MapSecretEncryptionKey("/keys/cocoar"); ``` ## Multi-tenant In multi-tenant deployments each tenant has its own certificate(s) under a `kid = tenant` subfolder (`basePath/{tenant}/cert.pfx`, configured with `UseCertificatesFromFolder`). The per-tenant endpoint returns **only the current key of the tenant the request already resolves to** — it never lists keys and never exposes another tenant: ```csharp app.MapTenantSecretEncryptionKey(); // GET /.well-known/cocoar/encryption-key ``` The tenant is read from `ITenantContext.Current` — your app supplies it from auth, subdomain, or route (the same seam used by [scoped tenant config](/guide/multi-tenancy/overview)), never from a client-chosen value. Register it via `AddCocoarTenantResolver(s => s.TenantId)` (HTTP: `AddCocoarTenantResolver(...)`) or your own scoped `ITenantContext`. | Route | Returns | |---|---| | `GET /.well-known/cocoar/encryption-key` | the resolved tenant's current public key; `404` when that tenant has none; `400` when no tenant is resolved | ::: warning Not secured by default Like `MapFeatureFlagEndpoints`, these routes are **open** unless you secure them. Public keys are safe to expose, but to put them behind auth chain `.RequireAuthorization()`: ```csharp app.MapTenantSecretEncryptionKey().RequireAuthorization(); ``` ::: ## Response shape The endpoint returns the current public key directly (no list wrapper): ```json { "kid": "prod-secrets", "alg": "RSA-OAEP-AES256-GCM", "walg": "RSA-OAEP-256", "enc": "AES-256-GCM", "format": "spki", "encoding": "base64url", "publicKey": "" } ``` Every field name is pinned, so a host JSON naming policy can't rename it. There is exactly **one current key per tenant** — the **newest certificate** in that tenant's set (per the configured certificate comparer; the default orders by file name). Older certificates stay available for **decryption only** (rotation). Key material is re-read on every request, so adding a newer certificate is reflected without a restart. ## How a producer uses it 1. Fetch the current key. `alg` / `walg` / `enc` describe the scheme; `publicKey` is the SPKI to import. 2. Generate a random AES-256 DEK, encrypt the value with AES-GCM, wrap the DEK with RSA-OAEP-256, and assemble the `cocoar.secret` envelope (with `kid` stamped from the key). 3. Send the envelope to your server. It is stored as-is and decrypted only on `Secret.Open()`. In the browser or Node, the **[`@cocoar/secrets`](/guide/secrets/client-encryption)** client does all three steps for you. The envelope wire format is documented in [Custom Providers → Secrets](/guide/providers/custom#secrets-in-custom-providers). The same envelope can be written through a WritableStore overlay via `SetSecretEnvelopeAsync` / `SetSecretAsync` — including per tenant with `GetWritableStoreForTenant(tenantId).SetSecretAsync(...)`, which is how a tenant stores a secret encrypted to its own published key. ## Availability Publishing is available when secrets are configured via [`UseSecretsSetup`](/guide/secrets/encryption-setup): * **Single-kid** (`UseCertificateFromFile`) publishes one key via `GetCurrentKey()` / `MapSecretEncryptionKey`. * **Folder / multi-tenant** (`UseCertificatesFromFolder`, `kid = tenant`) publishes one key per tenant via `GetCurrentKeyForTenant(tenantId)` / `MapTenantSecretEncryptionKey`. When no secrets are configured, the service is not registered and the endpoint returns `404`. The DI service behind the endpoints is `ISecretEncryptionKeyProvider` (`GetCurrentKey()` / `GetCurrentKeyForTenant(tenantId)`), registered wherever secrets are configured — resolve it directly to build your own controller (e.g. one that already knows the tenant) or workflow. --- --- url: /guide/reactive/tuples.md description: >- IReactiveConfig<(T1, T2)> for atomic multi-config updates — same-snapshot guarantee, per-element change detection, 2–8+ arities, automatic DI registration --- # Reactive Tuples When multiple configuration types need to stay in sync, use `IReactiveConfig<(T1, T2)>`. All types update together — you never see a mix of old and new values. ## The Problem Subscribing to two `IReactiveConfig` instances independently creates a race condition: ```csharp // Dangerous: A and B can be from different snapshots config1.Subscribe(a => { /* new A, but B might still be old */ }); config2.Subscribe(b => { /* new B, but A might still be old */ }); ``` If `AppSettings` and `FeatureFlags` change in the same recompute, independent subscriptions can fire at different times, giving you an inconsistent view. ## The Solution Request a tuple: ```csharp public class MyService(IReactiveConfig<(AppSettings App, FeatureFlags Flags)> config) { public void Start() { config.Subscribe(tuple => { var (app, flags) = tuple; // Both are guaranteed from the same snapshot Console.WriteLine($"{app.AppName}, Experiments={flags.EnableExperiments}"); }); } } ``` The tuple only emits when **all** elements are present and **at least one** has changed. Both values are from the same atomic snapshot. ## How It Works 1. The engine publishes a new snapshot with all configuration types at once 2. The tuple subscription listens to the snapshot stream (not individual type streams) 3. On each snapshot, it extracts all requested types 4. If any element changed (reference equality check per element), it emits the full tuple 5. If nothing changed, no emission This gives you an atomic, consistent view across multiple types. ## CurrentValue Access the current tuple synchronously: ```csharp var (app, flags) = config.CurrentValue; ``` Both values are from the same snapshot. ## Supported Arities Tuples support 2 to 8+ elements using C# value tuples: ```csharp // 2 elements IReactiveConfig<(AppSettings, DatabaseConfig)> // 3 elements IReactiveConfig<(AppSettings, DatabaseConfig, FeatureFlags)> // Named elements (recommended for readability) IReactiveConfig<(AppSettings App, DatabaseConfig Db, FeatureFlags Flags)> ``` For more than 7 elements, C# uses nested `ValueTuple` with a `Rest` field — this is handled automatically. ## DI Registration Tuple reactive configs are **registered automatically**. No explicit setup needed — just inject the type you want: ```csharp public class MyService(IReactiveConfig<(AppSettings, FeatureFlags)> config) { // Works out of the box if both AppSettings and FeatureFlags have rules } ``` If any element type has no rules defined, you'll get an `InvalidOperationException` at resolution time. ## Without DI ```csharp using var manager = ConfigManager.Create(c => c .UseConfiguration(rule => [ rule.For().FromFile("appsettings.json"), rule.For().FromFile("features.json"), ])); var reactive = manager.GetReactiveConfig<(AppSettings, FeatureFlags)>(); reactive.Subscribe(tuple => { var (app, flags) = tuple; Console.WriteLine($"{app.AppName}, Experiments={flags.EnableExperiments}"); }); ``` ## When to Use Tuples | Scenario | Use | |---|---| | Independent reaction to one type | `IReactiveConfig` | | Two+ types must stay in sync | `IReactiveConfig<(T1, T2)>` | | Decision depends on multiple configs | `IReactiveConfig<(T1, T2, T3)>` | | One-off read of a single type | Inject `T` directly (scoped) | Tuples add minimal overhead. The snapshot is already atomic — the tuple just projects multiple types from it in one emission. --- --- url: /guide/flags/registration.md description: >- Registering flags/entitlements via UseFeatureFlags/UseEntitlements, Register, global/class/property-level resolvers, priority cascade, Core-only no-DI overload --- # Registration Feature flags and entitlements are registered on the `ConfigManagerBuilder` using `UseFeatureFlags()` and `UseEntitlements()`. ## Basic Registration ```csharp builder.AddCocoarConfiguration(c => c .UseConfiguration(rule => [ rule.For().FromFile("appsettings.json"), rule.For().FromFile("plan.json"), ]) .UseFeatureFlags(flags => [ flags.Register(), flags.Register() ]) .UseEntitlements(entitlements => [ entitlements.Register() ])); ``` Each `Register()` call adds a flag or entitlement class to the system. The collection expression syntax (`[]`) matches `UseConfiguration`. ## What Registration Does 1. Registers the class as **Singleton** in DI 2. Uses the source generator to extract flag/entitlement descriptors (names, descriptions, expiry) 3. Registers `IFeatureFlagsDescriptors` / `IEntitlementsDescriptors` for health and REST endpoints 4. Pre-compiles evaluation delegates for the REST API ## With Context Resolvers When flags or entitlements have contextual properties (`FeatureFlag`), you need to register resolvers that bridge HTTP requests to your domain context. Resolver registration lives in the DI package (`Cocoar.Configuration.DI` or `Cocoar.Configuration.AspNetCore`). It appears as a second parameter on `UseFeatureFlags()` / `UseEntitlements()`: ```csharp .UseFeatureFlags( flags => [ flags.Register(), flags.Register() ], resolvers => [ resolvers.Global(), resolvers.For(r => r .Use() .ForProperty(f => f.BetaCheckout).Use()) ]) ``` Resolvers can be registered at three levels: ### Global (lowest priority) Applies to all flag/entitlement properties with a matching `TContext`: ```csharp resolvers.Global() ``` ### Class-level Applies to all properties in one class: ```csharp resolvers.For(r => r .Use()) ``` ### Property-level (highest priority) Applies to one specific property: ```csharp resolvers.For(r => r .ForProperty(f => f.BetaByEmail).Use()) ``` ### Priority Cascade When evaluating a contextual flag, the system looks for a resolver in this order: 1. **Property-level** — if registered for this specific property, use it 2. **Class-level** — if registered for this class, use it 3. **Global** — if registered globally for this `TContext`, use it See [Context Resolvers](/guide/flags/context-resolvers) for full details. ## Without DI Without DI, the Core-only overload takes a single parameter (no resolver registration): ```csharp using var manager = ConfigManager.Create(c => c .UseConfiguration(rule => [ rule.For().FromFile("appsettings.json"), ]) .UseFeatureFlags(flags => [ flags.Register() ])); var flags = manager.GetFeatureFlags(); var enabled = flags.NewOnboarding(); ``` ## Source Generator The source generator that produces descriptor metadata ships with the `Cocoar.Configuration` package — no separate install needed. It runs automatically at compile time when you reference `Cocoar.Configuration`. For `IFeatureFlags` and `IEntitlements` classes, the source generator also produces: * A constructor that accepts `IReactiveConfig` (or `IReactiveConfig<(T1, T2)>` for tuples) * The `Config` property that returns `IReactiveConfig.CurrentValue` --- --- url: /guide/configuration/required-optional.md description: >- Optional rules degrade gracefully to empty {} with Degraded health, Required() rolls back the recompute on failure with Unhealthy status and startup exception --- # Required vs Optional Rules Every rule is **optional by default**. This controls what happens when a provider fails — file not found, HTTP timeout, parse error. ## Optional Rules (Default) When an optional rule fails, the system continues with graceful degradation: ```csharp // Optional (default) — app continues if file is missing rule.For().FromFile("features.json") ``` **What happens on failure:** * The rule contributes an empty JSON object `{}` — it adds nothing * Values set by earlier rules remain unchanged * If this is the only rule for the type, the object is created with C# default values * Health status becomes `Degraded` * The failure is tracked and visible in health monitoring * The app keeps running ```csharp public class FeatureConfig { public bool EnableNewUI { get; set; } = false; // Gets this default public int MaxItems { get; set; } = 10; // Gets this default } ``` ## Required Rules Mark a rule as required when the app cannot function without it: ```csharp // Required — entire recompute rolls back if this fails rule.For().FromFile("database.json").Required() ``` **What happens on failure:** * **At startup:** Throws `ConfigurationDeserializationException` — the app does not start with broken config * **At runtime (recompute):** The entire recompute is rolled back. All config types keep their previous values. Health status becomes `Unhealthy`. The key insight: a required rule failure during recompute does not crash the app. It preserves the last known good state and signals the failure through health. ## Combining Required and Optional A common pattern is a required base file with optional overrides: ```csharp rule => [ rule.For().FromFile("appsettings.json").Required() .Named("Base Config"), rule.For().FromFile("appsettings.local.json") .Named("Local Overrides"), rule.For().FromEnvironment("APP_"), ] ``` If `appsettings.json` is missing at startup, the app fails immediately — that's the correct behavior because the base configuration is essential. If `appsettings.local.json` is missing, it's silently skipped with defaults. ## Accessing Configuration `GetConfig()` returns the current configuration instance. It throws `InvalidOperationException` if no rule is registered for the type — this is a static check that catches missing registrations early: ```csharp var config = manager.GetConfig(); // Returns the config instance. // Throws if no rules are defined for AppSettings. ``` For safe access when you're unsure if a type has rules: ```csharp if (manager.TryGetConfig(out var config)) { // config is available } ``` In config-aware rules and `.When()` predicates, use `GetConfig()` — you know the dependency exists because it was loaded by an earlier rule: ```csharp rule.For().FromFile("premium.json") .When(accessor => accessor.GetConfig()!.IsPremium) ``` ## Startup vs Runtime Behavior | Scenario | Startup | Runtime Recompute | |---|---|---| | Required rule fails | App throws, does not start | Rolls back, keeps last good state | | Optional rule fails | Continues with defaults | Continues with defaults | | All rules succeed | Config loaded normally | New snapshot replaces old one | This dual behavior means: strict validation at startup (catch misconfigurations early), resilient behavior at runtime (never lose working state because of a transient failure). --- --- url: /guide/flags/rest-endpoints.md description: >- MapFeatureFlagEndpoints/MapEntitlementEndpoints GET/POST routes, custom path prefixes, RequireAuthorization and middleware chaining, error status codes, resolver-backed POST evaluation --- # REST Evaluation Endpoints The ASP.NET Core package provides REST endpoints for evaluating flags and entitlements over HTTP. ```csharp app.MapFeatureFlagEndpoints(); app.MapEntitlementEndpoints(); ``` ::: info Package Requires `Cocoar.Configuration.AspNetCore`. ::: ## Routes Both methods generate routes for all registered flag/entitlement properties: | Method | Route | Use Case | |---|---|---| | GET | `/{prefix}/{ClassName}/{PropertyName}` | No-context flags/entitlements | | POST | `/{prefix}/{ClassName}/{PropertyName}` | Contextual flags/entitlements (request body = resolver input) | Default prefixes: `/flags` for feature flags, `/entitlements` for entitlements. ### Examples ``` GET /flags/AppFlags/DarkMode → { "value": true } POST /flags/AppFlags/BetaFeature { "userId": "beta_123" } → { "value": true } GET /entitlements/PlanEntitlements/MaxUsers → { "value": 100 } POST /entitlements/PlanEntitlements/RateLimit { "tenantId": "t_123" } → { "value": 10000 } ``` ## Custom Path Prefix ```csharp app.MapFeatureFlagEndpoints("/api/flags"); app.MapEntitlementEndpoints("/api/entitlements"); ``` ## Authorization Both methods return a `RouteGroupBuilder` for chaining ASP.NET Core middleware: ```csharp app.MapFeatureFlagEndpoints() .RequireAuthorization("AdminPolicy"); app.MapEntitlementEndpoints() .RequireAuthorization(); ``` You can also add rate limiting, CORS, or any other endpoint middleware: ```csharp app.MapFeatureFlagEndpoints() .RequireAuthorization() .RequireRateLimiting("fixed"); ``` ## Error Handling | Scenario | Status | Response | |---|---|---| | Unknown key | 404 | Not found | | Invalid request body | 400 | Bad request | | Evaluation error | 500 | `{ "detail": "...", "title": "Flag evaluation failed", "statusCode": 500 }` | ## How It Works * **GET endpoints** invoke the flag/entitlement delegate directly (no resolver needed) * **POST endpoints** deserialize the request body to `TRequest`, pass it through the registered [Context Resolver](/guide/flags/context-resolvers), then invoke the delegate with the resolved context The key format is `{ClassName}/{PropertyName}` — matching the class and property names from your code. --- --- url: /roadmap/overview.md description: >- Roadmap overview and priorities — ConfigHub portal, cloud KMS providers (Azure Key Vault, AWS), database provider; current limitations and Apache-2.0 commitment --- # Roadmap Cocoar.Configuration is the open-source foundation — fully functional today for configuration, feature flags, entitlements, and secrets. Here's what we're building next. ## At a Glance | Initiative | Status | Impact | |---|---|---| | [ConfigHub](/roadmap/confighub) | In Design | Management portal for config, secrets, and flags at scale | | [Cloud Providers](/roadmap/cloud-providers) | Planned | Azure Key Vault, AWS Secrets Manager | | [Database Provider](/roadmap/database-provider) | Planned | Tenant-specific config from SQL | ## Priority Order We don't publish specific dates — we ship when it's ready. Rough priority: 1. **Cloud Providers** (Azure Key Vault + AWS) — door openers for enterprise adoption 2. **Database Provider** — unlocks tenant-specific config from SQL 3. **ConfigHub private preview** — management portal for at-scale deployments ## Current Limitations These are things the library does not do today: * **No management UI** — you manage config files, environment variables, and JSON directly. [ConfigHub](/roadmap/confighub) will address this. * **No cloud KMS integration** — Azure Key Vault and AWS Secrets Manager require a [custom provider](/guide/providers/custom) today. [Native providers](/roadmap/cloud-providers) are planned. * **No database provider** — tenant config from SQL requires a custom provider. [Native SQL support](/roadmap/database-provider) is planned. ## Open Source Commitment The library is and stays **Apache-2.0**. Everything needed to run Cocoar.Configuration on your own is free, forever. ConfigHub is a separate commercial product for teams that need operational tooling at scale — the library does not require it. --- --- url: /guide/configuration/rules.md description: >- Rule anatomy with For().FromFile, top-to-bottom property-by-property JSON merge layering, last-write-wins, and Select to extract a sub-document --- # Rules & Layering Rules are the central concept in Cocoar.Configuration. A rule connects a **configuration type** to a **data source** and defines how they interact. ## Anatomy of a Rule Every rule starts with a type and a provider: ```csharp rule.For() // What type to populate .FromFile("appsettings.json") // Where to get the data ``` This creates a rule that reads `appsettings.json` and deserializes it into `AppSettings`. ## How Layering Works Multiple rules for the same type merge their JSON, property by property: ```csharp rule => [ rule.For().FromFile("appsettings.json"), // Rule 1: base rule.For().FromFile("appsettings.local.json"), // Rule 2: overrides rule.For().FromEnvironment("APP_"), // Rule 3: final overrides ] ``` Rules execute **top to bottom**. When two rules set the same property, the later one wins. Properties not set by later rules keep their value from earlier ones. **Example:** **appsettings.json** (Rule 1): ```json { "AppName": "MyApp", "MaxRetries": 3, "Debug": false } ``` **appsettings.local.json** (Rule 2): ```json { "MaxRetries": 10 } ``` ```shell # Environment variable (Rule 3) APP_Debug=true ``` **Result:** `{ AppName: "MyApp", MaxRetries: 10, Debug: true }` Each rule contributes only the properties it provides. The merge happens at the JSON level before deserialization. ## Select — Extract a Sub-Document When your JSON file contains multiple config sections, use `.Select()` to pick one: ```json { "App": { "Name": "MyApp", "Version": "1.0" }, "Database": { "Host": "localhost", "Port": 5432 } } ``` ```csharp rule => [ rule.For().FromFile("appsettings.json").Select("App"), rule.For().FromFile("appsettings.json").Select("Database"), ] ``` `.Select("App")` extracts the `App` object from the JSON before deserializing. The file is shared but each type gets its own section. Nested paths use colon notation: `.Select("App:Logging:Level")`. ## MountAt — Nest Under a Path The opposite of Select. MountAt places the provider's output under a JSON path before merging: ```csharp // Provider returns: { "Host": "localhost", "Port": 5432 } rule.For().FromFile("db-override.json").MountAt("Database") // Merged as: { "Database": { "Host": "localhost", "Port": 5432 } } ``` This is useful when a config file contains flat values that should map to a nested property on your type. ## Named — Label Rules for Health Monitoring Give rules human-readable names for observability: ```csharp rule.For() .FromFile("appsettings.json") .Named("Base App Settings") ``` The name appears in health snapshots, making it easy to identify which rule failed in dashboards and logs. ## Multiple Types, One Rule List All configuration types are defined in the same rule list: ```csharp ConfigManager.Create(c => c .UseConfiguration(rule => [ // AppSettings from file + env rule.For().FromFile("appsettings.json"), rule.For().FromEnvironment("APP_"), // DatabaseConfig from file only rule.For().FromFile("appsettings.json").Select("Database"), // FeatureConfig from a remote endpoint rule.For().FromHttp("https://config.example.com/features", pollInterval: TimeSpan.FromMinutes(5)), ])); ``` Rule ordering matters within the same type (for layering), but rules for different types are independent of each other. ## How Recompute Works When a source changes (file modified, HTTP poll returns new data, etc.): 1. The change is **debounced** (default 300ms) to coalesce rapid changes 2. All rules **re-execute** starting from the earliest changed rule 3. JSON is **re-merged** for affected types 4. Types are **re-deserialized** into new instances 5. If all required rules succeed, the new snapshot **atomically replaces** the old one 6. Subscribers receive the updated values The old snapshot remains available until the new one is fully built. There is no moment where partially-updated config is visible. --- --- url: /guide/secrets/secret-type.md description: >- Declaring Secret and ISecret properties for strings, byte arrays and numbers, Open() leases and SecretLease that zero decrypted bytes on dispose --- # Secret\ & Leases `Secret` is a property type that holds a value encrypted in memory. You access the decrypted value through a **lease** — a short-lived handle that zeros the decrypted bytes when disposed. ## Declaring Secrets Use `Secret` on properties that hold sensitive data: ```csharp public class DatabaseConfig { public required Secret ConnectionString { get; init; } public Secret? OptionalApiKey { get; init; } } ``` `Secret` works with any serializable type: ```csharp // Strings (most common) public required Secret Password { get; init; } // Byte arrays (for binary secrets like encryption keys) public required Secret EncryptionKey { get; init; } // Numbers public Secret? SecretPort { get; init; } ``` You can also use the interface `ISecret` for properties if you prefer abstractions: ```csharp public ISecret? ApiKey { get; init; } ``` ## Leases {#leases} A lease provides temporary access to the decrypted value: ```csharp using var lease = config.ConnectionString.Open(); var value = lease.Value; // Use the value within this scope // When the using block exits, decrypted bytes are zeroed ``` ### Why Leases? The lease pattern serves two purposes: 1. **Memory safety** — the decrypted `byte[]` is zeroed when the lease is disposed. The secret exists in plaintext memory only for the duration of the `using` block. 2. **Explicitness** — reading a secret is a deliberate action, not an accidental property access. This makes security-sensitive code paths visible in code review. ### SecretLease\ ```csharp public readonly struct SecretLease : IDisposable { public T Value { get; } public void Dispose(); // Zeros decrypted bytes } ``` `SecretLease` is a `readonly struct` — no heap allocation for the lease itself. ### Lease Lifecycle ```csharp // 1. Open() decrypts the value using var lease = secret.Open(); // 2. Value is available as plaintext SendToDatabase(lease.Value); // 3. Dispose() zeros the decrypted byte array // (happens automatically at end of using block) ``` ::: warning Strings Cannot Be Zeroed `string` values in .NET are immutable — they cannot be overwritten in memory. For `Secret`, the underlying byte array is zeroed, but the deserialized string remains in memory until garbage collected. For maximum security with binary secrets, use `Secret`. ::: ## Nullable Secrets A nullable `Secret?` property means "this secret may not be present in the config": ```csharp public class ApiConfig { public required Secret PrimaryKey { get; init; } // Must exist public Secret? FallbackKey { get; init; } // May be absent } ``` If `FallbackKey` is not in the JSON, the property is `null` — no lease to open. ## Encrypted vs Plaintext By default, `Secret` expects an encrypted envelope in the JSON. Opening a plaintext secret throws `InvalidOperationException`: ```json { "Password": "plaintext-value" } ``` ```csharp config.Password.Open(); // Throws: plaintext not allowed ``` To allow plaintext (development/testing only): ```csharp .UseSecretsSetup(secrets => secrets.AllowPlaintext()) ``` See [Encryption Setup](/guide/secrets/encryption-setup) for configuring certificates. ## ISecret\ Disposal `Secret` implements `IDisposable`. When the configuration type is replaced by a recompute, the old instance's secrets are disposed — zeroing any remaining plaintext bytes held internally. You don't need to dispose secrets manually. The configuration lifecycle handles it. --- --- url: /guide/secrets/overview.md description: >- Built-in encrypted-at-rest secrets via X.509 certificates and cocoar.secret envelopes, Secret properties, lease-based decrypted access with memory zeroing --- # Secrets Overview Cocoar.Configuration has built-in support for secrets — configuration values that are encrypted at rest and protected in memory. No separate package needed. ## The Problem Storing secrets in plaintext config files is a security risk: ```json { "Database": { "ConnectionString": "Server=prod;Password=s3cret" } } ``` Anyone with file access can read the password. It sits in memory as a `string` — visible in heap dumps, never garbage collected reliably, impossible to zero. ## The Cocoar Approach Secrets are encrypted in your config files using X.509 certificates: ```json { "Database": { "ConnectionString": { "type": "cocoar.secret", "kid": "prod-secrets", "alg": "RSA-OAEP-AES256-GCM", "wk": "base64...", "iv": "base64...", "ct": "base64...", "tag": "base64..." } } } ``` In your C# class, declare the property as `Secret`: ```csharp public class DatabaseConfig { public required Secret ConnectionString { get; init; } } ``` Access the decrypted value through a lease: ```csharp public class MyService(DatabaseConfig config) { public void Connect() { using var lease = config.ConnectionString.Open(); var connectionString = lease.Value; // Use it — value is zeroed when the lease is disposed } } ``` ## Key Concepts | Concept | Description | |---|---| | [`Secret`](/guide/secrets/secret-type) | A property type that holds an encrypted value | | [`SecretLease`](/guide/secrets/secret-type#leases) | Temporary access to the decrypted value — dispose to zero memory | | [Encryption Setup](/guide/secrets/encryption-setup) | Configure certificates for encryption/decryption | | [CLI Tools](/guide/secrets/cli) | Encrypt values and manage certificates from the command line | | [Security Model](/guide/secrets/security-model) | Memory safety, zeroization, threat model | ## Quick Setup ### 1. Configure encryption ```csharp builder.AddCocoarConfiguration(c => c .UseConfiguration(rule => [ rule.For().FromFile("appsettings.json"), ]) .UseSecretsSetup(secrets => secrets .UseCertificateFromFile("certs/prod.pfx") .WithKeyId("prod-secrets"))); ``` ### 2. Encrypt a value ```shell dotnet cocoar-secrets encrypt \ --value "Server=prod;Password=s3cret" \ --cert certs/prod.pfx \ --kid prod-secrets ``` ### 3. Paste the output into your config file The CLI outputs the encrypted JSON envelope. Replace the plaintext value with it. ### Development Mode For local development, skip encryption entirely: ```csharp .UseSecretsSetup(secrets => secrets.AllowPlaintext()) ``` With `AllowPlaintext()`, `Secret` properties deserialize from plain JSON strings. A trace warning is emitted to remind you this isn't for production. --- --- url: /guide/secrets/security-model.md description: >- Memory-safety guarantees of the lease pattern (Array.Clear/ZeroMemory, stackalloc keys), hybrid RSA-OAEP-SHA256 + AES-256-GCM encryption, certificate rotation --- # Security Model This page explains the memory safety guarantees, encryption design, and certificate rotation behind the secrets system. ## Memory Safety ### The Lease Pattern Decrypted values exist in plaintext memory only during the lease: ```csharp using var lease = config.Password.Open(); // Plaintext exists in memory here var value = lease.Value; DoSomething(value); // Dispose zeros the decrypted byte array ``` After `Dispose()`, the `byte[]` that held the decrypted data is overwritten with zeros via `Array.Clear()`. ### What Gets Zeroed | Data | Zeroed? | How | |---|---|---| | Decrypted `byte[]` from envelope | Yes | `Array.Clear()` on lease dispose | | AES data encryption key | Yes | `CryptographicOperations.ZeroMemory()` on stack, `Array.Clear()` on heap | | RSA-unwrapped key material | Yes | Stack-allocated, zeroed after use | | Deserialized `string` values | No | .NET strings are immutable | | `Secret` internal state on disposal | Yes | `Array.Clear()` on plaintext bytes, references nulled | ::: warning Strings in Memory `Secret` provides lease-based access, but the deserialized `string` value cannot be zeroed because .NET strings are immutable. The underlying `byte[]` is zeroed, but the string remains in memory until garbage collected. For maximum memory safety with binary secrets (encryption keys, tokens), use `Secret`. ::: ### Stack Allocation Temporary cryptographic keys use `stackalloc` to avoid heap allocation entirely: ```csharp Span dek = stackalloc byte[32]; // ... use key ... CryptographicOperations.ZeroMemory(dek); ``` Stack memory is automatically reclaimed when the method returns, providing an additional layer of protection. ## Encryption Algorithms | Component | Algorithm | Key Size | |---|---|---| | Key wrapping | RSA-OAEP-SHA256 | Certificate key size (typically 2048+ bit) | | Data encryption | AES-256-GCM | 256-bit | | IV/Nonce | Random | 96-bit | | Authentication tag | AES-GCM | 128-bit | This is **hybrid encryption**: RSA encrypts a random AES key, AES encrypts the data. This combines RSA's key management with AES's efficiency for arbitrary-length data. AES-GCM provides authenticated encryption — tampering with the ciphertext, IV, or wrapped key is detected and rejected. ## Provider Security Providers handle raw bytes, never strings. This is by design: * Providers return `byte[]` from `FetchConfigurationBytesAsync()` * No string conversion happens until deserialization * Providers never cache secret data — each fetch returns fresh bytes * The file provider validates paths and rejects symlinks to prevent path traversal ## Certificate Protection Certificates are protected by file system permissions, not passwords: * Password-less PFX files simplify automated deployments * File ACLs ensure only the application process can read the private key * The certificate file contains both public and private keys — protect accordingly ## Certificate Rotation {#rotation} ### Single Certificate with Multiple Kids Accept secrets encrypted with old and new certificates during a transition: ```csharp .UseCertificateFromFile("certs/prod-v2.pfx") .WithKeyId("prod-v2") .WithAdditionalKeyId("prod-v1") ``` Secrets encrypted with `kid: "prod-v1"` still decrypt. New secrets are encrypted with the v2 certificate. ### Certificate Folder For automated rotation, use a monitored folder: ```csharp .UseCertificatesFromFolder("certs/", searchPattern: "*.pfx") ``` **Rotation workflow:** 1. Generate a new certificate: `cocoar-secrets generate-cert -o certs/prod-v2.pfx` 2. Drop it into the folder — auto-discovered by the file monitor 3. Encrypt new secrets with the new certificate's public key 4. Old secrets still decrypt with the old certificate 5. Eventually remove the old certificate when no active secrets use it The system caches certificates (default 30s TTL) and invalidates the cache on file changes. ### Multi-Tenant Rotation Organize by tenant in subdirectories: ``` certs/ ├── tenant-a/ │ ├── cert-v1.pfx # Previous │ └── cert-v2.pfx # Current └── tenant-b/ └── cert.pfx ``` Each subdirectory name is a `kid`. During rotation, both certificates coexist — the system tries them in order. ## What This Does NOT Protect Against * **Process memory access** — if an attacker can read your process memory, they can see decrypted values during the lease window * **String interning** — `Secret` values may be interned by the runtime * **Swap file** — decrypted memory could be paged to disk (use OS-level encrypted swap if this matters) * **Logging** — if you log `lease.Value`, the secret is in your logs Cleanup is best-effort because .NET is a managed runtime: the GC can move objects in memory (leaving copies), strings are immutable and may be interned, and there's no guarantee that `ZeroMemory` runs before a crash. Despite these constraints, the secrets system minimizes the plaintext window as aggressively as the runtime allows — pinned buffers, explicit zeroing, and deterministic disposal via leases. For secrets that never need to become strings, `Secret` provides the strongest guarantees. --- --- url: /guide/di/service-backed.md description: >- Two-layer DI-aware config (ADR-006) — UseServiceBackedConfiguration with (sp,a) factories, FromHttp via IHttpClientFactory, FromStore, FromService, host-start activation --- # Service-Backed Configuration Some configuration sources need a service from your application container *to load* — an `IHttpClientFactory`, a Marten `IDocumentStore`, an EF `IDbContextFactory`. But `AddCocoarConfiguration` runs **before** `BuildServiceProvider()`, so those services don't exist yet. This is a hard boundary in every framework: config that needs the container can't also *bootstrap* the container. Cocoar solves it the same way Microsoft splits `IConfiguration` (eager, dumb sources) from `IOptions` (lazy, DI-bound) — with a **two-layer model**, in Cocoar's own ordered-layer idiom (see [ADR-006](/adr/ADR-006-di-aware-configuration)). | Layer | Method | When | `IServiceProvider`? | |---|---|---|---| | **Layer 1** | `UseConfiguration` | eager, at registration (wires the DI plan + bootstrap config) | **no** — file/env/static/HTTP-without-DI | | **Layer 2** | `UseServiceBackedConfiguration` | lazy, on host start | **yes** — factories receive the container | Layer 1 is unchanged and stays DI-free. Layer 2 is an additive, opt-in extension from `Cocoar.Configuration.DI`; the No-DI core never sees an `IServiceProvider`. ::: tip When do I need this? Only when a provider must resolve an application service to load — DB-backed config or HTTP via `IHttpClientFactory`. File/env/static and the plain `FromHttp(url)` provider stay in Layer 1. ::: ## The two authoring surfaces ```csharp services.AddCocoarConfiguration(c => c // Layer 1 — eager, no IServiceProvider, available before the container is built. .UseConfiguration(rules => [ rules.For().FromFile("appsettings.json"), // bootstrap log level ]) // Layer 2 — extension from the DI package; factories receive the IServiceProvider. .UseServiceBackedConfiguration(rules => [ rules.For().FromHttp( (sp, a) => sp.GetRequiredService().CreateClient("cocoar-config"), "logging.json", pollInterval: TimeSpan.FromSeconds(30)), rules.For().FromStore( (sp, a) => new MartenConfigBackend(sp.GetRequiredService(), a.Tenant)) .TenantScoped(), ])); ``` Layer-2 rules merge **after** Layer-1 rules — they win per key, exactly like any later rule. Each `(sp, a)` factory receives the application `IServiceProvider` and the current `IConfigurationAccessor` (its `Tenant` is set inside a tenant pipeline). ## HTTP via `IHttpClientFactory` `FromHttp((sp, a) => HttpClient, url, …)` (from `Cocoar.Configuration.Http`) sources its client from the container — gaining handler pooling/rotation and `AddHttpClient` policies (Polly). The provider does **not** dispose a factory-supplied client. ```csharp services.AddHttpClient("cocoar-config") .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler()) .AddPolicyHandler(retryPolicy); services.AddCocoarConfiguration(c => c .UseConfiguration(rules => [ rules.For().FromFile("appsettings.json") ]) .UseServiceBackedConfiguration(rules => [ rules.For().FromHttp( (sp, a) => sp.GetRequiredService().CreateClient("cocoar-config"), "https://config.internal/remote.json", pollInterval: TimeSpan.FromSeconds(30)), ])); ``` The plain `FromHttp(url)` overload (which `new`s its own `HttpClient`) stays available for Layer 1 / no-DI. ## DB-backed config with `FromStore` `FromStore((sp, a) => IStoreBackend)` reuses Cocoar's storage pipeline: implement `IStoreBackend` (`ReadAsync`/`WriteAsync` over your store) and source it from DI. Combine with `.TenantScoped()` for **DB-config-per-tenant** — the tenant gate and the service-provider gate compose, so the rule runs only inside a tenant pipeline, after the host has started. ```csharp public sealed class MartenConfigBackend(IDocumentStore store, string? tenant) : IStoreBackend { public async Task ReadAsync(string key, CancellationToken ct = default) { // Open a SHORT-LIVED unit per read on the recompute thread (never hold a session): await using var session = store.QuerySession(tenant ?? ""); var doc = await session.LoadAsync(key, ct); return doc?.Json is { } json ? Encoding.UTF8.GetBytes(json) : null; } public Task WriteAsync(string key, byte[] data, CancellationToken ct = default) => /* … */; } ``` This **exceeds** Microsoft's EF config provider, which `new`s its own `DbContext`: here you use the app's real, DI-managed, tenant-scoped store. ## Deriving config from a DI service — `FromService` When the config simply **comes from a DI service** (no I/O source — an in-memory registry, a computed default, another service's value), you don't need a custom provider at all. `FromService(s => …)` resolves the service from the container and projects it to the config value: ```csharp .UseServiceBackedConfiguration(rules => [ rules.For().FromService(s => s.Settings), ]) ``` This is Cocoar's equivalent of Microsoft's `services.Configure((opts, dep) => …)` / an `IConfigureOptions` with an injected dependency — and the natural target when migrating those. The service is resolved at recompute time (after host start); the rule is dormant until then, like any Layer-2 rule, and composes with `.TenantScoped()`. ::: warning Synchronous / in-memory only `FromService` snapshots once per recompute (no change detection) and the projection is synchronous. For I/O-bound sources (DB, HTTP, Key Vault) use an async provider — `FromStore`, `FromHttp((sp,a)=>…)`, or a custom provider — rather than blocking inside the projection. ::: ## Lifecycle & the readiness contract Layer 2 activates on **host start**. A `IHostedLifecycleService` publishes the root `IServiceProvider` and triggers a **recompute** (never a rebuild) from the Layer-2 boundary — Layer 1 stays stable, the Layer-2 suffix runs and merges on top. * Layer-2 values are **guaranteed after host start**. * A snapshot read (`GetConfig()`) **before** host start returns the **Layer-1 base**; a type that exists *only* in Layer 2 is unresolved (`TryGetConfig` returns `false`). * Because activation is a recompute on the same backplane, **every live `IReactiveConfig` view receives the Layer-2 value when it lands — even views obtained before the container was built.** ```csharp // Wire a Serilog level switch during bootstrap, BEFORE the host runs: var live = configManager.GetReactiveConfig(); live.Subscribe(c => levelSwitch.MinimumLevel = Map(c.Level)); // fires: now (Layer-1 file level) → on host start (Layer-2 remote level) → on every poll change after ``` ::: warning Subscribe, don't snapshot To receive the Layer-2 upgrade you must **subscribe** (`IReactiveConfig`), not read a one-time `GetConfig()` / `.CurrentValue` during container build. ::: ## Failure semantics Layer-2 rules are **optional** by default: if the source is down (DB/HTTP unreachable), the recompute rolls back to the last good state, **Layer-1 values persist**, and health goes degraded. A remote outage never faults host startup or nukes your config. ## Lifetime discipline The holder's `sp` is the **root** provider. Resolve **singletons / factories only** (`IDocumentStore`, `IDbContextFactory`, `IHttpClientFactory`) and open **short-lived units per read** on the recompute thread (`store.QuerySession(…)`, `factory.CreateDbContext()`). Never resolve a scoped service from root — config is computed once per tenant/global, cached and reactive, not per request. ## Precedence vs. gating These are separate. **Precedence** is list position (Layer 2 after Layer 1 → wins per key). **Gating** is per-rule and applies only to rules that actually use `sp`. A non-`sp` rule placed in Layer 2 runs eagerly *and* gains the later precedence — so "a non-DI rule must beat a DI-backed rule" is just: declare it once, in Layer 2, after the DI-backed rule. ## Activation without a Host For apps that build their own `IServiceProvider` without an `IHost`, activate manually with the **root** provider: ```csharp var provider = services.BuildServiceProvider(); await provider.ActivateServiceBackedConfigurationAsync(); // publishes sp + runs the Layer-2 recompute ``` It is idempotent with the automatic hosted-service activation and a no-op when no Layer-2 rules were registered. ## Custom (third-party) service-backed providers Whether a provider can be service-backed is **entirely the provider author's choice** — the framework just offers the seam. `UseServiceBackedConfiguration(rules => …)` hands each `rules.For()` a public `ServiceBackedProviderBuilder`. Author your own `(sp, a) =>` overload on it via the `ServiceBacked(...)` helper — `sp` arrives as a **parameter** invoked lazily at recompute time, and the rule is gated for you: ```csharp // In your provider package — uses only the public surface (no internals): public static ProviderRuleBuilder FromMyDb( this ServiceBackedProviderBuilder builder, Func backendFactory) where T : class => builder.ServiceBacked( (sp, a) => new MyOptions(backendFactory(sp, a)), // sp is a param, resolved lazily — never read too early _ => MyQuery.Default); ``` Two things make a provider service-backed: (1) author this `(sp, a)` overload on `ServiceBackedProviderBuilder`, and (2) have the provider's **options carry** the resolved artifact (HTTP carries a `ClientFactory`; WritableStore an `IStoreBackend`). The provider class itself (`ConfigurationProvider<,>`) stays DI-free — and a service-backed provider is usually its **own** small provider, not a no-DI one retrofitted with fallbacks. See [Building Custom Providers → Service-Backed Providers](/guide/providers/custom#service-backed-providers-di-aware) for a full worked example. Because these overloads target `ServiceBackedProviderBuilder`, using them inside the Layer-1 `UseConfiguration` (a plain `TypedProviderBuilder`) is a **compile error** — the type system, not a runtime check, keeps DI-backed loading out of Layer 1. ## See also * [Multi-Tenancy](/guide/multi-tenancy/overview) — `.TenantScoped()` and consuming a tenant's config (`ITenantReactiveConfig`) * [ASP.NET Core](/guide/di/aspnetcore) * [ADR-006](/adr/ADR-006-di-aware-configuration) — the design rationale --- --- url: /guide/configuration/setup.md description: >- Auto-registration of rule types as Scoped, the setup lambda with ConcreteType().ExposeAs(), Interface().DeserializeTo(), lifetimes, disabling auto-registration --- # Setup & Type Exposure Setup controls how configuration types are registered and exposed. It's the optional second parameter to `UseConfiguration()`. ## Auto-Registration If you don't provide any setup, all types from your rules are automatically registered: ```csharp builder.AddCocoarConfiguration(c => c .UseConfiguration(rule => [ rule.For().FromFile("appsettings.json"), rule.For().FromFile("database.json"), ])); ``` Both `AppSettings` and `DatabaseConfig` are automatically registered as **Scoped** in DI. You can inject them directly — no `IOptions` wrapper, no setup needed: ```csharp public class MyService(AppSettings settings, DatabaseConfig db) { // Just use them — resolved from ConfigManager's cache, no recomputation } ``` ::: tip You probably don't need setup For most applications, auto-registration is all you need. **Don't add `setup.ConcreteType()` just to register a type** — it's already registered if it has rules. Setup is only needed when you want to: * Expose a type through an interface (`.ExposeAs()`) * Change the DI lifetime (`.AsSingleton()`, `.AsTransient()`) * Map interfaces for deserialization (`.Interface().DeserializeTo()`) * Disable auto-registration (`.DisableAutoRegistration()`) ::: ## The Setup Lambda Setup is a second lambda passed to `UseConfiguration()`: ```csharp builder.AddCocoarConfiguration(c => c .UseConfiguration( rule => [ rule.For().FromFile("appsettings.json"), rule.For().FromFile("database.json"), ], setup => [ setup.ConcreteType().ExposeAs(), setup.Interface().DeserializeTo(), ])); ``` The setup callback receives a `SetupBuilder` and returns an array of `SetupDefinition[]`. Two methods are available: | Method | Purpose | |---|---| | `setup.ConcreteType()` | Configure how a concrete type is registered | | `setup.Interface()` | Configure deserialization for interface-typed properties | ## ConcreteType — Interface Exposure Use `.ConcreteType().ExposeAs()` when consumers should depend on an abstraction: ```csharp public interface IAppSettings { string AppName { get; } int MaxRetries { get; } } public class AppSettings : IAppSettings { public string AppName { get; set; } = "MyApp"; public int MaxRetries { get; set; } = 3; } ``` ```csharp setup => [ setup.ConcreteType().ExposeAs() ] ``` Now you can inject either the concrete type or the interface: ```csharp // Both work public class ServiceA(AppSettings settings) { } public class ServiceB(IAppSettings settings) { } ``` You can expose a type through multiple interfaces by chaining: ```csharp setup.ConcreteType() .ExposeAs() .ExposeAs() ``` ::: warning Type Safety `ExposeAs()` validates at call time: * `T` must be an interface — classes are rejected * The concrete type must implement `T` — otherwise you get an `InvalidOperationException` ::: ## Interface — Deserialization Mapping Use `.Interface().DeserializeTo()` when your configuration classes have interface-typed properties: ```csharp public class AppSettings { public string AppName { get; set; } = "MyApp"; public IDatabase Database { get; set; } // Interface property public ICache Cache { get; set; } // Interface property } ``` JSON deserializers can't instantiate interfaces. The setup tells the system which concrete type to create: ```csharp setup => [ setup.Interface().DeserializeTo(), setup.Interface().DeserializeTo(), ] ``` When the JSON is deserialized, any `IDatabase` property gets a `PostgresDatabase` instance, and any `ICache` property gets a `RedisCache` instance. ::: info When to use which * **`ConcreteType().ExposeAs()`** — Controls DI registration. "Register `AppSettings` and also let people inject `IAppSettings`." * **`Interface().DeserializeTo()`** — Controls deserialization. "When JSON has an `IDatabase` property, create a `PostgresDatabase`." They solve different problems. Use both when needed. ::: ## DI Lifetime Modifiers The `Cocoar.Configuration.DI` package adds lifetime methods to `ConcreteType()`: ```csharp setup => [ setup.ConcreteType().AsSingleton(), setup.ConcreteType().AsTransient(), setup.ConcreteType().AsScoped(), // Default, rarely needed explicitly ] ``` | Method | Lifetime | Use When | |---|---|---| | `.AsScoped()` | Scoped | Default — stable snapshot per request | | `.AsSingleton()` | Singleton | Changes should be visible immediately, even mid-request | | `.AsTransient()` | Transient | New instance per injection (rarely needed) | ::: warning Don't default to Singleton Scoped resolution is a dictionary lookup — there is no performance cost. Singleton is **not** an optimization: the DI container caches the first result forever, so config changes are never visible. Stick with the default Scoped unless you have a specific reason. For live updates in long-lived services, use `IReactiveConfig`. ::: ### Keyed Services All lifetime methods accept an optional key for [keyed services](https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection#keyed-services): ```csharp setup => [ setup.ConcreteType().AsSingleton("primary"), setup.ConcreteType().AsSingleton("replica"), ] ``` ### Disabling Auto-Registration If you want a type to load but not be registered in DI: ```csharp setup => [ setup.ConcreteType().DisableAutoRegistration() ] ``` The type still loads from its rules and is accessible via `ConfigManager.GetConfig()`, but it won't appear in the DI container. `IReactiveConfig` is still registered. ::: tip For full details on DI lifetimes, keyed services, and registration behavior, see [DI Lifetimes](/guide/di/lifetimes). ::: ## What Gets Registered For every configuration type, the DI system registers: | Registration | Lifetime | Description | |---|---|---| | `T` | Scoped (default) | The config type itself | | `IReactiveConfig` | Singleton (always) | Live-updating observable stream | | Any `ExposeAs()` interfaces | Same as `T` | Interface aliases | `IReactiveConfig` is always Singleton regardless of the concrete type's lifetime — it represents a continuous stream of updates, not a point-in-time snapshot. ## Without DI In console apps or tests using `ConfigManager.Create()`, setup still works for deserialization mapping: ```csharp using var manager = ConfigManager.Create(c => c .UseConfiguration( rule => [ rule.For().FromFile("appsettings.json"), ], setup => [ setup.Interface().DeserializeTo(), ])); var settings = manager.GetConfig(); // settings.Database is a PostgresDatabase instance ``` The `ExposeAs` and lifetime modifiers are DI-only concepts — they have no effect without a DI container. --- --- url: /guide/providers/static-observable.md description: >- FromStaticJson/FromStatic fixed-value providers and FromObservable wrapping IObservable or IObservable, BehaviorSubject for WebSocket/gRPC/queue/test updates --- # Static & Observable Providers These two providers serve different use cases but share a common trait: they don't read from external sources like files or HTTP endpoints. ## Static JSON The static provider holds a fixed JSON value. It never changes. ```csharp rule.For().FromStaticJson("""{ "MaxRetries": 5, "Debug": true }""") ``` ### Use Cases **Hardcoded defaults:** ```csharp rule => [ rule.For().FromStaticJson("""{ "MaxRetries": 3, "Debug": false }"""), rule.For().FromFile("appsettings.json"), ] ``` The static rule provides a guaranteed baseline. The file overrides what it sets. **Testing:** ```csharp rule.For().FromStaticJson("""{ "Feature": true }""") ``` Inject known values without needing files or environment variables. **From an object:** ```csharp rule.For().FromStatic(a => new AppSettings { MaxRetries = 10, Debug = true }) ``` The `FromStatic` overload serializes a C# object to JSON. The factory receives an `IConfigurationAccessor`, so it can derive values from earlier rules. ## Observable The observable provider wraps any `IObservable` as a configuration source. When the observable emits, it triggers a recompute. ```csharp var configStream = new BehaviorSubject(new FeatureConfig { Enabled = true }); rule.For().FromObservable(configStream) ``` ### From Objects Pass an `IObservable` where `T` is your configuration type. Each emitted value is serialized to JSON: ```csharp IObservable stream = /* your source */; rule.For().FromObservable(stream) ``` ### From JSON Strings Pass an `IObservable` where each string is raw JSON: ```csharp IObservable jsonStream = /* WebSocket, message queue, etc. */; rule.For().FromObservable(jsonStream) ``` ### From an Initial JSON String Pass a JSON string to create a `BehaviorSubject` internally: ```csharp rule.For().FromObservable("""{ "Enabled": true }""") ``` This is a convenience initializer — equivalent to `FromStaticJson` for one-off values. It creates a `BehaviorSubject` internally, but you don't get a reference to it. If you need programmatic updates, create your own subject and pass it via `FromObservable(IObservable)`: ```csharp // gRPC stream → BehaviorSubject → provider var configSubject = new BehaviorSubject(defaultConfig); grpcStream.Subscribe(update => configSubject.OnNext(update)); rule.For().FromObservable(configSubject) ``` ### Use Cases * **WebSocket / gRPC streams** — wrap as `IObservable` and pass to `FromObservable` * **Message queue** — consume config updates from Kafka, RabbitMQ, etc. * **In-process updates** — use `BehaviorSubject` to push changes programmatically * **Testing** — use `BehaviorSubject` to simulate config changes over time ::: tip SSE Support Available For Server-Sent Events, use the [HTTP provider](/guide/providers/http-polling) with `serverSentEvents: true` instead of building your own observable wrapper. ::: --- --- url: /guide/testing/overrides.md description: >- CocoarTestConfiguration with AsyncLocal isolation, ReplaceConfiguration vs AppendConfiguration, independent ReplaceSecretsSetup with AllowPlaintext for parallel-safe tests --- # Test Overrides `CocoarTestConfiguration` lets you replace or extend configuration in tests without touching real files, environment variables, or HTTP endpoints. It uses `AsyncLocal` for isolation — each test gets its own configuration context, parallel-safe. ## Replace vs Append ### ReplaceConfiguration Skips all original rules. Only your test rules execute: ```csharp using var _ = CocoarTestConfiguration.ReplaceConfiguration(rule => [ rule.For().FromStatic(_ => new DbConfig { ConnectionString = "Server=localhost;Database=test" }) ]); // ConfigManager now uses only the test rule ``` Use this when: * Original providers would fail in the test environment (missing files, unreachable URLs) * You want complete isolation from real configuration ### AppendConfiguration Original rules execute first, then your test rules overlay on top (last-write-wins merge): ```csharp using var _ = CocoarTestConfiguration.AppendConfiguration(rule => [ rule.For().FromStatic(_ => new AppSettings { MaxRetries = 999 }) ]); // Original AppSettings rules run first, then test values merge over them ``` Use this when: * You only need to override specific values * You want the rest of the configuration to behave normally ## Secrets Override Secrets setup is overridden independently from rules. You can combine it with either mode: ```csharp // Replace rules + allow plaintext secrets using var _ = CocoarTestConfiguration .ReplaceConfiguration(rule => [...]) .ReplaceSecretsSetup(s => s.AllowPlaintext()); // Only override secrets, keep original rules using var _ = CocoarTestConfiguration .ReplaceSecretsSetup(s => s.AllowPlaintext()); ``` `AllowPlaintext()` is the most common test override — it skips encryption so you can use plain JSON values in test configuration. ## Setup Override You can also override the setup (DI registration customization) alongside rules: ```csharp using var _ = CocoarTestConfiguration.ReplaceConfiguration( rules: rule => [ rule.For().FromStatic(_ => new AppSettings { LogLevel = "Debug" }) ], setup: setup => [ setup.ConcreteType().AsSingleton() ]); ``` ## Disposal The `using` pattern ensures cleanup. When the scope disposes, `CocoarTestConfiguration.Clear()` is called and the test context is removed from the `AsyncLocal`: ```csharp [Fact] public async Task MyTest() { using var _ = CocoarTestConfiguration.ReplaceConfiguration(rule => [...]); // Test code — configuration is overridden here // ... } // Automatically cleared, even on exception ``` ## How It Works 1. `CocoarTestConfiguration` stores a `TestConfigurationContext` in an `AsyncLocal` 2. The context flows through `async`/`await` chains automatically 3. When `ConfigManager` initializes, it checks `CocoarTestConfiguration.IsActive` 4. If active, it uses the test rules (Replace or Append mode) instead of or in addition to the configured rules 5. Each test's `AsyncLocal` is isolated — parallel tests don't interfere --- --- url: /guide/testing/strategy.md description: >- Test-at-the-right-layer principles, test project structure, in-memory TestProviders with no I/O in Core.Tests, trait filters (Unit/Stress), deterministic active-waiting over fixed delays --- # Testing Strategy Test behavior at the right layer, avoid redundancy, and isolate concerns. ## Core Principles 1. **Test YOUR code, not third-party libraries** -- assume external dependencies work 2. **Test at the appropriate layer** -- each test project has a specific responsibility 3. **Avoid redundancy** -- don't re-test the same behavior across multiple layers 4. **Isolate concerns** -- core logic should be testable without I/O dependencies 5. **Add tests when issues are detected** -- not preemptively for every edge case 6. **Deterministic over timing-dependent** -- use active waiting patterns, not fixed delays ## Quick Start ```bash # Run all tests dotnet test # Run only fast unit tests dotnet test --filter "Type=Unit" # Run specific provider tests dotnet test --filter "Provider=ObservableProvider" # Everything except stress tests dotnet test --filter "Type!=Stress" ``` ## Test Project Structure ### Core.Tests **Purpose:** Bulletproof the ConfigManager under all conditions -- rule evaluation, merging, reactive behavior, error recovery, and stress/concurrency. ::: warning No I/O in Core.Tests Use `TestProviders` (in-memory, deterministic) only. Never use `FileSourceProvider`, `HttpProvider`, or other I/O-based providers. This ensures fast execution, deterministic results, and clear failure attribution. ::: ```csharp [Fact] public async Task ConfigManager_Handles_Provider_Failure_Gracefully() { var rules = new List { TestRules.Failable(shouldFail: true), TestRules.StaticJson(fallbackJson) }; var manager = ConfigManager.Create(c => c.UseConfiguration(rules)); var config = manager.GetConfig(); Assert.NotNull(config); Assert.Equal(expectedFallbackValue, config.SomeProperty); } ``` ::: tip Why stress-test "simple" providers? Stress-testing providers validates integration points under ConfigManager's debounce/cancellation logic. When 100+ recompute signals hit simultaneously, it creates race conditions. Proving providers survive any stress means integration test failures must be in higher-level logic. ::: ### Provider.Tests **Purpose:** Test individual provider implementations -- file watching, HTTP polling, argument parsing, error handling, and query options. I/O is acceptable and expected here. Providers inherently depend on files, HTTP, and environment variables. Use temporary directories and mock HTTP handlers. ::: warning Don't re-test ConfigManager logic Test that providers return configuration bytes correctly. Don't test rule merging or orchestration -- that belongs in Core.Tests. ::: ```csharp [Fact] public async Task FileProvider_Detects_File_Changes() { using var tempDir = TempDirectoryHelper.Create(); var configFile = Path.Combine(tempDir.Path, "config.json"); File.WriteAllText(configFile, """{"value": 1}"""); var provider = new FileSourceProvider(new(tempDir.Path)); var config1 = await provider.FetchConfigurationBytesAsync(new("config.json")); File.WriteAllText(configFile, """{"value": 2}"""); await Task.Delay(100); var config2 = await provider.FetchConfigurationBytesAsync(new("config.json")); Assert.Equal(1, config1.ToJsonElement().GetProperty("value").GetInt32()); Assert.Equal(2, config2.ToJsonElement().GetProperty("value").GetInt32()); } ``` ### DI.Tests **Purpose:** Test dependency injection integration -- service registration, lifetimes, keyed services, and interface exposure. ```csharp [Fact] public void AsSingleton_Creates_Same_Instance() { var services = new ServiceCollection(); services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ rules.For().FromStaticJson(json).Required() ], setup => [ setup.ConcreteType().AsSingleton() ])); var sp = services.BuildServiceProvider(); var instance1 = sp.GetRequiredService(); var instance2 = sp.GetRequiredService(); Assert.Same(instance1, instance2); } ``` ### Secrets.Tests **Purpose:** Test encryption/decryption round-trips, certificate loading and validation, secrets provider behavior, and certificate expiration handling. I/O is acceptable for certificate operations. ### Analyzers.Tests **Purpose:** Test that Roslyn analyzers detect problematic code patterns and that the source generator produces correct output. Uses the Roslyn testing framework. ## Test Organization (Traits) All tests should use `[Trait]` attributes for CI filtering: ```csharp [Fact] [Trait("Type", "Unit")] [Trait("Provider", "FileSourceProvider")] public async Task Test_Scenario_ExpectedResult() ``` | Category | Values | |----------|--------| | **Type** | `Unit`, `Performance`, `Concurrency`, `Stress` | | **Provider** | Specific provider name | | **Component** | `ConfigManager`, `Secrets`, etc. | ## Active Waiting ```csharp // BAD: Timing-dependent, flaky await Task.Delay(1000); if (someCondition) { ... } // GOOD: Deterministic, fast when condition met await ActiveWaitHelpers.WaitUntilAsync( () => someCondition, timeout: TimeSpan.FromSeconds(5), description: "waiting for condition"); ``` ::: warning Debouncing in tests ConfigManager debounces by design (default 300ms). Test **final state** correctness, not emission counts. Debouncing coalesces rapid changes, so you'll always get fewer emissions than changes. Final state is always deterministic. ::: ```csharp // CORRECT: Test final state behaviorSubject.OnNext("""{"Name": "Updated"}"""); Thread.Sleep(500); var config = manager.GetConfig(); Assert.Equal("Updated", config.Name); // WRONG: Assumes no debouncing Assert.Equal(expectedEmissions, emissions.Count); // Flaky! ``` ## What Makes a Good Test ### Tests Behavior, Not Implementation ```csharp // BAD - Only tests instantiation var obj = new MyClass(); Assert.NotNull(obj); // Always passes, no value // GOOD - Tests actual behavior var service = new MyService(); var result = service.GetConfig(); Assert.Equal(expectedValue, result.Property); Assert.True(result.IsValid); ``` ### Test Name Matches Test Behavior ```csharp // BAD - Name claims "scoped" but doesn't verify it [Fact] public void Service_Is_Scoped() { var instance = provider.GetService(); Assert.NotNull(instance); // Doesn't verify scoped behavior } // GOOD - Actually tests scoped behavior [Fact] public void Service_Is_Scoped() { using var scope = provider.CreateScope(); var instance1 = scope.ServiceProvider.GetService(); var instance2 = scope.ServiceProvider.GetService(); Assert.Same(instance1, instance2); } ``` ### Appropriate Assertions for Test Scope | Layer | Assertion Style | |-------|----------------| | **Provider.Tests** | `Assert.NotNull(config)` is often sufficient -- the provider's job is to return config bytes | | **Core.Tests** | Assert actual values (`Assert.Equal`) and object identity (`Assert.Same`) | | **DI.Tests** | Verify lifetime behavior with `Assert.Same` (singleton/scoped) and `Assert.NotSame` (transient) | ### Use TestProviders in Core.Tests ```csharp // GOOD - Uses TestProvider (no I/O) var rules = new List { TestRules.StaticJson(json), TestRules.Observable(subject), TestRules.Failable(shouldFail: true) }; // BAD - Uses real I/O in Core.Tests var rules = new List { rules.For().FromFile("config.json").Required() }; ``` ## Common Anti-Patterns ### Testing External Libraries ```csharp // BAD - Testing that File.WriteAllText works File.WriteAllText("test.txt", "content"); var content = File.ReadAllText("test.txt"); Assert.Equal("content", content); // Testing .NET, not your code // GOOD - Testing YOUR code that uses File I/O var provider = new FileSourceProvider(options); var config = await provider.FetchConfigurationBytesAsync(query); Assert.NotNull(config); ``` ### Redundant Testing Across Layers ```csharp // BAD - Testing ConfigManager merging in Provider.Tests var provider = new FileSourceProvider(options); // ... complex merging logic test ... // This belongs in Core.Tests! // GOOD - Provider.Tests only test provider behavior var provider = new FileSourceProvider(options); var config = await provider.FetchConfigurationBytesAsync(query); Assert.NotNull(config); ``` ### Constructor-Only Tests ```csharp // BAD - Provides no value var obj = new MyClass(); Assert.NotNull(obj); // DELETE THIS ``` ## When to Add Tests **Always add tests for:** * New public API methods * Bug fixes (regression tests) * Complex logic or algorithms * Error handling and edge cases * Breaking changes (to document migration) **Consider NOT adding tests for:** * Simple property getters/setters * Pass-through methods to external libraries * Obvious constructor behavior * Trivial wrappers with no logic ::: tip External library bugs If you discover a bug in an external library: add a test that reproduces the issue, document it, implement a workaround, and let the test prove the workaround works. ::: ## Helper Utilities ### ActiveWaitHelpers ```csharp // Wait for condition await ActiveWaitHelpers.WaitUntilAsync( () => condition, TimeSpan.FromSeconds(5), "description"); // Wait for specific value var result = await ActiveWaitHelpers.WaitForValueAsync( () => getValue(), TimeSpan.FromSeconds(5), "description"); ``` ### ObservableTestHelpers ```csharp await ObservableTestHelpers.WaitForValueAsync( observable, expectedValue, timeout: TimeSpan.FromSeconds(5), description: "waiting for expected value"); ``` ## CI/CD Integration ### Pull Request Checks (Fast) ```bash # Quick feedback - runs in ~1 second dotnet test --filter "Type=Unit" --logger:trx ``` ### Full Test Suite ```bash # Complete validation including stress tests - ~5 seconds dotnet test --logger:trx --collect:"XPlat Code Coverage" ``` ### Performance Monitoring ```bash # Check for regressions dotnet test --filter "Type=Performance" --logger:trx ``` ## Test Quality Checklist When reviewing tests, ask: 1. Does this test validate **behavior**? (Not just instantiation) 2. Is it testing the **right layer**? (Core vs Provider vs DI concerns) 3. Would this test **fail if there's a bug**? (Or does it always pass?) 4. Is it testing **YOUR code**? (Not third-party libraries) 5. Is the **test name accurate**? (Does it match what's actually tested?) --- --- url: /guide/providers/toml.md description: >- FromTomlFile provider (Cocoar.Configuration.Toml) — reactive .toml watching, TOML typed values (string/int/float/bool/datetime/array/table) mapped to JSON, arrays-of-tables, Kubernetes ConfigMap support --- # TOML Provider `Cocoar.Configuration.Toml` reads `.toml` files into the configuration pipeline, with the same reactive file-watching, path resolution, and security as the [File provider](/guide/providers/file) — including `followSymlinks: true` for [Kubernetes ConfigMap / Secret mounts](/guide/providers/file#kubernetes-configmap-secret-mounts). Opt-in package (it takes a Tomlyn dependency). ```shell dotnet add package Cocoar.Configuration.Toml ``` ```csharp using Cocoar.Configuration.Toml; builder.AddCocoarConfiguration(c => c .UseConfiguration(rules => [ rules.For().FromTomlFile("appsettings.toml"), ])); ``` ## Typed values TOML is strongly typed, so the mapping to JSON is unambiguous — no scalar-style guessing as in YAML: | TOML | Binds as | |---|---| | `name = "hello"` | string | | `enabled = true` | boolean | | `port = 5432` | number | | `ratio = 1.5` | number | | `created = 1979-05-27T07:32:00Z` | string (ISO-8601) | | `hosts = ["a", "b"]` | array | | `[db]` (table) | object | | `[[servers]]` (array of tables) | array of objects | Date/time values are emitted as ISO-8601 strings; the binder coerces them to `DateTime` / `DateTimeOffset` as needed. ## Reactivity & per-tenant paths Editing the file triggers a recompute (same watcher as `FromFile`). A config-aware overload resolves the path per recompute — e.g. per tenant: ```csharp rules.For().FromTomlFile(a => $"tenants/{a.Tenant}/config.toml").TenantScoped() ``` ## Other formats For `.yaml` / `.yml` see the [YAML provider](/guide/providers/yaml); for `.env` see [Dotenv](/guide/providers/dotenv); for `.ini` see the [INI provider](/guide/providers/ini). --- --- url: /guide/roadmap.md description: >- Pointer to the full roadmap — ConfigHub, cloud providers, database provider, and other planned features --- # What's Next See the full [Roadmap](/roadmap/overview) for what's planned — including ConfigHub, cloud providers, database provider, and more. --- --- url: /guide/why-cocoar.md description: >- Cocoar versus IOptions — direct injection, ordered rule layering, IReactiveConfig updates, atomic multi-config tuples, required rollback, built-in flags and secrets --- # Why Cocoar.Configuration? ## The Problem with IOptions Microsoft's `IConfiguration` and `IOptions` work, but they come with friction: ```csharp // Microsoft: Setup is ceremony builder.Services.Configure(builder.Configuration.GetSection("App")); // Microsoft: Injection requires unwrapping public class MyService(IOptions options) { var settings = options.Value; // Unwrap every time } ``` * You must remember `Configure()` for every type * Consumers need `IOptions`, `IOptionsSnapshot`, or `IOptionsMonitor` — different wrappers for different lifetimes * Layering multiple sources (file + environment + remote) requires manual `IConfigurationBuilder` wiring * No atomic multi-config updates — if two config types need to change together, you can get inconsistent reads * Change notification requires subscribing to `IOptionsMonitor` with manual callback management ## The Cocoar Approach ```csharp // Cocoar: Setup is one line per type builder.AddCocoarConfiguration(c => c .UseConfiguration(rule => [ rule.For().FromFile("appsettings.json").Select("App") ])); // Cocoar: Inject directly — no wrapper public class MyService(AppSettings settings) { // Just use it } ``` ### What You Get | Capability | IOptions | Cocoar | |---|---|---| | Direct injection | `IOptions` wrapper | `T` directly | | Layering | Manual builder wiring | Rules in order, last write wins | | Reactive updates | `IOptionsMonitor` | `IReactiveConfig` | | Atomic multi-config | Not supported | `IReactiveConfig<(T1, T2)>` | | Conditional rules | Not supported | `.When(accessor => ...)` | | Required vs optional | Manual validation | `.Required()` with automatic rollback | | Health monitoring | Not built in | Per-rule status, degraded/unhealthy tracking | | Feature flags | Separate library | Built in | | Secrets | No memory safety | `Secret` with automatic zeroization | | Compile-time validation | Not available | Roslyn analyzers (COCFG001-006) | ### Design Principles **Explicit layering.** Rules execute in defined order and merge property by property — later rules overlay earlier ones. You read the rule list top to bottom and know exactly what happens. **Reactive by default.** Every configuration type automatically gets an `IReactiveConfig` in DI. Subscribe once and receive updates whenever config changes — file modifications, HTTP poll results, environment changes. **Atomic updates.** When multiple config types need to stay in sync, use `IReactiveConfig<(T1, T2, T3)>`. All types update together in one snapshot — you never see a mix of old and new values. **Fail-safe behavior.** Required rules roll back the entire recompute on failure — your app keeps the last known good config. Optional rules that fail contribute nothing — values set by earlier rules remain unchanged, and the failure is tracked in health. **Zero ceremony.** Define a class, add a rule, inject it. No `Configure()` registration, no options wrappers, no `GetSection()` calls. --- --- url: /guide/certificates.md description: >- X.509 certificates in Cocoar — why password-less, protecting PFX via file permissions on Linux/macOS/Windows and Docker/Kubernetes --- # Working with Certificates This guide explains how Cocoar.Configuration uses X.509 certificates, why we require them to be password-less, and how to manage them securely. These principles apply everywhere in the library where certificates are used. ## Why Password-Less? A password-protected certificate seems more secure — but it creates a circular problem: **where do you store the password?** * If you hardcode it → it's in your source code * If you put it in a config file → it's plaintext on disk * If you encrypt it → you need another key to decrypt it The password becomes another secret that needs managing. Every tool in the chain needs it — your app, your deployment scripts, your CI/CD pipeline. Each handoff is a potential leak. **Password-less certificates eliminate this problem entirely.** Instead of protecting the certificate with a password (something you know), you protect it with file system permissions (something the OS enforces). This is not unusual — it's the industry standard: | Software | Default Behavior | |----------|-----------------| | **nginx** | Expects password-less PEM/key files | | **HAProxy** | Expects password-less PEM bundles | | **Kestrel** | Supports password-less PFX for HTTPS | | **Docker/Kubernetes** | Mounts certs as files with permission control | | **Let's Encrypt** | Generates password-less PEM files | ## How to Protect Certificates Without a password, the certificate file's security depends entirely on file system permissions. This is actually **stronger** than a password — the OS enforces it at every access, not just at load time. ### Linux / macOS ```shell # Only the app user can read the certificate chmod 600 certs/secrets.pfx chown app-user:app-group certs/secrets.pfx # Verify ls -la certs/secrets.pfx # -rw------- 1 app-user app-group 2048 Mar 19 10:00 secrets.pfx ``` ### Windows ```powershell # Remove inherited permissions + grant read-only to app user icacls certs\secrets.pfx /inheritance:r /grant:r "AppPoolUser:(R)" ``` ### Docker / Kubernetes Mount certificates as read-only volumes with restricted permissions: ```yaml # Docker Compose volumes: - ./certs:/app/certs:ro # Kubernetes Secret apiVersion: v1 kind: Secret metadata: name: config-certs type: kubernetes.io/tls data: tls.crt: tls.key: ``` ## Why File-Based? Cocoar uses **file-based certificates** exclusively for secret encryption. This is a deliberate design choice: * **Cache & Dispose** — Certificates are loaded on demand, cached briefly (default 30 seconds), then disposed. The private key lives in memory only when actively used for decryption. * **Automatic Rotation** — Drop a new certificate into the folder. The file watcher detects it automatically. Old secrets still decrypt with the old certificate. No restart needed. * **Cross-Platform** — Works identically on Windows, Linux, macOS, and in containers. * **No Store Dependency** — The Windows Certificate Store, Linux keyrings, and macOS Keychain all behave differently. Files with permissions work everywhere. :::info What about the OS Certificate Store? The Windows Certificate Store offers hardware-backed key protection (TPM), but it doesn't support Cocoar's cache/dispose cycle or folder-based rotation. On Linux, .NET's `X509Store` is just a file directory under `~/.dotnet/` — no security benefit over direct file access. If you need OS Store certificates for **HTTP client authentication** (mutual TLS), load the certificate yourself and pass it via `HttpMessageHandler`. See [HTTP Provider Authentication](/guide/providers/http-polling#authentication). ::: ### Password-Less Files Use password-less certificate files. The private key is loaded into managed memory briefly — don't add a password that also enters managed memory: ```csharp var cert = new X509Certificate2("certs/secrets.pfx"); ``` :::warning `new X509Certificate2("cert.pfx", "password")` loads both the certificate AND the password into managed memory as strings. The password cannot be zeroed because .NET strings are immutable. Avoid this pattern — use `cocoar-secrets convert-cert` to remove passwords. ::: ## Certificate Formats | Format | Extensions | Notes | |--------|-----------|-------| | **PKCS#12** | `.pfx`, `.p12` | Contains certificate + private key in one file | | **PEM** | `.pem`, `.crt` + `.key` | Certificate and key as separate text files | Convert between formats with the CLI: ```shell # PFX to PEM cocoar-secrets convert-cert --input cert.pfx --output cert.pem # Password-protected to password-less cocoar-secrets convert-cert --input protected.pfx --ipass "OldPassword" --output cert.pfx ``` ## Rotation Certificate rotation follows the same principle as key rotation — overlap old and new during transition: 1. **Generate** a new certificate 2. **Deploy** alongside the old one (both accepted) 3. **Re-encrypt** secrets with the new certificate's public key 4. **Remove** the old certificate after all secrets are re-encrypted For automated rotation with certificate folders, see [Certificate Caching](/guide/secrets/certificate-caching). ## Summary | Do | Don't | |----|-------| | Use password-less certificate files | Use password-protected certificates | | Protect with file permissions (`chmod 600`, ACLs) | Store passwords in config files or code | | Use folder-based certs for automatic rotation | Manually restart on certificate change | | Rotate certificates periodically | Use the same certificate forever | | Use `cocoar-secrets convert-cert` to remove passwords | Load PFX with password in code | --- --- url: /guide/providers/writable-store.md description: >- FromStore writable override layer, sparse leaf persistence, IWritableStore SetAsync/ResetAsync/PatchAsync, reset vs explicit null, DescribeAsync provenance, secrets, IStoreBackend --- # Writable Store Provider The writable store is a **writable, application-controlled override layer**. Every other provider is an external source you read from — it is the one layer your application can write to at runtime. Its purpose is **overridable defaults**: the normal sources (files, environment, …) supply defaults, and the application overrides *individual* values at runtime — from an admin UI, an API, or a background job — while everything it doesn't touch keeps inheriting from the lower layers. ```csharp rules => [ rules.For().FromFile("appsettings.json"), // defaults rules.For().FromStore(), // app-controlled overrides (placed last → wins) ] ``` Position matters: place the writable-store rule **after** the rules whose values it should override. ## Sparse overrides The writable store persists a **sparse** JSON object — only the leaves you explicitly set. Everything else is physically absent and therefore inherits from the lower layers through the normal byte-level merge. Given the defaults `{ "Host": "smtp.default.com", "Port": 25, "UseSsl": false }`, after: ```csharp await storage.SetAsync(x => x.Port, 587); await storage.SetAsync(x => x.UseSsl, true); ``` the persisted overlay is just: ```json { "Port": 587, "UseSsl": true } ``` and the effective configuration is `Host=smtp.default.com` (inherited), `Port=587`, `UseSsl=true`. This is the key difference from a "save the whole object" store: setting one value never freezes the others. If a default changes in the file later, every key you didn't override picks it up. ## Reading and writing Inject `IWritableStore` (registered as a **Singleton**, thread-safe) to override values at runtime: ```csharp public class SettingsController(IWritableStore storage) { // Override a single value — only this leaf is persisted; a recompute fires // and IReactiveConfig emits the new effective value. public Task SetPort(int port) => storage.SetAsync(x => x.Port, port); // Reset one override — the value falls back to the inherited default. public Task ResetPort() => storage.ResetAsync(x => x.Port); // Clear every override this layer holds. public Task ResetAll() => storage.ClearAsync(); } ``` The selector must be a **simple member-access chain** (`x => x.Smtp.Port`). Indexers and method calls throw `NotSupportedException` (a type cast around the member chain is unwrapped and tolerated) — use the raw [overlay surface](#raw-overlay-surface) for dynamic paths. ::: tip Writes are reactive A write persists to storage, signals the provider, and triggers a (debounced) recompute. Subscribers of `IReactiveConfig` receive the new merged value automatically. ::: ### Reset vs. explicit null These are deliberately different operations: | Operation | Overlay result | Effective value | |---|---|---| | `ResetAsync(x => x.Host)` | key removed | **inherits** the lower-layer value | | `SetAsync(x => x.Host, null)` | `{ "Host": null }` | **overridden to `null`** (clobbers the base) | ### Overriding to a default-looking value Because only touched keys are persisted, overriding to a value that happens to equal the C# default still counts as an override: ```csharp await storage.SetAsync(x => x.Port, 0); // persists {"Port":0} → effective Port is 0, even if the base was 25 ``` This is the headline correctness win: "an admin chose `0`" is distinct from "nobody set it." ### Reading the overlay ```csharp SmtpSettings? overrides = await storage.ReadAsync(); // sparse partial T (unset members = C# defaults), null if empty JsonNode? raw = await storage.Overlay.ReadOverlayAsync(); // the raw stored fragment, null if empty ``` `ReadAsync` returns only what the overlay holds — **not** the merged result. For the effective value use `IReactiveConfig.CurrentValue` or `IConfigurationAccessor.GetConfig()`. ## Batch writes — one save, one recompute When more than one value changes together (a form save, an import), batch them with `PatchAsync`. Every mutation is applied under a **single** atomic read-merge-write — one write to the backend, one recompute — instead of one per property: ```csharp await storage.PatchAsync(b => b .Set(x => x.Host, "smtp.example.com") .Set(x => x.Port, 587) .Set(x => x.UseSsl, true) .Reset(x => x.Timeout)); // mix sets and resets freely ``` A 20-field form save triggers **one** recompute and **one** backend write, not 20 — and subscribers of `IReactiveConfig` never observe a half-applied state. For a database-backed `IStoreBackend` this also collapses 20 round-trips into a single transaction. The single-value `SetAsync` / `SetSecretAsync` / `ResetAsync` are thin shorthands over `PatchAsync` for the one-property case. ### Write semantics — presence-based There is no "magic null": what you call is exactly what happens. | In the patch | Effect | |---|---| | `Set(x => x.Host, "v")` | sets the value | | `Set(x => x.Host, null)` | sets an **explicit `null`** — only compiles where `null` is valid for the member (`string?`, `int?`, …) | | *(Set not called)* | the property is **left untouched** | | `Reset(x => x.Host)` | **removes** the override (restores inheritance) | This is the only model that lets you set `null` explicitly *and* delete an override — they are different operations. Mapping external input (an HTTP body, an `Optional` DTO's presence flags, …) onto these calls is **your** code's job; the library stays typed and never guesses. ### Secrets in a batch Secret-typed members use `SetSecret` with a pre-encrypted [envelope](/guide/secrets/client-encryption): ```csharp await storage.PatchAsync(b => b .Set(x => x.Port, 587) .SetSecret(x => x.ApiKey, envelope)); ``` When gathering values is itself asynchronous (e.g. encrypting the envelope), use the async overload so you can `await` inside: ```csharp await storage.PatchAsync(async b => b.Set(x => x.Port, 587) .SetSecret(x => x.ApiKey, await EncryptAsync(apiKey))); ``` ## Provenance for a management UI `DescribeAsync()` returns, per key, the base value, the effective value, and whether it is currently overridden — everything a "default vs. override, with reset" UI needs: ```csharp foreach (var entry in await storage.DescribeAsync()) { // entry.KeyPath, entry.BaseValue, entry.EffectiveValue, entry.IsSet } ``` | KeyPath | BaseValue | EffectiveValue | IsSet | |---|---|---|---| | `Host` | `"smtp.default.com"` | `"smtp.default.com"` | `false` | | `Port` | `25` | `587` | `true` | | `UseSsl` | `false` | `true` | `true` | `BaseValue` is the value computed from the layers **below** this overlay — i.e. what the key would be if the override were removed. ## Raw overlay surface For dynamic or non-expressible paths, use `IWritableStoreOverlay` (also resolvable directly from DI, or via `storage.Overlay`). Key paths are dotted; their segments correspond to the JSON property names: ```csharp await storage.Overlay.SetAsync("Smtp.Port", JsonValue.Create(587)); await storage.Overlay.ResetAsync("Smtp.Port"); ``` Key-path segments match the lower layers **case-insensitively** (the pipeline merges layers case-insensitively), so an override lands on the existing key regardless of casing — no need to mirror the exact casing of the base. Do **not** use the raw surface for secret paths. ## Arrays and secrets * **Arrays are replaced wholesale.** `SetAsync(x => x.Hosts, list)` overrides the entire array — there is no element-level merge. Per-element selectors (`x => x.Hosts[2]`) are rejected. * **Secrets need a pre-encrypted envelope.** A *plaintext* write of a `Secret` / `ISecret` member (via `Set` / `SetAsync`) throws `NotSupportedException` — it would persist the secret in the clear. To override a secret, use `SetSecret` (in a patch) or `SetSecretAsync` with a pre-encrypted [`SecretEnvelope`](/guide/secrets/client-encryption). Resetting a secret override **is** allowed — it only removes the key and exposes no plaintext. ## Writing your own endpoints There is no built-in REST surface — and that's deliberate. Writes are where *your* rules live (validation, normalization, authorization, audit logging, request shape), so the library gives you the injectable primitive and you own the endpoint. Inject `IWritableStore` (or `IWritableStoreOverlay`) anywhere and do your work *before* writing: ```csharp app.MapPut("/admin/smtp/port", async ( int port, IWritableStore storage, ILogger log) => { if (port is < 1 or > 65535) // validate return Results.BadRequest("Port must be 1–65535."); log.LogInformation("Admin override SMTP.Port = {Port}", port); // audit await storage.SetAsync(x => x.Port, port); // then persist (sparse) → recompute → reactive emit return Results.NoContent(); }) .RequireAuthorization("AdminPolicy"); // A full form save — many fields at once, one atomic write, one recompute: app.MapPut("/admin/smtp", async (SmtpForm form, IWritableStore storage) => { // validate/normalize `form` here, then map your DTO onto the typed patch: await storage.PatchAsync(b => b .Set(x => x.Host, form.Host) .Set(x => x.Port, form.Port) .Set(x => x.UseSsl, form.UseSsl)); return Results.NoContent(); }) .RequireAuthorization("AdminPolicy"); // Expose the provenance view for a management UI: app.MapGet("/admin/smtp", (IWritableStore storage, CancellationToken ct) => storage.DescribeAsync(ct)); ``` For a generic admin UI that sets arbitrary keys, inject the raw `IWritableStoreOverlay` and pass the dotted key path and a `JsonNode` yourself (your code is responsible for validating the path and value). Both `IWritableStore` and `IWritableStoreOverlay` are registered by `AddCocoarConfiguration` as the **same** singleton instance, so either can be injected into controllers or minimal-API handlers. ## Store backends By default, overrides are persisted as a JSON file under `{AppContext.BaseDirectory}/.cocoar/store/`, written atomically (temp-file-then-rename). Plug in your own store by implementing `IStoreBackend`: ```csharp rules.For().FromStore(new MyDatabaseBackend()); ``` A config-aware overload receives the current configuration and backend, for backends whose connection depends on earlier rules (e.g. a connection string): ```csharp rules.For().FromStore((accessor, current) => current ?? new DbBackend(accessor.GetConfig()!.ConnectionString)); ``` `ReadAsync` returns empty `{}` when nothing is stored (consistent with the [provider contract](/guide/providers/overview#the-provider-contract)), so an unwritten overlay is an invisible layer. For a ready-made PostgreSQL backend — including **tenant-aware, database-per-tenant** storage where each tenant's configuration lives in its own database — see the [Marten Store](/guide/providers/marten-store) package. ## How it works ``` IWritableStore.SetAsync(x => x.Port, 587) → resolve "Port" to a dotted key path → atomically read-merge-write the sparse overlay leaf to the backend → signal the provider's change observable → engine recompute (debounced) merges layers byte-for-byte, case-insensitively → IReactiveConfig emits the new effective value ``` The read/merge path is identical to every other provider — the writable store only adds the write path. See the runnable [WritableStoreExample example](https://github.com/cocoar-dev/cocoar.configuration/tree/develop/src/Examples/WritableStoreExample) for an end-to-end walkthrough. --- --- url: /guide/providers/yaml.md description: >- FromYamlFile provider (Cocoar.Configuration.Yaml) — reactive .yaml/.yml watching, YAML core-schema scalar type-inference (bool/number/null), quoted/block scalars stay strings --- # YAML Provider `Cocoar.Configuration.Yaml` reads `.yaml` / `.yml` files into the configuration pipeline, with the same reactive file-watching, path resolution, and security as the [File provider](/guide/providers/file) — including `followSymlinks: true` for [Kubernetes ConfigMap / Secret mounts](/guide/providers/file#kubernetes-configmap-secret-mounts). Opt-in package (it takes a YamlDotNet dependency). ```shell dotnet add package Cocoar.Configuration.Yaml ``` ```csharp using Cocoar.Configuration.Yaml; builder.AddCocoarConfiguration(c => c .UseConfiguration(rules => [ rules.For().FromYamlFile("appsettings.yaml"), ])); ``` ## Scalar types Plain (unquoted) scalars are mapped to their JSON types, so a YAML file binds exactly like the equivalent JSON: | YAML | Binds as | |---|---| | `enabled: true` | boolean | | `port: 5432` | number | | `ratio: 1.5` | number | | `note: null` (or `~`) | null | | `name: hello` | string | | `note: "true"` | **string** (quoted) | Quoted (`"…"` / `'…'`) and block (`|`, `>`) scalars are always strings. ## Reactivity & per-tenant paths Editing the file triggers a recompute (same watcher as `FromFile`). A config-aware overload resolves the path per recompute — e.g. per tenant: ```csharp rules.For().FromYamlFile(a => $"tenants/{a.Tenant}/branding.yaml").TenantScoped() ``` ## Looking for `.env`? The dotenv provider (`FromDotEnv`) is built into the **core** package — see [Dotenv](/guide/providers/dotenv).