fix(core): config-aware rules follow their source at runtime again - #54
Merged
Conversation
A rule factory resolved GetConfig<T>() against the committed backplane snapshot instead of the pass it was running in, so a derived value, file path, HTTP URL, environment prefix or .When() predicate kept deciding on the previous recompute. Where the changing part lives in the query options rather than the provider options, the rule never updated at all. Startup was unaffected because the backplane is not initialized yet and the accessor falls back to pending state, which is why this went unnoticed since 9723080 (v4.2.0) despite contradicting guide/configuration/config-aware.md. - ConfigurationAccessor takes preferPendingState; TenantPipeline exposes a dedicated RecomputeAccessor for the engine, while application-facing reads keep committed-only semantics - ConfigManager routes all five engine entry points through it; the runtime ScheduleRecompute was the load-bearing one, not the init paths - RuleManager folds the query key into the transform key so a changed file name, URL or prefix invalidates the rule cache - StaticJsonProviderOptions drops its GenerateProviderKey() => null override and uses the interface default, which already derives identity from the serialized options; StaticJsonProvider hands back a copy because a shared instance must not expose the buffer RuleManager zeroes No provider-specific behaviour: one central repair covers FromStatic, FromFile, FromHttp, FromEnvironment, .When() and chained derivations, and the chain reaches its leaf within a single pass. Every new test was run against the unfixed tree and fails there. Two adjacent findings are covered by skipped acceptance tests rather than fixed here: per-type reactive emission, and a replaced writable-store backend that never triggers a re-read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was broken
Config-aware rules — the ones documented under Re-evaluation on Change — did not follow their source at runtime:
FromStaticderived valueFromFilederived pathFromHttpderived URLFromEnvironmentderived prefix.When()derived predicateA → B → CStartup was always correct, which is why this went unnoticed in production: an app that wires config-aware rules once at boot and never changes the upstream value at runtime sees no fault.
Root cause
Regression from
9723080("Master Backplane Architecture for Atomic Configuration Updates", 2026-02-03), shipped since v4.2.0 — so v4.2.x, v5.x and v6.x are affected. Before that commitGetConfig<T>()resolved straight out of the JSON repository, which holds the pending values during a recompute.ConfigurationAccessor.GetConfig<T>()asked the backplane first, and the backplane only ever holds the last committed snapshot. The pending-aware path survived as a fallback that fires only while the backplane is uninitialized — hence correct at startup, stale at runtime.StaticJsonProviderOptionshad opted out of the central identity contract withGenerateProviderKey() => null, although the interface default already derives identity from the serialized options.The fix
One central repair, no provider-specific behaviour:
ConfigurationAccessortakespreferPendingState;TenantPipelineexposes a dedicatedRecomputeAccessorfor the engine, while application-facing reads keep committed-only semantics — external readers still never observe a half-built transaction.ConfigManagerroutes all five engine entry points through it. The runtimeScheduleRecomputewas the load-bearing one, not the init paths.RuleManagerfolds the query key into the transform key, so a changed file name/URL/prefix trips the existing dirty mechanism.StaticJsonProviderOptionsdrops the incorrect override;StaticJsonProviderreturns a copy, because sharing is now possible and the interface explicitly requires shared instances to be safe —RuleManagerzeroes every buffer it receives.Verification
Eight acceptance tests across
FromStatic,FromFile,FromHttp,FromEnvironment,.When(), chained derivations, rollback interaction and multi-tenancy. The chain now reaches its leaf within a single pass.Every one of them was run against the unfixed tree via
git stashand fails there — a test that does not catch the bug is worthless, so that was checked rather than assumed.Full suite: 769 passing, 4 skipped, 0 failing.
Deliberately not fixed here
Two adjacent findings ship as skipped acceptance tests instead, because both touch different subsystems:
guide/reactive/basics.md:76promises a subscriber is not called when its type's JSON is unchanged. Every commit re-deserializes every type, so theDistinctUntilChanged(ReferenceEqualityComparer<T>)inMasterBackplane.cs:142can never engage. Impact is spurious wakeups, not wrong values.WritableStoreState.ReplaceBackendswaps the field without signalling a change or invalidating the cache, so a Layer-2FromStore((sp, accessor) => …)seeds correctly at activation but ignores later upstream changes.🤖 Generated with Claude Code