NEW LAW + systemic guard: cache-class is decided by MUTABILITY, not addressability — shared policy definition, contract test, and an AGENTS.md law so stale-serve flip-flops (#259/#280/#381) cannot recur #382
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#382
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?
Supersedes #259 (same root cause, wider blast radius, permanent fix proposed). #259 scoped this to the issue view; the same stale-serve behavior exists on stars/watches (
/{o}/{r}/api/social), releases, pulls, and identity surfaces. Closing #259 in favor of this one is appropriate.The systemic problem
Mutating collaborative state — reactions, stars, watches, release edits, issue patches — then refreshing can serve the pre-mutation state, and the next refresh shows the post-mutation state. It is intermittent by construction and affects every surface served with the SWR cache class:
v<Thread.Version>internal/issues/http.go:459internal/social/http.go:248internal/releases/http.go:355,371,380HeadLiveinternal/pulls/http.go:493internal/identity/http.go(7 sites)All use
ccSWR = "private, max-age=0, stale-while-revalidate=60"(api/env.go:582, duplicated per package inissues/http.go:175,social/http.go:185).Mechanics of the flip-flop:
stale-while-revalidate=60permits the browser to answer a refresh from its cache — the stale pre-mutation body — while revalidating in the background. Refresh 1 paints old state (reaction "gone", star "off"); the background revalidation lands; refresh 2 paints new state. Server state is correct at all times (every mutation is a versioned CAS put and the ETag is version-keyed) — the header contract is what licenses the staleness.Why the client makes it worse: the SPA's
useDatalayer (web/src/lib/data.js) has its own TTL revalidation (5–30 s by surface), and mutations invalidate only the mutation's own cache entry — a hard refresh re-requests through the browser cache, where SWR serves stale before the client TTL logic even runs.Where the design went wrong
The repo's own spec drew the line in the right place and the implementation crossed it:
docs/go/07_api.md§4 ("the central design rule") defines exactly two cache classes, both for git content: sha-addressed → immutable; ref-dependent (name-addressed git views) → SWR-60 + sha ETag. The rationale is sound: git content changes only via refs, so a ref-state ETag makes SWR safe.docs/features/07_releases_stars.md:235explicitly says "single release / list / social GETs are ref-dependent class (SWR 60 s + ETag version token)" anddocs/features/02_issues.md:258says "GET-by-num is ref-class SWR keyed on the header version." That was the wrong call: a version-keyed ETag gives correct revalidation, but SWR's stale-serve window is a correctness concession that git content can afford (refs move rarely, stale ≈ seconds old) and mutable collab state cannot (the user just clicked the button).Proposed permanent solution
Principle: the SWR stale-serve window is for content whose staleness is bounded by ref movement, not for user-mutable state. Any GET whose resource can change via a direct user action must not serve stale.
ccNoCache = "private, no-cache"— for mutation-sensitive GETs:Cache-Control: private, no-cache+ the existing version ETag.no-cacherequires revalidation before every use (the browser still caches; the ETag still makes unchanged responses 304 with zero body), so the economics stay cheap — you lose only the stale-serve window, which is exactly the bug. This is a one-line class + per-site swap;writeCachedalready implements the If-None-Match → 304 path.07_releases_stars.md:235and02_issues.md:258change "ref-dependent class (SWR 60 s)" to the new class; add a sentence to07_api.md§4 defining the third class and the rule for when it applies (mutability, not addressability, decides). Note07_api.md§4 currently says "the two cache classes (the central design rule)" — this amendment touches a stated design rule, which is why this issue is flagged for the maintainer rather than just implemented.invalidate()the affecteduseDataentries and force a browser revalidation for the mutated resource (the SDK mutation helpers can issue the follow-up GET withIf-None-Matchstripped — a conditional GET the browser must forward underno-cache). This covers same-view updates without a reload; the header contract covers the reload case.no-cache, not SWR — the same test proposed in #272's registry work would be a natural home.Laws impact (decision requested)
07_api.md— adding a third class for mutable collab state. The rule's intent ("SWR is safe because staleness is bounded by ref movement") is preserved; the amendment narrows where the class applies. Per repo law 12 (existing keys never change meaning), git-content routes keep byte-identical headers; only collab GETs change.max-age=0only (no stale window) for collab GETs — same user-visible fix, keeps the class count at two, but loses the ETag-mandated revalidation semantics clarity ofno-cache. Both are one-line header changes; the difference is documentation framing.Acceptance criteria
If-None-Matchhandling on the new class).07_api.md§4 and the two feature specs document the class assignment rule; the per-package duplicatedccSWRconstants are consolidated or reference the shared definition.no-cache; sha-ETag GETs serve SWR/immutable as spec'd.AMENDMENT (2026-09-12, after #381): the systemic scope widens — this is now the authoritative fix ticket
#381 reproduced the identical flip-flop on the repo summary — the one surface this ticket's original scope did not include — proving the per-endpoint fix approach cannot close the class. The summary was left on
ccSWRbecause its ETag covers the mutable fields (~d/~m/~c/~vsuffixes all present atinternal/api/summary.go:181-187), which fixes revalidation but not stale-serve: SWR permits the browser to paint the pre-mutation body before revalidation runs, whatever the ETag says.The rule that actually holds (and the amendment to the spec)
SWR's stale-serve window is only safe for content whose changes are bounded by ref movement (git content). Any endpoint whose ETag is derived from a version/counter of user-mutable state must never serve stale — regardless of ETag quality. The ETag fixes revalidation; only the cache class fixes stale-serve. This amends the §4 "two cache classes" framing in
docs/go/07_api.mdas planned above, and adds the decision rule: addressability does not decide the class — mutability does.Systemic changes required (elevated from "fix direction" to binding)
ccSWR/ccNoStore/ccMutableconstants (issues, social, releases, pulls, identity each redeclare them; api has its own) into one shared definition that carries the rule in its documentation. New packages import it; nothing redeclares.Cache-Control: private, no-cache; sha-derived ETag ⇒ immutable/SWR per §4. This fails CI the moment a new feature package copiesccSWRonto mutable state — which is precisely how #259 → #280 → #381 recurred.internal/api/summary.go:187— #381), plus re-audit every remainingccSWRsite against the mutability rule; git-content routes stay byte-identical.no-cache, the accumulating ETag suffix hacks (~d/~m/~c/~v) become unnecessary there — a single version-based ETag suffices since the browser always revalidates. Optional cleanup, not required for correctness.Updated acceptance criteria (superset of the originals)
docs/go/07_api.md§4 documents the three classes and the mutability rule;DEVIATIONS.mdrecords the amendment.Systemic fix up in PR #389 (#389), branch fix/issue-382, no conflicts with main. Shared internal/cachepolicy definition, per-package cache-class contract tests with ExposedTemplates coverage (break-verified red-then-green), re-audit sweep (only SWR outside git content: pull diff + unversioned listings, boundary-documented), 07_api section 4 law + D-API-3. ETag-suffix simplification deferred per the amendment. Not merging — awaiting review.
REVIEW PR #389 (fix/issue-382) — verified in scratch worktree, all checks re-run. No browser (per instructions; header-contract change, covered by httptest + reasoning).
(1) Shared definition SOUND — internal/cachepolicy/cachepolicy.go: five values byte-exact (Immutable/SWR/Mutable/NoStore/NoCache), mutability rule in package doc. Check logic correct: SWR+version-ETag fails (table pins v12, store-version, ~suffix, content-hash, folded-stamp, non-hex-40 all red), SWR+sha40/sha64-weak/bare-sha/empty passes, all non-SWR classes pass unconditionally. isSHA lowercase-only is fail-safe direction. HeaderValuesExact test pins wire strings.
(2) Placement CLEAN — cachepolicy imports stdlib only (errors, strings; confirmed via go list -deps). Direction: api/issues/social/releases/pulls/identity import the leaf; nothing imports upward. No 14_extensibility surface touched.
(3) Aliasing — FIXED 3 stragglers, pushed as
0690b48: internal/issues/attachments.go:450 literal private-immutable == Immutable (now aliased), internal/api/sse.go:43,253 literal no-store x2 (now cachepolicy.NoStore). Remaining literals are distinct public-immutable variants (identity/http.go:359,580 avatar 86400; releases/http.go:566 asset bytes; server/static.go, health.go) — different values, immutable (never stale), out of the five-value scope; doc claim now true for all five shared values. server/auth.go:914 private,max-age=300 is a pre-existing auth-check value in an untouched package.(4) Contract tests — all 6 pin exact class + ETag presence per row + Check per pair. ExposedTemplates coverage loop present in all 5 feature packages (mutation-only sets look right: issues POST/DELETE-only, pulls POST/DELETE-only, identity team-member/invite/transfer, releases asset-template carve-out with separate immutable byte-row, social star PUT/DELETE). internal/api has no ExposedTemplates registry (only ExposedTemplatesCreate), so its fixed 15-row table is the best available enumeration — acceptable. Break-verification RE-RUN myself: summary ccMutable->ccSWR goes RED (exact-class pin fires), revert goes green.
(5) Re-audit boundary — pull diff SWR+no-ETag: genuinely ref-derived (patch body moves only on head/base push; comments/patches hit the Mutable view), Check passes via empty-ETag arm. Listings (owners/ownerRepos/owners-detailed) SWR+no-ETag: CONFIRMED different from #384's repos_detailed (per-owner visibility projections -> Mutable) vs owners/detailed (global counts+activity rollups, internal/api/owners_activity.go:142, no version token, no sibling under another class). One honest caveat for maintainer: the strict issue wording ('any GET whose resource can change via user action must never serve stale') would also cover these listings (create-repo -> stale owners list <=60s); the PR's 'no mutable projections / no sibling' rationale is a deliberate, documented tolerance, consistent with merged #384. Non-blocking, but the exception belongs to the maintainer to bless.
(6) Git-content routes byte-identical — all http.go/env.go diffs are const-alias + import + comment only (verified: no logic lines changed). Summary change is Mutable-for-Mutable (was already no-cache since #280).
(7) ETag-suffix deferral noted in 07_api §4 + Decisions (e). (8) Law 6: no store-trip changes (const swaps only).
TESTS (scratch worktree, post-fix commit): -race green all 7 pkgs; coverage cachepolicy 100.0, api 95.3, issues 96.3, social 99.5, releases 99.8, pulls 97.7, identity 95.6 (all >=95 gate); gofmt/vet clean; go build ./... clean.
DOCS: 07_api §3 summary line fixed (was stale SWR text post-#381), §4 law + shared-def + listings-boundary + suffix bullets, Decisions entry, D-API-3, 01_overview row. Nit (non-blocking): §4 heading still says 'three cache classes' while defining five values — pre-existing #280 framing, understandable (no-store/no-cache are non-cache classes), but a one-line heading touch-up would remove the ambiguity.
MERGE RECOMMENDATION: ready to merge (boundary caveat in (5) is maintainer-judgment, documented in the PR body and §4; my pushed commit
0690b48needs no re-review beyond the 6 aliased lines).Fixed by PR #389 (review clean + 3 literal stragglers fixed by reviewer; guard break-verified, boundaries ruled, all gates green), merged. Closing.