Skip to content

Architecture remediation: nine review candidates (c1–c9) - #31

Merged
DavidHLP merged 70 commits into
mainfrom
arch/2026-09-22-implement-candidates
Sep 23, 2026
Merged

DavidHLP merged 70 commits into
mainfrom
arch/2026-09-22-implement-candidates

Conversation

@DavidHLP

@DavidHLP DavidHLP commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

Closes #32

Problem

The 2026-09-22 architecture review surfaced nine remediation candidates (c1–c9); this branch implements all nine as ticket branches merged in three waves, plus the post-review follow-up and the review findings recorded below.

What changes

  • c1 — one owner for failure reporting. Package-private FailureReport seam replaces hand-rolled WARN/ERROR + fingerprint pairs at ~23 sites (13 files); key-privacy rule, count-once semantics and metric dimensions preserved; per-site privacy guards kept in FailureLogKeyPrivacyTest (verified non-vacuous by mutation).
  • c2 — metrics resolved at one non-null seam. resi-cache.metrics.enabled is read in exactly one place (ResolvedMetrics), declared in configuration metadata; the disabled path yields a zero-allocation no-op registry, so the null re-checks are gone; test and production paths cross the same resolve.
  • c3 — observer order owned by class-level @Order. The dispatch sort is real (it previously read a class @Order that nothing declared); @Bean-method @Order removed; factory Javadoc corrected; dispatch-order behaviour test added.
  • c4 — handler identity declared on HandlerOrder. Order slot, kebab disable name and metric/log tag declared once, frozen values pinned by HandlerIdentityContractTest; ordering contract (TTL before Actual, Actual last) checkable; skipAll rule guarded through the real factory.
  • c5 — one assembly root per boundary. Regex bean-ownership patterns collapsed onto per-boundary roots naming beans by class; resi-cache.enabled declared once; new SerializationMigrationOperatorConfiguration allowlisted.
  • c6 — one projection feeds both operation views. The AOP operation and the policy view derive from one RedisCacheAttributes instance per annotation with a kind + cacheName index; the Spring-operation/policy-view split is retained (Spring requires it).
  • c7 — unreachable degradation modes deleted (dead null guards, NullValueEncoder wrapper, convenience ctors, test-only surface) — package-private only: public HandlerResult.shouldTerminate() and the ChainObserver SPI kept.
  • c8 — TTL precedence concentrated in TtlPolicy. The annotation's implicit 60 removed, so the resolution has one implicit default — the configured one.
  • c9 — sync role lifecycle owned by the state owner. The 7-argument static is folded into an instance method; the publication lifecycle (enter → complete → exit → cleanup, exactly once) is stated in one place with an exactly-once guard test.

Behaviour changes (⚠️, accepted by the maintainer)

  • ChainObserver.beforeNode removed, dispatch typed (ObserverDispatch<CacheResult> at chain level, ObserverDispatch<HandlerResult> at node level). ⚠️ Breaking for extension authors: an override of beforeNode must move to onNodeStart/afterNode; STABILITY.md §4 carries the migration. Scope tokens stay Object (observer-private per-call state).
  • TTL default 60 → the configured value. @RedisCacheable#ttl/@RedisCachePut#ttl default to 0 ("no method-level declaration"), so an annotated method without an explicit ttl expires after resi-cache.default-ttl (30m) instead of 60 s. Zero, negative and null now all mean "no expiry", matching Spring Data Redis's DefaultRedisCacheWriter.shouldExpireWithin; an explicit positive ttl still wins with its jitter. Recorded in COMPATIBILITY.md, docs/REFERENCE.md and the annotation Javadoc; every input combination is pinned by a test.
  • Protection-health indicator independent of the metrics opt-in, reporting the real mode. It now reports SyncSupport.protectionMode(): fail-fast when no lock backend exists and local-only=false (sync=true operations fail fast on the first cache miss), local-only only when local-only=true is explicitly enabled (single-JVM synchronized, not multi-instance protection), no detail when a lock manager is present (5bf6adfb); the degraded WARN latches to once per mode. Documented in OPERATIONS/COMPATIBILITY/REFERENCE, ⚠️ in CHANGELOG.md.

