Commit graph

2620 commits

Author SHA1 Message Date
8eb8334459 remove already-fixed H25 from bugs.md 2026-05-12 11:47:16 -05:00
8379664cb0 fix: 3 more bugs (M17, M23, L9)
- M17: default SNMPv3 auth protocol on the four SNMP executors
  (interface/storage/processor/sensor) is now SHA-256 instead of MD5.
  Devices that explicitly set MD5 still use it; only the fallback for
  unconfigured devices changes.
- M23: permissions check now logs a warning when an org's memberships
  aren't preloaded so a silent denial is visible at the call site
  instead of looking identical to a real authz failure.
- L9: extracted the duplicated should_sync?/1 logic from gaiia, sonar,
  splynx, visp, and netbox sync workers into
  Towerops.Integrations.due_for_sync?/2. Each worker now passes its
  own default interval (10 or 30 minutes) to a single shared helper.
  Tests updated to cover the helper directly.
2026-05-12 11:45:22 -05:00
ea91dae0e6 fix: 6 medium-severity bugs (M2, M8, M9, M10, M11, M16)
- M2: get_user_by_email/1 uses lower(email) = lower(?) so User@Example.com
  and user@example.com resolve to the same account; closes a lookalike-
  registration / password-reset confusion risk.
- M8: webhook auth plug no longer echoes "Webhook authentication not
  configured" — the misconfiguration is logged server-side and the caller
  gets a generic Internal server error so endpoints can't be probed.
- M9: coverages controller logs the underlying KMZ build error and
  returns a generic "Failed to build KMZ" string — filesystem paths and
  zip internals no longer leak.
- M10: account-data controller no longer crashes with MatchError when an
  org has zero or multiple memberships; defaults to "member" and treats
  any owner membership as owner.
- M11: ActivityController switches to a hard whitelist
  (Atom.to_string/1 comparison) instead of String.to_existing_atom/1;
  unknown filter types are dropped silently and the atom table can't be
  grown from API input.
- M16: title-tracking MutationObserver is stored on `this` so destroyed()
  actually disconnects it — fixes a memory leak per LV navigation.
2026-05-12 11:38:13 -05:00
abfd44fd75 fix: 3 more high-severity bugs (H19, H20, H25)
- H19: /api/v1/mobile/auth/qr/verify no longer returns user_email. Knowing
  a QR token now only tells the caller the token is valid; the email is
  only revealed by /complete which consumes the token.
- H20: CoverageWorker max_attempts dropped from 3 to 1. The fail/2 path
  already returns :ok and writes the failure onto the coverage record,
  so the 3-attempt retry policy was never reachable and was misleading.
- H25: ChecksController.create rejects device_ids that don't belong to
  the caller's organization (via ScopedResource.fetch). Without this an
  API token could attach service checks to devices in another tenant.

