Visibility still bounces after #381: settings save may silently fail (403/409 with no reseed), stale CAS version, or store conditional-GET staleness #391

Closed
opened 2026-09-12 16:22:22 +00:00 by crueber · 3 comments
Owner

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 shows public — anyone may read again — the save either didn't stick or the page reads a stale value. Screenshot shows the settings select back on public.

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 served ccMutable (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)

  1. The save silently failed (403/409) and the UI didn't say so. The settings visibility save notes "Saving requires admin" (screenshot copy). saveVisibility (web/src/pages/Settings.jsx:139-160) re-reads the access doc for the CAS version and PUTs; on 409 it catches and shows setVisNote(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 — unlike Access.jsx's load() 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-TTL getAccess entry)?
  2. CAS version from a stale cache entry. saveVisibility takes doc?.version from the getAccess useData entry (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.jsx if (err?.status === 409) await load()); the settings saveVisibility does not reload on 409 — it just notes the error.
  3. Server-side stale read via the access LRU + conditional GET. GetAccess (internal/identity/access.go:174-196) does a conditional GET (If-NoneMatch: known) against the store, serving the LRU doc on NotModified. 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 evaluate If-None-Match on 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. The PutAccess LRU invalidate only covers the instance that handled the PUT.
  4. Multi-writer ordering: visibility writes go through direct CAS (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

  1. Instrument first: log (server-side, one line) the access GET result — served from LRU vs store, version, visibility — and the PUT outcome (version in, CAS result) on crueber/walhub. One repro cycle will identify which candidate is real.
  2. Make the settings save authoritative and loud:
    • On any save failure (403/409/network), reset the select to the server's current value (re-seed from a fresh access.get(), like Access.jsx does) and show a clear error — the user must never be left looking at a select that says something the server disagrees with.
    • Take the CAS version from a fresh access.get() immediately before the PUT (drop the 5s-TTL entry from the version source) — or handle 409 by re-reading and retrying once, the Access.jsx pattern.
    • Consider making saveVisibility reuse the exact save path Access.jsx uses so the two tabs can't drift.
  3. Store-level conditional correctness: verify the deployed store backend honors If-None-Match semantics 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 in docs/go/03_store_backends.md.
  4. If the root cause is a genuine multi-instance/multi-path staleness, that's a new finding on #382's contract test — extend it rather than patching the settings page.

Acceptance criteria

  • Root cause identified with evidence (instrumented logs from one repro cycle) and stated in the PR — not inferred.
  • Save failures (403/409) reset the select to server truth and surface a clear, specific error ("saving requires admin", "conflict — visibility changed elsewhere"); the select never silently shows an unsaved value after a failed save.
  • The visibility save uses a fresh CAS version (or retries once on 409); saves stick across refreshes deterministically.
  • The store backend's conditional-GET contract is documented (03_store_backends.md) and verified against the deployed backend.
  • Tests: settings saveVisibility failure paths (403/409 → reseed + error), fresh-version acquisition, and the store conditional-GET behavior for the deployed backend class.
## 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 shows `public — anyone may read` again — the save either didn't stick or the page reads a stale value. Screenshot shows the settings select back on `public`. 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 served `ccMutable` (`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) 1. **The save silently failed (403/409) and the UI didn't say so.** The settings visibility save notes "Saving requires admin" (screenshot copy). `saveVisibility` (`web/src/pages/Settings.jsx:139-160`) re-reads the access doc for the CAS version and PUTs; on 409 it catches and shows `setVisNote(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 — unlike `Access.jsx`'s `load()` 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-TTL `getAccess` entry)? 2. **CAS version from a stale cache entry.** `saveVisibility` takes `doc?.version` from the `getAccess` `useData` entry (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.jsx` `if (err?.status === 409) await load()`); **the settings saveVisibility does not reload on 409** — it just notes the error. 3. **Server-side stale read via the access LRU + conditional GET.** `GetAccess` (`internal/identity/access.go:174-196`) does a conditional GET (`If-NoneMatch: known`) against the store, serving the LRU doc on `NotModified`. 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 evaluate `If-None-Match` on 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. The `PutAccess` LRU invalidate only covers the instance that handled the PUT. 4. **Multi-writer ordering**: visibility writes go through direct CAS (`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 1. **Instrument first**: log (server-side, one line) the access GET result — served from LRU vs store, version, visibility — and the PUT outcome (version in, CAS result) on `crueber/walhub`. One repro cycle will identify which candidate is real. 2. **Make the settings save authoritative and loud**: - On **any** save failure (403/409/network), reset the select to the **server's current value** (re-seed from a fresh `access.get()`, like `Access.jsx` does) and show a clear error — the user must never be left looking at a select that says something the server disagrees with. - Take the CAS version from a **fresh** `access.get()` immediately before the PUT (drop the 5s-TTL entry from the version source) — or handle 409 by re-reading and retrying once, the `Access.jsx` pattern. - Consider making `saveVisibility` reuse the exact save path `Access.jsx` uses so the two tabs can't drift. 3. **Store-level conditional correctness**: verify the deployed store backend honors `If-None-Match` semantics 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 in `docs/go/03_store_backends.md`. 4. **If the root cause is a genuine multi-instance/multi-path staleness**, that's a new finding on #382's contract test — extend it rather than patching the settings page. ## Acceptance criteria - [ ] Root cause identified with evidence (instrumented logs from one repro cycle) and stated in the PR — not inferred. - [ ] Save failures (403/409) reset the select to server truth and surface a clear, specific error ("saving requires admin", "conflict — visibility changed elsewhere"); the select never silently shows an unsaved value after a failed save. - [ ] The visibility save uses a fresh CAS version (or retries once on 409); saves stick across refreshes deterministically. - [ ] The store backend's conditional-GET contract is documented (03_store_backends.md) and verified against the deployed backend. - [ ] Tests: settings saveVisibility failure paths (403/409 → reseed + error), fresh-version acquisition, and the store conditional-GET behavior for the deployed backend class.
crueber added this to the v1 milestone 2026-09-12 16:22:22 +00:00
Author
Owner

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.

Root cause found with evidence + fix proposed in PR #392 (https://git.packden.us/crueber/walhub/pulls/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.
Author
Owner

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.

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.
Author
Owner

Fixed by PR #392 (review clean; root cause evidence-backed, failure paths reseed + loud, fresh-version + retry, store contract documented), merged. Closing.

Fixed by PR #392 (review clean; root cause evidence-backed, failure paths reseed + loud, fresh-version + retry, store contract documented), merged. Closing.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
crueber/walhub#391
No description provided.