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
getreturns sentinel errors, not an outcome enum. The plan pinned acacheGetFound/cacheGetMiss/cacheGetInvalidvocabulary; this MR mirrorsstorage/cache/redis.URLCacheinstead, matched witherrors.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.Nilerror.recordStoreErrorskips 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
MarshalJSONrejects 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.
cacheKeyandappendLenPrefixedmove 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 onTestCacheKey_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