Follow-up after review — findings all fixed

  • Restored the engine-side rejection of a malformed HandlerResult (38f1ba1d) with HandlerResultProtocolTest; it conflicted with the documented SPI contract (STABILITY.md §4 "Handlers" item 7). Constructor-level rejection unchanged.
  • The metrics seam reads the opt-in before it touches registries (69e3c063): with metrics off, multiple MeterRegistry beans no longer fail startup, and the disabled seam is per-resolution rather than a process-wide static sink.
  • The factory keeps Spring's resolved observer order (453100da); the four standard adapters still declare @Order(1..4) on the class.
  • Disabled-path meter work closed as a class, not site by site. After the deny-all MeterFilter and stateless no-op seam (R1/R7), a full sweep of MeterRegistry touches found eight sites that still did work with metrics off — SerializationMigrationEngine.record, RedisProCacheMetricsRegistry, AbstractCacheHandler.attachMeterRegistry, RefreshTaskMetrics, RedisBloomIFilter.init, ChainTimerChainObserver.onNodeStart/onNodeEnd, the fired-counter observer and the failure reporter — each now short-circuits on the disabled signal derived once at construction (DisabledMetricsRegistry.isDisabledSeam(registry)), with no per-call registry checks and no reintroduced nulls. Red-first evidence: a recording MeterFilter measured four seam counter calls for four migrated keys before the guard, zero after; the timer-observer map assertions read 100/1/2 entries before, 0 after.
  • Other review findings: annotation value/cacheNames precedence restored to main's with the policy now applying to the cache actually used (R2); handlerTag() resolved once per handler class, off the request hot path (R3); FailureLogKeyPrivacyTest restored with 12 site guards (R4); the cached-null DEBUG line restored with main's wording (R5); the health indicator's per-probe Redis cost documented in docs/OPERATIONS.md (R6); failure-report shape qualified for throwable-less reports (a0f03676); CHANGELOG roll-up (44a0b59f, wording 5a02c38e).

Open items — deliberately not fixed

  • LocalBloomIFilter's cacheName-keyed localFilters/locks maps are populated independently of the metrics switch — pre-existing on main with a different trigger, reported for a follow-up.
  • Each RedisProCache call site allocates a capturing Supplier/Runnable before entering the metrics seam — once per operation, not per meter. This is not meter work, and no seam-side change removes it.

Maintainer ratification points

  • c2 deviation from ticket wording: the key is read through the single ResolvedMetrics seam plus additional-spring-configuration-metadata.json, not bound as a RedisProCacheProperties field — binding a fixed contract key would add a tenth public nested type (allowlist + STABILITY.md §4 churn) for an assembly detail.
  • c4 class-name fallback: a host handler without a declared identity keeps the class-simple-name tag, which is what main emitted for every handler; removing it would change frozen tag values.
  • c6 scope: recorded in docs/ARCHITECTURE.md as "one projection, both views".
  • c5 new public type: SerializationMigrationOperatorConfiguration, an allowlisted assembly root pinned by SerializationMigrationCliContractTest.

Verification

Current head 5bf6adfb — PR Pipeline run 35708615591 green end to end: ./mvnw clean verify -B with the JaCoCo coverage gate and check-test-names.sh, unit tests without Docker, lint/checkstyle, docs ↔ source consistency, quality advisories, bench module, package, ci-ok. Earlier full-gate runs on pushed heads d039c16c / 91b4af3d / 11b0a754 / d3307297 (remote JDK 21; clean verify, checkstyle, check-test-names.sh, check-docs-contracts.sh, check-external-consumer.sh all green) are superseded by this head; regression guards were confirmed red-first against the pre-fix implementations.

The runtime root now owns the `resi-cache.enabled` gate alone and imports
the enablement validation it protects; the second auto-configuration entry
and its duplicate gate are gone. Internal classes registered by a boundary
root's explicit import carry no component stereotype, so the runtime scan
no longer needs class-name patterns for them, and the operator CLI names
the beans it needs by class instead of scanning the runtime package.