Also strip bug ID references from in-code comments (per feedback that
H/M/L numbers don't survive past their bugs.md removal); commit history
keeps the audit trail.
2026-05-12 11:32:20 -05:00
d9afb6a3e4 remove fixed bugs (H12, L2, L5, L6, L8, L10, L11) from bugs.md 2026-05-12 11:27:32 -05:00
97232117f5 fix: H12 cookie hardening + 5 low/medium bugs (L2, L5, L6, L8, L10, L11)
- H12: session and remember-me cookies get http_only + secure (prod-only).
  Cookie can no longer be read via document.cookie (XSS exfil defense)
  and the Secure flag is set in production via config/prod.exs.
- L2: 404 tracker uses EXPIRE … NX so a sustained probe can't keep
  refreshing the 60s window and dodge the threshold ban.
- L5: vault only reads CLOAK_KEY when :env == :prod — a developer with
  a prod env var set in their shell won't accidentally encrypt local
  data with the production key.
- L6: health endpoint no longer leaks the app version.
- L8: Preseem.dismiss_insight/2 + InsightsLive uses it — dismissing now
  requires the insight to belong to the user's org (closes IDOR).
- L10: SidebarCollapse JS hook stores its click handler and removes it
  in destroyed(); listeners no longer accumulate across LV navigation.
- L11: WebMCP navigate tool rejects anything that isn't a same-origin
  absolute path (blocks javascript:, data:, off-site URLs).
2026-05-12 11:22:47 -05:00
6a9b471d1f fix: 4 more high-severity bugs (H23, H26, H28, H29)
- H23: introduce Towerops.Workers.SyncErrors.transient?/1 classifier;
  uisp/preseem/cn_maestro sync workers now retry transient failures (5xx,
  timeouts, rate limits) via Oban while still discarding permanent errors
  (401/403/404) as :ok. Stale monitoring data is the bigger risk than an
  extra retry on a known-bad credential.
- H26: add Monitoring.get_latency_data_for_devices/2 batch counterpart
  (currently a stub map but called from SiteLive.Show so any future ping
  implementation lands in a single query, not one-per-device).
- H28: Nominatim search popups in the coverage map now bind via a text
  node instead of an HTML string, so a compromised/poisoned response
  can't execute as JS in the popup.
- H29: yaml_profiles dot-boundary OID prefix match (handles optional
  trailing dot from YAML profile keys). Stops 1.3.6.1.4.1.9 from
  incorrectly matching 1.3.6.1.4.1.99.
2026-05-12 11:14:03 -05:00
b252cb81b9 fix: 5 more high-severity bugs (H6, H8, H17, H18)
- H6: batch interface stats query in get_site_capacity_summary, replacing
  per-interface SELECT with a single grouped query
- H8: explicit expires_at check in MobileAuth and GraphQLAuth plugs as
  defense in depth — the query already filters expired rows, but a future
  refactor dropping the filter can't quietly re-enable revoked sessions
- H17: Reports LiveView (toggle/delete/run_now) now uses
  Reports.get_organization_report/2 to scope by organization_id, fixing
  IDOR where a user could manipulate other tenants' reports by ID
- H18: ToweropsWeb.Api.ParamFilter strips identity fields (id,
  organization_id, user_id, created_by_id, inserted_at, updated_at) from
  user-supplied API params; applied to devices, sites, checks, and
  escalation_policies create/update paths to prevent mass-assignment of
  tenant ownership fields if a changeset cast list ever drifts
2026-05-12 11:04:31 -05:00
7e6ec7098c fix: 10 high-severity bugs from code audit (H1-H5, H14-H16, H27, H30)
- H1: batch count queries in MobileController (org sites/devices/alerts, site
  device counts) — reduces 1+3N+2M queries to 4 + 2
- H2: batch Oban cancel for stop_device_checks — single ANY(?) query instead
  of one per check
- H3: resolve_active_alerts_for_device uses update_all + deduped PubSub
  broadcasts (was N updates + N broadcasts)
- H4: atomic ON CONFLICT upsert for auto-discovery checks; restore partial
  unique index dropped in 20260404000002 (dedup migration included)
- H5: wrap apply_agent_to_all_equipment delete+insert in transaction so a
  failed insert_all rolls back the prior delete
- H14: replace String.to_integer/1 on untrusted SNMP OID components with
  Integer.parse/1 + validation (neighbor_discovery, discovery storage index,
  printer supply index)
- H15: ActivityFeedLive whitelists activity types against @all_types instead
  of String.to_existing_atom/1
- H16: defensive page param parsing in user_settings and admin dashboard
  LiveViews
- H27: search sanitize/1 delegates to QueryHelpers.sanitize_like/1 which
  escapes backslash first
- H30: JobCleanupTask no longer cancels jobs in "executing" state so
  in-flight polls complete naturally
2026-05-12 10:55:01 -05:00
810dafbf5e remove all fixed critical items from bugs.md, fix flaky MIB cache test 2026-05-12 10:35:52 -05:00
e96d077fdb remove C11 and C12 from bugs.md — not actual bugs 2026-05-12 10:24:15 -05:00
ca7bb75472 fix: 11 critical security/correctness bugs from code audit
- C1: Replace innerHTML template literals with DOM textContent in sites_map.ts
- C2: Derive user_id from current_scope instead of client params in policy consent
- C3: Fix broken halt() in GDPR data export (orphaned _ = ... halt())
- C4: Add owner/admin authorization check to MembersController update/delete
- C5: Require HMAC signature on agent release webhook (was optional)
- C6: Fix Repo.all_by/2 (not a real Ecto function) → Repo.all with query
- C7: Move auth exemption to before_send callback in BruteForceProtection
- C8: Block </style>/<script injection in status page custom_css validation
- C9: Create secrets.example.yaml with placeholders; secrets.yaml already gitignored
- C10: Load Stripe key from STRIPE_SECRET_KEY env var instead of hardcoded value
- C13: Remove credentials from PubSub backup broadcast; channel resolves them

C11 (no force_ssl): by design — Cloudflared terminates TLS
C12 (device quota race): false positive — FOR UPDATE serializes correctly
2026-05-12 10:20:52 -05:00
39e588c686 fix: API token access control, admin form crash, MIB validation, CSP dedup
- Add organization membership check before API token creation
- Fix admin security allowlist form reading current_user instead of current_scope.user
- Use recursive File.ls instead of Path.wildcard to include hidden files in MIB validation
- Add upload size check before ZIP extraction in MIB controller
- Centralize CSP in SecurityHeaders plug with per-request nonces instead of 'unsafe-inline'
2026-05-12 09:08:57 -05:00
320ced2d23 fix: MIB archive bomb protection, atom exhaustion guard, and vendor caching
- Enforce upload size limit (100 MB), max extracted files (2000), max
  uncompressed size (500 MB), and reject symlinks in MIB archives
- Replace unbounded String.to_atom calls in SNMP tokenizer with
  safe_atom/1 that warns when approaching the atom table limit
- Cache MIB vendor listing with persistent_term, invalidated on
  upload/delete to avoid blocking synchronous Path.wildcard scans
2026-05-12 08:31:01 -05:00
8338d0af1e fix: remaining bugs.md findings — Repo calls, process dict, CSS, rate limits, Oban, debug route
- Extract Repo calls from LiveViews into Agents.get_agent_token_for_org/2
  and Accounts.verify_new_totp_device/2 context functions
- Remove Process dictionary usage for cookie consent; use explicit assigns
- Reject url() references in status page custom CSS changeset
- Add per-organization rate limit check alongside per-IP
- Document Oban flush as emergency-only with audit logging
- Gate /admin/headers behind dev_routes (debug endpoint)
2026-05-11 19:34:18 -05:00
3632580d1b fix: address security and reliability findings from bugs.md review
- Client IP: only trust X-Forwarded-For from RFC 1918 proxy IPs
- Webhook auth: handle nil/blank secret with controlled error, not 500 crash
- Sudo redirect: reuse validated return_path? from login to prevent open redirect
- Map live: remove redundant inline script (ensureLeaflet hook handles loading)
- Bang calls: convert crash-prone exact matches to case in QR live and API controllers
2026-05-11 18:54:12 -05:00
2ff6a61bd1 fix(gaiia): add ghost devices tab, manual link, and refresh to reconciliation
Fix auto_match return tuple order (mac/ip/site were swapped). Add ghost
devices tab for cleaning up stale mappings to deleted devices. Add "Link"
button on untracked devices to manually link to existing Gaiia items.
Add manual refresh button for on-demand reconciliation.
2026-05-11 18:35:34 -05:00
ecec8ba845 feat(mikrotik): webhook receiver for MikroTik RouterOS events
Receives webhooks from MikroTik RouterOS when devices come online,
extracts MAC/IP data, and reconciles with Gaiia inventory.
2026-05-11 18:02:21 -05:00
97910aa178 fix(gaiia): correct mac_address to if_phys_address in inventory_creator
Also guard against empty model_id form submission.
2026-05-11 16:42:18 -05:00
7067bc2fa9 feat(gaiia): hyperlink IP addresses in untracked devices table 2026-05-11 16:39:53 -05:00
fb2d4b9b1a fix(gaiia): correct Interface MAC field name, relabel auto-match button
- Interface MAC field is `if_phys_address`, not `mac_address`
- Button now says "Auto-Match All" since it matches by MAC, IP, and site
2026-05-11 16:20:35 -05:00
3483d9d5f0 feat(gaiia): MAC-based auto-matching (phase 1, highest confidence)
Adds MAC address matching as the primary matching phase:
- Normalizes MACs (strips colons/dots/dashes) from SNMP interface data
- Matches against gaiia_inventory_items.mac_address (synced from Gaiia)
- Phase 1: MAC → Phase 2: IP → Phase 3: Site

Flash now shows breakdown: "Auto-matched 47 devices (12 by MAC, 34 by
IP, 1 by site). 17 remaining."
2026-05-11 16:09:41 -05:00
40dadededd fix(gaiia): wrap manufacturer select in form for phx-change to pick up name attr
A bare <select phx-change="..."> sends %{"value" => ...}, not the name
attribute. Wrapping in <form phx-change="..."> fixes this so the handler
receives %{"manufacturer_id" => value}.
2026-05-11 15:58:51 -05:00
8db9fb4fcd fix(gaiia): add error handling and logging for model fetch
- Handle empty manufacturer_id selection (select placeholder)
- Log and flash when model fetch fails or returns empty
- Add require Logger to reconciliation live view
2026-05-11 15:25:34 -05:00
2695e8a0eb refactor(gaiia): manufacturer/model dropdowns for inventory creation
Replaces auto-matching with explicit two-step selection:
1. Select manufacturer from Gaiia's inventory model library (dropdown)
2. Select model for that manufacturer (dropdown, filtered live)
3. Click Create

