Skip to content

fix(summarization): entitlement-gate the summarize provider chain — stop the anon 401 flood + 3x fan-out (#4913)#4915

Merged
koala73 merged 2 commits into
mainfrom
fix/4913-summarize-entitlement-gate
Jul 5, 2026
Merged

fix(summarization): entitlement-gate the summarize provider chain — stop the anon 401 flood + 3x fan-out (#4913)#4915
koala73 merged 2 commits into
mainfrom
fix/4913-summarize-entitlement-gate

Conversation

@koala73

@koala73 koala73 commented Jul 5, 2026

Copy link
Copy Markdown
Owner

Fixes #4913. Third instance of the server-gate-without-client-sweep flood class (#4865 classify-event → fixed by #4869; #4902 health → fixed by #4905). Found live on the anonymous /desktop console while verifying #4907.

Problem

#4675 → PR #4687 premium-gated /api/news/v1/summarize-article's LLM spend server-side, but src/services/summarization.ts kept dispatching for every principal via premiumNewsClient (premiumFetch is auth injection, not an entitlement gate). Anonymous dashboards fanned out up to 3 doomed RPCs per summarize attempt (ollama → groq → openrouter through the same gated endpoint) — repeating 401 console/Sentry noise — before landing on the browser-T5 fallback they'd get anyway.

Fix (the proven #4869 shape)

  • src/services/summarize-gate.ts — pure zero-import session gate: entitlement probe + 15-min timed suppression, fail-OPEN on probe errors (a client gating bug must never silence Pro summaries; the server still gates). Deliberately separate state from classify-gate.ts — a summarize 403 must not silence classification.
  • Single choke point: tryApiProvider consults the gate before any dispatch, covering runApiChain and the beta fire-and-forget call; probe wired to panel-gating.hasPremiumAccess() (dual-signal) at module init.
  • Server 403 → suppress the whole chain for a window, so entitlement-signal drift can't recreate the flood at news-refresh rate; window expiry self-heals a mid-session upgrade. 401 deliberately does not suppress: post-gate, the only in-practice 401 is a signed-in user racing session bootstrap (the premium-fetch.ts boot-window race), and benching a Pro user 15 min over a transient race is worse than one logged retry.
  • translateText stays ungatedmode='translate' via the plain newsClient is the server-allowed anon path (requiresPremium = mode !== 'translate').

Product behavior

No visible change for any tier. Free/anon users keep their summaries (browser T5, the designed anon path since #4687) — this removes only the wasted network round-trips and the console/Sentry noise. Pro users are unaffected (probe true → dispatch as today).

Verification

  • tests/summarize-entitlement-gate.test.mts: 9 pure-gate tests (probe deny/allow/fail-open, suppression window + expiry + probe-after, cross-gate isolation from classify-gate) + 5 source-grep wiring pins (gate before RPC, 403 suppression, probe wiring, exactly one premiumNewsClient.summarizeArticle call site so a new dispatch path can't bypass the gate, translate-path ungated). 14/14 pass; classify sibling suite 11/11 still green.
  • npm run typecheck clean; docs-stats --check green (AGENTS.md service count 195→196 + stats.json regen for the new module); tiered pre-push gate green.

https://claude.ai/code/session_01YKrVoDp8TfEKLYXo83kPFV

…top the anon 401 flood + 3x fan-out (#4913)

#4675/#4687 premium-gated summarize-article's LLM spend server-side but
the client kept dispatching for every principal: anon dashboards fanned
out up to 3 doomed RPCs per attempt (ollama->groq->openrouter through the
same gated endpoint) before landing on the browser-T5 fallback they'd get
anyway — console/Sentry 401 noise on every anonymous /desktop session.
Third instance of the server-gate-without-client-sweep class
(#4865 classify-event, #4902 health).

Same fix shape as #4869:
- src/services/summarize-gate.ts — pure session gate (probe + timed
  15-min suppression, fail-open on probe errors so a gating bug never
  silences Pro summaries). Separate state from classify-gate: a
  summarize 403 must not silence classification.
- tryApiProvider consults the gate before any dispatch (single choke
  point — covers runApiChain and the beta fire-and-forget call); probe
  wired to panel-gating's hasPremiumAccess at module init.
- Server 403 -> suppress the whole chain for a window (entitlement
  drift must not recreate the flood at news-refresh rate). 401 does NOT
  suppress: post-gate it only occurs on the signed-in boot-window race,
  and benching a Pro user 15min over a transient race is worse.
- translateText stays ungated (mode=translate via plain newsClient is
  the server-allowed anon path).

Free users keep summaries (browser T5) — this removes only the wasted
requests. Tests: pure-gate suite + source-grep wiring pins (gate before
RPC, 403 suppression, single call site, translate ungated); AGENTS.md
service count + stats.json regen for the new module.

Fixes #4913

Claude-Session: https://claude.ai/code/session_01YKrVoDp8TfEKLYXo83kPFV
@koala73
koala73 enabled auto-merge (squash) July 5, 2026 18:23
@vercel

vercel Bot commented Jul 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Jul 5, 2026 6:34pm

Request Review

@mintlify

mintlify Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
WorldMonitor 🟢 Ready View Preview Jul 5, 2026, 6:24 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@greptile-apps

greptile-apps Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a client-side entitlement gate (summarize-gate.ts) that short-circuits the three-provider API chain before any network dispatch when the current principal lacks premium access — directly mirroring the classify-gate.ts fix for the same flood class. It also installs a timed-suppression path (15 min) on server 403 responses to cover entitlement-signal drift.

  • summarize-gate.ts is a zero-import pure module with two mechanisms: an entitlement probe (wired to hasPremiumAccess, evaluated lazily per attempt) and timed suppression on 403; the gate fails open so a gating bug can never silence paying users.
  • summarization.ts wires the gate at module init, checks it inside tryApiProvider as the single dispatch choke-point, and adds an inner try/catch inside the summaryBreaker.execute callback to call suppressServerSummarization() on 403 before re-throwing.
  • Tests combine 9 pure-gate unit tests with 5 source-grep wiring assertions to pin the gate's position relative to the RPC dispatch, the 403 suppression branch, the single premiumNewsClient.summarizeArticle call site, and the ungated translate path.

Confidence Score: 5/5

Safe to merge — the gate is a pure additive client-side guard that fails open, so no paying user can be silenced by a gating bug; the server remains the authoritative enforcement point.

The new gate module is zero-import, fully unit-tested with correct boundary conditions, and deliberately isolated from classify-gate state. The single choke-point placement inside tryApiProvider covers every dispatch path including the BETA fire-and-forget. The 403 suppression is correctly positioned before the rethrow so the circuit breaker still records the failure. The translate path is correctly left ungated. No logic errors or edge-case gaps were found.

No files require special attention.

Important Files Changed

Filename Overview
src/services/summarize-gate.ts New pure zero-import gate module; probe + timed-suppression logic is correct, boundary conditions tested, and module-level state is properly isolated from classify-gate.
src/services/summarization.ts Gate wired at module init via configureSummarizeGate; single choke-point in tryApiProvider correctly checks before dispatch and suppresses on 403; translateText path correctly left ungated; BETA fire-and-forget also gated via same choke-point.
tests/summarize-entitlement-gate.test.mts 14 tests covering fail-open, probe deny/allow, suppression window boundaries, post-suppression probe re-evaluation, cross-gate isolation, and source-grep wiring pins on all critical properties.
AGENTS.md Service count incremented from 195 to 196 to reflect the new summarize-gate.ts module, consistent with prior PR conventions.
docs/generated/stats.json serviceTopLevelEntries bumped 195 to 196 to stay in sync with AGENTS.md and the new module.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[generateSummaryInternal called] --> B{skipCloudProviders?}
    B -- yes --> BT[tryBrowserT5]
    B -- no --> C[runApiChain: ollama to groq to openrouter]
    C --> D[tryApiProvider for each provider]
    D --> E{isFeatureAvailable?}
    E -- no --> SKIP[return null]
    E -- yes --> F{canAttemptServerSummarization?}
    F -- false --> NULL1[return null next provider]
    F -- true --> G[premiumNewsClient.summarizeArticle RPC]
    G --> H{response OK?}
    H -- 403 ApiError --> SUP[suppressServerSummarization 15 min then rethrow]
    SUP --> NULL2[tryApiProvider returns null]
    H -- other error --> NULL3[return null]
    H -- success --> RES[return SummarizationResult]
    NULL1 --> NXT{more providers?}
    NULL2 --> NXT
    NULL3 --> NXT
    NXT -- yes --> D
    NXT -- no --> BT
    RES --> DONE[return API result]
    BT --> BT2{T5 available?}
    BT2 -- yes --> DONE2[return browser result]
    BT2 -- no --> FAIL[trackLLMFailure return null]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[generateSummaryInternal called] --> B{skipCloudProviders?}
    B -- yes --> BT[tryBrowserT5]
    B -- no --> C[runApiChain: ollama to groq to openrouter]
    C --> D[tryApiProvider for each provider]
    D --> E{isFeatureAvailable?}
    E -- no --> SKIP[return null]
    E -- yes --> F{canAttemptServerSummarization?}
    F -- false --> NULL1[return null next provider]
    F -- true --> G[premiumNewsClient.summarizeArticle RPC]
    G --> H{response OK?}
    H -- 403 ApiError --> SUP[suppressServerSummarization 15 min then rethrow]
    SUP --> NULL2[tryApiProvider returns null]
    H -- other error --> NULL3[return null]
    H -- success --> RES[return SummarizationResult]
    NULL1 --> NXT{more providers?}
    NULL2 --> NXT
    NULL3 --> NXT
    NXT -- yes --> D
    NXT -- no --> BT
    RES --> DONE[return API result]
    BT --> BT2{T5 available?}
    BT2 -- yes --> DONE2[return browser result]
    BT2 -- no --> FAIL[trackLLMFailure return null]
Loading

Reviews (1): Last reviewed commit: "fix(summarization): entitlement-gate the..." | Re-trigger Greptile

…ulled the news RPC client into the main static graph

The instanceof check imported ApiError as a VALUE from the generated news
client, turning the previously type-only import into a static edge and
failing the eager-chunk budget (rpc-client-news-v1 must stay lazy).
Use the existing getRpcErrorStatusCode duck-type helper instead — same
one threat-classifier uses for the same reason. Wiring pin updated to
the new shape. Verified against a fresh VITE_VARIANT=full build:
dashboard-eager-chunks 154/154.

Claude-Session: https://claude.ai/code/session_01YKrVoDp8TfEKLYXo83kPFV
@koala73
koala73 merged commit d2a2190 into main Jul 5, 2026
25 checks passed
koala73 pushed a commit that referenced this pull request Jul 26, 2026
…by-design chains (#5377)

Both chain-exhausted sites (BETA + normal mode) warned 'All providers
failed' even when nothing was attempted — the entitlement gate (#4913/#4915)
had declined every server dispatch and browser T5 was unavailable, which is
the designed anonymous path. The outage-shaped warning is indistinguishable
from a real provider failure in console triage and cost the #5377
investigation two wrong hypotheses.

Route both sites through logChainOutcome(): when lastAttemptedProvider is
still 'none' (tryApiProvider marks attempts only after the feature +
entitlement gates; tryBrowserT5 only after the mlWorker availability check)
log at debug with an accurate 'skipped: no eligible provider' message;
reserve warn for chains where a provider was genuinely attempted and failed.

Note: #5377's part 1 (the enqueue gate — zero anon summarize dispatches)
already landed in #4915 via configureSummarizeGate(hasPremiumAccess) and is
pinned by tests/summarize-entitlement-gate; this closes the remaining
acceptance item.

Tests: extended tests/summarize-entitlement-gate.test.mts — the fail sites
must route through logChainOutcome (no unconditional string-literal 'All
providers failed' warn), debug-before-warn branch on 'none', and attempt
marking must stay behind the gates so 'none' means declined-by-design.
21/21 green; RED (1 fail) against the pre-fix source. tsc clean.

Co-Authored-By: Claude Fable 5 <[email protected]>
koala73 added a commit that referenced this pull request Jul 26, 2026
…by-design chains (#5377) (#5605)

* fix(summarization): stop logging 'All providers failed' for declined-by-design chains (#5377)

Both chain-exhausted sites (BETA + normal mode) warned 'All providers
failed' even when nothing was attempted — the entitlement gate (#4913/#4915)
had declined every server dispatch and browser T5 was unavailable, which is
the designed anonymous path. The outage-shaped warning is indistinguishable
from a real provider failure in console triage and cost the #5377
investigation two wrong hypotheses.

Route both sites through logChainOutcome(): when lastAttemptedProvider is
still 'none' (tryApiProvider marks attempts only after the feature +
entitlement gates; tryBrowserT5 only after the mlWorker availability check)
log at debug with an accurate 'skipped: no eligible provider' message;
reserve warn for chains where a provider was genuinely attempted and failed.

Note: #5377's part 1 (the enqueue gate — zero anon summarize dispatches)
already landed in #4915 via configureSummarizeGate(hasPremiumAccess) and is
pinned by tests/summarize-entitlement-gate; this closes the remaining
acceptance item.

Tests: extended tests/summarize-entitlement-gate.test.mts — the fail sites
must route through logChainOutcome (no unconditional string-literal 'All
providers failed' warn), debug-before-warn branch on 'none', and attempt
marking must stay behind the gates so 'none' means declined-by-design.
21/21 green; RED (1 fail) against the pre-fix source. tsc clean.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(summarization): isolate provider attempt tracking

* docs: update service module count

* fix(review): make the #5377 outcome classifier correct and its guards mutation-proof

Code review of #5605 found the outcome signal wrong in both directions and
all three new regression guards passing with the bug restored (proven by
execution). Addresses every finding.

Classification (#1, #5, #8) — `lastAttemptedProvider === 'none'` conflated
causes:
- too quiet: canAttemptServerSummarization() denies both an anon/free
  principal AND an entitled one inside the 403/429 cooldown, so a paying
  user's live entitlement outage was demoted to console.debug for up to
  15 min (24 h via Retry-After). trackLLMFailure is a no-op and no
  captureConsoleIntegration exists, so that was the only signal — the #5600
  shape. summarize-gate now exports isServerSummarizationSuppressed() and the
  chain records which denial it hit.
- too loud: CircuitBreaker.execute returns its default WITHOUT running the
  callback while on cooldown (breaker defaults maxFailures=2, cooldown=5min),
  so a chain that contacted nobody still reported "All providers failed".
  Marking now happens INSIDE the breaker callback, and a short-circuit is
  recorded as its own cause.
- the quiet path no longer claims "using designed fallback"; reaching it means
  browser T5 never ran and callers render an error/unavailable state.

Guards (#2, #3, #6) — each of these was green with the bug restored:
- indexOf matched the gate name inside a COMMENT, so moving the mark above the
  gate passed. Source assertions now strip comments; more importantly the mark
  lives inside the breaker callback, making the ordering structural.
- the locality assertion ran against the whole file, so hoisting the state to
  module scope passed. Now scoped to generateSummary with a creation-count pin
  and a broadened module-global check.
- the raw-warn regex only matched quoted literals while this file's idiom is a
  template literal. Now delimiter-agnostic.
- slice anchors are asserted to resolve; a renamed anchor made slice(a, -1)
  silently widen to end-of-file.

The decision surface moved into a pure classifier covered by an executed truth
table rather than grep. Verified: all three original bypasses now fail (32/32
green, 31/32 with each mutation applied); typecheck and biome clean.

Claude-Session: https://claude.ai/code/session_018XDm48Kzuv1GQe4PR1qimE

* chore(docs): regenerate stats after rebase onto main

Claude-Session: https://claude.ai/code/session_018XDm48Kzuv1GQe4PR1qimE

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Elie Habib <[email protected]>
benngee85 pushed a commit to benngee85/VISTA that referenced this pull request Jul 26, 2026
…by-design chains (koala73#5377) (koala73#5605)

* fix(summarization): stop logging 'All providers failed' for declined-by-design chains (koala73#5377)

Both chain-exhausted sites (BETA + normal mode) warned 'All providers
failed' even when nothing was attempted — the entitlement gate (koala73#4913/koala73#4915)
had declined every server dispatch and browser T5 was unavailable, which is
the designed anonymous path. The outage-shaped warning is indistinguishable
from a real provider failure in console triage and cost the koala73#5377
investigation two wrong hypotheses.

Route both sites through logChainOutcome(): when lastAttemptedProvider is
still 'none' (tryApiProvider marks attempts only after the feature +
entitlement gates; tryBrowserT5 only after the mlWorker availability check)
log at debug with an accurate 'skipped: no eligible provider' message;
reserve warn for chains where a provider was genuinely attempted and failed.

Note: koala73#5377's part 1 (the enqueue gate — zero anon summarize dispatches)
already landed in koala73#4915 via configureSummarizeGate(hasPremiumAccess) and is
pinned by tests/summarize-entitlement-gate; this closes the remaining
acceptance item.

Tests: extended tests/summarize-entitlement-gate.test.mts — the fail sites
must route through logChainOutcome (no unconditional string-literal 'All
providers failed' warn), debug-before-warn branch on 'none', and attempt
marking must stay behind the gates so 'none' means declined-by-design.
21/21 green; RED (1 fail) against the pre-fix source. tsc clean.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(summarization): isolate provider attempt tracking

* docs: update service module count

* fix(review): make the koala73#5377 outcome classifier correct and its guards mutation-proof

Code review of koala73#5605 found the outcome signal wrong in both directions and
all three new regression guards passing with the bug restored (proven by
execution). Addresses every finding.

Classification (koala73#1, koala73#5, koala73#8) — `lastAttemptedProvider === 'none'` conflated
causes:
- too quiet: canAttemptServerSummarization() denies both an anon/free
  principal AND an entitled one inside the 403/429 cooldown, so a paying
  user's live entitlement outage was demoted to console.debug for up to
  15 min (24 h via Retry-After). trackLLMFailure is a no-op and no
  captureConsoleIntegration exists, so that was the only signal — the koala73#5600
  shape. summarize-gate now exports isServerSummarizationSuppressed() and the
  chain records which denial it hit.
- too loud: CircuitBreaker.execute returns its default WITHOUT running the
  callback while on cooldown (breaker defaults maxFailures=2, cooldown=5min),
  so a chain that contacted nobody still reported "All providers failed".
  Marking now happens INSIDE the breaker callback, and a short-circuit is
  recorded as its own cause.
- the quiet path no longer claims "using designed fallback"; reaching it means
  browser T5 never ran and callers render an error/unavailable state.

Guards (koala73#2, koala73#3, koala73#6) — each of these was green with the bug restored:
- indexOf matched the gate name inside a COMMENT, so moving the mark above the
  gate passed. Source assertions now strip comments; more importantly the mark
  lives inside the breaker callback, making the ordering structural.
- the locality assertion ran against the whole file, so hoisting the state to
  module scope passed. Now scoped to generateSummary with a creation-count pin
  and a broadened module-global check.
- the raw-warn regex only matched quoted literals while this file's idiom is a
  template literal. Now delimiter-agnostic.
- slice anchors are asserted to resolve; a renamed anchor made slice(a, -1)
  silently widen to end-of-file.

The decision surface moved into a pure classifier covered by an executed truth
table rather than grep. Verified: all three original bypasses now fail (32/32
green, 31/32 with each mutation applied); typecheck and biome clean.

Claude-Session: https://claude.ai/code/session_018XDm48Kzuv1GQe4PR1qimE

* chore(docs): regenerate stats after rebase onto main

Claude-Session: https://claude.ai/code/session_018XDm48Kzuv1GQe4PR1qimE

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Elie Habib <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(summarization): anon dashboards dispatch premium summarize-article RPCs — 401 flood + 3-provider fan-out since #4687 (client caller never swept)

1 participant