The 1 h loop set MapView state every 600 ms, re-rendering the whole map
component and rebuilding (setIcon) every marker icon on each tick. The
frame now lives in a small external store that only the radar frames and
the valid-time badge subscribe to.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Weather Warnings overlay (layer key `wx`, saved in the view hash): NWS alert
polygons coloured by event (NWS hazard-map colours), outline dash by kind,
popup with event/headline/expiry/office and National Weather Service credit.
Polls GET /weather/alerts every 2 min while on and visible, refetches on pan.
A status chip always says what the layer shows; feed errors use the 511
FeedProblem wording; zone-only alerts are listed, never dropped.
- Radar: IEM nexrad-n0r -> n0q. Valid-time badge from IEM's n0q_0.json,
STALE past 15 min or when the time can't be read; dBZ legend; optional 1 h
loop (nexrad-n0q-900913-m05m..m55m), off by default. Still refreshed in
place, never remounted.
- liveWall: sampleRadarDbz reads the n0q tile with IEM's published 255-colour
ramp (verified against live tiles); pointInPolygon/alertsAt, wxStyle,
wantsWind helpers.
- Wall incident panel: "inside <warning>" chips, wind arrow + speed/gust +
station/obs time on fire/hazmat incidents; region Feeds gains an NWS row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The /incidents/[id] page kept its own stop numbering (every geocoded call,
1..N) and path length, while its MapView now numbers only movement
incidents' non-ghosted stops. A fire with 3 geocoded calls showed stops
1-3 on the spine and unnumbered dots on the map. Use the shared
pathStops()/stopNumbers(), show 'km path' only for movement incidents,
and surface the 'may be merged' flag in the header.
Legend: a plain pin is also what an older (pre-#236) pin with no recorded
precision looks like; say so instead of claiming 'Street address'.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
useIncident reports loading until its answer belongs to the current id
(the render before the effect read the previous id's loading=false), and
clears a stale doc on a denied read. LiveView holds the last selected doc
for the same id while the by-id lookup is pending, so a watched incident
resolving no longer deselects (fly to region) and reselects (fly back).
Follow-up for the panel's 'active'/elapsed copy on resolved incidents: #275.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Backend: _geocode_location now returns {lat, lng, location_type}, so the
precision rides onto the call doc and, unchanged via _resolve_location_pair,
onto the incident pin. Display-only: every correlator read indexes lat/lng.
Frontend: pins styled by precision (rooftop / interpolated ring /
GEOMETRIC_CENTER dashed uncertainty circle labelled with the spoken place /
unverified pre-#23 pins ghosted). liveWall.pathStops() is shared by the map
and the call spine: only police/accident incidents with a pursuit tag draw a
numbered path; others get unnumbered call dots. Segments over >10 min gaps or
talkgroup changes are dashed, stops implying >8 km/min are ghosted off the
path, paths >20 km get a "may be merged" badge. New optional MapView prop
pulseIncidentIds (wired by #238).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- LiveView: the selected incident merges useCallsByIncident (org_id +
incident_ids CONTAINS, existing index, no orderBy/limit) into the calls fed
to IncidentPanel and MapView, so a long incident keeps its full path and
spine past the 500-call window. Stale rows from the previous selection are
re-checked against the current id before merging.
- #inc= not in the active set is fetched with useIncident: a resolved one is
selected, drawn on the map and shown under a "Resolved HH:MM" banner; a
missing or other-org id shows "Incident not found". Auto-tour stays on
active incidents.
- TimeScrubber: new loadedSince prop (oldest call when the window came back
full). The axis before it is hatched and labelled "not loaded" instead of
reading as a quiet stretch.
- useCalls returns hasMore (full page, counted before the duplicate_of
filter); useIncident resets loading on a new id so a lookup doesn't flash
"not found".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Copy: picker, admin Servers/Streams/How-it-works, org Discord card, bot
/help and the server-limit refusal no longer imply the DRB server bot is
required; a radio bot alone is enough.
- Admin overview: per-server main_bot_present, radio_bots, linked_via,
directory_source; Servers tab gets a Bots column.
- discord_web docstring: states the reversed "never calls Discord" rule
(plan finding 5) and the optional server bot.
- Server bot directory cog: re-push (which drops C2's cached permission
answers) on overwrite changes, role permission changes and role deletes.
- tests/test_discord_owner_flows.py: one-bot org (BYO bot, owner-set
preferred, OAuth link, picker, start, reconciler) and full setup (server
bot + 4 radio bots: placement, shared channel, every refusal explained),
against a modelled Discord over httpx.MockTransport.
- storage.download_audio removed: no caller on main since #233.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- useOutagesIn kept the previous incident's answer while fetching the next
one (IncidentPanel isn't keyed), so a newly selected incident briefly read
'none reported here'. Drop the old answer when the target changes.
- OutageLayer built a fresh divIcon per marker every render; MapView
re-renders on live updates, so Leaflet rebuilt every marker's DOM. Memoize.
- Popup now carries the provider's coverage note (ODIN: non-reporting
utilities, e.g. Con Ed/O&R, show nothing even during an outage).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Storm/Traffic preset tag terms now match at the start of a word, so
"tree" no longer pulls "street-racing"/"street-fight" into Storm mode.
- FilterBar search compared the trimmed hash q against the untrimmed
typed text, so pausing after "main " reset the box to "main" and the
next word ran on ("mainst"). Compare trimmed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- lib/useOutages.ts: GET /outages for the map view (pan/zoom debounced) or a
fixed bbox; polls every 3 min while visible, refetches when a hidden tab
returns.
- components/map/OutageLayer.tsx: county markers at the label point sized by
meters out, stale-only counties dimmed/dashed; popup with count, % served
when known, since/ETR, per-utility counts, SourceCredit + fetch time, and
"county centre, not the outage location". An always-on status chip says
what the layer shows: loading, N counties, none reported in view (with the
source's coverage note), no source enabled (admin-only link to
/admin#outages), no licensed source covers this area, or feed error.
- MapView: "Power Outages" overlay, off by default, plus a legend entry while
it is on. Not hash-persisted yet: LAYER_NAMES lives in lib/liveWall.ts,
which #235 owns this batch.
- lib/outageMatch.ts (pure): county data matches by county bbox and says
"county has N out" ("in or near" when two bboxes hold the point); point
data matches within 500 m; stale rows never count as live; flags
wires-down/transformer/manhole/pole/tree-on-wires incidents.
- WallPanels: OUTAGE lines under the incident's situational context (hazard
chip, source credit, fetch time) and an OUTAGE row in the region Feeds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lib/liveFilter.ts (pure): WallFilter {types, sevFloor, systems, talkgroups,
ageH, tags, q, preset}, applyFilter over loaded incidents + calls, parse /
serialise to the f.* hash keys #234 reserved. Presets: Fire (fire + EMS
assists heard on a fire channel; Radar on), Storm (weather/wires/tree/flood
tags; Radar on), Traffic (accident / MVA tags; Traffic Events on).
components/live/FilterBar.tsx: one wrapping row under the wall bar; less-used
controls fold behind a toggle below md; read-only summary in kiosk.
LiveView applies the filter once and feeds the filtered lists to MapView
(pins, paths, rail), RegionPanel, TranscriptTicker, TimeScrubber and the
auto-tour; KPIs count the filtered set and say "filtered". Presets turn
layers on through the saved-view layer state, not a MapView edit. Pipeline
health stays unfiltered. liveWall.ts keeps commas literal in f.* values.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- discord_oauth: POST /discord/oauth/start and GET /options take an optional
system_id. The system must be the org's own or shared (another org's or
unknown = 404). Its preferred bot is strict: one that can't connect is a
404, never a quiet swap to Automatic. No preference = the usual order.
The picker's "bot for a system" now always sends system_id.
- #257: _channel_state gives a bot whose token Discord rejected its own
"invalid" state; the picker shows "Radio bot needs replacing" with no
invite links, instead of "No radio bot".
- #256: Start no longer stays disabled on "Needs fix". It re-checks with
Discord first (refresh=1) and starts only if the fresh answer allows it.
"Not a voice channel" still disables Start.
- #258: own tooltip for "Not a voice channel"; "platform admin" -> "DRB
support"; PreferredBotsCard says plainly that adding your own bot isn't
self-serve (no support address exists yet, see #47), and a load failure
gets a plain message + Try again instead of the raw C2 error.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without it the URL had no z/lat/lon until the first pan, so a link copied
from an untouched wall opened at the other browser's remembered view.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Cap one refresh's NWS zone fetches at 15 s wall clock. They run under the
feed lock, so a hung zone endpoint could otherwise stall every
/weather/alerts request for minutes; unfinished zones retry next refresh.
- Prod c2-core.env.j2 never set NWS_USER_AGENT, so the VM would have sent the
contact-less placeholder. Default it to the site URL (overridable via
vault_nws_user_agent); warn on the .env.example example.com placeholder too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- liveWall.ts: WallLink carries layers (short stable keys), z/lat/lon,
base, tourMs, kiosk and an f.<name> filter pass-through for #235;
parse/serialise stay pure and round-trip; old inc/cam/tour links parse.
- MapView: overlays/basemap/viewport come from initialLayers/initialBase/
initialView (defaults unchanged); onViewStateChange reports layer, base
and region-view changes (debounced). Initially-checked overlays now start
"shown" (react-leaflet adds a checked overlay before the control can fire
overlayadd). Closing an incident flies back to the pre-drill view (#137).
- wallPrefs.ts: per-browser last layers/base/view in localStorage, every
access wrapped; precedence hash > remembered > defaults.
- LiveView: one effect mirrors wall + map state into the hash; hash edits
remount the map with the new view; Copy view link; configurable tour.
- Kiosk (?kiosk=1 or #kiosk): no nav, fullscreen button (auto on first
touch), Screen Wake Lock re-acquired on visibility, reconnect banner
(offline / useCalls error) with backoff reload, 18-20 px type via a
data-kiosk attribute and Tailwind arbitrary variants.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- complete: S6 returns streams_stopped, not stopped — the "N radio
channels were stopped" note never showed.
- start: bot name is nested as bot.name.
- "The bot for a system" with an org-owned preferred bot now sends
token_id (S6 accepts it today); system_id only for a platform-pinned
pool bot, which still needs S6 to accept system_id.
- A 404 for one chosen bot no longer hides Connect and the picker
(the message told the user to try Automatic, but the button was gone).
- Server count uses GET /tokens' per-bot server_cap when present.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /weather/alerts — api.weather.gov/alerts/active for settings.nws_area,
sent with the NWS-required User-Agent, cached weather_alerts_ttl_s (90 s)
with single-flight refresh. Zone-based alerts (geometry null) get their
polygons from affectedZones (24 h zone cache, api.weather.gov/zones/ only);
any still unresolved are returned in zone_only, never dropped. Failed
refresh keeps last-good data + error + fetched_at; a payload where no
feature parses is a schema-change error. Optional bbox filter.
GET /weather/wind — nearest METAR (aviationweather.gov /api/data/metar,
bbox query) within 75 km, cached 5 min per 0.5-degree cell; VRB wind and
stale reports flagged; available:false with a reason otherwise.
Both behind require_service_or_firebase_token like /traffic.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A server linked to an org now shows in the node picker while a radio bot the
org may use is in it, even when the DRB server bot is absent or left. Its
voice-channel list is read from that radio bot's cached REST snapshot
(discord_rest.guild_directory, the same snapshot the permission check reads,
so no extra Discord request): inline when no list is stored, in the
background when the list is older than discord_directory_refresh_seconds.
The server bot's own fresh list still wins (back-compat) and its directory
push now drops the server's cached snapshots.
Guards: a server acting only through the unlinked-server fallback org still
needs the server bot; REST never creates a discord_guilds doc, never writes
org_id or bot_present, and never writes onto a server unlinked meanwhile.
Also fixes server-26#251: an interactive start (/join, admin page, web
picker, node discord_join) re-reads a cached "denied" snapshot older than
10 s once before refusing, so "fix it in Discord, then try again" works on
the next try. The reconciler never re-reads (no REST loop).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An org owner links a server by inviting one radio bot through that bot's
own OAuth2 invite (owner decision Q1). The state is a single-use, 10-minute
nonce bound to org + user (stored only as its sha256); the server linked is
the one Discord's code exchange names, never the redirect query.
- routers/discord_oauth.py: GET /discord/oauth/options, POST /start,
POST /complete. 404 while discord_oauth_redirect_uri is empty or no
usable bot has a secret. Bot order: explicit token_id, the node system's
preferred bot, the org's own bot, least-full pool bot under the cap.
- internal/discord_oauth.py: state mint/consume transaction (login-CSRF:
state.uid must be the caller; any presentation burns it), code exchange,
revoke, client-credentials secret check. Through discord_rest's shared
client and breaker; secrets/codes/tokens never logged or in errors.
- discord_guild_link: one link transaction body (_apply_link) and one
settle step that stops a server's streams when the org it acted for
changes (plan finding 3). /drb link redeem, the admin PUT link and OAuth
all use it; the previous org is now read inside the link transaction.
- tokens: GET /tokens is an allowlist (+ oauth_enabled, server_count,
server_cap); PUT/DELETE /tokens/{id}/oauth (admin) checks the secret with
Discord before storing it in bot_oauth/{id}, never echoes it.
- firestore.rules: explicit deny for bot_oauth and discord_oauth_states.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- /discord/connected: Discord's OAuth redirect target. Stashes code/state
in sessionStorage, strips them from the URL (replaceState), no-referrer
(metadata + response header), sends a signed-out user to
/login?next=/discord/connected and resumes, access_denied = "Nothing
changed", result card "Linked X: N ready, M need a fix".
- middleware.ts: the callback path joins NO_SESSION_COOKIE_GATE (plan
finding 2). Also needed for signed-in users: drb_session is
SameSite=Strict, so it is not sent on the cross-site redirect back from
discord.com.
- /login honours ?next= for an allowlist of one path (no open redirect).
- DiscordServersCard: Connect is the primary action with a radio-bot
picker (automatic / the bot for a system / the org's own bots); the
/drb link code moves to a collapsed secondary option and opens itself
when /discord/oauth/start answers 404.
- RadioBotsPanel: write-only client-secret field per bot (PUT/DELETE
/tokens/{id}/oauth), "Connect on/off", "N of 100 servers" with the
pool cap (90) warning.
- lib/discordOAuth.ts: the S6 client, kept out of c2api.ts; only ever
navigates to a discord.com /oauth2/authorize URL.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ODIN still lists Avangrid rows from April 2026 (ETR April/May) as active on
2026-10-05; parse_odin now counts events whose own ETR passed >24h ago as
stale_customers_out, not customers_out, and since/etr come from live events.
Puget Sound Energy sends 0001-01-01T00:00:00Z for no ETR; treat as None.
Ack dialog now shows a refused enable's error instead of hiding it behind
the overlay.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Part 1 of 2 for server-26#184 (map layer + incident cross-match follow in batch 3).
- app/internal/outages/: same gate as the camera registry -- settings doc
config/outage_providers, every provider OFF by default, unreadable settings
fail closed, stubs never enable, gated licences need acknowledge_licence
stored with who/when plus an audit_log write as a precondition, and the
acknowledgement is bound to the licence status it was given for.
Reuses cameras.base Licence/LicenceInfo/get_json/safe_error/require_parsed.
- ODIN adapter (Licence.UNVERIFIED, gated): public county-level API verified
first-hand against https://odin.ornl.gov/v3/api-docs -- /odi?format=JSON
(counts, start, ETR), /odi/map?scale=COUNTY (county name, bbox, label
point), /odi/status (utility freshness). Schema canary on all three.
metersServed is utility-wide, so customers_served stays None.
- Stubs: poweroutage_us (paid), coned_oru / nyseg_rge / central_hudson
storm centers and eagle_i_live (agreement required).
- GET /outages (signed-in) returns areas + per-provider credit/health and a
status (ok / none_enabled / no_source / error) so an empty layer is never
ambiguous. /admin/outage-providers GET + PUT, admin only, 409 with
requires_acknowledgement like cameras.
- Frontend: c2api getOutages/listOutageProviders/setOutageProvider, types,
Admin -> Outage providers tab (deep link /admin#outages).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Every configured node offline now degrades the Radio stage ("all N nodes
offline") so the wall no longer shows "Listening" with nothing up.
- nodes-hearing only counts configured nodes, so it can't exceed N/N.
- AI chip copy: "no incident writes in the recent window" instead of
"last incident write none in window".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The picker footer reads "Permissions checked N ago" from checked_at, but
voice_check stamped it with the response time. A denied result is cached
for 10 min, so a user who fixed the permission in Discord and reopened the
picker saw "checked 2s ago · Needs fix" from a stale snapshot. checked_at
now carries the oldest fetched_at behind the answer (now when there is none).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Backend: /media/calls/{id}/audio now honours a single HTTP Range
(bytes=a-b, a-, -n) with 206 + Content-Range, 416 for unsatisfiable,
and a 200 full body (Accept-Ranges: bytes) otherwise. The body is
streamed from GCS in 512 KiB span reads (storage.audio_object_size +
download_audio_range) instead of downloading the whole object first,
so a 600 s FLAC starts after one span and seeks fetch only what they
need. HMAC check and Firestore lookup unchanged and still first.
Frontend: InlinePlayer moved to components/InlinePlayer.tsx and now
drives lib/audioQueue.ts, a useSyncExternalStore store over ONE audio
element (one call plays at a time). Wall spine rows get play buttons
and a "Play incident" queue (oldest first) with the playing row
highlighted; map path-stop popups get a play button and the playing
stop gets an accent ring; rail cards get "play latest". Playback is
gated by canPlayAudio() (BUSINESS_MODEL section 3 / Gate A hook).
Nothing plays until clicked. Spine truncation is now stated
("showing latest 30 of M", show-all toggle, count outside the loaded
window) for server-26#239.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The '● Live · Ns ago' pill was reset by any node/activeCalls/incident change,
so heartbeats kept it green while STT/correlation were dead (#196).
- lib/usePipelineHealth.ts: polls /health/ai (auth-free) every 60 s and
derives stage staleness from data too, since /health/ai resets on restart.
Radio = newest call; STT = calls heard after the newest transcript, dry when
>=3 are waiting and the oldest is > N min; AI = newest incident write /
linked call + any degraded /health/ai tier. N = NEXT_PUBLIC_WALL_STALE_MIN
(default 10); radio silence and AI absence get 3N (weaker evidence).
- components/live/TruthStrip.tsx: per-stage chips; full-width amber
DEGRADED bar naming the dry stage and any degraded tier.
- LiveView: heartbeat lastUpdated removed and no longer passed to MapView
(hides the old pill without touching MapView); 'Listening' only when every
stage is healthy; map + rail greyed when degraded; nodes KPI is now
'nodes hearing' (a call in the last N min).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- guild_overview offered an invite link to a full pool bot whenever it was
in ANY server (`if member` tested the non-empty guild_ids list, not
membership of this guild). Now: in this guild, or invitable.
- start_stream re-reads membership once on pool_full as it already does on
no_bot_in_guild, so a full pool bot an admin just invited by hand is found.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Node-page picker and the admin Add form now show, per voice channel, whether
a radio bot can stream there, from S3's voice-check routes:
- Badge per channel: Ready / Needs fix (names the missing permission) /
Couldn't check / Bots busy / No radio bot / Not a voice channel / Limit N.
- The chosen channel shows "<bot> can't Speak in #dispatch: <what removed it>"
plus c2-core's fix sentence, and an expandable per-bot list.
- Footer: "Permissions checked 40s ago · Re-check" (refresh=1, rate limited
server-side; a limited re-check says so). Invite links when no bot fits.
- Start is disabled only on a definite refusal (needs_fix, bad_channel);
unknown fails open. A channel this system already plays in is judged by
the bot that carries it, so Update/move is never blocked by the other bots.
- Admin Add form disables bots that are definitely denied in the bot list.
- 409 refusals (missing_permissions, preferred_busy, preferred_other_node,
bad_channel) are shown verbatim, multi-line.
Org owners (owner decision Q2) get "Radio bot per system" on
Settings > Organization: their own systems, their own BYO bots, set/clear
through S3's PUT /tokens/{id}/prefer/{system}. New GET /tokens/org/preferred
lists only the caller org's systems and bots (no pool bots, no token
strings; a pool bot an admin chose is flagged, not named).
The prefer route now drops the 5-minute doc_get_cached entry for every
system it rewrites, so a new preference applies to the next start.
Stacked on feat/225-s3-perm-check: must merge after S3.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
start_stream now asks Discord (cached REST snapshots, S2) whether each bot
it could pick may View, Connect and Speak in the voice channel, before the
lease. Covers all four entry points: bot POST /discord/streams, web picker,
nodes discord_join, and the reconciler's re-place.
- pick_token: denied bots skipped; confirmed-ok sorts before unknown;
unknown is allowed (fail open); all denied -> missing_permissions naming
each bot, missing permission, trace reason and fix. The reuse branch
falls through when its token is denied.
- Bounded: discord_perm_check_concurrency at once, within
discord_perm_check_budget_seconds; unanswered = unknown.
- Non-voice channel -> 400 bad_channel (not missing_permissions).
- systems/{id}.preferred_token_id is now read (plan finding 1), as a hint.
- Pool bots at discord_pool_bot_guild_cap are never offered as invites;
pool_full when nothing else is left (owner Q3).
- handle_actual drops cached access on missing_permissions, channel_full,
not_in_guild, bad_channel.
- New GET /discord/nodes/{node_id}/guilds/{guild_id}/voice-check (org
member, names only) and GET /discord/guilds/{guild_id}/voice-check
(service key or admin).
- PUT /tokens/{id}/prefer/{system_id}: org owners for their own systems
and own BYO bots; shared systems and pool bots stay admin-only (owner Q2).
Keeps both pointers single-valued; audited.
- Kill switch: DISCORD_PERM_CHECK_ENABLED=false.
- tests/conftest.py: autouse fixture so no test ever reaches discord.com.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Firestore error inside classification (dropping membership, marking a
token invalid) propagated out of guild_snapshot and, behind asyncio.shield,
could die unretrieved. S3's start_stream must fail open, so the snapshot
now returns UNKNOWN (cached briefly) and logs only the exception type.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New app/internal/discord_rest.py: one shared httpx client with the
DiscordBot User-Agent, per-token rate-limit buckets (X-RateLimit-Bucket +
guild), one retry on a 429 of <=3s, global-429 pause per token, and a
process-wide breaker at 4000 401/403/unshared-429 responses per 10 min so
C2 can never get the shared egress IP banned. Tokens are never logged.
inspect_token / refresh_membership / leave_guild / TokenRejected move off
discord_leases onto it. /users/@me/guilds now follows after= pages (it read
one page of 200 and silently dropped the rest); a failed page is unknown,
never a truncated list.
guild_snapshot(token_doc, guild_id): cached, single-flight read of
/guilds/{g}, the bot's own member and /channels for S3's permission check.
401 marks the token invalid; 10004/10007 drop the server from guild_ids;
50001 is confirmed with one membership read and never changes guild_ids
on its own. TTLs: ok 120s, not-member/invalid 600s, unknown 30s.
config.py gets every setting the #225 slices use (perm-check kill switch,
TTLs, breaker budget, OAuth redirect URI, discord_pool_bot_guild_cap=90).
No behaviour change beyond pagination, User-Agent and the breaker.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A guild without its @everyone role (or that role's permissions) was read
as base 0, a definite deny that would block a start. It now raises
ValueError, which S3 treats as unknown and lets the start go ahead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>