Manufacturers and models are fetched from Gaiia on demand and cached
in socket assigns. Only one device form is open at a time.
2026-05-11 15:12:52 -05:00
31231d9fad fix(insights): correct ngettext usage in auto-match flash
ngettext is a macro that already calls dngettext internally. The
previous code was calling dngettext a second time on the result string,
passing nil as the count — causing a FunctionClauseError.
2026-05-11 15:07:39 -05:00
3267bb134f feat(gaiia): create inventory items for unmatched devices
Adds `Towerops.Gaiia.InventoryCreator` which:
- Matches a device's SNMP-discovered manufacturer/model against Gaiia's
  inventory model library to find the right modelId
- Calls `startAddInventoryItemsJob` to create the item, assigned to the
  correct Gaiia network site with MAC address from SNMP interfaces

A "Create in Gaiia" button now appears next to each untracked device on
the reconciliation page.
2026-05-11 14:58:37 -05:00
1f9fe2ee29 chore: add gaiia library dep, remove stale honeybadger/tidewave/cbor
- Add vendored Gaiia library (path: vendor/gaiia) with auto-generated GraphQL
  queries and mutations from the Gaiia API schema
- Add startAddInventoryItemsJob + updateInventoryItem mutations
- Remove `:cbor` (was unused — no references anywhere)
- Remove stale Honeybadger filter module, tests, router plug, and config
- Remove stale Tidewave plug from endpoint (dev-only, dep already gone)
- Unlock leftover deps from lock file
2026-05-11 14:46:27 -05:00
485748d253 feat(gaiia): site-based auto-matching + automatic site mapping during sync
Two improvements to Gaiia reconciliation:

