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:
- Creates one
RuleManagerper sub-rule, sharing the sameProviderRegistry(no duplicate FileWatchers). - On
ComputeAsync: executes each internal manager sequentially, parses results toMutableJsonObject, deep-merges them, and serializes back to bytes viaMutableJsonDocument.ToUtf8Bytes. - Catches inner Required failures within the aggregate boundary. If the aggregate is optional, failures are absorbed (Degraded). If required, they propagate (Rollback).
- Subscribes to all internal managers'
Changesand forwards signals to the outer engine. Internal managers hit cache for unchanged sources — only the changed source re-fetches. - Exposes
SubManagersfor ConfigHub drill-down into the aggregate structure.
Public API
// FromFiles — syntactic sugar
rule.For<DbSettings>()
.FromFiles("db.json", $"db.{env}.json")
.Required()
// Aggregate — full control
rule.For<DbSettings>()
.Aggregate(r => [
r.FromFile("db.json").Required(),
r.FromFile($"db.{env}.json")
])The Aggregate lambda receives TypedProviderBuilder<T> (not TypedRuleBuilder<T>), preventing recursive nesting.
Builder Hierarchy
TypedProviderBuilder<T> ← base, provider extensions target this
└── TypedRuleBuilder<T> ← adds Aggregate(), FromFiles()Existing extension methods retargeted from TypedRuleBuilder<T> to TypedProviderBuilder<T>. No breaking changes — TypedRuleBuilder<T> 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 interfacesrc/Cocoar.Configuration/Rules/AggregateRuleManager.cs— Implementationsrc/Cocoar.Configuration/Rules/AggregateConfigRule.cs— Rule definitionsrc/Cocoar.Configuration/Fluent/AggregateRulesExtensions.cs—Aggregate()+FromFiles()APIsrc/Cocoar.Configuration/Fluent/TypedProviderBuilder.cs— Base builder preventing recursive nesting