Visibility still bounces after #381: settings save may silently fail (403/409 with no reseed), stale CAS version, or store conditional-GET staleness #391
Labels
No labels
actions
bug
cli
duplicate
enhancement
fork
forum
git storage
help wanted
insights
invalid
issues
moderation
oidc
ownership transfer
packages
pr/merge protection rules
projects
pull requests
question
releases
sponsorships
tags
webhooks
wiki
wontfix
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
crueber/walhub#391
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What's wrong
Follow-up to #381: the visibility still bounces on the settings page. After saving a visibility change (e.g.
private — owner only), refreshing showspublic — anyone may readagain — the save either didn't stick or the page reads a stale value. Screenshot shows the settings select back onpublic.Since #381 was filed, the surfaces moved: #382 landed (cache-class-by-mutability law, shared
cachepolicy, contract guard), #384/#385 moved the detailed route and owner profile off SWR, and the summary is now servedccMutable(private, no-cache—internal/api/summary.go:192). So the #381 HTTP-cache explanation no longer covers this — the bounce survives the fix, which means the stale value is coming from somewhere else.Candidate causes (ranked, all need verification — live probing is auth-blocked for me)
saveVisibility(web/src/pages/Settings.jsx:139-160) re-reads the access doc for the CAS version and PUTs; on 409 it catches and showssetVisNote(String(e.message ?? e))— but the note is small, the select stays on the user's chosen value (getVis()is not reset to the server truth on failure — unlikeAccess.jsx'sload()which re-seeds from the server), and on next refresh the select re-seeds from the authoritative doc =public. The user sees the save "not stick" with no clear error. Check: does the PUT actually 403 (saves "requires admin" — is the OIDC user an admin?) or 409 (stale version from the 5s-TTLgetAccessentry)?saveVisibilitytakesdoc?.versionfrom thegetAccessuseDataentry (5 s TTL). If the user changed visibility in the Access tab (or elsewhere) within the TTL window, the settings page holds an older version → PUT → 409 → caught → note shown → refresh reseeds public. The Access tab handles 409 by reloading server truth (Access.jsxif (err?.status === 409) await load()); the settings saveVisibility does not reload on 409 — it just notes the error.GetAccess(internal/identity/access.go:174-196) does a conditional GET (If-NoneMatch: known) against the store, serving the LRU doc onNotModified. On the S3 backend, conditional headers ride unsigned on a presigned URL (s3.go:405-416, documented at :402-404) — some S3 implementations do not evaluateIf-None-Matchon presigned GETs (they answer 200 with the full body, which is correct), but if the deployed backend (rustfs/minio/real S3) answers 304 against a stale cached body anywhere in its chain (CDN/proxy in front of the store), the identity LRU serves its stale doc indefinitely. ThePutAccessLRU invalidate only covers the instance that handled the PUT.PutAccess), while other repo writes ride the WAL — if any component reads visibility through a WAL-replayed or manifest-cached surface rather than the direct doc, the two paths can disagree transiently. Verify no such second read path exists post-#374.What's needed
crueber/walhub. One repro cycle will identify which candidate is real.access.get(), likeAccess.jsxdoes) and show a clear error — the user must never be left looking at a select that says something the server disagrees with.access.get()immediately before the PUT (drop the 5s-TTL entry from the version source) — or handle 409 by re-reading and retrying once, theAccess.jsxpattern.saveVisibilityreuse the exact save pathAccess.jsxuses so the two tabs can't drift.If-None-Matchsemantics the identity LRU relies on (or that a 200-with-body is always treated as fresh — which it is, :188-196). If the production backend sits behind a caching proxy, the conditional GET contract may be the wedge; document the requirement indocs/go/03_store_backends.md.Acceptance criteria
Root cause found with evidence + fix proposed in PR #392 (#392). Short version: not store staleness (ruled out on memory+filesystem — instances converge via conditional revalidation); the save itself was failing (403 non-admin / 409 stale version) and the settings select kept the unsaved value until refresh reseeded truth = the bounce. Fix: shared save path with fresh-version PUT + one 409 retry, reseed-from-truth + specific error on any failure, permanent access GET/PUT outcome logs, documented conditional-GET contract.
Review of PR #392 (fix/issue-391, commit
e133fe2) — verified in scratch worktree (since removed). No browser used: tests + code reasoning only, per instructions.FINDINGS (all acceptance criteria met):
(1) Root-cause evidence — SOUND. internal/identity/access_visibility_391_test.go runs the REAL routeAccess handler against REAL memory + filesystem backends: fresh PUT sticks across refreshes incl. the LRU NotModified re-read (access_visibility_391_test.go:74-94), stale integer version deterministically 409s, stranger PUT deterministically 403s, and TwoInstancesConverge proves cross-instance convergence on next conditional revalidation. Candidates #3/#4 ruled out on these classes; #1+#2 confirmed. S3 rig skipped but honestly documented, and the S3 unsigned-header path is byte-untouched by this diff (s3.go:403-412) — now contract-pinned in words instead.
(2) Failure paths — CORRECT. Settings.jsx:162-175: on ANY failure reseedVisibility() reseeds the select from server truth (null-safe: never blanks on reseed failure) + invalidates access entry + friendlyAccessError note. 403→'admin role required', 409→'changed under you', 401→'sign in', network/other→message. Select never silently shows an unsaved value. Residual edge (not blocking): if the reseed GET itself fails (e.g. roleless stranger), truth=null keeps the chosen value — but the note is loud, and the choice is documented in accessSave.js:68-72.
(3) Fresh version — CORRECT. saveVisibilityOnly (accessSave.js:44-59) takes the CAS version from a fresh access.get() immediately before EACH PUT and retries exactly ONCE on 409 with a re-read version; only 409 retries (403/others throw — pinned by test '403 does not retry'). Access.jsx deliberately keeps its no-retry full-document PUT with documented rationale (blind retry would clobber a concurrent binding edit, accessSave.js:18-20) and shares only the wording — the issue's 'consider sharing' was considered and correctly declined for the PUT path.
(4) Logging — CLEAN, one line each, no leaks. GET outcomes at Debug (access.go:186/193/206: repo+source+version+visibility); handler denied/conflict/error/success at Info (http_invites.go:219/225/234/251/256/262). Fields are repo, principal NAME, versions, visibility, error strings — grep confirms no token/secret/password/credential/bearer in new logs (invite-token hits are pre-existing untouched code). Law 9: 403/409 stay real status codes, loud.
(5) Store contract doc — ACCURATE, matches s3.go: 304→NotModified echoing requested version (s3.go:440-443), 200→fresh Object with ETag version (s3.go:446-447), conditionals unsigned (s3.go:403-412). No backend code change needed; none made.
(6) No #382 extension — CORRECTLY RULED OUT. TwoInstancesConverge is exactly the test that would have extended #382's contract on genuine multi-instance staleness; it passes on both classes.
(7) Verification (scratch worktree, all green): identity -race -count=1 PASS; -race -count=5 -run 391 PASS (3 tests x 2 backends x 5); identity coverage 95.6% (gate holds); store suite PASS; gofmt(Go)/vet/build clean; node access-save 7/7 PASS; full unit suite 764/766 with the SAME 2 smoke dist-build failures as clean main (confirmed by running smoke on main: 1 pass/2 fail there too — pre-existing, unrelated). Docs: Decisions entries present in 03_store_backends.md (§2.2 + Decisions) and features/01_identity_permissions.md.
No fixes needed — nothing to push.
MERGE RECOMMENDATION: ready to merge.
Fixed by PR #392 (review clean; root cause evidence-backed, failure paths reseed + loud, fresh-version + retry, store contract documented), merged. Closing.