Repository navigation
fix(adversarial): cache verdicts by content, fail closed, reserve AI budget for the user (audit L2) - #975
Merged
Conversation
runyourempire
force-pushed
the
fix/l2-adversarial-verdict-cache
branch
3 times, most recently
from
October 17, 2026 16:48
8204d47 to
11a6372
Compare
…budget for the user (audit L2) The background adversarial pass re-asked the cloud model about the same few signal chains every refresh (334 calls / $2.49 on 2026-10-11 against a $1.50 cap, hit daily), then let rejected chains back in once the budget ran out, and the user's own "Assess with AI" was refused as "try again". - Verdicts are cached in kv_store under a SHA-256 of what the model judged (title, kind, urgency, explanation, deps, projects, prompt text + version, provider, model), 7-day TTL, pruned on save. No schema bump. - Fail closed: a cached rejection stays suppressed whatever the budget or provider does; an unvetted heuristic item (signal chain) is held back when the model cannot judge it. OSV-verified, LLM-assessed and corroborated Critical/High items still surface. A budget stop or provider error is not retried per item. - The cache-miss fast feed applies stored verdicts too, so a rejected chain cannot flash there. - The background pass stops at 60% of the daily caps, leaving the rest for user-initiated calls; a refused user call says "Daily AI budget reached - resets at HH:MM local time (in ...)" and the Blind Spots view shows it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018hjX3pEMmC7CHfG41PiPj7
… every background pass, cross-process ledger
- SF1: the budget refusal carries [budget_reset:HH:MM:MINUTES]; the UI renders
blindspots.ai.budgetReached / errors:errorMsg.budgetReached ({time}, {in}
via Intl.RelativeTimeFormat), translated into all 13 locales. The English
Rust sentence stays for other callers.
- SF2: preemption's comment states the fail-closed behaviour; the "dropped
ALL ... possible LLM failure" warn is gone, the info line names the cause
(filtered by verdict / held back unjudged / model unavailable).
- SF3: the background share now gates every background pass: LLMClient
refuses rerank_judge / ingest_judge / verdict_drain / adversarial at the
share (user-initiated purposes keep the whole cap), and
local_judge::budget_blocks uses the share. The share check re-seeds the
counters from the ai_usage ledger at most once a minute, so the reserve
holds across the GUI and fourda-engine.
- Nits: the fast feed fails closed without a database; build_user_message
notes it is part of the cache key via PROMPT_VERSION; the preflight
refusal reads "This call would exceed today's AI budget".
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hjX3pEMmC7CHfG41PiPj7
…s are background (round-3 review) - SF1: provider_error_message passes a budget refusal through whole (never re-classified), and every frontend site that shows a raw error from an LLM-backed command renders a budget refusal localized via budgetRefusalMessage(): the briefing slice (UnifiedAppBar), the graph detail summary, channel renders and the model eval. Other errors keep the backend's own message. - SF2: the scheduled stale-channel pass renders with purpose channel_render_auto, which is in BACKGROUND_PURPOSES; a render the user asks for keeps channel_render and the full cap. The scheduled monitoring_brief deliberately keeps the full cap (documented). - NIT: "in N hours" only within 5 minutes of a whole hour; otherwise exact minutes (never "in 1 hour" for 1 h 29 min). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018hjX3pEMmC7CHfG41PiPj7
runyourempire
force-pushed
the
fix/l2-adversarial-verdict-cache
branch
from
October 17, 2026 16:50
11a6372 to
9d00c3c
Compare
runyourempire
enabled auto-merge
October 17, 2026 16:50
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adversarial audit 2026-10-12, finding L2. Every Preemption refresh (about every 10 minutes) asked the cloud model again about the same handful of signal chains and LLM-judged items. The live
ai_usagetable (read-only) shows it:That is against a $1.50/day cap. Once the cap was spent, three things followed:
1. Verdict cache (
adversarial_cache.rs)PROMPT_VERSION, the system prompt text, the provider and the model id.judged_at.kv_storetable. It survives restarts and is shared across processes on the same DB, with no schema migration. A bump would forcefourdaandfourda-engineto be rebuilt together before either opens the DB, and would collide with parallel branches on the version number. A lost race between two writers costs one re-judge, never an unjudged item shown.2. Fail-closed (
adversarial_batch.rs, with the decision table in its module doc)ConfidenceProvenance::Heuristic,chain-*).llm-*items (already vetted by a model upstream), corroborated Critical/High items (safety floor) and the deterministic upgrade plan (appended after the filter). So the surface is never emptied of grounded items; the testunjudged_heuristics_are_held_back_but_grounded_items_still_surfacepins this.apply_stored_verdicts), so a rejected chain cannot flash there while the deliberated refresh runs.3. Background passes no longer starve user calls
BACKGROUND_BUDGET_SHARE_PERCENT = 60. Every background pass stops at 60% of either daily cap, leaving the rest for calls a user is waiting on:LLMClient::budget_refusedapplies the share only toBACKGROUND_PURPOSES = adversarial, rerank_judge, ingest_judge, verdict_drain, channel_render_auto. It does this incomplete,complete_structuredand the streaming call.channel_render), connection tests and translation. The testbackground_share_gates_only_background_purposespins the classification.channel_render::auto_render_stale_channelsruns after every monitoring cycle (app_setup.rs) and on the first-run "render all". It now uses its own purpose,channel_render_auto. Opening a channel, creating one and "render now" keepchannel_renderand the full cap.monitoring_briefdeliberately keeps the full cap. It is the brief the user reads, one call per window, and starving it would hide the day's findings behind background reranking.local_judge::budget_blocks, called only by the background judge passes, uses the share. This is a three-line change outside G6'slocal_judge.rshunks.background_llm_budget_reached()re-seeds the counters from the persistedai_usageledger, at most once a minute. That is one indexed SUM, applied withfetch_max, so nothing recorded in-process is lost. The reserve therefore holds across the GUI andfourda-engineon the same database; before, each process only saw the other's spend from its startup seed.[budget_reset:HH:MM:MINUTES], covering both the cap and the preflight cost wall.parseBudgetReset()andbudgetResetVars()inerror-messages.tsread the token. The UI rendersblindspots.ai.budgetReachedin Blind Spots anderrors:errorMsg.budgetReachedintranslateError, with{time}and{in}placeholders.{in}is a localized "in 3 hours" fromIntl.RelativeTimeFormat.digest_commands::provider_error_messagepasses a budget refusal through whole, so the "connect" and "model" checks can never re-classify it (testa_budget_refusal_passes_through_whole).budgetRefusalMessage(): the briefing slice (shown inUnifiedAppBar), the graph detail summary, channel renders and the model eval. Any other error keeps the backend's own message.translateError.generate_ai_briefing,generate_item_summary,render_channel_now,run_model_eval,assess_blind_spots_with_ai,test_llm_connection,trigger_translationandauto_render_all_channels.synthesize_searchhas no frontend caller; translation and auto-render failures are silent.Review nits taken
settle_stored, testfast_feed_without_a_database_holds_back_unvetted_chains).build_user_messagecarries a comment: changing the template requires bumpingPROMPT_VERSION, because the template is part of what the cache key stands for.warn!"dropped ALL … possible LLM failure" is gone: drops are routine once the share is spent, so aninfoline now names the cause (filtered_by_verdict,held_back_unjudged,model_unavailable).filter_batchreturns theBatchOutcomewith its stats.Expected cost effect
A refresh now calls the model only for material it has not judged in the last 7 days. A chain's explanation changes only when its source count or day span changes, so that is a few calls per chain per day at most, instead of one per chain per 10-minute cycle. Even before the cache is warm, background passes together cannot spend more than 60% of the cap.
Evidence
adversarial_cache_tests.rsuse an injected model that counts calls:cache_hit_makes_no_model_call(same material, new chain id: 0 calls);changed_material_is_judged_again;key_covers_model_and_prompt_but_not_the_item_id;rejected_chain_stays_suppressed_when_the_budget_is_spent, also through a provider error;unjudged_heuristics_are_held_back_but_grounded_items_still_surface;verdicts_expire_after_the_ttl;fast_feed_applies_stored_verdicts_without_a_model;fast_feed_without_a_database_holds_back_unvetted_chains;verdicts_persist_across_loads, on a real DB;corrupt_store_starts_empty.background_share_reserves_headroom_for_user_calls;background_share_follows_the_live_counters, where the background stops whileis_llm_limit_reachedis still false;budget_message_names_the_local_reset_time, which checks the token;background_share_gates_only_background_purposes.error-messages: the token is parsed; other failures are null; the wait is localized (en/de);translateErrornever says "try again";blindspots.ai.budgetReached.budgetRefusalMessagereplaces only budget refusals;error-messages, Blind Spots, the store slices andGraphDetailPanel, andtscis clean.kv_storehas noadversarial_verdicts_v1yet.ai_usagegrouped bytask_typeper day is the metric to watch after merge.Notes
llm.rsis also edited by G6, incomplete()around the local-judge block and at about line 1203 and later. My hunks are in the three budget gates, a newbudget_refused/BACKGROUND_PURPOSESblock,format_limit_error, the preflight message and a test. None overlap textually, but the gate atcomplete()sits a few lines above G6's local-judge hunk, so whichever lands second should rebase cleanly or with a trivial context fix. Thelocal_judge.rschange is three lines inbudget_blocks, outside G6's hunks.adversarial.rsis past the 700-line warning and under the 1000 error. The new logic lives inadversarial_batch.rsandadversarial_cache.rs.🤖 Generated with Claude Code
https://claude.ai/code/session_018hjX3pEMmC7CHfG41PiPj7