1. Auto-match now uses two-phase matching:
   - Phase 1: IP match (high confidence, same as before)
   - Phase 2: Site match — if a device is at a Towerops site linked to a
     Gaiia network site, match it to unmapped inventory assigned to that site

2. Gaiia sync now automatically maps network sites to Towerops sites by
   name during sync. When a Gaiia network site name matches a Towerops
   site name, `site_id` is set on the `gaiia_network_sites` row. This
   enables the site-based matching in phase 2.

Flash message now shows breakdown: "Auto-matched 47 devices (34 by IP,
13 by site). 17 remaining."
2026-05-11 14:17:41 -05:00
c00be779f9 chore: remove stale renovate config 2026-05-11 13:53:32 -05:00
80837b8b26 feat(gaiia): one-click auto-match untracked devices by IP
Adds `Gaiia.auto_match_untracked/1` which finds unmapped Gaiia inventory
items whose IP matches an untracked Towerops device and links them.

Button on the reconciliation page's Untracked tab runs the match
inline and shows a flash with matched/remaining counts.
2026-05-11 13:51:13 -05:00
6f46915eeb fix(insights): raise device overheating thresholds for outdoor equipment
Warning: 65°C → 80°C, Critical: 75°C → 90°C. Outdoor gear in Texas
routinely runs at 70-75°C ambient. The old thresholds generated useless
AI insights like "Verona to Altoga at 72°C may be overheating" — that's
normal for an enclosure in summer, not actionable.
2026-05-11 12:13:09 -05:00
e6e1326689 feat(insights): allow AI observations to link directly to devices
The LLM can now copy device_id from the snapshot into its observation
output. The worker wires it into the insight's device_id FK so the UI
can link directly to the referenced device.
2026-05-11 10:40:59 -05:00
f0058a4d77 feat(insights): feed AP radio/frequency/interference data to AI network insight
Adds a wireless section to the NetworkSnapshot with per-AP radio state
(channel, frequency, channel width, noise floor, RF/QoE scores), per-channel
occupancy (own-fleet + foreign neighbor counts), and APs with the worst
foreign interference. Updates the system prompt to describe this data and
removes device_id from the expected output schema — the LLM now references
devices by name.
2026-05-11 10:35:35 -05:00
9a10e01a24 fix(llm): switch to deepseek-chat (standard model, no reasoning overhead)
deepseek-v4-flash is still a reasoning variant and burns tokens on
chain-of-thought before producing content. deepseek-chat produces output
directly — 82 tokens all went to content in testing, vs 85-102 tokens
that produced empty observations on flash.
2026-05-11 10:23:18 -05:00
4bca41a700 fix(insights): add info logging to AI network insight worker
Info-level logs for: org count at start, observation count per org,
successful persistence, and dedup. Makes the worker observable without
needing database access.
2026-05-11 10:11:16 -05:00
4d91e691ad fix(llm): switch default model to deepseek-v4-flash
deepseek-v4-pro is a reasoning model that burns tokens on chain-of-thought
before producing content. The flash variant is faster, cheaper, and doesn't
starve the content output — a better fit for summarization and observation
generation tasks.
2026-05-11 09:53:37 -05:00
4cfb97a741 fix(llm): handle reasoning models, bump token limits to 20k
deepseek-v4-pro is a reasoning model that burns tokens on
reasoning_content before producing content. When max_tokens was too low
(400/1500), all tokens went to reasoning and content was empty.

