Skip to content

ADR-0032: Cached-Adapter-Store Hash-Bumping Protocol (Subscribe-via-Header)

Accepted Cross-Project Universal

Date: 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 header x-fs-cache-hashes stays a {cacheKey:hash} object — symmetric v1. prefix, asymmetric payload.)
  • The wrapper emits the header automatically from its per-HttpService registry of locally-cached stores (D1 below). Wrappers that do not speak v1. omit the header.

Backend middleware (authorization-filtered multi-key stamp)

For a request carrying a parseable v1. subscribe header, for each subscribed cache key:

  1. Parse the key shape (projects/{id}/lanes, projects/{id}/labels, …).
  2. Resolve the gating resource once per distinct parent — reuse the route-bound $project for the route's own id; batch-resolve any additional distinct ids in the subscription via a single whereIn('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.
  3. Run the Tier-1 policy the key's read route would run (LanePolicy::viewAny($user, $project), LabelPolicy::viewAny($user, $project)).
  4. 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 shapeMiddleware behaviour
Subscribe header present, v1., parseableAuthorize + stamp the authorized subset
Subscribe header absentToday's behaviour — stamp the project-bound default if a Project is route-bound
Subscribe header present but malformedLog a structured warning; fall back to today's behaviour
Subscribe header lists keys the user can't seeSilently 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

OptionVerdictReason
Read every registered key per response (DB-column, naive generalization)RejectedO(all keys) DB reads per response — the N+1 the pilot's route-binding avoided.
A — Subscribe-via-headerAcceptedRead 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 WebSocketRejectedCommander 2026-05-15. Broadcast as sole invalidation path has no HTTP fallback; a missed broadcast is undetectable.
C — Denormalized tenant-scoped hash row + Redis mirrorDeferredCheaper 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-HttpService consumer 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 / DeleteLabelAction must take Project $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 reintroductionMitigation: 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-key find().
  • Authorization bypass leaking resource existenceMitigation: 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-HttpService stalenessMitigation: documented limitation (D2); does not affect the current consumer set. Revisit only if a cached resource set spans services.
  • Stale-forever on a missed bumpMitigation: no staleAfterMs; a write path that bypasses the bump is a bug to fix, caught by CachedModelHashBumpTest presence tuples.

Enforcement

WhatMechanismScope
Every label-mutation Action bumps the labels hashtests/Arch/CachedModelHashBumpTest.php — 3 new [ActionClass, pattern] tuples (Create/Update/DeleteLabelAction)kendo app/Actions/Label/
Subscribe-parse + per-key authorization + safe fallbackFeature test (labels analogue of LanesCachedStoreProtocolTest) — incl. an unentitled-key-dropped case and an absent-header fallback casekendo 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 landskendo StampCacheHashesMiddleware
Wrapper emits subscribe header, old-backend-safefs-packages package tests under 100% coverage + 90% mutation gatescached-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

TerritoryStateNotes
kendoIn ProgressBackend 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-packagesIn ProgressOutbound 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.

Architecture documentation for contributors and collaborators.