ICheckpointInventory is an optional capability: a checkpoint store that does not implement it opts out of the orphaned-checkpoint check, and OrphanCheckpointHostedService receives null.
A decorator cannot answer that question statically. CachingCheckpointStore always implements the interface, but can only really enumerate when the store it wraps does. A plain cast would therefore always succeed and hand back a decorator that cannot answer — a false all-clear under OrphanCheckpointPolicy.Strict, where "no orphans found" is indistinguishable from "we could not look".
Alberto worked around this with an internal AsInventory gateway plus a concrete-type test at the single resolution site:
sp.GetKeyedService<ICheckpointStore>(serviceKey) is CachingCheckpointStore caching
? caching.AsInventory
: sp.GetKeyedService<ICheckpointStore>(serviceKey) as ICheckpointInventory,
That makes the seam shallow: to use ICheckpointInventory correctly you must know which concrete decorator types exist, and only Alberto's own internal type can answer the capability question. A third-party decorator over a non-enumerable inner store falls into the as branch, is treated as a live inventory, returns [], and silently clears a check it never performed.
The interface includes its error modes. "I might not be able to answer" is one, and it was missing.
Fix
Add bool CanEnumerate => true; as a default interface member. CachingCheckpointStore overrides it to _inner is ICheckpointInventory; the resolution site collapses to a single call that names no concrete type:
sp.GetKeyedService<ICheckpointStore>(serviceKey) is ICheckpointInventory { CanEnumerate: true } inv ? inv : null,
AsInventory is deleted — the deletion test passes, it was internal with exactly one call site. The NotSupportedException inside ListProcessorIdsAsync stays as a backstop for a caller that ignores CanEnumerate, since returning an empty list there would recreate the same false all-clear.
Public API
Purely additive. One new entry in PublicAPI.Unshipped.txt; PublicAPI.Shipped.txt untouched and no *REMOVED* entries. The DIM default means InMemoryCheckpointStore and PostgresCheckpointStore need no change.
ICheckpointInventoryis an optional capability: a checkpoint store that does not implement it opts out of the orphaned-checkpoint check, andOrphanCheckpointHostedServicereceivesnull.A decorator cannot answer that question statically.
CachingCheckpointStorealways implements the interface, but can only really enumerate when the store it wraps does. A plain cast would therefore always succeed and hand back a decorator that cannot answer — a false all-clear underOrphanCheckpointPolicy.Strict, where "no orphans found" is indistinguishable from "we could not look".Alberto worked around this with an
internalAsInventorygateway plus a concrete-type test at the single resolution site:That makes the seam shallow: to use
ICheckpointInventorycorrectly you must know which concrete decorator types exist, and only Alberto's own internal type can answer the capability question. A third-party decorator over a non-enumerable inner store falls into theasbranch, is treated as a live inventory, returns[], and silently clears a check it never performed.The interface includes its error modes. "I might not be able to answer" is one, and it was missing.
Fix
Add
bool CanEnumerate => true;as a default interface member.CachingCheckpointStoreoverrides it to_inner is ICheckpointInventory; the resolution site collapses to a single call that names no concrete type:AsInventoryis deleted — the deletion test passes, it was internal with exactly one call site. TheNotSupportedExceptioninsideListProcessorIdsAsyncstays as a backstop for a caller that ignoresCanEnumerate, since returning an empty list there would recreate the same false all-clear.Public API
Purely additive. One new entry in
PublicAPI.Unshipped.txt;PublicAPI.Shipped.txtuntouched and no*REMOVED*entries. The DIM default meansInMemoryCheckpointStoreandPostgresCheckpointStoreneed no change.