Status: accepted · 2026-09-21 · completes ADR-0023
An invalidation reaches every resource that is alive. That is the whole mechanism, and it has a hole in it that a kanban board found in ten seconds:
@invalidates(name: "boards"), so the store invalidates that tag — and
nothing is watching the list, so it reaches nobody.force: false, and the client hands it the answer from step 1.The card counts are wrong, nothing looks broken, and every layer did exactly what it was told. The store cannot fix it alone — the resource did not exist when the invalidation happened — and the cache cannot fix it alone, because it has never heard of a tag.
The workaround an application reaches for is to stop caching that request, which is what the showcase did while this was being decided. That works and it is a shame: the list is exactly the sort of thing a cache is for.
force
when you mount a list”, it is not a fix.cache.read(key, produce, tags), and
invalidate drops matching entries. Direct, and it puts the store’s
vocabulary in the transport — the thing ADR-0022 spent its length refusing.cacheTime and everything under it.force.Option 3.
createData({ remember: 60_000 }); // the default
The store keeps what it invalidated, with a timestamp. A resource declares its tags in the middle of a run — before a client asks its cache, which is what makes this possible at all — and at that moment the store is asked whether an invalidation it never saw applies:
const request: DataRequest = {
signal: controller.signal,
get force(): boolean {
return forced; // raised by `tags()`, read by the client afterwards
},
tags: declare,
};
force is a getter for that reason. A client reports the document’s tags,
the store raises the flag, and the cache lookup that follows sees it. Nothing
at the call site changed, and no client needed a new hook: they already declare
tags before they read, because an invalidation arriving mid-flight had to find
them (ADR-0022).
An invalidation is owed until somebody pays it. When a run with matching tags succeeds, the store drops the memory: the transport now holds an answer from after the change, and the next resource to appear may have it. Without that rule, one invalidation would force every resource created in the following minute.
And tags that arrive too late are honoured anyway. The design above rests
on a client declaring tags before it reads force, which every client in
this project does. A loader that cannot — one that only learns what it fetched
from the reply — would otherwise get the worst of both halves: the cache
answered before the flag went up, the entry it answered from carries no tags
for ADR-0025 to drop, and the run would then
settle the invalidation it had just failed to act on.
So the request notices whether it has been asked. If force was read before
the tags were known, raising it is too late to matter and the run is marked
superseded instead — the same mark an invalidation arriving mid-flight
leaves, and the same consequence: go again, with force, now that the tags
exist. A superseded run settles nothing, because it never reached the server.
The cost is one extra request, in one case: a late declaration that matches an
invalidation from the last remember window. Declaring first remains the fast
path and the documented one.
And it expires. remember is a window rather than a permanent record,
because the memory is about a cache’s contents and a cache entry does not
live for ever. A minute is longer than any sensible ttl and short enough to
be forgotten; remember: 0 switches the whole thing off and restores 0.6.x
behaviour exactly.
force reaches past them and that is the contract. This is about
the cache we ship, and about any other that honours force.One array of { patterns, at }, filtered on read and emptied by success. The
check runs once per declaration — that is once per resource run, over at most
as many entries as there have been invalidations in the last minute, which in
an application under a person’s hands is a single-digit number.
The request object gained a getter in place of a field. That is one property access per client, on a path that is already doing a network request.
Bounded by time rather than by count, which is the right bound here: entries
older than remember are dropped on the next read, and clear() empties them
with everything else.
What a developer does about this: nothing. That is the measure.
What they can do, if they want it: remember: 0 to switch it off, or a longer
window if their cache’s ttl is longer than a minute — the two numbers are
related, and the documentation says so.
invalidate({ persist: true }). An opt-in flag, so the case that is
invisible until it bites is also the one somebody has to know about first.