diff --git a/CHANGELOG.md b/CHANGELOG.md
index e7e9b70e..0f6d8bda 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,14 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Deploy this version with a new `CacheVersionModule` version (an app that binds its own cache adapter rather than this package's `Storage*Module`s must clear its pools instead): entries written by earlier versions may hold per-user content, and an earlier version reading this version's donut cache shows raw `[le2:...]` placeholders during a rolling deploy.
-- A `#[Cacheable]`/`#[CacheableResponse]`/`#[DonutCache]` response whose own code sets `Cache-Control` to `private` or `no-store` is not stored server-side; this includes a `$headers` property default such as `[ResponseHeader::CACHE_CONTROL => CacheControl::NO_STORE]`. To keep server-side caching with a client-facing `no-store`, declare `#[HttpCache(noStore: true)]` instead, which is still stored.
+- A `#[Cacheable]`/`#[CacheableResponse]`/`#[DonutCache]` response whose own code, including a `$headers` property default, sets `Cache-Control` to `private` or `no-store` is not stored server-side. To keep server-side caching with a client-facing `no-store`, declare `#[HttpCache(noStore: true)]` instead.
- `ResourceDonut::FORMAT` is no longer public; use `ResourceDonut::placeholder()`.
- Requires `koriym/semantic-logger` `^0.11`. Written logs use its `log` document shape, and diagnostic `kind` is now `code`.
- Requires `bear/resource` `^1.22.4`.
- `ResourceDonut::refresh()`: a hole that throws while an already-cached `#[DonutCache]` page is recomposed now degrades to a fresh render instead of failing the request directly from the refresh, the same way a cache-store failure already does. A hole that keeps throwing still fails the request, now from the fresh render instead of the refresh.
- `CacheDependency::depends()` no longer unsets the child's own `Surrogate-Key` header after copying it into the parent: a child that is both read by the parent and later rendered as a hole keeps its own key for its own independent cache entry.
+- `KeepMutationsAndFailures` no longer keeps a session only because it contains a `semantic_logger_error` diagnostic; a session is kept for a mutation, a failed effect or the sample.
+- Interceptors and other implementation classes that applications do not construct are now marked `@internal`; reference the attributes, interfaces and modules instead.
### Fixed
+- A cached response whose `Cache-Control` contains `no-store` (e.g. `#[HttpCache(noStore: true)]` or `#[NoHttpCache]`) no longer gets a `max-age` appended, so the client header is not a contradictory `no-store, max-age=…` (#243).
- `LogFileWriter`: two sessions flushed in the same microsecond no longer overwrite each other. The timestamped filename now carries a random suffix, so both survive (#221).
- A `#[DonutCache]` page embedding an `#[Embed]` hole whose URI carried two or more query parameters leaked a raw `[le:...]` placeholder to the client and could throw `ParameterException` on the next request - fixed by moving new placeholders to a `[le2:...]` marker (#232).
- An exception while rendering a `#[DonutCache]`/`#[CacheableResponse]` page - a hole's own resource or the page's own template - no longer stores an empty template that is served to every later visitor; the failure now surfaces to the caller instead of being swallowed into an empty page.
diff --git a/README.ja.md b/README.ja.md
deleted file mode 100644
index b867c7a7..00000000
--- a/README.ja.md
+++ /dev/null
@@ -1,28 +0,0 @@
-# BEAR.QueryRepository
-
-[](https://scrutinizer-ci.com/g/bearsunday/BEAR.QueryRepository/?branch=1.x)
-[](https://codecov.io/gh/koriym/BEAR.QueryRepository)
-[](https://shepherd.dev/github/bearsunday/BEAR.QueryRepository)
-[](https://github.com/bearsunday/BEAR.QueryRepository/actions/workflows/coding-standards.yml)
-[](https://github.com/bearsunday/BEAR.QueryRepository/actions/workflows/static-analysis.yml)
-[](https://github.com/bearsunday/BEAR.QueryRepository/actions/workflows/continuous-integration.yml)
-
-**BEAR.QueryRepository** は、[CQRS](http://martinfowler.com/bliki/CQRS.html) にインスパイアされた分散キャッシュフレームワークで、BEAR.Resource アプリケーションのパフォーマンスとリソース利用効率を最適化します。読み取り(クエリ)と書き込み(コマンド)を分離することで、効率的なキャッシュ管理を実現します。
-
-## 主な機能
-
-- **イベント駆動型キャッシュ無効化**: データ変更時にキャッシュを自動的に無効化し、一貫性を保ちます。
-- **依存解決**: リソース間の依存関係を解決し、関連するキャッシュを自動的に更新します。
-- **ドーナッツキャッシュ**: 動的コンテンツと静的コンテンツを組み合わせた部分キャッシュを実現します。
-- **CDN統合**: FastlyやAkamaiなどのモダンなCDNと連携し、共有キャッシュを効率的に管理します。
-- **ETagを利用した条件付きリクエスト**: `304 Not Modified` レスポンスでネットワークリソースを節約します。
-- **分散キャッシュサポート**: サーバーサイドキャッシュ(例: Redis、APC)、共有キャッシュ(例: CDN)、クライアントサイドキャッシュに対応します。
-
-## ドキュメント
-
-詳細は [BEAR.Sunday キャッシュマニュアル](http://bearsunday.github.io/manuals/1.0/ja/cache.html) をご覧ください。
-
-- [ログの読み方](docs/reading-the-log.ja.md) — ログに現れる全ての語と、セッションの読み方
-- [QueryRepository ログはなぜ全てを記録するのか](docs/why-the-log-records-everything.ja.md) — 設計根拠、コスト実測、既定オフの判断
-- [このログは何を証明し、何を証明しないか](docs/what-the-log-proves.ja.md) — ログが答える問い、その答えを正直に保つ機構、そして宣言された境界
-- エージェント用スキル: [bear-cache-log](https://github.com/bearsunday/BEAR.Skills/blob/1.x/skills/bear-cache-log/SKILL.md)(セッションのインストール・読解・公開スキーマとの照合)と [bear-cache-gate](https://github.com/bearsunday/BEAR.Skills/blob/1.x/skills/bear-cache-gate/SKILL.md)(フローごとの保存・ヒット・無効化・コストを証明する常設オラクルの設置)——いずれも `/plugin install bear-skills` から。キャッシュに限らないリクエスト全体のトレースには [bear-observe](https://github.com/bearsunday/BEAR.EventSourcing/blob/1.x/skills/bear-observe/SKILL.md) を参照。
diff --git a/README.md b/README.md
index 7ec18d4c..8e94e71d 100644
--- a/README.md
+++ b/README.md
@@ -1,45 +1,27 @@
# BEAR.QueryRepository
-[](https://scrutinizer-ci.com/g/bearsunday/BEAR.QueryRepository/?branch=1.x)
-[](https://codecov.io/gh/koriym/BEAR.QueryRepository)
-[](https://shepherd.dev/github/bearsunday/BEAR.QueryRepository)
-[](https://github.com/bearsunday/BEAR.QueryRepository/actions/workflows/coding-standards.yml)
-[](https://github.com/bearsunday/BEAR.QueryRepository/actions/workflows/static-analysis.yml)
[](https://github.com/bearsunday/BEAR.QueryRepository/actions/workflows/continuous-integration.yml)
+[](https://codecov.io/gh/bearsunday/BEAR.QueryRepository)
-**BEAR.QueryRepository** is a distributed caching framework for BEAR.Resource applications, inspired by [CQRS](http://martinfowler.com/bliki/CQRS.html). It segregates reads and writes into separate repositories to optimize performance and resource utilization.
+**The repository of the query side: a read model that writes keep current, not a cache left to expire.**
-## Key Features
+A [CQRS](http://martinfowler.com/bliki/CQRS.html)-style read side for BEAR.Sunday: the results of cacheable GETs are kept as read models. On a miss, `onGet` builds one on demand; the commands that change it refresh or purge it. A resource declares this with attributes and AOP does the rest - storing, answering conditional requests, and invalidating. By default an entry lives until a write invalidates it; a TTL is available for data that may simply go stale.
-- **Event-Driven Cache Invalidation**: Automatically invalidates cache when data changes, ensuring consistency.
-- **Dependency Resolution**: Resolves dependencies between resources and updates related caches automatically.
-- **Donut Caching**: Combines dynamic and static content for efficient partial caching.
-- **CDN Integration**: Seamlessly integrates with modern CDNs (e.g., Fastly, Akamai) for shared cache management.
-- **Conditional Requests with ETag Support**: Reduces network overhead by leveraging `ETag` and `304 Not Modified` responses.
-- **Distributed Cache Support**: Works with server-side caches (e.g., Redis, APC), shared caches (e.g., CDNs), and client-side caches.
-- **Runtime-Private Response Guard**: A `#[Cacheable]`/`#[CacheableResponse]`/`#[DonutCache]` resource whose own code sets `Cache-Control` to `private` or `no-store` at runtime is not written to the shared cache; its header is kept and CDN headers are removed. A value declared by `#[HttpCache]`/`#[NoHttpCache]` is still stored. The guard reads only the response's own header (see [Cache dependencies](#cache-dependencies)).
- - Deploy this version with a new `CacheVersionModule` version: entries written by earlier versions may hold per-user content, and a rolling deploy would otherwise show raw placeholders. An app that binds its own cache adapter directly must clear those pools instead.
+- **The resource graph is the dependency graph**: a cached child taken in with `#[Embed]` becomes a dependency of the parent.
+- **Writes invalidate**: a command (`onPut`, `onPatch`, ...) invalidates what it changes with `#[Purge]`, `#[Refresh]` or `#[RefreshCache]`, rather than waiting for expiry.
+- **One tag crosses every layer**: the origin cache, the ETag pool behind `304 Not Modified`, and the CDN's `Surrogate-Key` are invalidated by the same tags.
+- **Three uses of a child**: a per-user hole (`#[DonutCache]`), a hole rebuilt on events (`#[CacheableResponse]`), and material the page reads.
+- **Verifiable**: the semantic log records each store, hit, dependency and invalidation.
-## Cache dependencies
+A cache attribute declares the response shared with every visitor: put per-user values in a non-cacheable `#[DonutCache]` hole.
-Declare a dependency with `#[Embed]` and never `unset()` or overwrite the slot - a cached `onGet()` must not fetch dependent data with `$resource->get()`. What a child becomes depends on how the page uses it:
-
-A `#[Cacheable]`/`#[CacheableResponse]`/`#[DonutCache]` response is shared with every visitor, so a per-user value belongs in a non-cacheable `#[DonutCache]` hole, not in the cached response itself. QR inspects only the response's own header, so a per-user value that reaches the cached output any other way - a child's value read into the body in `onGet()`, a value received through `#[ResourceParam]`, or a value generated in the template (e.g. a Twig function) - is stored and served to every later visitor. In particular, do not receive a per-user value with `#[ResourceParam]` (e.g. `app://self/login#nickname`) in a cached resource - put it in a non-cacheable `#[DonutCache]` hole instead.
-
-| Need | Use | Effect of a child purge |
-|---|---|---|
-| Per-user content (no shared cache) | `#[DonutCache]` hole (non-cacheable child) | that hole alone re-runs on every HIT |
-| Shared content that changes on an event | `#[CacheableResponse]` hole (cacheable child, never read in `onGet()`) | the shell is recomposed from the stored template; the page's own `onGet()` does not re-run |
-| A value the page's own `onGet()` reads | material (the child is tagged, whether or not it is also rendered as a hole) | the whole template is dropped; the next GET is a fresh miss |
-| Data that is merely stale after a while | `#[Cacheable]` with a TTL | expires on its own, no purge needed |
-
-A writer-side `#[Purge]`/`#[Refresh]` on the command that changes the data is the alternative to waiting on this chain.
+
## Documentation
-- [BEAR.Sunday cache manual](http://bearsunday.github.io/manuals/1.0/ja/cache.html)
-- [LLM Documentation](https://bearsunday.github.io/BEAR.QueryRepository/llms-full.txt)
-- [Why the QueryRepository Log Records Everything](docs/why-the-log-records-everything.md) — design rationale, the cost measurements, and why recording is off by default
-- [Reading the Log](docs/reading-the-log.md) — every word the log can contain, and how to read a session
-- [What the Cache Log Proves — and What It Does Not](docs/what-the-log-proves.md) — the questions the log answers, the mechanisms that keep the answers honest, and the declared boundaries
-- Agent skills: [bear-cache-log](https://github.com/bearsunday/BEAR.Skills/blob/1.x/skills/bear-cache-log/SKILL.md) (install, read and verify one session against the published schemas) and [bear-cache-gate](https://github.com/bearsunday/BEAR.Skills/blob/1.x/skills/bear-cache-gate/SKILL.md) (install a persistent oracle that proves store/hit/invalidation/cost per flow) — both via `/plugin install bear-skills`. For broader single-request tracing beyond caching, see [bear-observe](https://github.com/bearsunday/BEAR.EventSourcing/blob/1.x/skills/bear-observe/SKILL.md).
+- Cache manual: [English](https://bearsunday.github.io/manuals/1.0/en/cache.html) / [日本語](https://bearsunday.github.io/manuals/1.0/ja/cache.html)
+- Reading the Log ([en](docs/reading-the-log.md) / [ja](docs/reading-the-log.ja.md)): every word the log can contain, and how to read a session
+- Why the Log Records Everything ([en](docs/why-the-log-records-everything.md) / [ja](docs/why-the-log-records-everything.ja.md)): the design rationale, the cost measurements, and why recording is off by default
+- What the Log Proves ([en](docs/what-the-log-proves.md) / [ja](docs/what-the-log-proves.ja.md)): the questions the log answers, and its declared boundaries
+- [LLM documentation](https://bearsunday.github.io/BEAR.QueryRepository/llms-full.txt)
+- Agent skills, via `/plugin install bear-skills`: [bear-cache-log](https://github.com/bearsunday/BEAR.Skills/blob/1.x/skills/bear-cache-log/SKILL.md) and [bear-cache-gate](https://github.com/bearsunday/BEAR.Skills/blob/1.x/skills/bear-cache-gate/SKILL.md)
diff --git a/docs/llms-full.txt b/docs/llms-full.txt
index b1b2cf5e..2af9efd5 100644
--- a/docs/llms-full.txt
+++ b/docs/llms-full.txt
@@ -205,7 +205,7 @@ A donut refresh is not marked in any response header — only in the log (`refre
- Off by default (`QueryRepositoryModule`): `SemanticLoggerInterface` annotated with `#[CacheLog]` → `Koriym\SemanticLogger\NullSemanticLogger`. The qualifier is why installing this package cannot decide an application's own `SemanticLoggerInterface` binding; resolve the cache log with `getInstance(SemanticLoggerInterface::class, CacheLog::class)`. Nothing is recorded, nothing accumulates, no flush is owed — the same rule the package already applies to its cache engine (`NullAdapter`). A measured healthy read carries no information (0 failure entries out of 28-95 per session in the four demos), while a recorded session costs ~1.4 KB per request until something flushes it, so recording is something an app asks for.
- Development: `new DevQueryRepositoryLogModule($appDir . '/var/log/query-repository', module: new QueryRepositoryModule())`. Every session is written whole, one file per request, plus a fixed `latest.json` — `vendor/bin/stree var/log/query-repository/latest.json` renders the request that just ran. Note the wrapping: a module's `install()` cannot replace a binding the installer already holds (`Container::merge()` keeps existing keys), which is why the log module takes the module it turns recording on for, exactly as `DevEtagModule` does.
-- Production: `new ProdQueryRepositoryLogModule('php://stdout', sampleRate: 1000, module: new QueryRepositoryModule())`. The session is buffered and judged at flush (`RetentionPolicyInterface`), and the line is who else knows rather than success-vs-failure: mutations (a `command` scope, a `manual_*` call, or an `invalidate` that is not marker-preceded pre-write cleanup), effects that were intended and did not happen (`saved: false`, `invalidate` with `roPool`/`etagPool`/`cdn` `failed`, a `manual_*` result of `failed`, `cache_error{operation: write}` - a store or an invalidation abandoned - plus `semantic_logger_error`, which means the records themselves may be incomplete), and an optional sample. Healthy reads are dropped, and so are two things that look like incidents but are accounted for elsewhere: `cache_error{operation: read}` is already reported by the application's own warning channel - both interceptors `trigger_error()` before degrading, for every cause and not only the outage a pool's monitoring would see - and it repeats on every request while the incident lasts (so the separation of a degraded miss from a cold one is a development-time reading), and every `put_skipped` records a store that was never attempted because a rule forbade it - a response code the path will not cache (`#[Cacheable]` skips any non-200, a donut skips 4xx and above), a validator already set, or a page served from its template - which is a decision, stable while the code and the configuration are, rather than a runtime failure. `saved: false` can still repeat per write under a capacity problem: that is the signal, and a host that needs a ceiling decorates `LogWriterInterface`. This buys forensics, not monitoring — hit rates belong to metrics. An app that wants full recording for one request implements `RetentionPolicyInterface` and overrides the binding, since only the app knows what authorizes it.
+- Production: `new ProdQueryRepositoryLogModule('php://stdout', sampleRate: 1000, module: new QueryRepositoryModule())`. The session is buffered and judged at flush (`RetentionPolicyInterface`), and the line is who else knows rather than success-vs-failure: mutations (a `command` scope, a `manual_*` call, or an `invalidate` that is not marker-preceded pre-write cleanup), effects that were intended and did not happen (`saved: false`, `invalidate` with `roPool`/`etagPool`/`cdn` `failed`, a `manual_*` result of `failed`, `cache_error{operation: write}` - a store or an invalidation abandoned), and an optional sample. Healthy reads are dropped, and so are two things that look like incidents but are accounted for elsewhere: `cache_error{operation: read}` is already reported by the application's own warning channel - both interceptors `trigger_error()` before degrading, for every cause and not only the outage a pool's monitoring would see - and it repeats on every request while the incident lasts (so the separation of a degraded miss from a cold one is a development-time reading), and every `put_skipped` records a store that was never attempted because a rule forbade it - a response code the path will not cache (`#[Cacheable]` skips any non-200, a donut skips 4xx and above), a validator already set, or a page served from its template - which is a decision, stable while the code and the configuration are, rather than a runtime failure. `saved: false` can still repeat per write under a capacity problem: that is the signal, and a host that needs a ceiling decorates `LogWriterInterface`. This buys forensics, not monitoring — hit rates belong to metrics. An app that wants full recording for one request implements `RetentionPolicyInterface` and overrides the binding, since only the app knows what authorizes it.
- A written session is application-log material, not an artefact: it records resource URIs **with their query strings** (`AbstractUri::__toString()` appends them, so `page://self/reset?token=…` is written in full), the client's raw `If-None-Match`, `$_SERVER['REQUEST_URI']` on the `HttpCache` path, cache/surrogate tags, CDN header values, and `cache_error.error` - the verbatim `getMessage()` of whatever the read/write path threw, which for an HTTP CDN purger can carry the purge endpoint and its credentials. No response body is recorded by any context. `LogFileWriter` therefore creates its directory 0700 and its files 0600, and a host that must scrub decorates `LogWriterInterface` rather than waiting for a redaction option: the seam is one class (`final class Scrubbed implements LogWriterInterface { public function __construct(private LogWriterInterface $inner) {} }`). The same seam rate-limits: during a cache-pool outage every request emits `cache_error`, so retention rises from `1/sampleRate` to 100% - a decorator that caps kept sessions per interval keeps an incident from flooding the collector it exists to inform.
- Destinations are `LogWriterInterface` implementations, so a session's payload stays a tree rather than a string: `LogFileWriter` (one whole session per file — what `stree` reads), `LogStreamWriter` (one JSON line per session to a stream or file), `PolicyLogWriter` (the retention decorator) and `PsrLogWriter`, which hands the tree to the app's PSR-3 logger under the `log` context key. PSR-3 is an adapter and not the interface for two reasons: it carries strings, so a `LineFormatter` would flatten the tree the schemas describe, and its severity would act as a second filter able to drop what the content-based retention policy chose to keep (the level is configuration on the adapter, never derived).
- Both log modules bind `SemanticLoggerInterface` annotated with `#[CacheLog]` → `SafeSemanticLogger` in singleton scope via `SafeSemanticLoggerProvider`, with the session of the request in progress resolved through `SessionStoreInterface` (`ProcessSession`, one per process, by default). The core logger never throws (koriym/semantic-logger 0.9), so a logging failure cannot break a cache read/write; `SafeSemanticLogger` keeps open-scope depth on the `Session` the store resolves and keeps no session across the serialization boundary.
diff --git a/docs/llms.txt b/docs/llms.txt
index 1cf26b86..d14ab72e 100644
--- a/docs/llms.txt
+++ b/docs/llms.txt
@@ -40,7 +40,7 @@ Cache operations are logged as an open/event/close tree (Koriym.SemanticLogger)
- A resource GET opens a `get` scope; embedded child GETs nest under it; the scope closes with `cache_hit` / `cache_miss` (field `layer`: `resource` / `donut` / `donut-view`).
- Saves (`save_value`, `save_view`, `save_etag`, `save_donut`, `save_donut_view`), dependencies (`depends_on`) and invalidations (`invalidate`) are events inside the active scope. A write opens a `command` scope recording the method, its `#[Refresh]`/`#[Purge]` annotations and the producing interceptor (`source`).
-- Recording is off by default: `QueryRepositoryModule` binds `SemanticLoggerInterface` annotated with `#[CacheLog]` → `Koriym\SemanticLogger\NullSemanticLogger`, so nothing is recorded, nothing accumulates and no flush is owed — the same rule as the null cache engine default. An app turns it on by wrapping the module (an `install()` cannot replace a binding the installer already holds): `new DevQueryRepositoryLogModule($appDir . '/var/log/query-repository', module: new QueryRepositoryModule())` writes every session as one file per request (`latest.json` is `stree`-readable), and `new ProdQueryRepositoryLogModule('php://stdout', sampleRate: 1000)` buffers each session and writes only the ones nothing else can account for: mutations, effects that were intended and did not happen (`saved: false`, `invalidate` with a `failed` target, a `manual_*` result of `failed`, `cache_error{operation: write}`, `semantic_logger_error`), and an optional sample. Deliberately dropped: `cache_error{operation: read}` (the application's warning channel already has it - both interceptors `trigger_error()` before degrading - and it repeats on every request during the incident) and every `put_skipped` (a store never attempted because a rule forbade it - a response code the path will not cache, a validator already set, a page served from its template - which is a decision, stable while the code is, not a runtime failure). Both bind `SemanticLoggerInterface` annotated with `#[CacheLog]` → `SafeSemanticLogger` in singleton scope, its session resolved through `SessionStoreInterface` (`ProcessSession`, one per process, by default - a concurrent host binds a keyed store); the core logger never throws (koriym/semantic-logger 0.9), so a logging failure never breaks a cache read/write.
+- Recording is off by default: `QueryRepositoryModule` binds `SemanticLoggerInterface` annotated with `#[CacheLog]` → `Koriym\SemanticLogger\NullSemanticLogger`, so nothing is recorded, nothing accumulates and no flush is owed — the same rule as the null cache engine default. An app turns it on by wrapping the module (an `install()` cannot replace a binding the installer already holds): `new DevQueryRepositoryLogModule($appDir . '/var/log/query-repository', module: new QueryRepositoryModule())` writes every session as one file per request (`latest.json` is `stree`-readable), and `new ProdQueryRepositoryLogModule('php://stdout', sampleRate: 1000)` buffers each session and writes only the ones nothing else can account for: mutations, effects that were intended and did not happen (`saved: false`, `invalidate` with a `failed` target, a `manual_*` result of `failed`, `cache_error{operation: write}`), and an optional sample. Deliberately dropped: `cache_error{operation: read}` (the application's warning channel already has it - both interceptors `trigger_error()` before degrading - and it repeats on every request during the incident) and every `put_skipped` (a store never attempted because a rule forbade it - a response code the path will not cache, a validator already set, a page served from its template - which is a decision, stable while the code is, not a runtime failure). Both bind `SemanticLoggerInterface` annotated with `#[CacheLog]` → `SafeSemanticLogger` in singleton scope, its session resolved through `SessionStoreInterface` (`ProcessSession`, one per process, by default - a concurrent host binds a keyed store); the core logger never throws (koriym/semantic-logger 0.9), so a logging failure never breaks a cache read/write.
- Flushing is owned by the log module, not the app: it binds a `LogSinkInterface` (`ShutdownFlush`) that registers a shutdown-time flush once per process, so the session survives however the request ends — after output, after a 304 early `exit()`, after an uncaught error. Under PHP-FPM and the CLI that is the request's end; `ShutdownFlush` refuses to arm and warns on a runtime it can prove is concurrent (a RoadRunner worker, or inside a Swoole coroutine), where shutdown arrives once per worker and concurrent requests would share one session (#179).
Verifying the event-driven cache from logs:
diff --git a/src/AbstractDonutCacheInterceptor.php b/src/AbstractDonutCacheInterceptor.php
index 87f5113e..fe72ddd2 100644
--- a/src/AbstractDonutCacheInterceptor.php
+++ b/src/AbstractDonutCacheInterceptor.php
@@ -28,6 +28,7 @@
use const E_USER_WARNING;
+/** @internal */
abstract class AbstractDonutCacheInterceptor implements MethodInterceptor
{
protected const IS_ENTIRE_CONTENT_CACHEABLE = false;
diff --git a/src/CacheDependency.php b/src/CacheDependency.php
index d36b2edb..c08b228f 100644
--- a/src/CacheDependency.php
+++ b/src/CacheDependency.php
@@ -17,6 +17,7 @@
use function implode;
use function sprintf;
+/** @internal */
final readonly class CacheDependency implements CacheDependencyInterface
{
public function __construct(
diff --git a/src/CacheInterceptor.php b/src/CacheInterceptor.php
index 6765d30a..72610bcc 100644
--- a/src/CacheInterceptor.php
+++ b/src/CacheInterceptor.php
@@ -34,6 +34,7 @@
*
* @see \BEAR\RepositoryModule\Annotation\Cacheable
* @see https://bearsunday.github.io/manuals/1.0/en/cache.html#cacheable
+ * @internal
*/
final readonly class CacheInterceptor implements MethodInterceptor
{
diff --git a/src/Cdn/AkamaiCacheControlHeaderSetter.php b/src/Cdn/AkamaiCacheControlHeaderSetter.php
index 74b865cb..9c767c7b 100644
--- a/src/Cdn/AkamaiCacheControlHeaderSetter.php
+++ b/src/Cdn/AkamaiCacheControlHeaderSetter.php
@@ -11,6 +11,7 @@
use function sprintf;
+/** @internal */
final class AkamaiCacheControlHeaderSetter implements CdnCacheControlHeaderSetterInterface
{
public const CDN_CACHE_CONTROL_HEADER = 'Akamai-Cache-Control';
diff --git a/src/Cdn/FastlyCacheControlHeaderSetter.php b/src/Cdn/FastlyCacheControlHeaderSetter.php
index 3bb03792..88ca7737 100644
--- a/src/Cdn/FastlyCacheControlHeaderSetter.php
+++ b/src/Cdn/FastlyCacheControlHeaderSetter.php
@@ -10,6 +10,7 @@
use function sprintf;
+/** @internal */
final class FastlyCacheControlHeaderSetter implements CdnCacheControlHeaderSetterInterface
{
public const CDN_CACHE_CONTROL_HEADER = 'Surrogate-Control';
diff --git a/src/Cdn/FastlyCachePurger.php b/src/Cdn/FastlyCachePurger.php
index 78473328..629ee35e 100644
--- a/src/Cdn/FastlyCachePurger.php
+++ b/src/Cdn/FastlyCachePurger.php
@@ -8,6 +8,7 @@
use BEAR\QueryRepository\PurgerInterface;
use Override;
+/** @internal */
final readonly class FastlyCachePurger implements PurgerInterface
{
public function __construct(private FastlyCachePurgerInterface $fastlyCachePurger)
diff --git a/src/CdnCacheControlHeaderSetter.php b/src/CdnCacheControlHeaderSetter.php
index ca69c68f..82b72f3f 100644
--- a/src/CdnCacheControlHeaderSetter.php
+++ b/src/CdnCacheControlHeaderSetter.php
@@ -12,6 +12,7 @@
/**
* @see https://www.ietf.org/archive/id/draft-cdn-control-header-01.html
* @see https://blog.cloudflare.com/cdn-cache-control/
+ * @internal
*/
final class CdnCacheControlHeaderSetter implements CdnCacheControlHeaderSetterInterface
{
diff --git a/src/CommandContextFactory.php b/src/CommandContextFactory.php
index 461eec31..a2966a55 100644
--- a/src/CommandContextFactory.php
+++ b/src/CommandContextFactory.php
@@ -14,6 +14,8 @@
* Captures the command method and its #[Refresh]/#[Purge] annotations so invalidations
* that follow are recorded nested under this open node. Shared by CommandInterceptor,
* DonutCommandInterceptor and RefreshInterceptor.
+ *
+ * @internal
*/
final class CommandContextFactory
{
diff --git a/src/CommandInterceptor.php b/src/CommandInterceptor.php
index ec70f27b..ed062fea 100644
--- a/src/CommandInterceptor.php
+++ b/src/CommandInterceptor.php
@@ -25,6 +25,7 @@
* @see \BEAR\RepositoryModule\Annotation\Purge
* @see \BEAR\RepositoryModule\Annotation\Refresh
* @see https://bearsunday.github.io/manuals/1.0/en/cache.html#tag-based-cache-invalidation
+ * @internal
*/
final readonly class CommandInterceptor implements MethodInterceptor
{
diff --git a/src/CommandsProvider.php b/src/CommandsProvider.php
index 33687bf1..7d7f9e83 100644
--- a/src/CommandsProvider.php
+++ b/src/CommandsProvider.php
@@ -7,7 +7,10 @@
use Override;
use Ray\Di\ProviderInterface;
-/** @implements ProviderInterface> */
+/**
+ * @implements ProviderInterface>
+ * @internal
+ */
final readonly class CommandsProvider implements ProviderInterface
{
public function __construct(
diff --git a/src/DevEtagSetter.php b/src/DevEtagSetter.php
index b684e472..947ee7e6 100644
--- a/src/DevEtagSetter.php
+++ b/src/DevEtagSetter.php
@@ -10,6 +10,7 @@
use function gmdate;
+/** @internal */
final readonly class DevEtagSetter implements EtagSetterInterface
{
/**
diff --git a/src/DonutCacheInterceptor.php b/src/DonutCacheInterceptor.php
index 484a7383..87936d6c 100644
--- a/src/DonutCacheInterceptor.php
+++ b/src/DonutCacheInterceptor.php
@@ -11,6 +11,7 @@
*
* @see \BEAR\RepositoryModule\Annotation\DonutCache
* @see https://bearsunday.github.io/manuals/1.0/en/cache.html#donut-cache
+ * @internal
*/
final class DonutCacheInterceptor extends AbstractDonutCacheInterceptor
{
diff --git a/src/DonutCacheableResponseInterceptor.php b/src/DonutCacheableResponseInterceptor.php
index 2983591f..70009d7b 100644
--- a/src/DonutCacheableResponseInterceptor.php
+++ b/src/DonutCacheableResponseInterceptor.php
@@ -11,6 +11,7 @@
*
* @see \BEAR\RepositoryModule\Annotation\CacheableResponse
* @see https://bearsunday.github.io/manuals/1.0/en/cache.html#event-driven-content
+ * @internal
*/
final class DonutCacheableResponseInterceptor extends AbstractDonutCacheInterceptor
{
diff --git a/src/DonutCommandInterceptor.php b/src/DonutCommandInterceptor.php
index be3f25b9..bc06f97a 100644
--- a/src/DonutCommandInterceptor.php
+++ b/src/DonutCommandInterceptor.php
@@ -31,6 +31,7 @@
* @see \BEAR\RepositoryModule\Annotation\CacheableResponse
* @see \BEAR\RepositoryModule\Annotation\DonutCache
* @see https://bearsunday.github.io/manuals/1.0/en/cache.html#event-driven-content
+ * @internal
*/
final readonly class DonutCommandInterceptor implements MethodInterceptor
{
diff --git a/src/DonutRenderer.php b/src/DonutRenderer.php
index 13171a61..0d26124b 100644
--- a/src/DonutRenderer.php
+++ b/src/DonutRenderer.php
@@ -8,6 +8,7 @@
use function strtr;
+/** @internal */
final class DonutRenderer implements DonutRendererInterface
{
/** @var array */
diff --git a/src/DonutRepository.php b/src/DonutRepository.php
index 51f3f2ea..fe15dbdb 100644
--- a/src/DonutRepository.php
+++ b/src/DonutRepository.php
@@ -30,6 +30,7 @@
use function explode;
use function max;
+/** @internal */
final readonly class DonutRepository implements DonutRepositoryInterface
{
private CacheDependencyInterface $cacheDependency;
diff --git a/src/DonutRequest.php b/src/DonutRequest.php
index 332300a2..85b0525f 100644
--- a/src/DonutRequest.php
+++ b/src/DonutRequest.php
@@ -14,6 +14,8 @@
/**
* Runs an embedded hole while a donut template renders
+ *
+ * @internal
*/
final readonly class DonutRequest implements Stringable
{
diff --git a/src/EntityTags.php b/src/EntityTags.php
index ac7bce55..932870a0 100644
--- a/src/EntityTags.php
+++ b/src/EntityTags.php
@@ -18,6 +18,8 @@
* half-reading a validator risks answering about content nobody asked for. A token with a
* PSR-6 reserved character or `*` is dropped rather than looked up, since no setter could
* have issued one.
+ *
+ * @internal
*/
final class EntityTags
{
diff --git a/src/GlobalServerContext.php b/src/GlobalServerContext.php
index d3bf59d9..502b2687 100644
--- a/src/GlobalServerContext.php
+++ b/src/GlobalServerContext.php
@@ -13,6 +13,8 @@
*
* $_SERVER is safe to read directly there; for concurrent processing (Swoole, RoadRunner),
* use a request-scoped implementation instead.
+ *
+ * @internal
*/
final class GlobalServerContext implements ServerContextInterface
{
diff --git a/src/HeaderSetter.php b/src/HeaderSetter.php
index 82c611c8..a7cc43fe 100644
--- a/src/HeaderSetter.php
+++ b/src/HeaderSetter.php
@@ -14,6 +14,7 @@
use function str_contains;
use function strtolower;
+/** @internal */
final readonly class HeaderSetter
{
/**
@@ -81,8 +82,17 @@ private function setCacheControlMaxAge(ResourceObject $ro, int $age): void
return;
}
- if (str_contains($ro->headers[$existingName], 'max-age')) {
- return;
+ // no-store forbids keeping the response at all, so a lifetime beside it is contradictory;
+ // every case variant of the key is a Cache-Control the client receives
+ foreach ($ro->headers as $name => $value) {
+ if (strtolower($name) !== strtolower(Header::CACHE_CONTROL)) {
+ continue;
+ }
+
+ $lower = strtolower($value);
+ if (str_contains($lower, 'max-age') || str_contains($lower, 'no-store')) {
+ return;
+ }
}
$ro->headers[$existingName] .= ', ' . $setMaxAge;
diff --git a/src/HttpCacheInterceptor.php b/src/HttpCacheInterceptor.php
index 17177c89..0988aba5 100644
--- a/src/HttpCacheInterceptor.php
+++ b/src/HttpCacheInterceptor.php
@@ -22,6 +22,7 @@
* @see \BEAR\RepositoryModule\Annotation\HttpCache
* @see \BEAR\RepositoryModule\Annotation\NoHttpCache
* @see https://bearsunday.github.io/manuals/1.0/en/cache.html
+ * @internal
*/
final class HttpCacheInterceptor implements MethodInterceptor
{
diff --git a/src/Log/Context/CdnHeadersContext.php b/src/Log/Context/CdnHeadersContext.php
index 9d73b6ce..63b6469c 100644
--- a/src/Log/Context/CdnHeadersContext.php
+++ b/src/Log/Context/CdnHeadersContext.php
@@ -16,8 +16,9 @@
* effect, not the request: the bound setter's default when the caller asked for no
* sMaxAge, the resource's own value when it set one, and nothing when nothing gives
* the CDN a lifetime - which is how an un-cacheable donut page is meant to stay off
- * the CDN, and how a response this gate refused as unstorable (issue #222) is too:
- * every CDN-facing header is stripped, so this records none. What the CDN then does
+ * the CDN, and how a response DeclaredCacheControl::reason() refuses (its own code set
+ * private/no-store) is too: every CDN-facing header is stripped, so this records none.
+ * A no-store declared by #[HttpCache] is not refused. What the CDN then does
* is outside this record.
*
* `headers` holds the literal name => value pairs of every CDN-facing header this
diff --git a/src/Log/KeepMutationsAndFailures.php b/src/Log/KeepMutationsAndFailures.php
index 4c969eb4..072f9d2f 100644
--- a/src/Log/KeepMutationsAndFailures.php
+++ b/src/Log/KeepMutationsAndFailures.php
@@ -30,8 +30,7 @@
* decide the tags, TTLs and CDN keys every later read repeats.
* - effects that did not happen: `saved: false`, `invalidate` with `roPool`/`etagPool`/`cdn`
* `failed`, a `manual_*` result of `failed`, and `cache_error{operation: write}` - a store or
- * an invalidation that was aborted. `semantic_logger_error` joins them because it means the
- * records themselves may be incomplete.
+ * an invalidation that was aborted.
* - a sample, when a rate is configured.
*
* Deliberately not kept: `cache_error{operation: read}` (availability, monitored elsewhere -
@@ -176,10 +175,6 @@ private function isPoint(array $record): bool
*/
private function isMissingEffect(array $node, mixed $type): bool
{
- if ($type === 'semantic_logger_error') {
- return true; // the records themselves may be incomplete
- }
-
$context = $node['context'] ?? null;
if (! is_array($context)) {
return false;
diff --git a/src/Log/PoolErrorLogger.php b/src/Log/PoolErrorLogger.php
index 056a9a84..8a481434 100644
--- a/src/Log/PoolErrorLogger.php
+++ b/src/Log/PoolErrorLogger.php
@@ -23,6 +23,8 @@
* `symfony/cache` adapters do not throw: they log the backend failure and answer as if the entry
* were absent. Given to the pools, this turns that report into a `pool_error` event, so a store
* that is down is visible where every other cache decision already is.
+ *
+ * @internal
*/
final class PoolErrorLogger extends AbstractLogger
{
diff --git a/src/Log/SafeSemanticLoggerProvider.php b/src/Log/SafeSemanticLoggerProvider.php
index 3d68f56a..25f27138 100644
--- a/src/Log/SafeSemanticLoggerProvider.php
+++ b/src/Log/SafeSemanticLoggerProvider.php
@@ -20,6 +20,7 @@
* and a module that turns recording on also decides where the session goes.
*
* @implements ProviderInterface
+ * @internal
*/
final class SafeSemanticLoggerProvider implements ProviderInterface
{
diff --git a/src/MarshallerProvider.php b/src/MarshallerProvider.php
index 7c8e0091..4b5c0695 100644
--- a/src/MarshallerProvider.php
+++ b/src/MarshallerProvider.php
@@ -11,7 +11,10 @@
use Symfony\Component\Cache\Marshaller\DeflateMarshaller;
use Symfony\Component\Cache\Marshaller\MarshallerInterface;
-/** @implements ProviderInterface */
+/**
+ * @implements ProviderInterface
+ * @internal
+ */
final readonly class MarshallerProvider implements ProviderInterface
{
/** @param array{enabled?: bool, type?: string, use_igbinary?: bool} $options Marshalling options */
diff --git a/src/MatchQuery.php b/src/MatchQuery.php
index a7470ac1..fc8a5a06 100644
--- a/src/MatchQuery.php
+++ b/src/MatchQuery.php
@@ -11,6 +11,7 @@
use function sprintf;
+/** @internal */
final class MatchQuery implements MatchQueryInterface
{
/**
diff --git a/src/MobileEtagSetter.php b/src/MobileEtagSetter.php
index da729168..14336b7c 100644
--- a/src/MobileEtagSetter.php
+++ b/src/MobileEtagSetter.php
@@ -14,6 +14,7 @@
use function serialize;
use function time;
+/** @internal */
final readonly class MobileEtagSetter implements EtagSetterInterface
{
public function __construct(
diff --git a/src/QueryRepository.php b/src/QueryRepository.php
index 1b4ecabe..2624688c 100644
--- a/src/QueryRepository.php
+++ b/src/QueryRepository.php
@@ -30,6 +30,7 @@
use function strtotime;
use function time;
+/** @internal */
final readonly class QueryRepository implements QueryRepositoryInterface
{
public function __construct(
diff --git a/src/RedisDsnProvider.php b/src/RedisDsnProvider.php
index 50a90be2..5242d8e1 100644
--- a/src/RedisDsnProvider.php
+++ b/src/RedisDsnProvider.php
@@ -16,7 +16,10 @@
use Relay\Relay;
use Symfony\Component\Cache\Adapter\RedisAdapter;
-/** @implements ProviderInterface */
+/**
+ * @implements ProviderInterface
+ * @internal
+ */
final readonly class RedisDsnProvider implements ProviderInterface
{
/**
diff --git a/src/RefreshInterceptor.php b/src/RefreshInterceptor.php
index c5efcbc0..147d6c53 100644
--- a/src/RefreshInterceptor.php
+++ b/src/RefreshInterceptor.php
@@ -27,6 +27,7 @@
* @see \BEAR\RepositoryModule\Annotation\Purge
* @see \BEAR\RepositoryModule\Annotation\Refresh
* @see https://bearsunday.github.io/manuals/1.0/en/cache.html#event-driven-content
+ * @internal
*/
final readonly class RefreshInterceptor implements MethodInterceptor
{
diff --git a/src/RefreshSameCommand.php b/src/RefreshSameCommand.php
index c666adf8..cacfaa61 100644
--- a/src/RefreshSameCommand.php
+++ b/src/RefreshSameCommand.php
@@ -19,6 +19,7 @@
use function is_callable;
use function str_starts_with;
+/** @internal */
final readonly class RefreshSameCommand implements CommandInterface
{
public function __construct(
diff --git a/src/ResourceStorage.php b/src/ResourceStorage.php
index 73ec97b5..1709d0a9 100644
--- a/src/ResourceStorage.php
+++ b/src/ResourceStorage.php
@@ -48,6 +48,7 @@
* etagProvider: ProviderInterface,
* serverContext: ServerContextInterface
* }
+ * @internal
*/
final class ResourceStorage implements ResourceStorageInterface, ScopedValidatorInterface
{
diff --git a/src/ResourceStorageSaver.php b/src/ResourceStorageSaver.php
index ac6eddbd..db9e8702 100644
--- a/src/ResourceStorageSaver.php
+++ b/src/ResourceStorageSaver.php
@@ -9,6 +9,7 @@
use function assert;
+/** @internal */
final class ResourceStorageSaver
{
/** @param list $tags */
diff --git a/tests/HeaderSetterTest.php b/tests/HeaderSetterTest.php
index 1f9ec011..f8a44baa 100644
--- a/tests/HeaderSetterTest.php
+++ b/tests/HeaderSetterTest.php
@@ -4,6 +4,7 @@
namespace BEAR\QueryRepository;
+use FakeVendor\HelloWorld\Resource\Page\Html\CacheableResponseNoStore;
use FakeVendor\HelloWorld\Resource\Page\None;
use PHPUnit\Framework\TestCase;
@@ -70,4 +71,29 @@ public function testSetCacheControlMaxAgeDoesNotDuplicateAnExistingLowercaseMaxA
$this->assertArrayNotHasKey(Header::CACHE_CONTROL, $ro->headers, 'no second, canonically-cased header was added');
$this->assertSame('max-age=30', $ro->headers['cache-control']);
}
+
+ public function testSetCacheControlMaxAgeDoesNotAppendALifetimeToNoStore(): void
+ {
+ // A declared #[HttpCache(noStore: true)] is still stored server-side, but the client
+ // header must not pair no-store with a lifetime (#243).
+ $ro = new CacheableResponseNoStore();
+ $ro->headers[Header::CACHE_CONTROL] = 'no-store';
+
+ ($this->headerSetter())($ro, 31536000, null);
+
+ $this->assertSame('no-store', $ro->headers[Header::CACHE_CONTROL]);
+ }
+
+ public function testSetCacheControlMaxAgeChecksEveryCacheControlKeyForNoStore(): void
+ {
+ // An app-set lowercase 'cache-control' can sit beside the declared 'Cache-Control:
+ // no-store'; whichever key comes first, no lifetime may be added to either.
+ $ro = new CacheableResponseNoStore();
+ $ro->headers = ['cache-control' => 'public', Header::CACHE_CONTROL => 'no-store'];
+
+ ($this->headerSetter())($ro, 60, null);
+
+ $this->assertSame('public', $ro->headers['cache-control']);
+ $this->assertSame('no-store', $ro->headers[Header::CACHE_CONTROL]);
+ }
}
diff --git a/tests/QueryRepositoryTest.php b/tests/QueryRepositoryTest.php
index 4df7c5b4..558f33c6 100644
--- a/tests/QueryRepositoryTest.php
+++ b/tests/QueryRepositoryTest.php
@@ -317,7 +317,7 @@ public function testCacheableWithNoHttpCacheStillStores(): void
// must-revalidate) - a header that equals the declared value is the attribute
// speaking, not a per-request value, and must not trip the unstorable check.
$ro = $this->resource->get('app://self/cacheable-no-http-cache');
- $this->assertStringContainsString('max-age=10', $ro->headers[Header::CACHE_CONTROL]);
+ $this->assertSame('private, no-store, no-cache, must-revalidate', $ro->headers[Header::CACHE_CONTROL], 'no lifetime is appended beside no-store (#243)');
$this->logger->flush();
$this->resource->get('app://self/cacheable-no-http-cache');
diff --git a/tests/RetentionPolicyTest.php b/tests/RetentionPolicyTest.php
index 11314dc0..b721742d 100644
--- a/tests/RetentionPolicyTest.php
+++ b/tests/RetentionPolicyTest.php
@@ -172,19 +172,6 @@ public function testNoSkipReasonIsAnIncidentOfItsOwn(): void
}
}
- public function testASessionWhoseOwnRecordsMayBeIncompleteIsKept(): void
- {
- // An out-of-order close is a LIFO violation: the core logger records it in-band and
- // discards the close it could not match, so this session is missing a fact it should have.
- // Dropping it as a healthy read would hide that the log itself is unreliable here.
- $first = $this->logger->open(new GetContext('page://self/html/blog-posting'));
- $second = $this->logger->open(new GetContext('page://self/html/comment'));
- $this->logger->close(new CacheHitContext('view'), $first);
- $this->logger->close(new CacheHitContext('view'), $second);
-
- $this->assertTrue($this->policy->keeps($this->logger->flush()));
- }
-
public function testAFailedOutcomeIsFoundWhereTheRootKeepsItAsARecord(): void
{
// A `close` is one entry on a scope, but a root record carrying `openId` when its