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### Cachingprose, its illustrative proto comment, and thecache_ttlline of the YAML example.proto/artifactregistry/config/v1/config.proto— thecache_ttlcomment.gen/is regenerated, and its whole delta is the Go doc comment onIAMConfig.CacheTtl:protoc-gen-gostripsSourceCodeInfo, so the serialized descriptor bytes do not move. Regenerating with the pinned toolchain reproduces the committed file exactly.internal/config/iam.go— thedefaultIAMCacheTTL/maxIAMCacheTTLblock, theerrCacheTTLAboveMaxcap-violation string, and theIAMConfig.CacheTTLfield godoc.docs/dev/configuration-reference.md— theiam.cache_ttlrow, 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— thecache_ttlcomment.
docs/specs/S09-authorization.md gains two Security Considerations
bullets. It is the enforcement spec and it stated no window at all:
- 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.
- The organization binding, which is a longer and separate window.
authz.OrganizationMemberDeniedcompares the token'sgitlab.origin_idagainst 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 carrygitlab.organization_role(ADR-020), no Go file underinternal/orcmd/reads it, and the claims the binding does turn on (gitlab.originandgitlab.origin_id, plusgitlab.local_idon 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 listingdocs/specs/S09-authorization.mdacceptance criterion Listing, ancestor escape hatchdocs/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 Questionssection read "None". The empty-versus-non-empty TTL question is written out there now. errCacheTTLAboveMaxcarried a clause of design rationale into a startup failure, where it told the operator nothing about what to do. It now matches its siblingerrNamespaceKeyCacheTTLAboveMaximuminencryption.go: what a larger value costs, with the bound derived frommaxIAMCacheTTLrather than spelled a second time.
Review round two: four sites the reviewer caught
4cf85ba65S09's role bullet asserted the exemption without naminggitlab.organization_role, which ADR-020 does put in the token. It names the claim and the condition now, and mirrorsinternal/authz/decision.go:49'sSubject.Origin == relationships.OriginOrganizationrequirement into the bullet above it.1969e0b99The configuration-reference row stated the alternation unconditionally, and it is false on a single replica. Expiry is per cached entry, keyed oncacheKey(req), andwire_iam.go'siamCachingResolversgives 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.33b156622S25 cited the~90sobservation 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.2d23bb18bS09 offered a deny-versus-allow TTL split.cacheEntryholds tuples, not verdicts, andstoreLockedwrites every result under onec.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" inget_started.md,roles_and_permissions.md, andtroubleshooting.md. All three measurements above exceed 30s. Reported on that MR rather than edited here, so this MR does not touchdocs/user/. - The write-to-read lag inside
iam-data-accessis still unmeasured. That is why the corrected wording names the lag as a term instead of stating a total. Tracked on #1025 (closed). LookupResourcespresent-tense elsewhere in S09. The### Repository listingsection 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)