Skip to content

fix(core): config-aware rules follow their source at runtime again - #54

Merged
windischb merged 1 commit into
developfrom
fix/config-aware-runtime-staleness
Aug 4, 2026
Merged

fix(core): config-aware rules follow their source at runtime again#54
windischb merged 1 commit into
developfrom
fix/config-aware-runtime-staleness

Conversation

@windischb

Copy link
Copy Markdown
Contributor

What was broken

Config-aware rules — the ones documented under Re-evaluation on Change — did not follow their source at runtime:

Case Before
FromStatic derived value frozen at its startup value
FromFile derived path never switched files
FromHttp derived URL never switched endpoints
FromEnvironment derived prefix did not follow
.When() derived predicate decided on the previous pass
Chained A → B → C never reached the leaf

Startup 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 commit GetConfig<T>() resolved straight out of the JSON repository, which holds the pending values during a recompute.

  1. The accessor read committed state. 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.
  2. Cache validity ignored the query options. File name, URL and environment prefix live in the query options, not the provider key, so a change invalidated nothing. Stacked on (1), that turned "one pass behind" into "never updates".
  3. StaticJsonProviderOptions had opted out of the central identity contract with GenerateProviderKey() => null, although the interface default already derives identity from the serialized options.

The fix

One central repair, no provider-specific behaviour:

  • ConfigurationAccessor takes preferPendingState; TenantPipeline exposes a dedicated RecomputeAccessor for the engine, while application-facing reads keep committed-only semantics — external readers still never observe a half-built transaction.
  • 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/prefix trips the existing dirty mechanism.
  • StaticJsonProviderOptions drops the incorrect override; StaticJsonProvider returns a copy, because sharing is now possible and the interface explicitly requires shared instances to be safe — RuleManager zeroes 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 stash and 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:

  • Per-type reactive emission. guide/reactive/basics.md:76 promises a subscriber is not called when its type's JSON is unchanged. Every commit re-deserializes every type, so the DistinctUntilChanged(ReferenceEqualityComparer<T>) in MasterBackplane.cs:142 can never engage. Impact is spurious wakeups, not wrong values.
  • A replaced writable-store backend is never re-read. WritableStoreState.ReplaceBackend swaps the field without signalling a change or invalidating the cache, so a Layer-2 FromStore((sp, accessor) => …) seeds correctly at activation but ignores later upstream changes.

🤖 Generated with Claude Code

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>
@windischb
windischb merged commit 9511979 into develop Aug 4, 2026
4 checks passed
@windischb
windischb deleted the fix/config-aware-runtime-staleness branch August 4, 2026 08:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant