ADR-0032: Cached-Adapter-Store Hash-Bumping Protocol (Subscribe-via-Header)
Accepted Cross-Project UniversalDate: 2026-06-08 Compliance: ISO 27001 (A.5.33 — the stamped hash and its authorization filter are an information-disclosure surface)
Context
The cached-store request-suppression protocol lets the SPA skip an HTTP refetch when a server-stamped resource hash equals the hash it already holds. The pilot (kendo PR #1132) shipped one cache key — projects/{id}/lanes — stamped by StampCacheHashesMiddleware off the route-bound Project ($project->lanes_hash, zero extra queries) and consumed by the @script-development/fs-cached-adapter-store wrapper. Lane memoization (PR #1401) made the suppression persist across navigation; staging confirmed /lanes fires once per session.
The pilot middleware reads a single hash from a route-bound model. It does not generalize. Reading every registered cache key on every response would be N DB reads per response (an N+1). The 2026-05-15 deliberation (campaigns/cross-territory/2026-05-15-cached-store-protocol-stamping-scope.md) resolved this before a second key shipped, and planted code-level annotations on StampCacheHashesMiddleware and BumpProjectLanesHashAction forbidding ad-hoc widening: the second cache key is the trigger to adopt this protocol.
Labels (projects/{id}/labels) is that second key — an ideal cache target (rarely mutated), flagged by the ally who authored the wrapper. The naive path (add labels_hash + stamp the labels route the same route-bound way) would reinforce the route-binding shape we are walking away from and inherit the pilot's accepted-but-real cross-resource staleness gap: a tab parked on screens that never resolve a project binding for the relevant resource serves a stale hash. This ADR defines the protocol that stamping a second key requires.
Decision
Option A — subscribe-via-header. The SPA declares, on each request, the set of cache keys it caches locally. The backend authorizes that set against the current user and stamps only the authorized subset on the response.
Request header (frontend → backend)
x-fs-cache-hashes-subscribe: v1.<rawurlencoded JSON array of cache keys>- Flat JSON array of strings (
["projects/10/lanes","projects/10/labels"]). (The response headerx-fs-cache-hashesstays a{cacheKey:hash}object — symmetricv1.prefix, asymmetric payload.) - The wrapper emits the header automatically from its per-
HttpServiceregistry of locally-cached stores (D1 below). Wrappers that do not speakv1.omit the header.
Backend middleware (authorization-filtered multi-key stamp)
For a request carrying a parseable v1. subscribe header, for each subscribed cache key:
- Parse the key shape (
projects/{id}/lanes,projects/{id}/labels, …). - Resolve the gating resource once per distinct parent — reuse the route-bound
$projectfor the route's own id; batch-resolve any additional distinct ids in the subscription via a singlewhereIn('id', $distinctIds)query. Read cost is O(distinct parents), not O(keys), and never one query per key. This is the N+1 guard — enforced by an arch test from day one (§Enforcement), not left to review. - Run the Tier-1 policy the key's read route would run (
LanePolicy::viewAny($user, $project),LabelPolicy::viewAny($user, $project)). - Stamp only the keys whose policy check passed.
Authorization is non-negotiable — a stamped hash signals the resource exists, so an unentitled key must be silently dropped from the response (not 403; consumer registries may be stale, and quiet is safer).
Fallback semantics
| Request shape | Middleware behaviour |
|---|---|
Subscribe header present, v1., parseable | Authorize + stamp the authorized subset |
| Subscribe header absent | Today's behaviour — stamp the project-bound default if a Project is route-bound |
| Subscribe header present but malformed | Log a structured warning; fall back to today's behaviour |
| Subscribe header lists keys the user can't see | Silently drop those keys; do not error |
Replace, don't layer. The route-bound default and the subscribe handler must not co-exist as two parallel stamping paths in the class — the subscribe path subsumes the default (absent header → the same project-bound default).
Bump Action (unchanged invariant)
Each cache key keeps a dedicated Bump{Entity}{CacheKey}HashAction (e.g. BumpProjectLabelsHashAction), called inside the mutating Action's transaction, after the write. No observers (CLAUDE.md Principle 1 / ADR-0011). Coverage follows the response payload, not the namesake model.
D1 — Wrapper key-storage
The wrapper reuses its existing per-HttpService registry keys to build the subscription set (a read-accessor feeding a new outbound request middleware), rather than maintaining a separate subscription structure. The registry keys already are the subscription set. Closes cross-resource staleness within a single HttpService; the multi-HttpService case (e.g. kendo central+tenant) is out of scope — acceptable because a given consumer's cached resource set lives on one service.
Options Considered
| Option | Verdict | Reason |
|---|---|---|
| Read every registered key per response (DB-column, naive generalization) | Rejected | O(all keys) DB reads per response — the N+1 the pilot's route-binding avoided. |
| A — Subscribe-via-header | Accepted | Read cost O(subscription size); cross-resource staleness solved as soon as the SPA includes the key on any request; authorization co-located with stamping. |
| B — Broadcast hash bumps via WebSocket | Rejected | Commander 2026-05-15. Broadcast as sole invalidation path has no HTTP fallback; a missed broadcast is undetectable. |
| C — Denormalized tenant-scoped hash row + Redis mirror | Deferred | Cheaper than A at scale but reintroduces the cache-coherence problem the DB column exists to avoid. Fallback only if A's per-request authz cost becomes the bottleneck. |
Consequences
Positive
- A second cache key (labels) ships without an N+1 and without ad-hoc middleware widening.
- Cross-resource staleness is closed for the single-
HttpServiceconsumer set ({lanes, labels}on the tenant service). - Authorization is structurally co-located with stamping — a hash never leaks the existence of an unentitled resource.
- The wrapper change is a backward-compatible minor; an old backend safely ignores the new request header.
Negative
- The middleware grows a request-header parser + per-key authorization loop — net-new surface with zero test precedent (the pilot never parsed a request header).
UpdateLabelAction/DeleteLabelActionmust takeProject $project(threaded from their controllers) to call the bump — a signature change, not a drop-in mirror of the lane Actions.- Two repos move in lockstep at one integration point (kendo middleware ↔ wrapper header).
Risks
- N+1 reintroduction — Mitigation: resolve one gating resource per distinct parent, reuse the route-bound model, batch-resolve additional parents via one
whereIn. Hardened to a Level-1 arch test from day one (CachedStoreSubscribeResolutionTest) — not review-only; teeth proven by red-on-reintroduced per-keyfind(). - Authorization bypass leaking resource existence — Mitigation: the per-key Tier-1 policy check is mandatory; silently drop unentitled keys; cover with a feature test that subscribes to a key the user can't see and asserts it is absent from the response.
- Multi-
HttpServicestaleness — Mitigation: documented limitation (D2); does not affect the current consumer set. Revisit only if a cached resource set spans services. - Stale-forever on a missed bump — Mitigation: no
staleAfterMs; a write path that bypasses the bump is a bug to fix, caught byCachedModelHashBumpTestpresence tuples.
Enforcement
| What | Mechanism | Scope |
|---|---|---|
| Every label-mutation Action bumps the labels hash | tests/Arch/CachedModelHashBumpTest.php — 3 new [ActionClass, pattern] tuples (Create/Update/DeleteLabelAction) | kendo app/Actions/Label/ |
| Subscribe-parse + per-key authorization + safe fallback | Feature test (labels analogue of LanesCachedStoreProtocolTest) — incl. an unentitled-key-dropped case and an absent-header fallback case | kendo tests/Feature/CachedStoreProtocol/ |
| No per-key parent lookup (N+1 guard) | tests/Arch/CachedStoreSubscribeResolutionTest.php (Level 1, day one) — asserts the subscribe path batch-resolves additional gating parents via a single whereIn and contains no per-key find()/first()/firstOrFail() lookup in the subscription loop. Teeth proven by stash-and-reintroduce of a naive per-key find(). AST-lite/source-pattern limitation documented in the test header; PHPStan-rule escalation path if a third key lands | kendo StampCacheHashesMiddleware |
| Wrapper emits subscribe header, old-backend-safe | fs-packages package tests under 100% coverage + 90% mutation gates | cached-adapter-store outbound middleware |
Resolved Questions
Why not just stamp the labels route the same route-bound way as lanes?
Resolved 2026-06-08. That is the forbidden ad-hoc 1→2 transition. It reinforces the route-binding shape that does not generalize and inherits the cross-resource staleness gap. The second key is precisely the agreed trigger to adopt subscribe-via-header.
Does the wrapper need a new subscription data structure?
Resolved 2026-06-08 (D1). No. The existing per-HttpService registry's keys are the subscription set; a read-accessor feeding a new outbound request middleware reuses them.
Is emitting the new header safe against a backend that doesn't speak Option A?
Resolved 2026-06-08. Yes — structurally. An old backend ignores an unknown request header; the wrapper's response-side parse is unchanged. (Structural confirmation; a runtime curl against staging is the operational check before the wrapper release tags.)
Implementation
| Territory | State | Notes |
|---|---|---|
| kendo | In Progress | Backend keystone shipped — PR #1440 (base development, Agent Review Requested). Option-A middleware + labels_hash migration + BumpProjectLabelsHashAction + 3 Action bumps + N+1 arch test (teeth-proven) + labels feature test. CI green (Feature Tests finishing). Follow-up: memoize makeLabelStoreForProject after merge. Order: orders/kendo/labels-cache-phase-3-backend-engineer-deployment.md. |
| fs-packages | In Progress | Outbound subscribe-header middleware (D1, reuse-registry) + as HttpService cast (Pick-widen would cascade). Minor 0.2.0. PR #114 to @jasperboerhof (Agent Review Requested), 8 gates green locally (mutation 95.5%). Order: orders/fs-packages/cached-store-subscribe-header-engineer-deployment.md. |