- parse_response now falls back to reasoning_content when content is blank
- max_tokens raised to 20_000 across all call sites and default
- improved parse logging shows raw/cleaned byte sizes
2026-05-11 09:49:30 -05:00
d90d24198c chore(deps): update absinthe, honeybadger, phoenix, swoosh
absinthe 1.10.1 → 1.10.2
honeybadger 0.26.0 → 0.27.0
phoenix 1.8.6 → 1.8.7
swoosh 1.25.1 → 1.25.2
+ transitive: decimal 3.1.0, mint 1.8.0, telemetry 1.4.2
2026-05-11 09:36:45 -05:00
fc60e4ece8 fix(llm): increase receive_timeout to 120s and allow per-call override
The network insight prompt is large and max_tokens is 1500 — DeepSeek
takes longer than 30s. Also plumb opts[:receive_timeout] through so
workers can tune it.
2026-05-11 09:30:15 -05:00
a05907b76d fix(insights): add logging to diagnose why AI network observations never persist
The parse function silently returned {:ok, []} on any decode failure or
observation rejection. Log warnings for both cases so we can see what the
LLM is actually returning.
2026-05-11 09:18:09 -05:00
78f7745c3c e2e tests 2026-05-10 17:53:09 -05:00
3b0961dfc0 refactor(webhooks): enqueue Oban jobs immediately, ACK fast
All 4 webhook controllers now verify the signature, insert an Oban job,
and return immediately. Previously, Stripe and PagerDuty did DB queries +
writes inline; Gaiia ran upserts; Agent Release broadcast to all connected
WebSockets — all in the request path.

New workers:
- StripeWebhookWorker — delegates to WebhookProcessor.process/1
- GaiiaWebhookWorker — delegates to Webhooks.process_event/3
- PagerdutyWebhookWorker — resolve/acknowledge alerts by dedup key
- AgentReleaseWebhookWorker — broadcast mass update to agents

Also fix: log error when resolve_alert fails in agent_channel (was silently
discarded, causing "Resolving stuck device_down alert" to repeat in logs).
2026-05-10 17:18:23 -05:00
36edf54e7c fix(insights): don't hold DB transaction across LLM API calls
The Repo.transaction wrapping Repo.stream() held a checkout for the
entire iteration, including the LLM HTTP call. When the LLM took >15s,
Postgrex killed the connection, cascading to all subsequent orgs.
Collect org IDs first, then process each independently.
2026-05-10 16:54:01 -05:00
a8f119261e fix(insights): surface all LLM/insight errors instead of silently swallowing them
- Regenerate button now logs Oban.insert failures and shows per-worker counts
- AI network insight worker: log errors for LLM failures, insert failures,
  Usage.record failures, and transaction failures (was discarding all)
- Enrichment worker: handle apply_llm_enrichment errors instead of crashing,
  log LLM and Usage.record failures
- Wire up build_gaiia in network snapshot to actually query reconciliation
  finding counts instead of hardcoding zeros
2026-05-10 15:31:26 -05:00
fd235d05b7 feat(insights): AI surfaces its own observations from full network state
Adds a parallel LLM path that reviews the whole org snapshot — Preseem
APs, SNMP signals, backhaul, agents, and the insight types the rules
already covered — and emits novel observations as ai_observation
insights (source=ai). Existing rule-based insights and per-insight
enrichment continue unchanged.

