docs(specs): back the relationship cache with the key-value store
📝 What this changes
ADR-021's closed-beta bullet now specifies the relationship-response cache as backed by the key-value store the registry is provisioned with — one entry with one expiry serving the whole fleet, degrading to a direct relationships-API call rather than failing a decision when the store is unreachable (handbook!21097).
S25 and S09 still described that cache as per-pod, in-process, and unshared. This brings both in line.
📂 S25 (iam-relationships-client)
Substantive rewrite of Caching, covering only what the client adds on top of the ADR rather than restating it:
- The
cachepurpose client from S05-A, with the two decorator instances the composition root builds over it, and the listing chunk reads that hold no cache at all. - Key shape: the existing request encoding reserves
:, which the S05-A grammar forbids inside<id>, so<id>is that encoding's hex-encoded SHA-256 digest. A worked example is included. Hashing also fixes the segment's length — a verdict read keys on a whole page's object union, which at the 100-row page cap would otherwise run past four kilobytes per key. <name>also carries a value-format version (decision_v1,verdict_v1), so a format change that still decodes is handled by a bump rather than a flush. The version rides<name>because that is the segment S25 owns; a fifth segment would be an S05-A grammar change. One cost:<name>no longer equals therelationship_cache_events_totalinstance label, so key-space-to-metric matching becomes a prefix match.- Value shape: the tuple slice as JSON, with a worked example. A value that fails to decode counts as a miss; a renamed field or renumbered enum that still decodes is what the version segment covers, since
encoding/jsonwould otherwise zero-fill it into a wrong tuple. - Entry lifetime as the key's own expiry, so no capacity bound, purge pass, or full-clear path remains. Closes #527 (closed) as the issue asks.
- The single-flight collapse is kept, because a shared entry removes a repeat lookup only once it exists.
errorandinvalidjoinrelationship_cache_events_total.- The store hop and
iam.timeoutare additive on the degraded path.read_timeouttimes out one attempt rather than the hop — the store client retries a timed-out command — so the wrapper owns its own per-command deadline, sized as a fraction ofiam.timeout, and that deadline is what a slow store costs a decision.
Also: a Dependencies row on S05-A, a Cached tuples at rest security bullet, the cache_ttl proto and YAML comments, and the read-caching acceptance criterion rewritten as a two-instances-one-lookup assertion with new criteria for key expiry and for a store outage.
Six Resolutions entries record what was settled, including the rejected alternatives: storing the upstream response bytes as received, truncating the digest, and extending the collapse across pods with a distributed lock.
📂 S09 (authorization)
Two lines. The staleness window survives and stops being per-pod: entries still expire per key, so a denial establishes revocation only on the resource it named, and confirming one across resources means sampling each rather than inferring the rest from one. That per-key-not-per-pod distinction is what keeps ADR-021's security instruction standing, so it is preserved rather than collapsed.
⚠️ Owed by the implementation MR, not this one
Guardrail 14 makes the configuration reference document as-is behavior, so it cannot describe a store-backed cache before the code lands. These follow the implementation:
docs/dev/configuration-reference.mdconfig.example.yamlproto/artifactregistry/config/v1/config.proto'scache_ttlcommentinternal/config/iam.godocs/dev/observability.md'srelationship_cache_events_totalrow, for the two new event values — and for a second reason: that row closes on theiam: relationship cache full, clearing all entriesWARN and how its cardinality reading "holds fordecisionand not forverdict". This work deletes that WARN along with the full-clear path, so the sentence goes stale with it.
Work item 1279's AC 6 lists the configuration reference alongside the spec. That ordering is wrong for the same reason, and the AC should be read as spec-first.
🔍 Notes for the reviewer
- S25 moves from
DrafttoApproved. It was merged carryingDraft, which the guardrail reserves for a spec being revised in-tree after merge. Nothing is, so the status was stale rather than meaningful, and this MR corrects it alongside the amendment. S09 was alreadyApprovedand is unchanged. - The staging measurement is gone from S25. The removed text carried a 2026-09-08 staging measurement of revocation flapping (denied at 6s, still served at 23s, denied from 25s). It characterised per-pod alternation specifically, so keeping it would contradict the rewrite. It survives on #1025 (closed), which the section still references.
- No S05-A amendment is owed. Its Out-of-Scope entries route rather than forbid:
Cache[T]is a general marshalling type this work does not build, and the wrapper is purpose-specific followinginternal/cache/counter. Its degradation standard already leaves the fail-open choice to the consumer, which sanctions the posture here. - IAM-driven invalidation is not promised anywhere, and this key shape would not support it. A note on 1279 records it as GA scope. The
<id>segment is a digest of the request encoding, so IAM cannot reconstruct a key without the exact object set that request passed, and deleting by resource or principal would need an index plus a multi-keyDELthat is cross-slot absent a hash tag. Neither targeted nor org-scoped invalidation is available on this shape; nothing here commits to either, and the spec states no invalidation claim. Whether keys carry an organization segment is deferred to S05-A, which owns the key grammar; it is tracked on #1360 and recorded in S25's Follow-ups.
Related to #1279