A class rename in either boundary now fails compilation instead of
silently emptying a context: the operator root is excluded by class, and
the CLI excludes the runtime auto-configuration by class rather than by a
property string.
The contract test stops string-syncing exclusion patterns: it asserts the
runtime scan names its one excluded boundary class by identity, that the
only name pattern left is the same-package test-class filter, and that
the auto-configuration imports resource registers exactly the runtime
root by class name. The backoff invariant now enumerates @bean methods
instead of a hand-maintained list, so a new default bean without
@ConditionalOnMissingBean fails. The CLI contract test proves the operator
context assembles the migration beans without the runtime auto-config.
23 failure paths in 13 files each re-applied the same prose rule by hand
("WARN/ERROR carries cacheName-or-fingerprint, never raw key or exception
message; the full stack stays at DEBUG"), so the rule only existed per site and
the two remediation commits that introduced it (e598693, 34fc53d) had to patch
sites one by one. FailureReport now takes what failed (text + raw key + throwable)
and emits the sanctioned pair; callers no longer choose levels, pair DEBUG with
WARN, or format fingerprints.

The privacy rule becomes structural: a raw key only enters as a key and is
fingerprinted by the seam, a throwable only as a throwable and rendered as its
type chain. Typed exception messages take the same fingerprint helper. Metric
(one report per failure), classification and count-once semantics are untouched,
and each call site keeps its own logger so log categories stay stable.

Two wording tokens move to the shared context suffix: LoaderOrchestrator's
documented CacheErrorHandler bypass keeps its behaviour (single redacted WARN,
no metric) but now reports "cause=" like every other site, and the sites whose
fingerprint label was ad hoc use the canonical "keyFingerprint=".
FailureLogKeyPrivacyTest enumerated the rule one method at a time (591 lines,
one appender plus one fragment list per site) and asserted DEBUG wording the
seam now owns, so it grew with every new failure path. FailureReportTest tests
the rule once (one WARN/ERROR plus one stacked DEBUG, raw key and exception
message never rendered, context assembled from what is available) and keeps
thin per-site coverage for the shapes production actually crosses: Bloom fail-open,
refresh retries, local-only degradation and observer isolation.
LockStack.close has no cache key in scope (it only knows the handles), so the
release-failure report keeps the context-less form it always had: one ERROR
with the type chain plus the stacked DEBUG pair.
TTL precedence used to be decided by branch order inside TtlHandler while
the same 60-second default was also declared by the annotations, the Spring
@cACHEpUT adapter's builder and the handler fallback. Add package-private
TtlPolicy as the single resolution point (annotation > Duration parameter >
fallback default; zero/negative parameter means permanent) and let
TtlHandler only apply the decision, keeping its three debug log messages and
the jitter counter semantics unchanged.

The annotation default and the Spring @cACHEpUT builder default stay at 60s
because both are reachable and dropping either would change behaviour; the
conflict with resi-cache.default-ttl is recorded once, in TtlPolicy, as an
open product decision.
TtlPolicyTest pins every reachable input combination without a handler
chain: attribute set / unset (the annotation's own 60s default, proven
through projector -> operation -> policy), explicit ttl=0 with and without a
Duration parameter, zero/negative parameter, no declaration at all, and the
configured 30m default. The jitter bounds tests move with the method they
cover, and TtlHandlerTest keeps the handler-level application and counter
cases (including the parameter path, which must not count as jittered).
The local-only degradation WARN is emitted through the elected role's logger
(Leader carries the key context), not SyncSupport's, and the no-throwable warn
is asserted on rendered messages instead of the concatenated text.
AnnotationParser built every RedisCache annotation twice: RedisCacheAttributes
for the policy operation and an independent BuilderPopulator field list for the
Spring-facing operation, so the two graphs could disagree on any shared field
(they did: the AOP face preferred `value`, the projection preferred
`cacheNames`) and one new field had to be threaded through both. Both faces now
derive from one RedisCacheAttributes instance per annotation, and the shared AOP
field set is declared once (applyToSpringCommonFields) beside the policy
COMMON_SINKS mapping, so the values cannot diverge.

The snapshot indexes policy operations by kind + cacheName when it is built, so
RedisCacheRegister.get no longer depends on a backwards scan whose overwrite
semantics lived in a comment; "last declaration wins" is stated and implemented
once in ParsedAnnotations.PolicyIndex.

Deleted the dead injector seam: RedisCacheAttributesProjector and
SpringCacheableAdapter carried @component with no injectors, and the
two-argument AnnotationParser constructor that accepted them had no caller.
Javadoc that understated what a new field costs (projector,
RedisCacheAttributeSink, BuilderPopulator) now records the measured inventory.

AnnotationAopBehaviorMatrixTest pins both faces coming from one projection, the
shared value/cacheNames resolution, and last-wins for repeated declarations.
The nested proxy-eligibility gate is a member configuration class, and
Spring processes member classes only for a @Component-annotated
configuration, so dropping @configuration from the outer class silently
removed the proxy advisor and interceptor (caught by the default-assembly
contract test). The outer keeps the stereotype; the inner gate stays
without one so the scan registers the outer exactly once. The contract
test now reads the aliased `classes` attribute it actually declares.
…ergence

Closes the ratified T8 (c8) "preserve + document" second half: TtlPolicy
already concentrates TTL precedence in code, and this records it in the
canonical docs without touching any default value.

Precedence semantics (owner: docs/REFERENCE.md) state the verified order —
annotation ttl>0 (default 60s, jittered) > Duration parameter carrying
resi-cache.default-ttl (only when no method-level TTL is set; zero/negative
parameter = permanent) > null-parameter fallback 60s — all resolved once in
TtlPolicy. The 60s-vs-configured-default divergence is recorded as a
supported-behaviour limitation in COMPATIBILITY.md; the two pages
cross-reference instead of duplicating.

Every reachable per-input outcome is preserved unchanged (annotated -> 60s;
plain Spring / ttl=0 -> configured default; null Duration -> 60s). Verified
against TtlPolicy.resolve and RedisProCacheProperties; docs-only change,
scripts/ci/check-docs-contracts.sh passes.
…ssembled collaborators

The null-handling branches in RedisProCache, RedisProCacheWriter,
CacheOperationResolver and LoaderOrchestrator existed only so tests could
pass null; production wires the collaborators unconditionally (verified
against RedisProCacheConfiguration and PublicSurfaceContractTest / external-
consumer surface). HandlerResult is untouched so the public shouldTerminate()
contract stays intact.

- make the loader-path collaborators (operationResolver, bloomGate,
  syncSupport, syncLockTimeout) requireNonNull at construction, matching
  production assembly; drop ResiCacheFeatures.none() and per-caller null guards
- fold the NullValueEncoder wrapper into CacheValueCodec.toValueBytes (Java
  null and NullValue.INSTANCE now share one null-placeholder branch)
- delete the two CacheHandlerChainFactory convenience constructors
- drop ChainEngine's re-check of HandlerResult's constructor-enforced invariant
  and the duplicate shouldHandle dispatch in AbstractCacheHandler
- adapt the affected unit/integration tests to pass real collaborators
NullValueEncoderTest exercised the wrapper deleted last commit; its
null-vs-NullValue decision is now covered by CacheValueCodecTest.
HandlerResultProtocolTest only asserted ChainEngine's null-decision re-check,
which is unreachable by construction (HandlerResult's record constructor
rejects a null decision; the test reached it solely by mocking a final record).
shouldTerminate() coverage is unaffected — HandlerResult and its existing
TtlHandler/BloomFilter/ActualCacheHandler tests are unchanged.
Route WARN/DEBUG failure pairs through the internal FailureReport/FailureDiagnostics
seam so callers stop re-deriving the key-privacy rule by hand.
Remove dead null-guard branches, the NullValueEncoder wrapper, convenience ctors and
test-only surface; keep public HandlerResult.shouldTerminate() and beforeNode per the
ratified decision.
Collapse the four regex bean-ownership declarations onto per-boundary roots, declare
resi-cache.enabled once, and make the backoff invariant enumerate bean methods.
Collapse the two positionally-associated operation graphs into one operation model;
record the contract decision dissolving the ARCHITECTURE.md operation-representation
prohibition and reconcile the projector/sink Javadoc with reality.
Resolve TTL from annotation/parameter/configured in one module with the precedence
visible in its interface; TtlHandler applies the decision. Preserve per-path behaviour;
record the precedence rule in REFERENCE.md and COMPATIBILITY.md.
…an method

The factory's observerOrder() reads the class-level @order, but @order lived
on the four @bean methods, so the sort was a no-op and the "registration
(@order) order" STABILITY.md §4 promises was only incidentally satisfied by
injection order. Move @order(1..4) onto MDCStamp/ChainDebugLog/ChainTimer/
FiredCounter observer classes so the factory sort is the single declaration
actually in force, and correct the factory/config Javadoc that claimed Spring
pre-sorts the injected list.
Add a behavioral ChainObserverTest case that injects three @order observers in
reverse order through the real factory and asserts they fire in @order order
(hook sequence via observable effect, no reflection). Update
RedisProCacheConfigurationContractTest to assert @order on the observer
classes (the location the factory reads) instead of the inert bean methods.
…ion-surface test

c7 made LoaderOrchestrator's constructor require bloomGate/syncSupport/syncLockTimeout
(unreachable null branches deleted). boundCallbacks_productionEntryUsesOnlyLoaderAndKey
still passed nulls and now NPEs. Reuse the migrated bound() helper (real no-op
collaborators) so the case keeps its intent: production orchestrate crosses only
(loader, key). Assertions unchanged.
…P face

c6 made AOP and policy operations derive from one RedisCacheAttributes projection
so the two faces cannot disagree, but AnnotationAopBehaviorMatrixTest.
bothFacesComeFromOneProjection failed: for an unset `unless` the policy builder set
the raw "" while the AOP builder (and SpringCacheableAdapter) guard text fields with
hasText and leave it null — two representations from one projection.

Route the policy applyTo(Cacheable/Put) `unless` through BuilderPopulator.applyText,
the same seam the AOP face and SpringAnnotationAdapter already use, so a blank unless
is null on both faces. Real values are unaffected.
resi-cache.metrics.enabled is now read in exactly one place
(ResolvedMetrics.resolve), and a null Environment no longer skips the
opt-in check, so test and production assembly cross the same rule. The
seam is never null: the disabled case is a shared no-op
CompositeMeterRegistry, which removes the per-caller registry null
re-checks in the chain factory, the timer/fired observers, the failure
reporter and the migration engine.

RedisCacheHealthIndicator no longer borrows the metrics switch as its
activation gate and drops the properties field it never read; its health
details and ordering are unchanged.
Factory tests no longer pass a null Environment to bypass the opt-in:
they resolve the seam with an explicit Environment (disabled by
default, enabled where the fired counter is asserted). Observer and
reporter tests hand out the shared no-op seam instead of null, and the
assembly contract now asserts the disabled case is that seam. The health
indicator test follows its two-argument constructor.
REFERENCE.md names resi-cache.metrics.enabled (default false, read once by
ResolvedMetrics, declared in the additional configuration metadata),
OPERATIONS.md states that the disabled case is a no-op seam and that the
Redis health indicator is not metrics-gated, and COMPATIBILITY.md drops
the stale statement that the indicator requires the metrics property.
Fold the re-entrant 7-argument SyncSupport.executeRoleWork static into an
executeRoleWork method on the SyncStateAccess owner, and move the SyncState
registry impl next to the roles it mutates so state and lifecycle read in one
file. Extract the once-only publication ordering (enter -> complete -> exit ->
cleanup) into Leader.finishPublication as a stated protocol. Behavior, log
strings/levels, lock pairing and the public nested role types are unchanged; add
a SyncRoleTest check that the lifecycle registers each step exactly once.
DavidHLP and others added 16 commits September 22, 2026 11:43
The class-level @order must reach Spring's list injection, not just an
instance-level comparator: if it stopped being honoured, the order would
fall back to bean-name order and the debug log would read the request id
before it is stamped.
… hook

The observer dispatch helper now carries each level's own result type
(CacheResult at chain level, HandlerResult at node level), so the two
downcasts in ChainEngine are gone; scope tokens stay Object, paired back
positionally per observer. MDCStampChainObserver and
ChainTimerChainObserver no longer re-check their own token's runtime type:
the engine's index pairing makes the token their own reference, so the
private token type is a cast, not a defensive instanceof.

beforeNode had zero production implementers (grep over src/main: 12 hits,
all javadoc plus the hook declaration and its dispatch call, no @OverRide
in any adapter); it is removed from the SPI and the engine's node loop is
onNodeStart -> handler.handle -> afterNode -> onNodeEnd. Order and content
of every remaining dispatch are unchanged.

BREAKING CHANGE: ChainObserver.beforeNode(CacheHandler, CacheContext) is
removed. Move node pre-execution work to onNodeStart (or afterNode when
the evaluated result is needed); per-call state moves with the documented
onNodeStart/onNodeEnd token pairing. STABILITY.md §4 Observers records the
migration path.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The `ttl` attribute of `@RedisCacheable`/`@RedisCachePut` defaulted to 60
seconds and `TtlPolicy` reads "policy ttl > 0 wins", so an annotated method
that never set `ttl` took 60 s and pre-empted the configured cache TTL.

Re-point the unset sentinel at 0 — the encoding `resolve` already reads as
"no method-level declaration" and the one `@RedisCacheEvict.ttl()` already
uses — so an unset attribute falls through to `resi-cache.default-ttl`
(30m unless overridden, per-cache `caches.*.ttl` still applies). Collapse
`RedisCachePutOperation.Builder.ttl` (60) onto the same sentinel as
`RedisCacheableOperation`/`RedisCacheEvictOperation` (0), leaving the
annotation as the only place a method-level TTL can be declared.

Delete `TtlPolicy.DEFAULT_TTL_SECONDS` and the branch that applied it when
the Duration parameter is null. That branch is not reachable on a write
path: TtlHandler only handles PUT/PUT_IF_ABSENT, whose Duration SDR 4.0
computes from `RedisCacheConfiguration`'s `TtlFunction` — `persistent()`
(i.e. `Duration.ZERO`, not null) when no TTL is configured, and `entryTtl`
rejects null — while ResiCache's own null-TTL writes are GET/REMOVE/CLEAN.
A null parameter now means the same thing SDR's own writer means by it
(no expiry): permanent, like zero and negative. `TtlPolicy` keeps the
precedence visible as annotation > parameter (the configured default as
Spring computes it) > permanent.

Co-Authored-By: Claude Code <noreply@anthropic.com>
`docs/REFERENCE.md` stated the annotation `ttl` default as 60 and that a
null Duration parameter falls back to a 60-second default, and
`COMPATIBILITY.md` recorded the 60s-versus-30m divergence as an unresolved
product decision kept as-is. Both are now false.

State the decided resolution in `REFERENCE.md` — annotation `ttl` greater
than zero is the only declaration that overrides the configured default; an
unset attribute falls through to `resi-cache.default-ttl`; a zero, negative
or null parameter means no expiry. Record the behaviour change and its
migration options in `COMPATIBILITY.md`, including the corrected claim that
a caller-supplied `RedisCacheConfiguration` yields a zero (`persistent()`)
parameter, not a null one.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The disabled path was an empty `CompositeMeterRegistry`, which publishes
nothing but still retains every meter id and tag string it is asked for in
its own meter map. With dynamically named caches that map grows for the life
of the context. Replace it with `DisabledMetricsRegistry`: a stateless
`MeterRegistry` over Micrometer's public `io.micrometer.core.instrument.noop.*`
types, whose constructor installs a deny-all `MeterFilter` so the base
class short-circuits before its own `meterMap.put` and nothing is retained.
Because it holds no state it can be a single JVM-wide instance, which is
what `docs/REFERENCE.md` already claimed; the Javadoc and the reference now
state the real sharing behaviour.

Covered by `DisabledMetricsRegistryTest` (same instance across resolutions,
meter table stays empty over repeated dynamic-name registrations) and the
updated no-registry choice assertion in the configuration contract test.

Co-Authored-By: Claude Code <noreply@anthropic.com>
`main` built the Spring operation with `ann.value().length > 0 ? ann.value()
: ann.cacheNames()` at all three `parseRedisCache*` sites, so `value` won on
the operation face. The c6 unification routes both faces through
`RedisCacheAttributesProjector.resolveCacheNames`, which preferred
`cacheNames` - a silent precedence flip for a declaration that sets both
attributes (`@RedisCacheable(value="a", cacheNames="b")`).

Both-set behaviour, stated explicitly:
- on `main`: the operation targeted cache `a` (`value`), while the policy
  snapshot was registered under `b` (`cacheNames`), so the declared policy
  silently did not apply to the cache that was actually used;
- now: both faces resolve to `a` (`value`). The cache in use is unchanged
  from `main`, and the policy now applies to it; the `cacheNames` alias no
  longer carries a second policy snapshot.
Only the both-set declaration changes; declaring a single attribute - the
overwhelmingly common case - is unaffected.

The single resolution for both faces is kept (the c6 goal). The rule is
recorded in COMPATIBILITY.md and in the three annotations' `value` /
`cacheNames` Javadoc, and pinned by
`AnnotationAopBehaviorMatrixTest.aliasResolutionIsSharedByBothFaces`,
`AnnotationPolicySnapshotTest.bothCacheNameAttributesSet_valueWinsOnBothFaces`
and the projector's `value_wins_over_cacheNames`.

Co-Authored-By: Claude Code <noreply@anthropic.com>
`CacheHandlerChain.handlerTag` is evaluated on the request hot path - per
node per request by the Engine post-processing log and by
`FiredCounterChainObserver.afterNode` - and the argument is evaluated even
when DEBUG is off. Since c9 it is `HandlerIdentity.of(handler).tag()`, i.e.
a reflective `getAnnotation(HandlerPriority.class)` plus a record allocation
per call.

Identity depends only on the handler class, so memoize the resolution in a
`ClassValue` keyed by class: one reflective lookup per class for the life of
the loader, and no static map holding strong class references across
classloaders. Tag values are unchanged, including the class-simple-name
fallback for handlers with no declared identity.

`HandlerIdentityContractTest.identityIsResolvedOncePerHandlerClass` pins
stable tags and same-instance resolution for both the annotated and the
class-name-derived path.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Deleting `NullValueEncoder` folded the null decision into
`CacheValueCodec.toValueBytes` and dropped the DEBUG line `main` emitted at
that decision. Log wording is frozen, so losing it is an observable change;
the codec has no cacheName/key context to log from.

The decision now lives once, in `CacheValueCodec.isNullDecision`, and both
byte production and the log use it. The two `toValueBytes` call sites
(`ActualCacheHandler` cached-hit and PUT_IF_ABSENT-existing) each hold the
context, so a private `encodeForReturn(value, context)` in `ActualCacheHandler`
carries the line with `main`'s exact wording and level - the codec itself stays
context-free.

Scope note: the restored line fires on the codec's whole decision, i.e. also
when the value in hand is `NullValue.INSTANCE`. `main`'s trigger was the
narrower `value == null` (its encoder saw a non-null `NullValue` and logged
nothing); both cases mean the same thing - the null sentinel is being returned -
and the cached-null read path (`CachedValue` payload `null`) is the case `main`
did log, so that observable line is restored byte-for-byte.

`ActualCacheNullReturnLogTest` pins the line for a null payload and for
`NullValue.INSTANCE`, and its absence for a normal value.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Removing the `@ConditionalOnProperty(resi-cache.metrics.enabled)` gate is
already documented, but the load it adds is not: every application that has
Actuator, Redis and ResiCache now assembles `RedisCacheHealthIndicator`, so
each `/actuator/health` probe runs a synchronous `connection.ping()`.

State the per-probe round trip, that it is per application instance, and that
the previous behaviour was no probe traffic at all when metrics were off;
point at the connection-pool/probe-interval sizing consequence. The
Actuator row in COMPATIBILITY.md carries the same note. No code change.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Replacing `FailureLogKeyPrivacyTest` (591 lines) with `FailureReportTest`
(294 lines) kept the seam plus BloomSupport, RefreshRetryPolicy, ChainEngine
(observer) and SyncSupport, but dropped the per-site guards: `FailureReport`
makes the key *argument* safe, yet nothing stopped a call site from
concatenating the raw key into the free-text argument, and no test failed if
one did.

Restore one thin guard per dropped site, each asserting that the raw key
placed in that site's context does not appear in its captured WARN/ERROR text
(including the throwable message chain, which is where a leak would land).
Sites covered: `RedisBloomIFilter` (add/check/delete), `DistributedLockManager`
(acquire timeout WARN, interrupted ERROR, release retry WARN, release-exhausted
ERROR, interrupted-during-retry ERROR), `ChainEngine` post-processing
(execution and predicate), `EarlyRefresh` async refresh, `SyncRoleLockExecutor`
lock acquire and release, and `SerializationMigrationEngine` forward plus
rollback rejected key - the last of which the pre-replacement suite did not
cover either (byte[] keys, never guarded before). Sites already covered
elsewhere by message assertions (`DistributedLockManagerIntegrationTest`
interrupt, `SyncSupportTest`, `BloomFailureLogKeyPrivacyTest` check) are not
duplicated.

Class list, before -> after:
- dropped: `FailureLogKeyPrivacyTest` (591 lines)
- kept: `FailureReportTest` (294 lines, unchanged)
- added: `FailureLogKeyPrivacyTest` (473 lines, 12 site guards)
Verified non-vacuous by mutation: making the seam emit the raw key turns 6 of
the 12 guards red.

Co-Authored-By: Claude Code <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 91b4af3ddd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

DavidHLP and others added 4 commits September 22, 2026 14:09
The guard removal in 9e4c2fd made the no-op sink reach three writers that
still built their keys and filled their own maps, so "metrics off" retained
state again: one TimerKey per distinct cacheName, one fired counter per
handler class, one failure counter per tag combo.

Derive the flag once at construction from DisabledMetricsRegistry, the sink
that owns the meaning of "disabled" (isDisabledSeam), and return before key
construction, map write and Noop* allocation. No caller sees a null seam and
no site re-reads resi-cache.metrics.enabled.

Regression: ChainObserverTest.noopSeam_retainsNoTimers drives 100 distinct
cacheNames through the production ResolvedMetrics disabled path; the two
no-op seam tests now assert the maps stay empty instead of only "no throw".

Co-Authored-By: Claude Code <noreply@anthropic.com>
The un-gating in 9e4c2fd made the indicator unconditionally assembled, so
its existing WARN was re-emitted on every health probe. The degradation is
construction-constant (LockManager list plus the local-only property), so
one latched WARN per context carries the same information without a
per-probe log volume that scales with the probe cadence.

Level, wording and both protection.degraded detail keys are unchanged, and
every probe still reports the degraded state in its response; the indicator
is not re-gated on any property.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ontract

Four javadocs still asserted that a null MeterRegistry means metrics are
off. ResolvedMetrics has returned the shared stateless
DisabledMetricsRegistry.INSTANCE since the seam commit, so those claims were
false and read as evidence that disabled-path retention was impossible.

State the current contract instead: the seam is never null, metrics-off is
signalled by the shared INSTANCE and can be queried with isDisabledSeam, and
registrations on it publish and retain nothing. Comment text only.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…fixes

The disabled-seam work replaced "registry == null" with a never-null shared
no-op sink, but two files still documented the retired contract. Restate both
truthfully: production never injects null, the disabled sink allocates no-op
meters that publish and retain nothing, and the null branch survives only for
the test/defensive path.

Also roll up the two behaviour notes into CHANGELOG.md: the disabled seam keeps
no meter, timer or per-cache entry, and the degraded-protection warning fires
once per context instead of once per health probe.

Co-Authored-By: Claude Code <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 11b0a75462

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

DavidHLP and others added 4 commits September 22, 2026 15:04
record(outcome) is the engine's only MeterRegistry touch and is reached
1-4 times per scanned key. On the shared disabled seam the deny-all filter
stops retention but not the work: each call rebuilt the varargs tag array,
constructed a Meter.Id, walked the registry filter under the meter map lock
and allocated a NoopCounter that was discarded.

Derive the seam identity once in the constructor and return before that
work, byte-for-byte the pattern already used by ChainTimerChainObserver,
FiredCounterChainObserver and CacheFailureReporter. The guard sits at the
single choke point, so all eight call sites are covered without a per-site
enabled check.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…stry

The class's null-registry short-circuits were dead code, because the seam is
never null. Widening the two shared registration helpers to reject the
disabled seam makes all seven fields null on that path, so every existing
null guard goes live again with no new field and no per-call check.

That removes two residues at once: seven Meter.Id builds, filter walks,
Noop* allocations and meter-map-lock acquisitions per distinct cache name,
and the per-operation cost of holding Noop* meters, which made every get,
put, evict and clear pay two clock reads, a try/finally and a virtual no-op
record through the guards that were meant to short-circuit it.

The null-registry path keeps its behaviour: isDisabledSeam(null) is false,
so a null argument still yields the same null fields.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…on path

Three more consumers registered through the shared disabled sink and then
kept non-null Noop* fields, which defeated the null short-circuit they
already had. Widen the single hook each class owns so the disabled seam
falls into the null path the class already handles: the handler base's
semantic counter (read inside the per-node chain loop), the refresh-task
metrics' three counters (read on every submit, completion and cancellation)
and the bloom filter's two failure counters (read on the failure path).

The null arm stays, so the null-registry path the tests exercise directly is
byte-identical. The two small package-private observers exist only so the
cold sites can be asserted on; the disabled path has no other observable
output, since the seam returns non-null no-op meters and retains nothing.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The disabled-seam round guarded the timer observer's onNodeEnd but left
onNodeStart constructing a TimerScope unconditionally, so every handler node of
every cache operation still read the clock and allocated a token that the chain
then discarded - the default configuration, one allocation per node.

Return null on the disabled path, which is what ChainEngine already pairs back
and what this observer did before the non-null seam existed. The observer's
class Javadoc and the onNodeEnd comment now state both null-token sources
(disabled seam, or a failed start hook) instead of only the latter.

Red-first: ChainObserverTest.noopSeam_doesNotAllocateScopeToken fails with
"expected: null" against the pre-fix onNodeStart and passes after.

Co-Authored-By: Claude Code <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d330729716

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

SyncSupport.isDegraded() was true exactly when no lock backend existed and
local-only was NOT enabled — the fail-fast state — yet the indicator labeled
that observation protection.degraded=local-only, while the explicit local-only
degradation it names reported nothing. SyncSupport now owns one ProtectionMode
derivation (DISTRIBUTED / LOCAL_ONLY / FAIL_FAST); the indicator attaches the
matching detail per mode, warns at most once per mode, and omits protection
details when a backend exists. CHANGELOG and OPERATIONS updated to the shipped
semantics.
@DavidHLP
DavidHLP merged commit 24e9c90 into main Sep 23, 2026
18 of 20 checks passed
@DavidHLP
DavidHLP deleted the arch/2026-09-22-implement-candidates branch September 23, 2026 12:09
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.

refactor(architecture): implement 2026-09-22 review candidates c1–c9

1 participant