- lib/towerops/llm/network_snapshot.ex builds the bounded snapshot
- lib/towerops/llm/network_insight_prompt.ex builds the prompt and
  parses the LLM's JSON observations array
- lib/towerops/workers/ai_network_insight_worker.ex runs nightly at
  03:30 UTC (after the rule pass + per-insight enrichment) and on
  the superuser regenerate button
- preseem/insight.ex extends @valid_types with "ai_observation" and
  @valid_sources with "ai"
- insights_live/index.ex includes the new worker in the regen list
  and adds an "AI" source badge style

Bumps the SnmpKit Manager timeout-options test threshold from 500ms
to 3000ms (was flaking on loaded dev machines), and scopes
list_unenriched_insights/1 assertion in insights_test:573 to the
test's org so cross-suite leaks don't fail the count.
2026-05-10 14:29:56 -05:00
6338556945 fix(snmp): scale agent-polled temperatures + quiet two info logs
- ToweropsWeb.AgentChannel now runs sensor readings through
  Towerops.Snmp.SensorScale.normalize/2 in both resolve_sensor_value/2
  and the GraphQL publisher. Without this, MikroTik mtxrHl* deci-degree
  readings (e.g. Culleoka reporting 294 -> 29.4°C) were stored and
  alerted on at 294°C. The DevicePollerWorker path already did this;
  only the agent path was missing it.
- Demote "Received SNMP result from agent" and "Processing polling
  result for X" from info to debug — they fire every poll cycle on
  every device and were dominating prod logs.
2026-05-10 14:00:18 -05:00
bdfb20efdf feat(insights): superuser regenerate button + dialyzer cleanup
- /insights now shows a "Regenerate insights" button for superusers
  that enqueues all seven insight workers (Preseem baselines,
  capacity, device-health, Gaiia, system, wireless, LLM enrichment).
  Non-superusers neither see the button nor can trigger the event.
- Dialyzer is clean: added :tools to plt_add_apps, gave
  Preseem.Insight a @type t, pinned a few unmatched returns, and
  suppressed a known MapSet opaque false-positive in Towerops.Unused.
- e2e: NODE_OPTIONS=--disable-warning=DEP0205 silences the Node 25
  deprecation noise from Playwright's internal ESM loader.
2026-05-10 13:41:25 -05:00
01d64a446e refactor(llm): split token tracking into a dedicated llm_usage table
Token-usage doesn't belong on the per-insight row — every consumer of
Towerops.LLM (insight enrichment today; billing-anomaly summaries,
monitoring narratives, fleet weekly reports tomorrow) needs to record
into one queryable place. Per-insight columns also made it impossible
to track LLM calls that aren't tied to an insight.

Schema: `llm_usage` keyed (organization_id, day, model, purpose) with a
unique index. organization_id is nullable for system-level analyses.
Each row carries running totals plus a request_count — callers UPSERT
and increment atomically.

API: `Towerops.LLM.Usage.record/1` for write, `summarize/2` for
read-side rollups. The worker now calls `Usage.record(%{... purpose:
"insight_enrichment"})` after each successful enrichment.

Removed: `llm_prompt_tokens` / `llm_completion_tokens` from
preseem_insights — same migration drops the columns and creates
llm_usage in one go. `apply_llm_enrichment` reverts to /3 (no usage
arg).
2026-05-10 13:13:52 -05:00
fd3c9691f2 feat(insights): auto-resolve stale insights after 7 days
If a rule keeps re-firing the underlying condition, the dedup query
suppresses duplicate inserts but `inserted_at` of the live row stays
old — so a real 6-month-old issue and a one-off blip both linger
forever. Auto-resolving any active insight older than 7 days clears
the dashboard. Still-real issues will be re-created by the next rule
pass (dedup only checks `status: "active"`), so the operator sees
fresh evidence rather than stale text.

- Towerops.Workers.InsightExpiryWorker (queue: :maintenance) flips
  `status` to "resolved" on rows where `inserted_at < now - 7d`.
- Window is configurable via `:max_age_days` for ops who want a
  different cadence per env.
- Oban cron: 04:00 UTC nightly, after the 02:00 rule pass and 03:00
  LLM enrichment.
2026-05-10 13:06:35 -05:00