chore(iam): relationship cache store on the cache purpose client (S25 relationship cache plan: 1/3)

📦 What

Adds the relationship cache store — the type that turns an IAM ReadRequest into a versioned key, encodes the tuple slice as JSON, and reads and writes it through the cache purpose client under its own per-command deadline. This is step 1 of 3 of the relationship cache plan.

Keys follow the key grammar, with the digest as the <id> segment:

ar:iam_relationships:decision_v1:<64 hex characters>
ar:iam_relationships:verdict_v1:<64 hex characters>

🔍 Scope — nothing consumes this yet

No production code constructs the store in this MR, by design. That is what lets it land ahead of the resolver: step 2 rewires CachingResolver and the composition root onto it, and step 3 drives it against a live Redis. The widened event cardinality set and the new json tags are likewise inert until then.

Concretely: cacheStore, newCacheStore, and CacheStoreClient have zero references outside internal/iam/cachestore.go and its two test files, and wireIAM is untouched.

🔨 Notable decisions

  • get returns sentinel errors, not an outcome enum. The plan pinned a cacheGetFound/cacheGetMiss/cacheGetInvalid vocabulary; this MR mirrors storage/cache/redis.URLCache instead, matched with errors.Is. The cache-abstractions spec names the URL cache as the pattern for a miss the caller branches on and distinguishes it from the counter's absent-is-zero contract. The only consumer needs one bit — served or not — so a third return value carrying what the store already records itself would be a second source of truth. The plan's Naming Conventions are updated in this MR to match.
  • A caller that goes away is not a store fault. The per-command context derives from the caller's, so a disconnect or an expired request deadline surfaces as a non-goredis.Nil error. recordStoreError skips those, as the URL cache middleware does on the same arm, so a disconnect wave cannot make the store look broken on the one series that separates a failing store from a failing decision. The store's own deadline expiring still books.
  • An encode failure books unconditionally. A timestamp is not range-checked on the way in and MarshalJSON rejects a year outside [0,9999], so the arm is reachable. Unlike a command failure it is deterministic and local, so a departed caller does not excuse it — and the caller writes detached, making the counter the only place such a write is ever observed.
  • The constructor asserts the derived deadline, not the timeout: integer division floors any timeout under the divisor to zero, which would expire every command before it was sent.
  • cacheKey and appendLenPrefixed move unchanged. Relocating them makes their previously-grandfathered doc comments answer to the comment caps, so both compress; the delimiter-collision rationale they carried is retained in full on TestCacheKey_NoDelimiterCollision, which is where the invariant is pinned.

📊 Metrics

relationship_cache_events_total gains error (a failed command or encode) and invalid (a stored value that will not decode) beside hit, miss, and coalesced. Both internal/metrics/cardinality.go anchors move together: the pinned event set gains the two values, and the budget rises 9 → 11 (the de-duplicated union across every event emitter goes 8 → 10, keeping the existing single unit of headroom).

TestRegisterMetrics_PassesCardinalityAudit now emits every pinned event value before it audits. It previously observed only iamRequestsTotal, and Gather omits a vector nothing observed — so the closed-set check could not see this vector at all, and the plan's acceptance criterion for it was satisfiable without any change.

📏 Size

1015 reviewable LoC, above the 500 ceiling development-model.md asks to justify and within the 1000-line working limit this plan sets. Split by file group:

Group LoC Files
Production 304 cachestore.go +196, caching.go −60, metrics.go 26, types.go 14, cardinality.go 8
Tests 679 store suite 407, stub 218, metrics_test.go 25, types_test.go 29
Plan 32 Naming Conventions and Step 1 acceptance

Two thirds is test code, and a split cannot help: tests must travel with the code they cover, so the store's suite and its stub cannot leave the store. The only clean cut available — the domain tags and the metrics anchors on one side, the store and its whole suite on the other — is roughly 90 against 925, which is two MRs for no reviewer benefit.

🧪 Tests

Unit tests run against stubStoreClient, the first hand-written stateful go-redis fake in this repository: a map behind a fake clock that honours the expiry passed to Set, per-command failure and parked arms for both verbs, an undecodable-value arm, and a record of every Set call. It carries its own integrity tests, because two ways of getting it wrong would leave every test that depends on it green — an expiry stamped off the wall clock rather than the injected clock, and an "invalid" fixture that is really valid JSON (null and [] both decode to an empty slice with no error).

Covered: order-independent keys; distinct keys per subject, objects, kinds, and instance name; the key grammar; version-suffix isolation proved by planting a value at the same digest under v2; a full-fidelity round trip; miss on an absent key and on a lapsed one; invalid and error events with their negative cases; the elapsed bound on a parked client for get and set alike; the TTL written as the key's expiry; and both constructor panics.

E2E scenario catalogs: no change. No route, status, or body moves, and nothing in the running service reaches this code yet. Conformance suites do not apply — no Maven, npm, or Container/OCI protocol behaviour. No configuration, proto, Bruno, or run-recipe surface changes.

⚠️ One coupling on the record

set's caller-cancellation guard has no production caller whose context can end, because the resolver runs the write under context.WithoutCancel. It is a correct defensive contract and symmetric with get, but its arm is reachable only from tests today.

Related to #1279

Merge request reports

Loading
Loading