docs: the cache TTL bounds only AR's share of the role-change window

Summary

S25 said the role-assignment staleness window "is the TTL". It is not, and the docs said so in four places.

A pod fills its relationship-cache entry from whatever iam-data-access returns at that moment. A fill that lands after the assignment is written, but before that write is readable, caches the pre-change answer for a further full TTL. So the window a caller actually sees is that service's write-to-read lag plus up to one TTL. Only the second term is ours to bound.

This matters in the revoke direction, where the window is a security bound, and in the grant direction, where the masked 404 during the window reads as "the grant did not work".

Inside that window the verdict can alternate rather than flipping once, because expiry is per cached entry rather than per role change: several pods, and the separate caches one pod holds for the enforcement and verdict paths, each expire on their own schedule. Measured on staging against a 30s TTL, a revoked principal kept intermittent read access for 23 seconds, and two requests seconds apart at +6s returned 404 and then 200 on the same token and repository. One denied request therefore does not establish that a revocation has taken hold, so anything confirming one has to sample across the window rather than once (#1025 (closed)).

Why the old claim is checkable and false

The loader caps iam.cache_ttl at 60s, so the old wording promised a maximum. What falsifies it is the mechanism, not any one measurement: a pod caches whatever iam-data-access returns at the moment it fills, so a fill landing before the write is readable serves the pre-change answer for a further full TTL, and no total can be stated while that lag is unmeasured. The staging measurements below are the record of what has been observed, not the proof:

Direction Observed Source
Grant, end to end ~90s #1025 (closed) description, 2026-09-01
Grant at most 33s #1067 row 86, 2026-09-02
Organization owner demote 53s gitlab-org/gitlab!250806 (merged), 2026-09-07

None of the three is a bound on its own, and #1025 (closed) settles that twice: the cache is per pod and staging runs several, so a single poll measures the fastest pod rather than the window, and the ~90s above and the 33s in #1067 are the same mechanism sampled once at different moments. The best-instrumented data point is the controlled re-run of 2026-09-08, which settled at 23 to 29 seconds against a 30-second TTL, consistent with an upstream lag near zero.

What changed

Prose and comments, plus one runtime-visible string: errCacheTTLAboveMax is the message an operator gets when startup rejects an over-cap iam.cache_ttl. No schema, no logic, no test change.

The sites that carried the claim:

  • docs/specs/S25-iam-relationships-client.md — the ### Caching prose, its illustrative proto comment, and the cache_ttl line of the YAML example.
  • proto/artifactregistry/config/v1/config.proto — the cache_ttl comment. gen/ is regenerated, and its whole delta is the Go doc comment on IAMConfig.CacheTtl: protoc-gen-go strips SourceCodeInfo, so the serialized descriptor bytes do not move. Regenerating with the pinned toolchain reproduces the committed file exactly.
  • internal/config/iam.go — the defaultIAMCacheTTL / maxIAMCacheTTL block, the errCacheTTLAboveMax cap-violation string, and the IAMConfig.CacheTTL field godoc.
  • docs/dev/configuration-reference.md — the iam.cache_ttl row, per the configuration-reference guardrail. The operational facts (default, "0s", the cap) come first and the caveat follows, and the row names the Artifact Registry rather than saying "this service" one clause after naming a different one.
  • config.example.yaml — the cache_ttl comment.

docs/specs/S09-authorization.md gains two Security Considerations bullets. It is the enforcement spec and it stated no window at all:

  1. The tuple window above. It is scoped to permission-carrying decisions: the repository listing's chunk reads take the uncached client, and the no-permission binding reads no tuples at all, so neither is on the cache's window. Both are named.
  2. The organization binding, which is a longer and separate window. authz.OrganizationMemberDenied compares the token's gitlab.origin_id against the resolved anchor, and the exchange fixes that claim at mint from the caller's home organization, not from the set they belong to. Leaving that organization therefore leaves a token that still names it until the token expires; ADR-020 admits a client-requested lifetime from 1 second to 12 hours and defaults to 5 minutes, so which of the two windows is longer depends on the lifetime requested. Joining a second organization is not on a window at all, because no token the caller can mint names it until their home organization changes. A role change is subject to neither, and the bullet says why in a way a reader can check: the token does carry gitlab.organization_role (ADR-020), no Go file under internal/ or cmd/ reads it, and the claims the binding does turn on (gitlab.origin and gitlab.origin_id, plus gitlab.local_id on the future drain) do not depend on role.

The LookupResources drain is named as a future second reader of the same claim, not a present one. git grep LookupResources -- '*.go' hits only gen/proto/lookup/, so the sentence uses the condition-and-event form this spec's ## Dependencies and ## Follow-ups already use for that seam.

Verified at monolith master cd0047ee85b8: gitlab.organization_role is in the token but no AR Go file reads it, and ArtifactRegistry::NamespaceMapping.for_member joins on user_id with no access_level filter, so the organization the claim names does not move on a role change.

Review round: three sites the first pass missed

The first pass claimed it fixed every site. It did not, and two of the misses are in a file it already edits. All three understate the same divergence the same way, by measuring it from the write rather than from the moment iam-data-access can serve it:

  • docs/specs/S09-authorization.md ### Repository listing
  • docs/specs/S09-authorization.md acceptance criterion Listing, ancestor escape hatch
  • docs/plans/2026-09-04-lookup-resources-list-visibility.md

The divergence is reachable for up to one TTL after the grant is servable, so the lag is a term there too.

Two smaller ones from the same round:

  • S09 declared an open decision inline while its ## Open Questions section read "None". The empty-versus-non-empty TTL question is written out there now.
  • errCacheTTLAboveMax carried a clause of design rationale into a startup failure, where it told the operator nothing about what to do. It now matches its sibling errNamespaceKeyCacheTTLAboveMaximum in encryption.go: what a larger value costs, with the bound derived from maxIAMCacheTTL rather than spelled a second time.

Review round two: four sites the reviewer caught

  • 4cf85ba65 S09's role bullet asserted the exemption without naming gitlab.organization_role, which ADR-020 does put in the token. It names the claim and the condition now, and mirrors internal/authz/decision.go:49's Subject.Origin == relationships.OriginOrganization requirement into the bullet above it.
  • 1969e0b99 The configuration-reference row stated the alternation unconditionally, and it is false on a single replica. Expiry is per cached entry, keyed on cacheKey(req), and wire_iam.go's iamCachingResolvers gives a pod two caches, so per-entry expiry is the cause that covers both and degrades to a single flip on one replica. S09 and S25 move with the row.
  • 33b156622 S25 cited the ~90s observation as evidence for the upstream term, and #1025 (closed) attributes that number to sampling one pod. The spec states no total instead, matching the merged ADR amendment word for word on that point.
  • 2d23bb18b S09 offered a deny-versus-allow TTL split. cacheEntry holds tuples, not verdicts, and storeLocked writes every result under one c.ttl, so no such knob exists. The live distinction is an empty relationship set against a non-empty one, which is where #1025 (closed) landed.

What this does not reach

  • The user-facing wording belongs to !2271 (merged), which is open and replaces docs/user/. It says "up to 30 seconds" in get_started.md, roles_and_permissions.md, and troubleshooting.md. All three measurements above exceed 30s. Reported on that MR rather than edited here, so this MR does not touch docs/user/.
  • The write-to-read lag inside iam-data-access is still unmeasured. That is why the corrected wording names the lag as a term instead of stating a total. Tracked on #1025 (closed).
  • LookupResources present-tense elsewhere in S09. The ### Repository listing section and the credential-type-blind bullet describe the drain's IAM-side admission contract in the present tense. Those describe what the call does when it runs, which is not falsified by the call being unbuilt, so they are left alone.

Merge gate

handbook!21025 merged on 2026-09-10, so the gate is clear. docs/adr/021_authorization.md:198 carried the same claim ("may take up to that window to take effect") and is the authority the proto comment, the Go const block and errCacheTTLAboveMax all cite; that paragraph now states the two-term model and says no total window can be stated today. The amendment changed no decision: the 60-second cap and the caching design stay as they are.

The local docs/adr/ copy is synced from the handbook by a daily CI job and has not picked the amendment up yet, so the copy on this branch still shows the old paragraph. That is the sync lagging, not a disagreement this MR introduces.

Testing

No logic change, so no test change. go build ./... and go test ./internal/config/... pass, and nothing in the repo asserted the old errCacheTTLAboveMax text (the cache-TTL cases assert DurationParseError.Field only). golangci-lint on ./internal/config/... reports 0 issues, and the comment-caps, markdownlint, Vale and lychee gates all pass locally. No e2e scenario is added or affected: this MR changes only comments and prose.

Merge order

git diff --name-only main...HEAD overlaps one open MR, and it needs no specific order. It merges cleanly into this branch, and so does main, both checked with git merge-tree --write-tree at 4cf85ba65 against main at b158b7653:

MR Shared files
!2332 configuration-reference.md

It touches a different configuration block, so the reference-table rows do not collide. !2351 (merged) and !2307 (merged) were on this list and have merged; !1011 was on it and is closed.

config.pb.go is not a conflict surface for this MR: its only change here is a Go doc comment, and the serialized descriptor is untouched. The usual caution about that file, that unrelated messages collide because the descriptor wraps on \n, applies to MRs that change the schema and not to this one.

Related to #1025 (closed)

Edited by Dzmitry (Dima) Meshcharakou

Merge request reports

Loading
Loading